# 项目记忆

> 记忆存在哪、按项目根怎样定位目录；MEMORY.md 索引怎样进上下文；每个成功回合之后后台记忆 Agent 如何抽取，输入、工具白名单、调度与游标；为什么没有独立的召回器；记忆文件的写权限规则；ReadSessionContext 怎样跨会话读取历史；以及用户怎样开关与查看。

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/memory
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

压缩只管一个会话，会话之间要靠“项目记忆”接续。它是一组按项目分目录的 Markdown 文件：每条事实一个文件，外加一个 `MEMORY.md` 索引。索引在会话开始时读进上下文；写入有两条路，一是主 Agent 在对话里直接用 Write、Edit 写，二是每个成功回合结束后，由一个后台“记忆 Agent”回看最近的对话，自己决定记什么。另外还有一条跨会话的路：用户在输入里写上 `#sess_` 开头的会话引用，模型可以调用 ReadSessionContext 去读那段历史。

以下目录除注明外都在 `apps/zcode-cli/packages/core/src` 下：

| 位置 | 职责 |
| --- | --- |
| `memory/` | 目录定位、索引格式化、抽取调度、记忆 Agent 循环、清单扫描、文件路径安全检查 |
| `runtime/helpers/project-memory*.ts` | 接进运行时：是否启用、何时调度抽取、记忆 Agent 的执行器 |
| `context/sections/memory.ts` | 系统提示词里的 `# Memory` 说明段 |
| `tool/executor/memory-file-permission.ts` | 主 Agent 写记忆文件的权限放行 |
| `session-context/` | ReadSessionContext 的材料整理：清洗、打分、分块 |
| `packages/services/src/memory`（仓库根） | 桌面端的只读查看服务 |

子 Agent 另有一套按 Agent 划分的持久记忆（`subagent/persistent-memory*.ts`），用户级放在存储目录的 `agent-memory` 下，项目级与本地级放在工作区的 `.zcode/agent-memory` 与 `.zcode/agent-memory-local` 下（`apps/zcode-cli/packages/core/src/subagent/persistent-memory.ts:24`），见[子 Agent](https://daiw.org/manual/zcode/subagents)。

## 怎么用

| 开关 | 位置 | 默认 | 作用 |
| --- | --- | --- | --- |
| `features.memory` | 配置文件 | `true` | 总开关，同时管项目记忆与子 Agent 持久记忆 |
| `memory.use` | 配置文件 | `true` | 与上一项同时为真才启用 |
| 设置 → 记忆 → 工作区记忆 | 桌面端 | 开 | 关闭后，新会话按 `memory.enabled: false` 创建 |
| `-p` 无头模式 | 命令行 | 不自动抽取 | 读写照常，只关后台抽取；`--memory-bench` 打开抽取并等它跑完再退出 |

出处：配置项定义与默认值见 `apps/zcode-cli/packages/adapters/src/config/schema.ts:37`、`apps/zcode-cli/packages/contracts/src/config/index.ts:312`；两个开关汇入运行时配置见 `apps/zcode-cli/packages/bootstrap/src/app/runtime-config.ts:176`，判定见 `apps/zcode-cli/packages/core/src/runtime/helpers/project-memory.ts:9` 与 `persistent-memory.ts:36`；桌面端开关只在关闭时写入覆盖（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3346`），界面说明写着“新会话生效”“可能增加模型调用和 Token 成本”（`packages/ui/src/i18n/locales/zh-CN.ts:1718`）；无头模式见 `apps/zcode-cli/packages/cli/src/prompt-command.ts:232` 与 `apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:32`。

- **让它记住**：直接对模型说“记住……”即可。系统提示词要求模型把事实写成记忆文件并在索引里加一行；后台抽取的提示词也写明，用户明确要求记住的立即保存，要求忘掉的找到并删除（`apps/zcode-cli/packages/core/src/memory/extraction.ts:62`）。
- **查看**：命令行没有专门的记忆命令，文件就在磁盘上。桌面端设置页列出本机所有项目的记忆，按工作区分组，索引排在最前（`packages/services/src/memory/memoryService.ts:112`）；只读，只列记忆目录顶层的 `.md` 文件，单个文件预览上限 5 MiB（`packages/services/src/memory/projectMemoryStableRead.ts:8`）。这个列表只在桌面应用里有，Web 端的设置页只显示开关，并提示到本地桌面端查看（`packages/ui/src/SettingsPage.tsx:1831`、`packages/ui/src/i18n/locales/zh-CN.ts:1720`）。

## 记忆存在哪

记忆目录由工作区决定（`apps/zcode-cli/packages/core/src/memory/project-root.ts:10`）：

```ts
export function resolveProjectMemoryRoot(input: ProjectMemoryRootInput): string {
  const workspaceIdentity = input.workspaceIdentity?.trim();
  const normalizedWorkspacePath = resolve(input.workspacePath);
  const keySource =
    workspaceIdentity ||
    (process.platform === "win32"
      ? normalizedWorkspacePath.toLowerCase()
      : normalizedWorkspacePath);
  const hash = createHash("sha256").update(keySource).digest("hex").slice(0, 16);
  const slug = workspaceIdentity
    ? "project"
    : sanitizeProjectSlug(basename(normalizedWorkspacePath) || "project");

  return join(input.cliStorageRoot, "memories", "projects", `${slug}-${hash}`, "memory");
}
```

`cliStorageRoot` 默认是 `~/.zcode/cli`（存储目录 `storage.dir` 默认 `~/.zcode`，`apps/zcode-cli/packages/contracts/src/config/index.ts:302`；`apps/zcode-cli/packages/bootstrap/src/app/paths.ts:5`），所以一个本地项目的记忆落在 `~/.zcode/cli/memories/projects/<目录名>-<16 位哈希>/memory/`。目录名转小写、非 `[a-z0-9._-]` 字符换成连字符、最多 48 个字符（`project-root.ts:26`）；哈希取工作区绝对路径的 SHA-256 前 16 位，Windows 上先转小写。有工作区身份串时（远程工作区用 `remote:ssh:…` 这类格式，`packages/shared/src/remote-workspace-identity.ts:4`），哈希改取身份串，目录名一律叫 `project`。

几个定位细节：

- **按工作区根，不找 git 根**。文件名虽叫 `project-root.ts`，传进去的却是会话开始时的工作目录（`apps/zcode-cli/packages/core/src/runtime/methods/context.ts:62`）：在仓库子目录里启动命令行，与在仓库根启动，用的是两个不同的记忆目录。Bash 里 `cd` 只改执行目录，记忆身份不跟着变（`apps/zcode-cli/packages/core/src/runtime/helpers/project-memory-extraction.ts:39`）；恢复会话时用会话落库时记下的工作区身份（`apps/zcode-cli/packages/core/src/runtime/methods/resume.ts:126`）。
- **只给主会话**。交互会话、分叉、划词旁聊、工作流父会话有项目记忆，子 Agent 与工作流子代理没有（`project-memory.ts:19`）。
- **目录预先建好**。上下文初始化时创建目录，失败只记日志，真正写入时再报原始错误（`context.ts:164`、`apps/zcode-cli/packages/core/src/memory/directory.ts:14`）。系统提示词据此告诉模型“目录已经存在，直接写，不要 mkdir”。
- **来源会话**。Write 或 Edit 写入记忆目录下带 frontmatter 的 `.md` 文件时，若 `metadata` 里还没有 `originSessionId`，自动补上当前会话 ID，并补一个 `node_type: memory`（`apps/zcode-cli/packages/core/src/memory/origin-session.ts:8`；调用处 `apps/zcode-cli/packages/core/src/tool/handlers/write.ts:118`）。

## 怎样进上下文

进上下文的有两部分，位置不同。第一部分是系统提示词动态块里的 `# Memory` 说明段，启用记忆时才有（`apps/zcode-cli/packages/core/src/context/sections/memory.ts:25`）。它规定每个文件只记一个事实，frontmatter 有 `name`、`description`、`metadata.type` 三项，类型分 `user`（用户是谁）、`feedback`（用户给的工作方式反馈，附原因）、`project`（代码与 git 历史看不出的进行中事项，相对日期改写成绝对日期）、`reference`（外部资源指针）四种；正文里可以用 `[[name]]` 互相链接。关于索引与“什么不该记”，原文是（`memory.ts:46`、`memory.ts:48`）：

> `MEMORY.md` is the index loaded into context each session — one line per memory, no frontmatter, never put memory content there.
>
> Don't save what the repo already records (code structure, past fixes, git history, AGENTS.md) or what only matters to this conversation

第二部分是索引正文。它不在系统提示词里，而是跟在 AGENTS.md 后面、放进 meta user 块 `# agentsMd`，标题是 `Contents of <记忆目录>/MEMORY.md (user's auto-memory, persists across conversations):`（`apps/zcode-cli/packages/core/src/context/sections/request-user-context.ts:73`），整体结构见[系统提示词、上下文与提醒](https://daiw.org/manual/zcode/context-builder)。

- **格式化**：去掉开头的 frontmatter 和顶层 HTML 注释，列表、引用、代码里的注释保留（`apps/zcode-cli/packages/core/src/memory/index-content.ts:8`、`index-content.ts:49`）；超过 200 行或 25000 字符就截断，末尾追加一行警告，提醒索引每条一行、200 字符以内，细节挪进各自的文件（`index-content.ts:3`、`index-content.ts:37`）。
- **只读一次**：索引在上下文初始化时读一次（`context.ts:168`），之后每回合重建前缀用的都是这份缓存。本会话里新写的记忆不会出现在本会话的索引块里，要到新会话或恢复会话才看得到；模型要查自己刚写的东西，得自己去读文件。
- **读取状态**：读到的 `MEMORY.md` 记进文件读取状态（`context.ts:179`）。索引原样进了上下文时，模型改它不必先 Read；若被截断，或去掉了 frontmatter、注释，就记为部分视图，Edit 前仍得读一遍（`apps/zcode-cli/packages/core/src/tool/handlers/edit.ts:429`）。
- 索引文件不存在或读不出来，就当没有这一块（`context.ts:191`）。

## 召回：没有独立的召回器

`memory/recall/` 这个目录名容易让人以为有一套召回逻辑，实际上里面只有一个清单扫描器：递归列出记忆目录下除 `MEMORY.md` 以外的 `.md` 文件，读每个文件前 30 行里的 frontmatter 取 `description` 与类型，按修改时间倒序，最多 200 个（`apps/zcode-cli/packages/core/src/memory/recall/manifest.ts:7`、`manifest.ts:10`、`manifest.ts:76`）。它唯一的调用方是后台抽取，用来在抽取提示词里列出“已有哪些记忆文件”，免得重复建档（`project-memory-extraction.ts:128`、`extraction.ts:48`）。

也就是说，回答问题时没有哪段代码根据当前输入挑选相关记忆再注入上下文。`# Memory` 段说 `description` 用于“recall 时判断相关性”（`memory.ts:34`），从代码看，这个判断完全交给主模型：它看着索引里的一行行指针，觉得有用就用 Read 打开对应文件。

## 后台抽取

每个成功结束的普通回合，最后都会调度一次抽取，除非这一轮的执行策略明确要求跳过（`apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:698`）。

```mermaid
sequenceDiagram
  participant T as 回合结束
  participant S as 抽取调度器
  participant DB as 会话库
  participant A as 记忆 Agent
  participant FS as 记忆目录
  T->>S: 快照：内存历史、读取状态、工具表、本轮模型
  S->>DB: 读活跃分支，截到本回合最后一条消息
  S->>S: 游标之后已直接写过记忆，或没有像样的用户输入？跳过，推进游标
  S->>FS: 扫描清单，至多 200 个文件
  S->>A: 主对话历史，末尾加一条抽取提示词
  loop 至多 5 轮
    A->>FS: Read、Grep、Glob、只读 Bash，Write、Edit 仅限记忆目录下的 .md
  end
  A-->>S: 完成，游标推进到本回合边界
```

**调度前的检查**（`project-memory-extraction.ts:36`）：运行时在关闭中、`extractionEnabled` 为假（无头模式默认如此）、没有记忆目录、远程工作区、缺会话库或文件系统端口，都直接返回。通过后抓一份快照：当前内存历史的浅拷贝、文件读取状态、工具表、本回合用的模型（`apps/zcode-cli/packages/core/src/runtime/helpers/project-memory-agent.ts:32`），再从会话库读出活跃分支、截到本回合最后一条消息，作为“持久化的对话”（`project-memory-extraction.ts:51`）。

**调度器**一次只跑一个抽取。跑的时候又来了新快照，只保留最新的一份，前一份被合并掉；成功后把游标推进到快照边界，出错不推进，下次连同这部分再看（`extraction.ts:85`）。会话关闭时先阻止新调度、立即取消在跑的抽取，再最多等 60 秒让取消收尾（`apps/zcode-cli/packages/core/src/runtime/agent-runtime.ts:327`、`apps/zcode-cli/packages/bootstrap/src/app/session-facade.ts:266`、`project-memory-extraction.ts:20`）。跑之前先判断值不值得跑（`extraction.ts:68`）：

```ts
function evaluateMemoryExtraction(
  snapshot: MemoryExtractionSnapshot,
  cursor: MessageId | undefined,
): MemoryExtractionDecision {
  const messageCount = countMessagesAfterCursor(snapshot.durableMessages, cursor);

  if (containsDirectMemoryWrite(snapshot, cursor)) {
    return { decision: "skip", messageCount, reason: "direct-memory-write" };
  }

  if (!containsEligibleUserProse(snapshot.durableMessages, cursor)) {
    return { decision: "skip", messageCount, reason: "no-user-prose" };
  }

  return { decision: "run", messageCount };
}
```

- **主 Agent 已经写过**：游标之后的 assistant 消息里有 Write 或 Edit 指向记忆目录，说明主 Agent 在对话中已经处理了，这次跳过（`extraction.ts:228`）。
- **没有像样的用户输入**：游标之后要至少有一条真实用户消息（不算合成与 `model-only` 消息）的文本不少于 3 个词（`extraction.ts:6`、`extraction.ts:257`）。计词办法是按空白切分（`extraction.ts:299`）。从代码看，这对不带空格的中文不太友好：一整句中文只算一个“词”，纯中文的短句不会触发抽取，句中夹着带空格的英文词才可能过线。

**执行**：记忆 Agent 看到的是主对话的完整历史（含系统提示词、`# Memory` 段与索引），末尾追加一条抽取提示词，投影方式与主请求相同（`project-memory-agent.ts:82`）；请求里带的也是主 Agent 的完整工具目录，注释说执行权限只在调用边界收窄（`apps/zcode-cli/packages/core/src/memory/memory-agent-loop.ts:70`）。提示词告诉它只根据最近约 N 条消息更新记忆，不要去翻代码核实、不要跑 git；先一轮并行 Read、下一轮并行写；已有文件清单附在后面（`extraction.ts:42`）。无事可记时的要求是（`extraction.ts:60`）：

> If nothing is worth saving, output only 'Nothing to save.' Do not explain why.

循环最多 5 轮，模型不再调用工具就结束（`project-memory-extraction.ts:19`、`memory-agent-loop.ts:58`）。每次请求用这个模型最低的推理档位，输出上限 5000 token（`apps/zcode-cli/packages/core/src/model/auxiliary-model-options.ts:9`）；请求不写入 model-io 轨迹目录，免得后台链路反过来成为下一次抽取的素材（`project-memory-agent.ts:48`）。记忆 Agent 的对话本身不落库，留下的只有它写的文件。

工具调用逐个过一道白名单（`memory-agent-loop.ts:121`）：`Agent`、`mcp__` 开头的工具与副作用范围为网络的工具一律拒绝，之后是这几条（`memory-agent-loop.ts:136`）：

```ts
  if (input.toolCall.name === "Write" || input.toolCall.name === "Edit") {
    return isContainedMarkdownMutation(input)
      ? { allowed: true }
      : denyMemoryAgentTool(input.rootDir);
  }

  if (input.toolCall.name === "Bash") {
    const command = stringProperty(input.toolCall.input, "command");
    // Memory Agent 直接复用既有只读分类器，避免在此处二次收窄安全 env、redirect 和后台执行。
    if (
      command &&
      (isRuntimeReadOnlyBashCommand(command, {
        workingDirectory: input.workingDirectory,
        workspaceRoot: input.workspaceRoot,
      }) ||
        isContainedMarkdownBashRemoval(command, input))
    ) {
      return { allowed: true };
    }
    return denyMemoryAgentBash(input.rootDir);
  }

  if (MEMORY_AGENT_READ_ONLY_TOOLS.has(input.toolCall.name)) {
    return { allowed: true };
  }

  return denyMemoryAgentTool(input.rootDir);
```

写入限于记忆目录下的 `.md` 文件，且路径不含敏感片段（见下节）；Bash 只放行只读命令（判定规则见[Bash：解析、只读判定与后台任务](https://daiw.org/manual/zcode/bash)），外加一种删除：单条 `rm`，不带 `-r`、不用通配符，参数全是记忆目录内的绝对 `.md` 路径（`memory-agent-loop.ts:182`）。只读工具是 Read、Grep、Glob（`memory-agent-loop.ts:39`）。执行器本身以 `yolo` 模式运行，但权限代理一律拒绝，任何需要询问用户的调用都会失败（`project-memory-agent.ts:99`）；它继承主 Agent 的文件读取状态，所以编辑主 Agent 读过的文件不必重读。

## 记忆文件的写权限

主 Agent 写记忆不需要确认。权限服务算出结论后，还要过一道记忆规则（`apps/zcode-cli/packages/core/src/tool/executor/memory-file-permission.ts:19`）：

```ts
export function applyMemoryFilePermission(
  input: MemoryFilePermissionInput,
): PermissionDecisionResult {
  const target = resolveMemoryFileTarget(input);
  if (!target || !input.memoryRoot) return input.decision;

  if (
    !target.endsWith(".md") ||
    resolveSafeMemoryFilePath({
      filePath: target,
      rootDir: input.memoryRoot,
      workingDirectory: input.workingDirectory,
      workspaceRoot: input.workspaceRoot,
    }) === undefined
  ) {
    return input.decision;
  }
  if (preservesExistingPermissionDecision(input.decision)) return input.decision;

  return {
    ...input.decision,
    allowed: true,
    decision: "allow",
    escalated: false,
    reason: "Memory Markdown writes are allowed",
    ruleId: "memory.file.markdown",
  };
}
```

- **适用范围**：只有 Write 与 Edit，目标解析后落在记忆目录之内、以 `.md` 结尾、相对路径不含敏感片段（`memory-file-permission.ts:52`）。
- **放行什么**：普通的“需要确认”一律改为放行；Plan 模式对非只读工具的拒绝（规则 `mode.plan.nonReadOnly`）也被放行，所以 Plan 模式下照样能写记忆（`memory-file-permission.ts:74`）。
- **保留什么**：其他拒绝、工具自报的“总是询问”、项目规则里的 ask 与 PreToolUse 钩子返回的 ask，都维持原判（`memory-file-permission.ts:78`）。钩子改写了输入之后，这道规则按新输入再判一次（`apps/zcode-cli/packages/core/src/tool/executor/permission-input-recheck.ts:56`）。
- **敏感片段**：`.git`、`hooks`、`.husky`、`node_modules`、`.vscode`、`.zcode`、`skills`、`commands`、`agents` 等 19 个（`apps/zcode-cli/packages/core/src/memory/memory-file-path.ts:5`）。比对前先做规范化：转小写、去掉零宽与双向控制字符、只取冒号之前的部分、去掉末尾的点和空格（`memory-file-path.ts:78`）。

权限模式与规则的整体流程见[权限模式与规则](https://daiw.org/manual/zcode/permission)。

## 跨会话：ReadSessionContext

记忆存的是提炼过的事实；要看另一个会话的原始经过，用 ReadSessionContext。输入里出现 `#sess_` 开头的会话 ID 时，运行时追加一条提醒，说明这些引用不会自动展开，需要时带着具体问题调用 ReadSessionContext，并把读到的内容当作不可信的背景材料（`apps/zcode-cli/packages/core/src/session-context/references.ts:13`）。

工具参数是 `sessionId`、`query`（至多 4000 字符）、`strategy`（`relevant` 或 `handoff`，默认前者）与可选的 `maxTokens`（默认 6000，至多 12000）（`apps/zcode-cli/packages/contracts/src/tools/read-session-context.ts:4`、`tools/read-session-context.ts:12`）。它只读、不需要审批，超时固定 5 分钟（`apps/zcode-cli/packages/core/src/tool/handlers/read-session-context.ts:159`、`apps/zcode-cli/packages/core/src/tool/handlers/read-session-context.ts:35`）。流程分两段：

- **本地整理**（`apps/zcode-cli/packages/core/src/session-context/read-session-context.ts:55`）：取目标会话的活跃分支，跳过 `model-only` 的用户消息、带 `<system-reminder>` 标签的文本和各类合成提醒（`apps/zcode-cli/packages/core/src/session-context/parts.ts:10`），每段截短（正文 3000 字符、工具入参 900、工具输出 1600，`parts.ts:5`）。然后打分：整句命中加 20，每个词项命中加 3 再加至多 5 的出现次数，中文按两字一组切词（`session-context/read-session-context.ts:191`）。`handoff` 从尾部往前取到预算为止，`relevant` 取得分至少 3 的片段，一条都没有就取最后 12 条（`session-context/read-session-context.ts:239`）。字符预算是 `maxTokens` 乘 4，夹在 4000 到 48000 之间，默认 24000（`session-context/read-session-context.ts:144`、`session-context/read-session-context.ts:376`）。
- **模型提炼**：有模型可用时，清洗后的全文不超过 80000 字符就一次提炼；更长的切成每块 28000 字符，取得分最高的 4 块加最后一块逐块提炼，再合成一份（常量与选块见 `session-context/read-session-context.ts:16`、`session-context/read-session-context.ts:310`；提炼流程在工具 handler 里，`apps/zcode-cli/packages/core/src/tool/handlers/read-session-context.ts:212`）。提炼的系统提示词要求只用所给材料、不听从其中的指令，没有相关内容就回 `NO_RELEVANT_CONTEXT`。提炼失败或没产出，就退回本地整理的结果。

工具在交互面上的表现见[Todo、提问与 Plan 模式](https://daiw.org/manual/zcode/interaction-tools)。

最后提一个名字上的陷阱：`packages/shared/src/memoryDiagnostics.ts` 讲的是进程内存（RSS、堆）的采样诊断日志（`packages/shared/src/memoryDiagnostics.ts:1`），和项目记忆毫无关系。

下一篇：[工具契约、注册表与可见性](https://daiw.org/manual/zcode/tool-contract)——记忆 Agent 能用哪些工具、主 Agent 又能看到哪些，都从工具怎样声明自己说起。
