项目记忆

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

作者 David更新于 13 篇(共 47 篇)

压缩只管一个会话,会话之间要靠“项目记忆”接续。它是一组按项目分目录的 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

怎么用

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

出处:配置项定义与默认值见 apps/zcode-cli/packages/adapters/src/config/schema.ts:37apps/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:9persistent-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:232apps/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:1831packages/ui/src/i18n/locales/zh-CN.ts:1720)。

记忆存在哪

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

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 默认 ~/.zcodeapps/zcode-cli/packages/contracts/src/config/index.ts:302apps/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:164apps/zcode-cli/packages/core/src/memory/directory.ts:14)。系统提示词据此告诉模型“目录已经存在,直接写,不要 mkdir”。
  • 来源会话。Write 或 Edit 写入记忆目录下带 frontmatter 的 .md 文件时,若 metadata 里还没有 originSessionId,自动补上当前会话 ID,并补一个 node_type: memoryapps/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 有 namedescriptionmetadata.type 三项,类型分 user(用户是谁)、feedback(用户给的工作方式反馈,附原因)、project(代码与 git 历史看不出的进行中事项,相对日期改写成绝对日期)、reference(外部资源指针)四种;正文里可以用 [[name]] 互相链接。关于索引与“什么不该记”,原文是(memory.ts:46memory.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),整体结构见系统提示词、上下文与提醒

  • 格式化:去掉开头的 frontmatter 和顶层 HTML 注释,列表、引用、代码里的注释保留(apps/zcode-cli/packages/core/src/memory/index-content.ts:8index-content.ts:49);超过 200 行或 25000 字符就截断,末尾追加一行警告,提醒索引每条一行、200 字符以内,细节挪进各自的文件(index-content.ts:3index-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:7manifest.ts:10manifest.ts:76)。它唯一的调用方是后台抽取,用来在抽取提示词里列出“已有哪些记忆文件”,免得重复建档(project-memory-extraction.ts:128extraction.ts:48)。

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

后台抽取

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

图表加载中…

调度前的检查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:327apps/zcode-cli/packages/bootstrap/src/app/session-facade.ts:266project-memory-extraction.ts:20)。跑之前先判断值不值得跑(extraction.ts:68):

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:6extraction.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:19memory-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):Agentmcp__ 开头的工具与副作用范围为网络的工具一律拒绝,之后是这几条(memory-agent-loop.ts:136):

  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:解析、只读判定与后台任务),外加一种删除:单条 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):

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)。
  • 敏感片段.githooks.huskynode_modules.vscode.zcodeskillscommandsagents 等 19 个(apps/zcode-cli/packages/core/src/memory/memory-file-path.ts:5)。比对前先做规范化:转小写、去掉零宽与双向控制字符、只取冒号之前的部分、去掉末尾的点和空格(memory-file-path.ts:78)。

权限模式与规则的整体流程见权限模式与规则

跨会话:ReadSessionContext

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

工具参数是 sessionIdquery(至多 4000 字符)、strategyrelevanthandoff,默认前者)与可选的 maxTokens(默认 6000,至多 12000)(apps/zcode-cli/packages/contracts/src/tools/read-session-context.ts:4tools/read-session-context.ts:12)。它只读、不需要审批,超时固定 5 分钟(apps/zcode-cli/packages/core/src/tool/handlers/read-session-context.ts:159apps/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:144session-context/read-session-context.ts:376)。
  • 模型提炼:有模型可用时,清洗后的全文不超过 80000 字符就一次提炼;更长的切成每块 28000 字符,取得分最高的 4 块加最后一块逐块提炼,再合成一份(常量与选块见 session-context/read-session-context.ts:16session-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 模式

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

下一篇:工具契约、注册表与可见性——记忆 Agent 能用哪些工具、主 Agent 又能看到哪些,都从工具怎样声明自己说起。

本页目录