上下文存储 4.2
ContextChef 留在上下文窗口之外的所有东西 —— 持久的记忆条目、卸载出去的工具输出、归档的压缩片段、模型自己的工作笔记 —— 本质上是同一种东西的不同地址。4.2 把过去三套互不相干的存储接口收敛成一套基底和一个面向模型的工具。
这对应五轴架构里的 ③(持久化)和 ④(取回)。模块级的细节仍留在各自的页面:记忆讲 TTL、selector 和 allowedKeys,卸载与 VFS 讲截断与清理生命周期,溢出讲什么会被归档。
基底
两层。StorageBackend 为一个或多个命名空间搬字节;Store 在其上加了命名空间、context:// URI、访问索引和淘汰。
interface StoredEntry {
content: string;
meta: { createdAt: number; updatedAt: number; bytes?: number } & Record<string, unknown>;
}
interface StorageBackend {
read(ns: string, path: string): MaybePromise<StoredEntry | null>;
write(ns: string, path: string, entry: StoredEntry): MaybePromise<void>;
delete(ns: string, path: string): MaybePromise<boolean>;
list(ns: string, prefix?: string): MaybePromise<ListedEntry[]>;
// optional capabilities, queried rather than assumed:
readAll?(ns: string, prefix?: string): MaybePromise<StoredEntries>; // StoredEntries = Map<string, StoredEntry>
exists?(ns: string, path: string): MaybePromise<boolean>;
append?(ns: string, path: string, content: string): MaybePromise<void>;
search?(ns: string, query: string): MaybePromise<SearchHit[]>;
snapshot?(ns: string): Record<string, StoredEntry>;
restore?(ns: string, data: Record<string, StoredEntry>): void;
getPhysicalPath?(ns: string, path: string): MaybePromise<string | null>;
supports?(capability: StoreCapability, ns?: string): boolean;
}StoredEntries 就是 Map<string, StoredEntry>,NamespaceView.entries() 返回的也是同一个 Map。键的顺序是有意义的:Memory.getAll() 就按这个顺序渲染 <memory> 块,而普通对象会把形如整数的键提到最前面。snapshot() 返回 Record,因为它干的是反过来的活 —— 一个可序列化的快照,顺序在那里无关紧要。
每个方法都可以是同步或异步的,Store 会把这个选择原样透传下去,所以同步后端能让同步调用点(chef.offload、memory.snapshot)继续保持同步。向某个命名空间要它的后端做不到的能力会抛 StoreCapabilityError,并写明缺哪个能力 —— context 工具会把它转成模型能读懂的错误文本,而不是让这一轮失败。
库内置两个后端:
| 后端 | 存储方式 | 同步? |
|---|---|---|
InMemoryBackend | 每个命名空间一个 Map | 是 —— 临时性的,适合测试 |
FileSystemBackend | 根目录下每个命名空间一个目录 | 是 —— 落盘的那个 |
FileSystemBackend 为每个命名空间保留各自的磁盘布局,Offloader 那批扁平的 vfs_<ts>_<hash>.txt 文件因此能原样往返 —— 是 Offloader 为 vfs/ 装上了那套布局。但它不会读 VFSMemoryStore 的 <base64url>.mem 旧文件:那套布局在 VFSMemoryStore 内部,裸的 FileSystemBackend 写的是 memory/<key>,内容是 { content, meta } 信封。把它指向已有的记忆目录之前,先看本页下方的「从 4.1 的存储接口迁移」。
命名空间
| 命名空间 | 装什么 | 谁写 | 在窗口里吗? |
|---|---|---|---|
memory/ | 值得跨对话携带的持久事实 | Memory、模型 | 每次编译都注入 |
notes/ | 模型自己的工作草稿空间 | 模型,通过 context 工具 | 只有模型去读时才在 |
vfs/ | 大到不能内联的工具输出 | Offloader | 只留截断标记 + URI |
archive/ | 预留给被压缩出窗口的片段 | 4.x 里没有 —— overflow.archive 仍然写进 vfs/ | 摘要里的一条引用 |
memory/ 是唯一自动展示给模型的命名空间;其余命名空间在模型主动去读之前都不进窗口。这正是 notes/ 的意义所在:一个放计划或运行日志的地方,且每轮不产生任何成本。
4.x 里归档仍然写进 vfs/
overflow.archive 仍把片段存到 context://vfs/... 之下,好让已有的 URI 保持字节一致。专门的 archive/ 命名空间是预留且只读的:4.x 里没有任何东西往里写,所以 context 工具的描述和存储说明都不提它 —— 模型翻进去只会扑空的命名空间,不值得占那点前缀字节。给它一条 archive/ 路径仍然读得到,走的是和 vfs/ 一样的召回渲染。5.0 起它才是归档的正式落点。
ChefConfig.store
一个后端撑起所有命名空间:
import { ContextChef, FileSystemBackend } from '@context-chef/core';
const chef = new ContextChef({
store: new FileSystemBackend('./.context'),
memory: {},
vfs: { threshold: 5000 },
overflow: { archive: 'vfs' },
});store 只填补别处没有指定的部分。显式的 memory.store 对记忆仍然优先,显式的 vfs.store / vfs.adapter / vfs.storageDir 对 VFS 仍然优先 —— 两者都不设时,今天的默认行为原样保留,所以给已有配置加上 store 绝不会在你背后搬运数据。
如果你想要按命名空间的淘汰上限或 URI 前缀,传一个构造好的 Store 而不是裸后端:
import { FileSystemBackend, Store } from '@context-chef/core';
const store = new Store(new FileSystemBackend('./.context'), {
eviction: {
vfs: { maxFiles: 500, maxBytes: 100 * 1024 * 1024 },
archive: { maxAge: 7 * 24 * 60 * 60 * 1000 },
},
});寻址
每个条目只有一个地址:context://<namespace>/<path>。
const notes = chef.getStore().namespace('notes');
await notes.put('plan.md', '# Plan\n1. Read the failing test\n');
const entry = await notes.get('plan.md');
notes.uri('plan.md'); // 'context://notes/plan.md'
Store.parseUri('context://notes/plan.md'); // { ns: 'notes', path: 'plan.md' }传了 ChefConfig.store 时 chef.getStore() 返回那个共享 store,否则返回 Offloader 自己的那个 —— 因此它永远是 context 工具读写的同一个 store。用它在开跑前预置 notes,或在跑完后检查模型写了什么。
NamespaceView 还提供 list、entries、append、delete、search、exists、getPhysicalPath 和 supports,以及给同步后端用的 getSync / putSync / deleteSync / listSync。put 返回 { path, uri };不带 path 调用(put(content, meta))时它会按内容派生路径,因此在 agent 循环里重复卸载相同内容是幂等的,URI 也能跨进程保持稳定。
context 工具
一个工具、七个命令、所有命名空间:
| 命令 | 做什么 |
|---|---|
view | 读一个条目,或列出一个目录(context://notes/ 就是目录) |
create | 写一个新条目;已存在则失败 |
str_replace | 把 old_str 唯一一次精确出现替换成 new_str |
insert | 把 insert_text 放到 0 基行号 insert_line 上 |
delete | 删除一个条目 |
rename | 在同一命名空间内移动到 new_path |
search | 在 path 之下查找内容匹配 query 的条目 |
这份 schema 是静态、冻结且引用稳定的:只有一个 enum,而且它是命令列表 —— 绝不是你的 memory key 的实时列表。这正是它能安心待在缓存前缀里、不让任何东西失效的原因。「此刻有什么」由 prompt 更下方的部分传达:注入的记忆块,以及这个工具自己的返回结果。
行为按命名空间区分,每个命名空间都保留其模块的语义:
memory/经由 Memory 模块,因此allowedKeys、onMemoryUpdate否决、onMemoryChanged、TTL 和更新计数的行为与直接调用 API 完全一致。rename是一次 create 加一次 delete。notes/直达store.namespace('notes')—— 带行号的view、要求唯一匹配的str_replace、0 基的insert,以及在后端没有原生检索时退回「list + get」的search。vfs/和archive/默认只读,并通过与recall_context相同的召回路径渲染。
路径在读写之前先被校验。 命名空间必须是一个普通片段:.、..、空名字、反斜杠、里面的 : 或 / 一律拒绝,控制字符也是。路径的每一段同样对待 —— 不许 .、不许 ..、不许空片段(只有末尾那一个空片段例外,它表示「目录」)、不许反斜杠。除此之外嵌套路径是自由的:notes/dir/file 就是一个普通的 key,对 notes/ 做 view 会递归列出它。FileSystemBackend 在最底层再查一次,任何解析后落到命名空间目录之外的物理路径都会被拒。
模型自己的错误从不抛异常。未知路径、被拒的路径、缺参数、写只读命名空间、不被允许的 memory key、onMemoryUpdate 的否决,都会以 Error: … 文本返回,让模型读到并自行纠正。只有 chef 不拥有的工具名会抛异常 —— 那是你循环里的路由 bug,模型修不了。
访问策略
读永远不受限:store 里的一切都是这段对话自己溢出的内容,一个能拿到 context:// URI 的模型,也就能拿到 URI 背后的东西。写则受限:
const chef = new ContextChef({
store: new FileSystemBackend('./.context'),
memory: {},
tools: 'unified',
contextTool: { writable: ['notes'] }, // memory becomes read-only to the model
});默认值是 ['memory', 'notes']。加上 'vfs' 可以让模型编辑卸载出去的工具输出。策略在调用分发时执行,绝不在 store 里执行 —— 你自己的代码想写哪儿写哪儿。
分发 —— ownsTool 与 handleTool
库拥有的每个工具只有一个入口:context、new_context,以及遗留的 create_memory / modify_memory / recall_context 三件套。
for (const call of response.tool_calls) {
if (chef.ownsTool(call.function.name)) {
const content = await chef.handleTool({
name: call.function.name,
arguments: call.function.arguments,
});
history.push({ role: 'tool', tool_call_id: call.id, content });
continue;
}
await executeYourOwnTool(call);
}arguments 既接受 OpenAI 和 Anthropic SDK 给出的 JSON 字符串,也接受 Anthropic input / Gemini args 里已经解析好的对象。
ownsTool 与 ChefConfig.tools 无关:模式决定 compile() 发出什么,而不是 handleTool 理解什么。一个已经发出 context、但模型偶尔还会去调 create_memory 的迁移期是可用的,反过来也一样。
tools 模式
const chef = new ContextChef({ tools: 'unified' /* default: 'legacy' */ });| 模式 | payload.tools 携带 | 词汇表 |
|---|---|---|
'legacy'(4.x 默认) | Memory 的 create_memory / modify_memory。recall_context 与 new_context 保持可选 —— 自行注册 | 4.x 的说法:记忆是一个自带工具的独立特性 |
'unified' | 一个 context 工具,外加配置了 overflow.handoff 时的 new_context。不再发出遗留记忆工具 | 全程 context:// 寻址 |
两套永远不会同时出现在一个 payload 里:它们用两套词汇描述同一批操作,同时拿到两套的模型只能去猜宿主到底分发哪一套。
为什么默认仍是 'legacy'。 工具名是你 agent 循环里的分发键。翻转默认值等于在一个 minor 版本里悄悄给你 if (name === 'create_memory') 匹配的工具改名。它会在 5.0 变成 'unified' —— 那是一个 major,你会先读到迁移说明。
你不必等模式切换才用上这个工具。两种模式下自行注册都可以:
import { getContextToolDefinition, getNewContextToolDefinition } from '@context-chef/core';
chef.registerTools([getContextToolDefinition(), getNewContextToolDefinition()]);词汇表开关
模式同时决定本会话的词汇表 —— 在构造函数里解析一次,然后注入到库为模型渲染文本的每一处:
| 模型读到的东西 | 'legacy' | 'unified' |
|---|---|---|
| 记忆用法说明 | Prompts.MEMORY_INSTRUCTION | Prompts.CONTEXT_STORE_INSTRUCTION —— 命名空间、寻址、哪些是自动展示的 |
| 注入记忆块的头部 | 4.x 的头部 | Memory (context://memory/*) currently holds: |
| 卸载截断标记 | 4.x 的占位符 | 点名 context 工具与 context://vfs/ URI |
| 摘要包装 | 4.x 的包装 | 引用 context://archive,并带上窗口谱系行 |
| handoff 通知 | Prompts.HANDOFF_NOTICE_TEMPLATE | 用 context:// 寻址的版本 |
要点在于:prompt 绝不会提到 payload 里并不存在的工具。LEGACY_VOCABULARY 逐字委托给现有的 Prompts 字符串,这正是默认行为与 4.1 字节一致的原因 —— golden payload 固定用例会断言这一点。
不经过 chef 单独构造的模块(new Memory(...)、new Offloader(...))在你不手动传词汇表时,行为与从前完全一致。
从 4.1 的存储接口迁移
什么都不会坏。旧接口是新基底之上的废弃包装层,现有配置全部继续可用。
| 已废弃 | 替代 | 说明 |
|---|---|---|
MemoryStore | StorageBackend | 由 Store.fromMemoryStore 包装;MemoryStoreEntry 与 StoredEntry.meta 一一对应 |
VFSStorageAdapter | StorageBackend | 由 Store.fromVfsAdapter 包装 |
FileSystemAdapter | FileSystemBackend | 磁盘格式相同 |
VFSMemoryStore | FileSystemBackend + memory 命名空间 | 仅限新目录。VFSMemoryStore 已在 FileSystemBackend 上重建,也仍然读得到自己写过的文件,但那套布局是它自己装的 —— 见下文 |
InMemoryStore | InMemoryBackend | 仍然导出、仍然可用 |
各模块各自的 memory.store / vfs.storage | 一个 ChefConfig.store | 设置了模块级 store 时仍以模块级为准 |
Memory 与 Offloader 三种都接受 —— 遗留 store、StorageBackend、或 Store —— 且公开 API 未变。它们在 5.0 移除。
VFSMemoryStore 这一行不是换个目录就完事
它的磁盘布局 —— 每个 key 一个 base64url 命名的 .mem 文件,里面是裸的 MemoryStoreEntry —— 是 VFSMemoryStore 自己的构造函数装上去的,不是 FileSystemBackend 装的。把裸后端指向 4.1 写出来的 ./.context_memory 目录,它列不出东西、读出来是 null,然后开始在它忽略的那批文件旁边写 memory/<key> 信封。想保住这些条目有两条路:
import { ContextChef, Store, VFSMemoryStore } from '@context-chef/core';
// Keep the deprecated store — it still reads and writes its own files.
new ContextChef({ memory: { store: new VFSMemoryStore('./.context_memory') } });
// Or wrap it: same files, on the new substrate.
new ContextChef({
memory: { store: Store.fromMemoryStore(new VFSMemoryStore('./.context_memory')) },
});遗留 store 是单一扁平 keyspace,所以包装之后也只服务 memory/。想让一个后端同时撑起 notes/、vfs/ 和 archive/,就给 chef 一个指向新根目录的 FileSystemBackend,再用 memory.getAll() 把条目搬过去。