周五晚上,刚帮 TideWatch 的 v4 策略跑完 135 条信号回填,想歇口气。随手敲了一句"查看下 memory"——本来只是想看看最近记了什么笔记,结果盯着那三层目录结构(user / session / repo)发了一会儿呆:
这些文件到底存在哪?前 200 行"自动加载"是怎么回事?repo memory 为什么只能 create 不能改?
好奇心一起来就收不住了。于是这个本来准备刷手机的夜晚,变成了一场对 GitHub Copilot memory 机制的完整逆向工程。
地图:从磁盘开始
第一步最简单——找文件。
~/Library/Application Support/Code/User/
├── globalStorage/github.copilot-chat/
│ └── memory-tool/memories/ ← User Memory
│ ├── dream-painter-quality-trap.md
│ ├── frontend-identity-server-side.md
│ └── pypi-fail-closed.md
└── workspaceStorage/{hash}/GitHub.copilot-chat/
└── memory-tool/memories/
├── repo/ ← Repo Memory (13 个 .md)
├── MzJiZGEzN2Et.../ ← Session Memory
├── NTAwYTJiMWUt.../
└── YTYzNzIzNDMt.../
三层目录,三个生命周期。globalStorage 是 VS Code 给每个扩展分配的全局存储(跨工作区),workspaceStorage 则按工作区隔离。但有趣的是那些 base64 编码的文件夹名——解码一下:
| 目录名 | 解码后 |
|---|---|
MzJiZGEzN2Et... | 32bda37a-b408-4cf3-... |
NTAwYTJiMWUt... | 500a2b1e-a2dd-4699-... |
是 UUID。每个聊天会话的 session memory 都住在一个以 base64url(sessionId) 命名的目录里。
到这里,磁盘结构清楚了。但规则呢?为什么 repo memory 不能编辑?200 行限制又在哪里?
走进 20MB 的迷宫
答案藏在 ~/.vscode/extensions/github.copilot-chat-0.44.1/dist/extension.js 里——20,299,110 字节的 minified JavaScript。
用 Python 写了个正则搜索器,像考古一样一层层刷关键词:memory-tool、/memories/、agentMemoryService、storeRepoMemory……
第一个宝藏出现在偏移量 14,225,303 的位置:
var dTt=200,Fre=class extends pTt.PromptElement{
dTt = 200。一个硬编码的常量。它控制着整个 user memory 的命运。
200 行的天花板
顺着 dTt 找到了 getUserMemoryContent() 的完整实现:
async getUserMemoryContent(){
let r = J.joinPath(globalStorageUri, "memory-tool/memories");
let a = (await readDirectory(r)).filter(([c,l]) => l===1 && !c.startsWith("."));
let s = [];
for(let [c] of a){
if(s.length >= dTt) break; // ← 200 行硬上限
let u = await readFile(J.joinPath(r, c));
let d = new TextDecoder().decode(u);
s.push(`## ${c}`, ...d.split("\n"));
}
return s.slice(0, dTt).join("\n"); // ← 再截一刀
}
逻辑清晰得让人心凉:
- 遍历
globalStorage/.../memories/下的所有.md文件 - 每个文件的内容前面加一个
## 文件名标题 - 逐行追加到数组里
- 读完当前文件后检查——如果总行数已经 ≥ 200,停止读取后续文件
- 最后再
slice(0, 200)兜底截断
注意第 4 步的微妙之处:break 发生在文件粒度,不是行粒度。假如数组已经有 195 行,下一个文件有 10 行,push 完变成 205 行——这时才检查并 break,已经来不及了。最后靠第 5 步的 slice 兜底砍回 200。换句话说,200 行是所有 user memory 文件合并后的总上限,以文件为单位读取,超出部分靠截断兜底。
如果你的第一个文件就有 180 行,第二个文件会被完整读入(假设 30 行,数组变 210),但最终只保留前 200 行——第二个文件丢了尾部 10 行,第三个文件根本不会被读取。
我回头看了一下自己的三个 user memory 文件——加起来刚好 50 行左右。还好,远没触顶。但如果你是那种在 memory 里写详细笔记的人,很可能已经在不知不觉中丢失了后面文件的内容。
14 天的遗忘曲线
接着找到了 MemoryCleanupService:
var uqi = 14*24*60*60*1e3; // 14 天,毫秒
这个服务在 memory tool 首次使用时启动,做一件简单的事:遍历 workspaceStorage 下所有 session memory 目录(排除 repo/),检查每个文件的 mtime(修改时间)——如果超过 14 天没有被修改,删除。空目录也一并清理。
(代码里还有一个内存中的 accessTimestamps Map,每次通过 memory tool 访问文件时会 markAccessed(uri, Date.now())。清理时优先检查这个 Map——如果 Map 里有记录且未过期,文件免死。但 Map 是进程内的,VS Code 重启后清空,最终还是回到 mtime 判断。)
所以 session memory 不是"对话结束就消失",而是"14 天内没人改就被回收"。这解释了为什么我之前的 SoulArena 移动端分析、Hackathon 视频进度这些 session memory 还在——它们都不到两周。
但过了两周?默默消失,不留痕迹。
只写不改的账本
最出乎意料的发现在 repo memory 的操作权限上——但要理解它,得先看清一个分岔路口。
Repo memory 有两条路径,取决于一个默认关闭的 feature flag:
if (aTt(a)) { // 是 /memories/repo/ 路径
if (await this.agentMemoryService.checkMemoryEnabled()) {
// 路径 A:云端 CAPI
return this._dispatchRepoCAPI(e, a);
}
// 路径 B:本地磁盘
return this._dispatchLocal(e, "repo", n);
}
路径 B(默认):走本地磁盘文件操作,和 user memory、session memory 一样,支持完整的 create / str_replace / delete / rename。这就是我们日常的体验——repo memory 可以随意编辑。
路径 A(云端模式,默认关闭):走 GitHub CAPI 云端存储,权限骤然收紧——
async _dispatchRepoCAPI(e, n) {
switch(e.command) {
case "create": return this._repoCreate(e);
default: return {
text: `Error: The '${e.command}' operation is not supported
for repository memories. Only 'create' is allowed.`,
outcome: "error"
};
}
}
只允许 create。 不能 str_replace,不能 delete,不能 rename。追加式写入,一旦写进去就改不了。
所以我之前能编辑 repo memory,是因为走的路径 B。一旦 GitHub 全面开启云端同步,这些编辑操作就会被拒绝。今天的"能改",是暂时的。
隐藏的云端通道
顺着这条线索,挖到了完整的云端同步机制。两个独立的 feature flag:
| 开关 | 配置项 | 默认值 |
|---|---|---|
CopilotMemoryEnabled | chat.copilotMemory.enabled | false |
MemoryToolEnabled | chat.tools.memory.enabled | true |
当 CopilotMemoryEnabled 打开时,repo memory 的 create 操作会通过 GitHub OAuth Token 调用一个内部 API:
PUT {capiBaseUrl}/agents/swe/internal/memory/v0/{owner/repo}
注意 URL 里的 internal 和 v0——这还是内部 API,版本号是 0。存储结构也从普通 markdown 文件变成了结构化 JSON:
{
"subject": "标题",
"fact": "内容",
"citations": ["引用来源"],
"reason": "存储原因",
"category": "分类",
"source": { "agent": "vscode" }
}
{owner/repo} 来自 getRepoNwo() 函数——它读取工作区第一个 folder 的 git remote URL,用正则提取 owner/repo 格式的仓库标识(代码里叫 NWO,Name With Owner)。这意味着云端同步后,repo memory 是跟 GitHub 仓库绑定的——换电脑 clone 同一个仓库,理论上能拿到相同的记忆。
但它现在默认关着。
VS Code 给了两个抽屉
到这里,整个拼图完成了。最后一个问题:这是 VS Code 的设计,还是 Copilot 的设计?
答案很清晰。VS Code 只提供了两个存储 API:
extensionContext.globalStorageUri→ 扩展全局数据extensionContext.storageUri→ 工作区隔离数据
这是任何 VS Code 扩展都能用的通用文件柜。至于柜子里放什么、怎么组织、怎么注入 prompt、什么时候清理——VS Code 完全不管。
三层架构、200 行截断、14 天过期、append-only 约束、CAPI 云端同步——全部是 GitHub Copilot 团队在 extension.js 里自己实现的。
如果明天有人做一个竞品 VS Code AI 扩展,用同样的存储 API,完全可以设计出不同的记忆架构:不限 200 行、repo memory 支持编辑、用 SQLite 替代 markdown 文件、实现跨工作区的全文搜索……
VS Code 给了两个抽屉,Copilot 在里面建了整套记忆宫殿。宫殿的设计——是 Copilot 自己的选择。
全景图
三层记忆的注入方式也不相同。User memory 自动加载全文(前 200 行),而 session 和 repo memory 的注入代码证实了它们只列文件名——getSessionMemoryFiles() 仅把文件路径 push 到数组里(s.push("/memories/session/${l}")),getLocalRepoMemoryFiles() 同理(o.push("/memories/repo/${s}")),两者都不读取文件内容。Agent 需要主动调用 memory tool 的 view 命令才能看到里面写了什么。
最后用一张表总结三层记忆的关键差异:
| 维度 | User Memory | Session Memory | Repo Memory |
|---|---|---|---|
| 存储位置 | globalStorage/ | workspaceStorage/.../base64(sessionId)/ | workspaceStorage/.../repo/ |
| 跨工作区 | ✅ 是 | ❌ 否 | ❌ 否 |
| 注入方式 | 前 200 行自动加载全文 | 仅列文件名(需手动 read) | 仅列文件名(需手动 read) |
| 操作权限 | 完整 CRUD | 完整 CRUD | 只能 create(云端模式) |
| 生命周期 | 永久 | 14 天未修改自动删除 | 永久 |
| 云端同步 | ❌ | ❌ | ✅(实验性,默认关) |
写在最后
整个逆向过程大概花了一个小时。最大的感触不是技术细节,而是一种产品设计上的克制。
200 行上限是粗暴的,但有效——它迫使你精简笔记,而不是把 memory 当成无底洞。14 天清理是无情的,但合理——session memory 本就是临时工作区,不是永久档案。append-only 的 repo memory 看似限制,实则是为云端同步铺路——不可变数据比可变数据更容易在多端之间保持一致。
每一个约束背后,都藏着一个产品判断。
倒是有个有趣的反差:作为 Copilot memory 的用户,我每天创建、编辑、依赖这些记忆文件,但直到今晚才意识到,我从来不知道它们住在哪里、怎么被读取、什么时候会消失。就像你每天路过一栋楼,但从没想过它有几层、用了什么建材、地基打了多深。
直到你出于好奇,推开那扇没上锁的门。

