@cuylabs/agent-memory-filesystem
v5.1.0
Published
Filesystem-backed memory provider for @cuylabs/agent-core
Readme
Agent Memory Filesystem
Filesystem-backed memory provider for @cuylabs/agent-core.
The package is organized by implementation area:
src/
provider.ts # provider surface for agent-core
settings.ts # provider options and public filesystem memory types
capture.ts # connects agent-core capture hooks to provider-owned extraction
agents/
capture.ts # optional private memory writer built on agent-core
recall.ts # optional private read-only memory recall worker
storage/
io.ts # low-level file reads and directory traversal
reader.ts # turns durable record files into records
store.ts # file I/O and writable record operations
records.ts # record IDs and serialization
paths.ts # path defaults and resolution
tools/
memory-tools.ts # foreground memory tools installed by the provider
search/
filesystem.ts # line-aware filesystem search and ranking
tokenizer.ts # query and record tokenizationThe provider is intentionally filesystem-specific. The generic boundary lives
in @cuylabs/agent-core; sibling packages can implement the same provider
contract for graph, vector, or service-backed memory without sharing this
package's storage code.
import { createAgent } from "@cuylabs/agent-core";
import { createFilesystemMemoryProvider } from "@cuylabs/agent-memory-filesystem";
const agent = createAgent({
model,
memory: createFilesystemMemoryProvider({ root: ".agent-memory" }),
});Memory remains opt-in. @cuylabs/agent-core owns the provider contract and
lifecycle wiring; this package owns concrete provider adapters. There is no
YAML discovery layer in the default path: applications import a provider
factory, pass provider-specific options, and give the resulting provider to
createAgent({ memory }).
The filesystem provider uses line-aware local file search underneath its memory
tools and private recall worker. When automatic recall is configured,
agent-core calls provider.recall(...) once per user turn by default, using the
latest user message as the recall task, then reuses that result across
tool-loop steps. The provider searches durable records/*.md files. Search
results include bounded snippets with source path and line metadata, so the
recall worker can inspect the exact file location instead of receiving a whole
memory file blindly.
For memory_search, the query is primary: the provider scans visible record
content and metadata, then ranks matching snippets. Tags are only ranking hints
for search because model-guessed tags should not hide content matches. For
memory_list, tags remain exact filters because list is an explicit inventory
operation.
There is no database, vector service, or hardcoded stop-word policy in the
default path. Callers can provide a custom tokenizer, explicit stop words,
snippet sizing, or a different search engine through
createFilesystemMemoryProvider({ recall: ... }) when the default lexical
ranking is not enough.
Automatic recall is agentic-only. Configure recall.agent with a private recall
worker model. Agent-core still calls provider.recall(...); the filesystem
provider owns the worker and only gives it read-only memory tools such as
memory_search, memory_get, and memory_list.
const memory = createFilesystemMemoryProvider({
root: ".agent-memory",
recall: {
agent: {
model: memoryModel,
maxSteps: 4,
timeoutMs: 10_000,
},
},
});The intended model-based flow is:
agent-core before LLM call
-> provider.recall()
-> filesystem recall agent
-> memory_search returns ranked file snippets with line ranges
-> memory_get reads an exact memory record when needed
-> recall agent returns one compact summary
-> core injects that summary
-> main agent LLM callWhen no recall agent is configured, the provider does not install automatic
recall. The foreground agent can still use provider-owned memory_search,
memory_get, and memory_list tools, and application code can still call
provider.search(...) directly.
The default on-disk layout is:
.agent-memory/
records/
2026-05-14-package-manager-a1b2c3d4.mdrecords/ is the canonical durable memory corpus. Raw conversation history
belongs to SessionStore implementations such as
@cuylabs/agent-session-store-file, not to the memory provider. The provider
does not read or generate root-level MEMORY.md, memory_summary.md,
index.json, or journal files in the default path. If those become first-class
artifacts later, they should be added as explicit provider-owned components
instead of implicit side files.
Each record is Markdown with JSON frontmatter:
---
{
"id": "2026-05-15-release-plan-a1b2c3d4",
"title": "Release plan",
"kind": "fact",
"scope": "project",
"tags": ["release"],
"metadata": {
"sessionId": "s1",
"memoryKey": "release-plan"
}
}
---
The release codename is copper.The JSON block is intentionally not YAML. It is still a frontmatter block, but the provider can parse it with the platform JSON parser, avoid adding a YAML dependency, and scan only the header when it later needs metadata-first operations. The Markdown body remains the searchable memory text.
Memory vs Sessions
@cuylabs/agent-core sessions and this provider's memory files are separate
layers:
| Layer | Package | Default durable shape | Purpose |
| --- | --- | --- | --- |
| Raw chat history | @cuylabs/agent-core/sessions plus a SessionStore | Store-specific, for example <session-id>.jsonl in @cuylabs/agent-session-store-file | Reconstruct conversation messages, branches, and compaction entries. |
| Durable memory | @cuylabs/agent-memory-filesystem | .agent-memory/records/*.md | Searchable facts, preferences, summaries, and lessons that should survive beyond the current prompt window. |
Normal turn capture and compaction-commit capture both write durable memory by
calling the provider's remember path. That path creates, updates, or skips one
canonical records/*.md file. Record metadata includes the originating
sessionId, optional turnId, source hook, tags, scope, and provider-managed
dedupe metadata.
Records do not need to be split into per-session folders. Isolation is handled by record scope:
sessionrecords are only recalled when the currentsessionIdmatches the metadata on the record.projectrecords are recalled across sessions in the same memory root.userandglobalare available for providers that want broader sharing.
By default, filesystem memory writes use project scope because durable memory
is usually meant to help future sessions in the same project. Set
defaultScope: "session" or have your capture worker/tool call pass
scope: "session" when a memory should stay private to one chat session.
Pluggability model
The public integration point is the provider factory:
const memory = createFilesystemMemoryProvider({
root: ".agent-memory",
defaultScope: "session", // omit this for project-wide memory by default
recall: {
tokenizer: customTokenizer,
engine: customSearchEngine,
},
remember: {
captureTurn: customTurnExtractor,
},
});That keeps the hookup simple while still allowing the implementation to get more powerful. Recall, storage, and remembering can evolve behind the provider boundary without changing application code.
Memory Capture
agent-core calls provider capture hooks when memory capture is enabled with
memory.capture. This package keeps the low-level core hook names internal and
exposes remember.captureTurn and remember.captureBeforeCompactionCommit as the
memory-facing options. src/capture.ts adapts core lifecycle input to the
remember pipeline.
The default provider does not silently write every conversation turn into durable memory. Turn-end writes are enabled by passing a provider-owned extractor:
const memory = createFilesystemMemoryProvider({
root: ".agent-memory",
remember: {
async captureTurn({ input, output }) {
if (!output) return [];
if (!input.includes("remember")) return [];
return {
title: "User preference",
content: output,
kind: "turn-capture",
scope: "project",
tags: ["preference"],
};
},
},
});That extractor receives the full AgentMemoryTurnEndInput from core and
returns memory drafts. The file provider stores those drafts as normal records
with source: "turn_capture", so recall and memory_search can find them on later
turns. If memory.capture is disabled in createAgent({ memory: { ... } }),
core does not call turn-start or turn-end capture hooks.
Compaction-commit capture is separate from turn capture. When a provider
implements remember.captureBeforeCompactionCommit, agent-core awaits it after
the cut and summary are prepared, before the compacted history is committed. Use
it for last-chance durable extraction from context that is about to be
compacted. Omit remember.captureBeforeCompactionCommit to disable this path:
const memory = createFilesystemMemoryProvider({
root: ".agent-memory",
remember: {
async captureBeforeCompactionCommit({ removedMessages, nextSummary }) {
if (removedMessages.length === 0) return [];
return {
title: "Compacted conversation facts",
content:
nextSummary ?? removedMessages.map((m) => String(m.content)).join("\n"),
kind: "compaction-capture",
scope: "project",
};
},
},
});The provider decides whether captureTurn and captureBeforeCompactionCommit are
deterministic functions, calls to a smaller extraction model, or wrappers around
another service. From core's perspective they are provider-owned memory writes,
not a replacement for the compaction algorithm.
The filesystem store enforces a conservative dedupe policy after the extractor or private writer proposes a record:
- exact duplicate content in the same visible scope is skipped and returns the existing record id;
memoryKeycan target an existing logical memory, including an existing record id;onExisting: "append"adds new content to that keyed record;onExisting: "replace"rewrites the keyed record's body;- fuzzy "related" memories are not merged automatically because semantic merge can accidentally combine contradictory facts.
Use keyed append for deliberate incremental records:
await memory.remember?.({
sessionId,
cwd,
title: "Release plan",
content: "The release codename is copper.",
scope: "project",
memoryKey: "release-plan",
});
await memory.remember?.({
sessionId,
cwd,
title: "Release plan",
content: "The launch window is June.",
scope: "project",
memoryKey: "release-plan",
onExisting: "append",
});For model-based extraction, use the agentic helper. It creates a private
agent-core worker with an isolated in-memory session and a narrow memory tool
set bound to the file provider. The tool names intentionally match the
AgentMemoryProvider read/write contract used by foreground memory tools:
memory_searchcalls provider search for related durable memory.memory_getreads one exact durable memory record by id.memory_listcalls provider list, or the provider's search fallback, for a filtered inventory.memory_rememberwrites one durable memory through the provider's normalrememberpipeline. It can pass amemoryKeyplusonExisting: "append"when extending an existing record found through search/list.
The normal workflow is: search/list existing memory, remember only new durable
facts, use keyed append for explicit incremental updates, then return
NO_REPLY. The store still verifies the write, so exact duplicate content is
skipped even if the private worker misses it. The worker never receives generic
file read/write tools, memory_forget, or the foreground agent's full tool set.
Automatic recall is still triggered by core through provider.recall(...);
when agentic recall is configured, the provider implements that hook with its
own private read-only worker.
A profile carries the model, prompt, tool filters, and step limits in the same shape used by subagents.
import {
createAgenticMemoryCapture,
createFilesystemMemoryProvider,
} from "@cuylabs/agent-memory-filesystem";
const memory = createFilesystemMemoryProvider({
root: ".agent-memory",
remember: {
captureBeforeCompactionCommit: createAgenticMemoryCapture({
model: memoryModel,
profile: memoryCaptureProfile,
maxSteps: 4,
timeoutMs: 15_000,
}),
},
});That worker is provider-owned host policy. It is not exposed as a foreground
tool, and agent-core still awaits captureBeforeCompactionCommit before
committing the compacted session history.
Pass any additional read helpers through tools, and apply the private
worker's own middleware, approval, or toolExecutionMode policy there. The
memory worker should not inherit the foreground agent's full tool set.
No SQL database is required for the default provider. Search is built in-process from local files on each call, with exact/snippet matching and lexical ranking over Markdown memory files. A SQLite FTS or vector backend should be added behind the same recall/provider boundary when memory size or latency requires a persistent index.
See examples/README.md for runnable local examples.
After building @cuylabs/agent-core and this package, run them with:
pnpm run example:basic
pnpm run example:records
pnpm run example:agentic
pnpm run example:automatic-recall