跳到主要内容

Claude Memory Tool 适配:把文件式记忆桥接到远端存储

30 秒导读: Anthropic 给 Claude 配了一个"记忆工具"——模型以为自己在一个叫 /memories 的目录里读写文件(view/create/str_replace/insert/delete/rename)。这一章讲 Supermemory 写的一个巧妙适配器:让这些文件操作背后其实读写远端 container,而不是本地磁盘。结果是同一段 agent 代码一字不改,记忆就从"关掉进程就没了"变成"跨会话、跨设备都在"。

本章只讲这一条桥:packages/tools/src/claude-memory.ts(624 行)。通用的框架中间件(withSupermemory)在 03;记忆背后的数据模型(文档 / 记忆 / 容器)在 01;把记忆装进任意 AI 助手的 MCP 方案在 02


1. 这是什么(零基础也能懂)

先搞懂:Claude 的"记忆工具"到底是什么

2025 年 Anthropic 给 Claude 加了一个内置工具类型 memory_20250818。开启后,模型会自己决定"我要记点东西"或"我要翻翻之前记的",并发出一条文件系统风格的命令。真实的调用长这样(来自官方 SDK 用法):

// src/claude-memory-simple-example.ts:20-26 —— 开启记忆工具
const response = await anthropic.beta.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 2048,
messages: [{ role: "user", content: userMessage }],
tools: [{ type: "memory_20250818", name: "memory" }], // ← 就这一行开启
betas: ["context-management-2025-06-27"],
})

模型不会真的碰你的磁盘。它只是吐出一个 JSON,说"请帮我在 /memories/notes.txtview"或"在这个路径 create 这段文本"。谁来真正执行这条命令、把文件放哪儿——由你决定。这就是适配器的切入点。

这个适配器解决什么问题

Anthropic 的官方示范:命令来了,你就去本地磁盘上真的建个 /memories 目录、真的写文件。问题是——本地文件会话结束就没了、换台设备就找不着了

Supermemory 的这个适配器把执行方换掉:同样的命令进来,不落本地磁盘,而是翻译成对 Supermemory 远端 container 的增删查改。于是记忆变成了远端持久化的、可跨会话跨设备取回的。

模型以为的世界 适配器真正做的事
┌──────────────────────────┐ ┌────────────────────────────┐
Claude →│ view /memories/notes.txt │ ───→ │ search.execute() 查远端文档 │
│ create /memories/todo.md │ │ client.add() 写远端 container│
│ str_replace ... │ │ (Supermemory 云端持久化) │
└──────────────────────────┘ └────────────────────────────┘
"文件系统语义" "远端记忆存储"

用起来什么样

对接方(你的 agent 代码)只做两件事:创建一个 memoryTool,然后把 Claude 吐出的每条命令喂给它。

// test/anthropic-example.ts:34-37,80-89 —— 真实对接循环(节选)
const memoryTool = createClaudeMemoryTool(SUPERMEMORY_API_KEY, {
projectId: "anthropic-sdk-demo",
memoryContainerTag: "claude_memory_anthropic",
})

for (const block of response.content) {
if (block.type === "tool_use" && block.name === "memory") {
// 把模型的命令交给适配器执行,拿回 Anthropic 认识的 tool_result
const memoryResult = await memoryTool.handleCommand(block.input as any)
// ...把 memoryResult 组装成 tool_result 发回给 Claude
}
}

妙点先说破: 模型侧、SDK 侧、agent 循环侧的代码完全是 Anthropic 标准写法,没有一处 Supermemory 特有的东西。所有"记忆搬到云端"的魔法,都封在 memoryTool.handleCommand() 这一个方法里。换掉这个 memoryTool,就换掉了记忆的落盘方式。

一句话直觉

把 Claude 的记忆工具想成一个假的文件系统驱动:上层程序照常 open/read/write,但驱动底下接的不是硬盘,而是一根网线通向 Supermemory 云端。文件路径只是"钥匙",真正的柜子在远端。


2. 顶层全景(它大概怎么转)

三个角色

角色是什么在代码里
协议类型Claude 命令 / 适配器返回的 TypeScript 形状MemoryCommandMemoryResponseMemoryToolResult(claude-memory.ts:10-38)
翻译器把每条文件命令翻成远端调用的类ClaudeMemoryTool(claude-memory.ts:44-614)
工厂一行创建翻译器实例createClaudeMemoryTool(claude-memory.ts:619-624)

一条命令的旅程

下面是"从 Claude 命令到远端存储"走一遍(高层,不进代码):

Claude 吐出命令 {command:"str_replace", path:"/memories/todo.md", old_str, new_str}


handleCommand(command) claude-memory.ts:77
│ ① 先做安全校验:路径必须以 /memories/ 开头、不含 ../
│ isValidPath() claude-memory.ts:607

│ ② switch 按 command 分派到对应私有方法

strReplace(path, old_str, new_str) claude-memory.ts:363
│ ③ 用 path 反查远端文档 getFileDocument() → search.execute()
│ ④ 在取回的内容上做局部编辑(字符串替换)
│ ⑤ 用同一个 customId 写回 client.add()

返回 MemoryResponse {success:true, content:"String replaced..."}

▼(可选)
handleCommandForToolResult() claude-memory.ts:150
└─ 包装成 Anthropic 认识的 {type:"tool_result", tool_use_id, content, is_error}

核心映射:三样东西的对应关系

适配器的全部智慧,是把"文件系统世界"映射到"Supermemory 世界"。记住这张表,后面全是它的展开:

文件系统概念Supermemory 概念怎么映射
一整个 /memories 命名空间一个 container(容器标签)memoryContainerTag 定名(默认 "claude_memory"),整套记忆文件都进这一个 container
单个文件的身份(路径)文档的 customIdnormalizePathToCustomId():/memories/a.txtmemories_a_txt
文件的原始路径(为了列目录)文档 metadata.file_path原样存进 metadata,列目录时靠它重建"目录树"
文件内容文档 content / raw直接读写

关键理解:远端根本没有"目录"这种东西。 所有文件都平铺在同一个 container 里,路径被拍扁成 customId;"目录结构"是列目录时临时从 metadata.file_path 里算出来的幻觉。这一点是理解 view 目录列举(§3.2)的钥匙。


3. 核心原理(逐个机制,由浅入深)

3.1 协议映射:命令类型 → switch 分派

要解决的小问题: Claude 会发 6 种命令,每种带的字段还不一样(viewview_range,createfile_text,str_replaceold_str/new_str……)。得先把这些形状定义清楚,再逐一分派。

类型定义 把 6 种命令合并成一个可辨识联合:

// claude-memory.ts:10-25 —— 一条记忆命令的形状
export interface MemoryCommand {
command: "view" | "create" | "str_replace" | "insert" | "delete" | "rename"
path: string
view_range?: [number, number] // view 专用
file_text?: string // create 专用
old_str?: string; new_str?: string // str_replace 专用
insert_line?: number; insert_text?: string // insert 专用
new_path?: string // rename 专用
}

分派逻辑 集中在 handleCommand(claude-memory.ts:77-145)。它先做一件所有命令都要做的事——路径安全校验,再进 switch:

// claude-memory.ts:80-132 —— 先校验、再分派(节选)
if (!this.isValidPath(command.path)) {
return { success: false, error: `Invalid path: ${command.path}. All paths must start with /memories/` }
}
switch (command.command) {
case "view": return await this.view(command.path, command.view_range)
case "create": /* 校验 file_text 存在 */ return await this.create(...)
case "str_replace": /* 校验 old_str/new_str */ return await this.strReplace(...)
case "insert": /* 校验 insert_line/insert_text */ return await this.insert(...)
case "delete": return await this.delete(command.path)
case "rename": /* 校验 new_path */ return await this.rename(...)
}

关键细节 —— 沙箱边界: isValidPath(claude-memory.ts:607-613)是安全闸门,只放行 /memories/ 下、且不含路径穿越的路径:

// claude-memory.ts:607-613 —— 防路径穿越
private isValidPath(path: string): boolean {
return (
(path.startsWith("/memories/") || path === "/memories") &&
!path.includes("../") && !path.includes("..\\")
)
}

这挡住了模型(或提示注入)试图用 /etc/passwd/memories/../../secret 之类的路径逃出记忆命名空间。测试里专门验证了 /etc/passwd 会被拒(test/test-memory-tool.ts:128-132)。

3.2 view:同一个命令,两种含义(列目录 vs 读文件)

要解决的小问题: Claude 的 view 既能"列目录内容"又能"读文件某几行",全看路径长啥样。适配器要先分流。

分流规则(claude-memory.ts:169-182):路径以 / 结尾、或正好是 /memories,就当列目录;否则当读文件

view(path)

├─ path 以 "/" 结尾 或 path === "/memories" → listDirectory()

└─ 其它(具体文件名) → readFile(path, view_range)

(a) 列目录:从元数据里"重建"一棵不存在的树

因为远端没有真目录,listDirectory(claude-memory.ts:187-248)得搜出所有记忆文档,再靠 metadata.file_path 现算目录结构:

// claude-memory.ts:190-226 —— 列目录的核心逻辑(节选)
const response = await this.client.search.execute({
q: "*", containerTags: this.containerTags, limit: 100, includeFullDocs: false,
})
for (const result of response.results) {
const filePath = result.metadata?.file_path as string // 真实路径在元数据里
if (!filePath || !filePath.startsWith(dirPath)) continue // 只要本目录前缀下的
const relativePath = filePath.substring(dirPath.length) // 去掉目录前缀
const slashIndex = relativePath.indexOf("/")
if (slashIndex > 0) dirs.add(`${relativePath.substring(0, slashIndex)}/`) // 还有下一级 → 子目录
else if (relativePath !== "") files.push(relativePath) // 没斜杠 → 本级文件
}

直觉:拿到所有文件的完整路径,掐掉当前目录前缀;剩下的部分若还带 /,说明它落在某个子目录里,于是登记一个子目录名;若不带 /,它就是本目录里的一个文件。这样就从一堆平铺文档里,凭前缀关系拼出了"这一层有哪些子目录、哪些文件"。测试 test/test-memory-tool.ts:100-113 就验证了 create /memories/personal/preferences.md 后,列 /memories/ 会显示出 personal/ 这个子目录。

(b) 读文件:远端内容的行切片 + 行号

readFile(claude-memory.ts:253-323)先按 customId 反查文档,再按 view_range 切行、加行号(模仿 cat -n)。

关键细节 —— -1 哨兵值(易错点): Anthropic 约定 view_range 的结束行 -1 表示"读到文件末尾"。但如果直接把 -1 丢给 Array.slice,JS 会把它当成"倒数第一个"从而悄悄丢掉最后一行。这里专门做了映射:

// claude-memory.ts:283-302 —— 行切片,正确处理 -1 哨兵
if (viewRange) {
const lines = content.split("\n")
const [startLine, endLine] = viewRange
// endLine === -1 是"读到文件尾"的约定;负数一律映射为数组长度,
// 否则 slice 会当成从末尾倒数的索引、丢掉最后一行。
const sliceEnd = endLine < 0 ? lines.length : endLine
const selectedLines = lines.slice(startLine - 1, sliceEnd) // startLine 是 1-based
const numberedLines = selectedLines.map((line, index) => {
const lineNum = startLine + index
return `${lineNum.toString().padStart(4)}\t${line}` // 4 位右对齐 + 制表符
})
content = numberedLines.join("\n")
}

src/claude-memory.test.ts 有一整组回归测试盯着这个坑:view_range: [1, -1] 必须返回包括 line5 在内的全部行(src/claude-memory.test.ts:41-57),[3, -1] 从第 3 行读到尾(:59-70),[2, 4] 只返回 2–4 行(:72-84)。

3.3 create / str_replace / insert:在远端内容上做"局部编辑"

共同套路: 远端文档没有"追加一行""替换一段"的原生 API,所以三个写命令都走读改写(read-modify-write):先把整篇内容取回本地,在字符串上改,再用同一个 customId 整篇写回。因为 customId 相同,Supermemory 按 customId 覆盖(upsert),等效于"改了这个文件"。

create —— 直接整篇写入

最简单,不用先读,直接 client.add(claude-memory.ts:328-358),并把重建目录树要用的元数据一并塞进去:

// claude-memory.ts:335-346 —— 创建/覆盖一个记忆文件
await this.client.add({
content: fileText,
customId: normalizedId, // 由路径拍扁而来
containerTags: this.containerTags, // 落到记忆 container
metadata: {
claude_memory_type: "file",
file_path: filePath, // 原始路径 → 供列目录重建
line_count: fileText.split("\n").length,
created_by: "claude_memory_tool",
last_modified: new Date().toISOString(),
},
})

str_replace —— 取回、替换、写回

strReplace(claude-memory.ts:363-415)先用 getFileDocument 取回文档,做存在性检查,再替换:

// claude-memory.ts:378-403 —— 局部替换(节选)
const originalContent = readResult.document.raw || readResult.document.content || ""
if (!originalContent.includes(oldStr)) {
return { success: false, error: `String not found in file: "${oldStr}"` }
}
const newContent = originalContent.replace(oldStr, newStr) // ← 注意:只替换首次出现
await this.client.add({ content: newContent, customId: normalizedId, containerTags: this.containerTags, metadata: {...} })

坑(gotcha): 第 390 行的 originalContent.replace(oldStr, newStr) 传的是字符串,JS 的 String.replace 只替换第一处匹配。真正的编辑器工具通常要求 old_str 唯一、否则报错;这里的近似是"只改第一处"。若同一段文本出现多次,行为会和模型预期不同。

insert —— 按行号插入

insert(claude-memory.ts:420-474)取回、按 \n 切成数组、校验行号、splice 插入、join 写回:

// claude-memory.ts:437-449 —— 在指定行插入(节选)
const lines = originalContent.split("\n")
if (insertLine < 1 || insertLine > lines.length + 1) { // 边界校验
return { success: false, error: `Invalid line number: ${insertLine}. File has ${lines.length} lines.` }
}
lines.splice(insertLine - 1, 0, insertText) // 1-based 转 0-based
const newContent = lines.join("\n")

3.4 delete / rename:语义近似(诚实说清"没完全做到")

这两个命令是适配器里最不完整的部分,源码注释自己承认了。

delete(claude-memory.ts:479-504):它会先确认文件存在,然后……直接返回成功,并没有真的删:

// claude-memory.ts:490-497 —— delete 目前是"半成品"
// Delete using the document ID
// Note: We'll need to implement this based on supermemory's delete API
// For now, we'll return a success message
return { success: true, content: `File deleted: ${filePath}` }

也就是说:对模型报告删除成功,但远端文档其实还在。(§1 数据模型讲的软删除 memories.forget / 文档删除 API 在别处存在,但这条桥还没接上。)

rename(claude-memory.ts:509-559):它做了一半——用新 customId、新 file_path 把内容复制成一份新文档,但没删旧的:

// claude-memory.ts:536-548 —— rename = 复制新的,但旧的没删
await this.client.add({
content: originalContent,
customId: newNormalizedId,
containerTags: this.containerTags,
metadata: { ...readResult.document.metadata, file_path: newPath, last_modified: ... },
})
// Delete the old document (would need proper delete API) ← 注释:旧文档待删
return { success: true, content: `File renamed from ${oldPath} to ${newPath}` }

净效果:rename 之后新旧路径同时存在。这是当前实现的已知边界(见 §6)。

3.5 反查一个文件:getFileDocument 的"模糊命中"

前面所有写命令都靠 getFileDocument(claude-memory.ts:564-602)按路径取回文档。它的做法是"customId 当查询词做搜索,再在结果里挑精确匹配":

// claude-memory.ts:570-583 —— 先搜、再精确匹配(节选)
const normalizedId = this.normalizePathToCustomId(filePath)
const response = await this.client.search.execute({
q: normalizedId, containerTags: this.containerTags, limit: 5, includeFullDocs: true,
})
const exactMatch = response.results?.find((r) => r.documentId === normalizedId)
const document = exactMatch || response.results?.[0] // 优先精确;否则退回第一个

为什么这么绕: 远端没有"按路径直接 GET 文档"的接口,只有语义搜索。于是适配器把 customId 当搜索词,拿回若干候选,优先取 documentId 精确等于 normalizedId 的那个;找不到精确匹配时,退回搜索排第一的结果——这一步是"尽力而为"的近似(见 §6 边界)。注意 readFile(claude-memory.ts:260-271)也用同款逻辑,但 limit: 1,精确匹配的窗口更窄。


4. 工厂与配置:如何接进 Anthropic 的记忆循环

工厂函数与配置

对接的全部入口就是 createClaudeMemoryTool(claude-memory.ts:619-624)——薄薄一层 new:

// claude-memory.ts:619-624
export function createClaudeMemoryTool(apiKey: string, config?: ClaudeMemoryConfig) {
return new ClaudeMemoryTool(apiKey, config)
}

ClaudeMemoryConfig(claude-memory.ts:6-8)在通用配置 SupermemoryToolsConfig(src/types.ts:5-24)之上,只多一个字段:

配置字段作用默认
memoryContainerTag给这套记忆命名空间定 container 名"claude_memory"(claude-memory.ts:67)
projectId / containerTags继承自通用配置,决定基础 container(见 03)["sm_project_default"]
baseUrl自定义 Supermemory API 地址SDK 默认

容器标签怎么拼出来(构造函数,claude-memory.ts:60-72):把通用的 getContainerTags(config)(来自 src/tools-shared.ts:62-70,projectIdsm_project_<id>)得到的基础标签,再追加 memory 专属标签:

// claude-memory.ts:67-71 —— 基础容器 + 记忆专属标签
this.memoryContainerPrefix = config?.memoryContainerTag || "claude_memory"
const baseContainerTags = getContainerTags(config)
this.containerTags = [...baseContainerTags, this.memoryContainerPrefix]

于是记忆文件既落在项目容器里、又带一个 claude_memory 维度,方便和普通记忆区隔。

接进 SDK 的记忆循环

对接方拿到 memoryTool 后,在 Anthropic 的对话循环里,每遇到一个 tool_usename === "memory" 的块,就交给它执行。适配器提供两种拿返回值的方式:

方法返回何时用
handleCommand(cmd)MemoryResponse({success, content?, error?})想自己拼 tool_result 时(见 test/anthropic-example.ts:80-89 手动拼)
handleCommandForToolResult(cmd, toolUseId)MemoryToolResult(直接是 Anthropic 的 tool_result 形状)想少写样板代码,一步到位

handleCommandForToolResult(claude-memory.ts:150-164)就是把前者的成败翻译成 Anthropic tool_result 的字段约定:成功放 content,失败把 error 前缀成 "Error: ..." 并置 is_error: true:

// claude-memory.ts:156-163 —— 产出 Anthropic 认识的 tool_result
return {
type: "tool_result",
tool_use_id: toolUseId,
content: response.success
? response.content || "Operation completed successfully"
: `Error: ${response.error}`,
is_error: !response.success,
}

src/claude-memory-simple-example.ts:29-52 展示了最干净的完整循环:收集所有 tool_result,再作为一条 user 消息连同上一轮 assistant 内容发回,让 Claude 拿着记忆结果继续说话。


5. 巧妙之处(可借鉴的技术)

  • "驱动"式适配,上层零改动。 把远端存储伪装成本地文件系统:agent 循环、SDK 调用、模型侧全是 Anthropic 标准写法,替换的只有 memoryTool 一个对象(src/claude-memory-simple-example.ts:14-16 vs :20-26)。这是"适配器模式"教科书级的落点——桥的两端谁都不用知道对方存在。

  • 目录树是算出来的幻觉。 远端只有平铺文档,"目录结构"完全靠 metadata.file_path 前缀在列目录时现场重建(claude-memory.ts:208-226)。省掉了在存储层维护真实层级的复杂度。

  • -1 哨兵的正确处理。 一个极易踩的 off-by-one:把 Anthropic 的"读到文件尾"哨兵 -1 直接喂 slice 会丢最后一行;这里显式映射为数组长度(claude-memory.ts:291),还配了专门的回归测试(src/claude-memory.test.ts:41-84)。是"照抄语义约定"的正面样本。

  • customId 覆盖 = 廉价的"编辑文件"。 没有专门的更新 API,却靠"读改写 + 同 customId upsert"实现了 create/str_replace/insert 三种编辑(claude-memory.ts:337:394:453 都写同一个 normalizedId),用最小的接口面凑齐了文件语义。

  • 诚实的语义近似。 delete/rename 没能力做全,就在代码里用注释明说"待接删除 API"(claude-memory.ts:491-492:547),而不是假装完成——对读代码的人友好。


6. 边界与局限

诚实列出这条桥当前做不到 / 会出岔子的地方(均有源码为证):

局限说明源码
delete 不真删对模型报告成功,远端文档仍在claude-memory.ts:490-497
rename 留副本新路径复制成功但旧路径未删,二者并存claude-memory.ts:536-548
str_replace 只替首处String.replace(str) 只换第一次出现,不像编辑器要求唯一匹配claude-memory.ts:390
反查依赖语义搜索无"按路径直取"接口,靠 search + 精确匹配,精确匹配落空时退回搜索首条,可能取错文档claude-memory.ts:572-583readFile :260-271
列目录上限 100search.execute limit: 100,记忆文件超过 100 个时目录列举可能不全claude-memory.ts:190-195
无并发保护读改写非原子,两条 str_replace/insert 并发会互相覆盖(last-write-wins)strReplace/insert 整体

这些多数源于"用一个为语义搜索设计的存储去模拟精确的文件系统"这一根本张力——桥能通,但不是每块砖都严丝合缝。


7. 横向对比(同组其它章)

  • 这条桥是为 Claude 记忆工具专供的适配器;要给任意框架的 agent 一行挂上记忆,看 03-framework-middleware.mdwithSupermemory 通用中间件。
  • 它读写的"文档 / 记忆 / container"到底是什么、软删除与画像怎么回事,看 01-data-model.md
  • 想不写代码、直接在 Claude Desktop / Cursor 等助手里挂记忆,看 02-mcp-server.md 的 MCP 方案。
  • 全景与阅读地图见 index.md

三种"挂记忆"方式的定位对比:

方式面向谁记忆命令从哪来本章
Claude Memory Tool 适配(本章)用 Anthropic SDK + memory_20250818 的 agent模型自己吐文件命令04
框架中间件 withSupermemory任意框架 agent中间件自动注入/召回03
MCP 服务器任意 MCP 助手,零代码助手通过 MCP 协议调工具02

8. 代码地图(导航索引)

按符号名 grep 可抗行号漂移。所有引用 as-of sourceCommit 603d0512

主题文件路径符号名
命令 / 返回 / tool_result 类型packages/tools/src/claude-memory.tsMemoryCommandMemoryResponseMemoryToolResult
翻译器主类packages/tools/src/claude-memory.tsClaudeMemoryTool
命令分派 + 安全校验packages/tools/src/claude-memory.tshandleCommandisValidPath
产出 Anthropic tool_resultpackages/tools/src/claude-memory.tshandleCommandForToolResult
路径 → customId 拍扁packages/tools/src/claude-memory.tsnormalizePathToCustomId
view 分流(目录 vs 文件)packages/tools/src/claude-memory.tsview
列目录(重建目录树)packages/tools/src/claude-memory.tslistDirectory
读文件 + 行切片 + -1 哨兵packages/tools/src/claude-memory.tsreadFile
创建 / 替换 / 插入packages/tools/src/claude-memory.tscreatestrReplaceinsert
删除 / 重命名(语义近似)packages/tools/src/claude-memory.tsdeleterename
按路径反查文档packages/tools/src/claude-memory.tsgetFileDocument
工厂 + 配置packages/tools/src/claude-memory.tscreateClaudeMemoryToolClaudeMemoryConfig
容器标签生成packages/tools/src/tools-shared.tsgetContainerTagsCONTAINER_TAG_CONSTANTS
通用配置packages/tools/src/types.tsSupermemoryToolsConfig
最小对接示例packages/tools/src/claude-memory-simple-example.tschatWithMemory
SDK 循环示例packages/tools/test/anthropic-example.tschatWithMemoryTool
手动集成测试(15 用例)packages/tools/test/test-memory-tool.tstestMemoryTool
view_range 回归测试packages/tools/src/claude-memory.test.tsdescribe("ClaudeMemoryTool view_range")