Skip to content

MewCode 上下文工程与记忆系统深度分析笔记 ​

分析范围:mewcode/internal/compact/、internal/session/、internal/memory/、internal/instructions/ 交叉引用(为了把调用链讲清楚,额外读取):internal/agent/agent.go、internal/agent/run_to_completion.go、internal/conversation/conversation.go、internal/prompt/{prompt,modules}.go、internal/tui/{resume,commands}.go、cmd/mewcode/main.go 所有结论均来自实际读到的源码;无法从源码确认的地方显式标注「源码未体现」。


模块职责 ​

  • internal/compact/(上下文压缩引擎,包内 8 文件) 唯一入口 ManageContext(ctx, ManageInput),按 TriggerKind 三分支(TriggerAuto / TriggerManual / TriggerEmergency)。layer1.go 做无 LLM 的确定性工具结果落盘(OffloadAndSnip),layer2.go 做 LLM 摘要压缩(AutoCompact / ForceCompact),token.go 做锚点式 token 估算,state.go 存放四类会话态(ContentReplacementState / AutoCompactTrackingState / RecoveryState / SessionContext),recovery.go 生成摘要后的「三段恢复附件」,summary_prompt.go 是摘要 prompt 模板与序列化器,const.go 集中全部阈值。设计上它是纯函数式核心 + 可变账本:layer1/layer2 都不修改入参切片,只返回新切片,由调用方 in.Conv.ReplaceMessages(newMsgs) 提交。
  • internal/session/(JSONL 持久化)writer.go 追加写 conversation.jsonl(每条 Sync(),即逐条 fsync),load.go 从最后一个 compact 标记之后恢复历史并截断孤立工具调用,list.go 扫描会话目录列表(标题取首条 user 消息、截断 50 rune),cleanup.go 按 session ID 内嵌时间戳删除过期目录。
  • internal/memory/(长期笔记)manager.go 编排两级 Store(project + user)、合并索引、异步触发 LLM 更新(UpdateAsync);store.go 管理笔记文件与 MEMORY.md 索引的 CRUD;types.go 定义 4 类笔记与 UpdateAction;prompt.go 是「记忆管理助手」prompt 模板。它是对话结束后的旁路消费者,不参与主请求路径。
  • internal/instructions/(项目指令)loader.go 单文件模块,三层 MEWCODE.md 加载 + 行级 @include 展开,带深度限制、环路检测、路径逃逸检测、二进制检测。加载结果作为 system prompt 的 自定义指令 模块(Priority 80)。

Token 统计与阈值 ​

估算方式:锚点 + 增量字符折算 ​

token.go 只有两个函数,核心是「真实 usage 锚点 + 之后新增消息按字符数折算」的混合估算:

go
// UsageAnchor 将 Stream 尾事件中的 usage 合并成单一锚点值。
func UsageAnchor(u *llm.Usage) int64 {
	if u == nil {
		return 0
	}
	return u.InputTokens + u.OutputTokens + u.CacheWrite + u.CacheRead
}

func EstimateTokens(anchor int64, allMsgs []llm.Message, anchorMsgLen int) int64 {
	if anchorMsgLen < 0 {
		anchorMsgLen = 0
	}
	var tail []llm.Message
	if anchorMsgLen < len(allMsgs) {
		tail = allMsgs[anchorMsgLen:]
	}
	chars := messageChars(tail)
	return anchor + int64(math.Ceil(float64(chars)/estimateCharsPerToken))
}

messageChars 的统计口径:len(m.Content) + Σlen(ToolCalls[i].Input) + Σlen(ToolResults[i].Content)。

关键性质与偏差(源码事实 + 推导):

  1. estimateCharsPerToken = 3.5,即 3.5 字符 ≈ 1 token。
  2. Go 的 len(string) 是字节数不是 rune 数,所以对中文/emoji 是「按字节算」。一个汉字 3 字节 → 3/3.5 ≈ 0.857 token/汉字,而 Claude 分词器对中文通常接近 1 token/字甚至更高,中文场景系统性低估;英文 3.5 字节 ≈ 3.5 字符/token,与主流分词器(~4 字符/token)接近甚至略保守。这是一个真实的方向性偏差:中文长会话更容易撞上 prompt_too_long(由 TriggerEmergency 兜底)。
  3. 锚点是「累积和」而非「上轮 input」:UsageAnchor 把 InputTokens + OutputTokens + CacheWrite + CacheRead 相加。缓存命中时 CacheRead 与 InputTokens 的语义在不同 provider 实现下可能重叠(Anthropic 的 input_tokens 通常不含 cache 部分,OpenAI 的 prompt_tokens 含 cached_tokens),源码未体现统一的归一化处理,因此这个和可能高估或低估。这是本项目 token 统计中最容易被面试官追问的点。
  4. 锚点只在「主对话路径」成功后更新:agent.go 中 streamOnce 返回非 nil usage 时执行 a.runtime.UpdateAnchor(compact.UsageAnchor(usage), conv.Len());摘要请求内部的 usage 被刻意丢弃(layer2.go 注释:「Usage 在摘要请求内部捕获但不回写 SessionRuntime」)。RunForceCompact 路径也复用同一 runtime 锚点。
  5. 紧急压缩后锚点重置:a.runtime.ResetAnchor() 后重新用 EstimateTokens(0, conv.Messages(), 0) 全量按字符估算,即「摘要后的消息全部当新内容算」,这是保守方向。
  6. 摘要产出的新历史不继承锚点:AutoCompact/ForceCompact 里 afterTok := EstimateTokens(0, newMsgs, 0)。

全部阈值常量(const.go 逐条) ​

常量值含义
singleResultLimit50000 字节单条 tool_result 超过即落盘
messageAggregateLimit200000 字节单条 RoleTool 消息内未决策结果聚合成交量超此值即落盘
SummaryReserve20000 token给摘要 LLM 输出预留的空间
AutoSafetyMargin13000 token自动触发的安全余量
ManualSafetyMargin3000 token手动/紧急路径只检查「摘要请求本身能否塞下」
recoveryFileLimit5恢复附件最多带 5 个文件快照
recoveryTokensPerFile5000 token单文件快照上限,折算 5000 * 3.5 = 17500 字节截断
recentKeepTokens10000 token近期原文保留的 token 下界之一
recentKeepMessages5近期原文保留的条数下界之一
maxConsecutiveAutoCompactFailures3自动摘要熔断阈值
ptlRetryLimit3摘要请求自身 PTL 的前 3 次「每次丢最旧 1 组」
ptlDropPercentage0.2第 4 次起按 20% 比例丢组
estimateCharsPerToken3.5字符→token 折率
previewHeadBytes2048预览头部字节上限
previewHeadLines20预览头部行数上限

其他模块常量:memory.maxIndexSize = 25 * 1024(25KB,索引注入上限);instructions.maxIncludeDepth = 5;cmd/mewcode/main.go 会话清理 30*24*time.Hour;tui/resume.go 恢复时 est > cw-8000 触发强制压缩、elapsed > 6*time.Hour 追加时间跨度提醒。

触发压缩的条件(三条路径,全部实读) ​

ManageContext 首行有一个降级保护:任一必需状态对象为 nil 就直接跳过压缩并返回原估值(「若任一必需状态对象为 nil,降级为跳过压缩」),保证不因状态缺失而崩溃。

  1. 自动(manageAuto),顺序严格:
    • a. 先跑 layer1:layer1Out, _ := OffloadAndSnip(in.Conv.Messages(), in.Replacement, in.Session),然后 in.Conv.ReplaceMessages(layer1Out)(注释明确「layer1 必须先做,因为 layer1 节省的 token 需要反映在阈值判断里」)。
    • b. 用 layer1 之后的消息重算:estTokens := EstimateTokens(in.UsageAnchor, layer1Out, in.AnchorMsgLen)。
    • c. sanity check:if in.ContextWindow <= SummaryReserve+AutoSafetyMargin(即 ≤ 33000)直接跳过自动 layer2 并打日志。
    • d. 阈值与熔断:threshold := in.ContextWindow - SummaryReserve - AutoSafetyMargin;if estTokens < int64(threshold) || in.AutoTracking.Tripped() → 只保留 layer1 效果。
    • e. 否则 AutoCompact。以 200k 窗口为例,实际触发线是 200000 − 20000 − 13000 = 167000 token。
  2. 手动 /compact:TriggerManual → ForceCompact,注释「跳过 layer1、阈值、熔断」。TUI 侧入口是 internal/tui/commands.go:104 的 m.ag.RunForceCompact(...),Agent 侧加 runMu 互斥,保证与 Run 不并发。
  3. 紧急(TriggerEmergency):由 streamOnce 返回 llm.ErrPromptTooLong 触发(errors.Is(sErr, llm.ErrPromptTooLong) && !emergencyRetried)。先强制 OffloadAndSnip 把大工具结果挪走,再无条件 ForceCompact;压缩后 ResetAnchor 重估,若 est2 >= int64(cw - ManualSafetyMargin)(余量 3000)则判定压缩无效,抛出原始 ErrPromptTooLong 并结束本轮,不再重试(emergencyRetried 单次闸门)。

工具结果溢出与落盘 ​

落盘路径与幂等写入 ​

SessionContext 在进程启动时由 NewSessionContext(workspace) 生成:

go
type SessionContext struct {
	SessionID  string // 格式:YYYYMMDD-HHMMSS-xxxx
	SessionDir string // <workspace>/.mewcode/sessions/<SessionID>
	SpillDir   string // SessionDir + "/tool-results"
}

落盘文件即 SpillDir/<tool_use_id>,写入是幂等的(已存在则直接返回):

go
func spillSingle(session *SessionContext, toolUseID, content string) error {
	path := filepath.Join(session.SpillDir, toolUseID)
	if _, err := os.Stat(path); err == nil {
		return nil
	}
	return os.WriteFile(path, []byte(content), 0o644)
}

注意:文件名直接来自 tool_use_id,源码未体现任何字符清洗/filepath.Base 归一化。正常 provider 生成的 id 是 toolu_xxx 这类安全串,但这是「信任上游」的隐含假设。

替换体(预览)构造 ​

go
func headPreview(content string) string {
	lines := strings.Split(content, "\n")
	if len(lines) > previewHeadLines {   // 20 行
		lines = lines[:previewHeadLines]
	}
	head := strings.Join(lines, "\n")
	if len(head) > previewHeadBytes {    // 2048 字节
		head = head[:previewHeadBytes]
	}
	return head
}

func buildPreview(originalBytes int, head, spillPath string) string {
	var b strings.Builder
	fmt.Fprintf(&b, "[content offloaded] original size: %d bytes\n", originalBytes)
	fmt.Fprintf(&b, "[saved to] %s\n", spillPath)
	b.WriteString("[head preview]\n")
	b.WriteString(head)
	b.WriteString("\n\n完整内容已保存到上述路径,如需查看请用文件读取工具读取该路径,不要凭头部预览猜测全文")
	return b.String()
}

这段文案是对抗幻觉的关键工程细节:既给出「原大小」让模型知道被截断的比例,又用命令式语句禁止它凭预览猜全文,并显式给出「用文件读取工具」这一恢复动作,把「上下文丢失」转化成一个可执行的工具调用。

替换决策:字节倒序 + 聚合预算 + 一次性账本 ​

OffloadAndSnip 的算法(源码逐段):

  1. 先做浅层深拷贝:out[i] = msgs[i],并对 ToolCalls/ToolResults 各自 copy 一份切片,保证不改入参(与 Conversation.ReplaceMessages 的深拷贝策略一致)。
  2. 只处理 msg.Role != llm.RoleTool || len(msg.ToolResults) == 0 之外的消息,即只对 RoleTool 消息下手。
  3. 已决策项走账本短路:if state.IsSeen(tr.ToolCallID) { tr.Content = state.DecideOnce(tr.ToolCallID, tr.Content, func() (string,string){ return "kept", "" }) },把历史决策稳定重放到每一轮的新切片上。
  4. 未决策项收集后按字节倒序排序:sort.Slice(undecided, func(i, j int) bool { return undecided[i].size > undecided[j].size })。
  5. 聚合预算从「未决策项字节总和」开始,按倒序逐项判断:
go
for _, it := range undecided {
	needSpill := it.size > singleResultLimit || remaining > messageAggregateLimit
	...
	if needSpill {
		state.DecideOnce(it.id, it.content, func() (string, string) {
			if err := spillSingle(session, it.id, it.content); err != nil {
				return "skip", ""
			}
			spillPath := filepath.Join(session.SpillDir, it.id)
			preview := buildPreview(it.size, headPreview(it.content), spillPath)
			return "replaced", preview
		})
		tr.Content = state.DecideOnce(it.id, it.content, func() (string, string) {
			return "kept", ""
		})
		remaining -= it.size
	} else {
		state.DecideOnce(it.id, it.content, func() (string, string) {
			return "kept", ""
		})
	}
}

要点解读:

  • 倒序 + 递减预算等价于「优先挪走最大的那几个,直到剩余聚合量 ≤ 200000」。这是一个贪心的 knapsack 近似,好处是单调:一旦某轮决定 spill,后续轮次 remaining 只减不增,决策在账本里被固定,不会来回抖动(这正是 DecideOnce 存在的原因)。
  • DecideOnce 的双次调用模式:第一次调用执行 spill + 写账本,第二次调用借助「已 Seen 短路」把存量的 preview 取出来赋给 tr.Content。行为正确但可读性差,等价写法是直接用第一次的返回值。注释也承认了这一点(「因为已经写入账本,再次调用 DecideOnce 会短路返回 preview」)。
  • 失败降级:spillSingle 出错时决策为 "skip" → 不写 seenIds,本轮保留原文,下一轮可重试(DecideOnce 的文档注释:「skip: 不写账本,返回原 content(下一轮可重试)」)。
  • kept 分支不写 remaining -= it.size,因为没挪走,聚合量保持不变——语义正确。
  • 函数签名返回 error 但实现里恒 nil:所有调用点都写成 layer1Out, _ := OffloadAndSnip(...),即错误通道目前是死代码。
  • 只处理 ToolResults,不处理 assistant 的 ToolCalls[i].Input:一个「写文件 5MB」的工具入参不会被落盘。这是与实时大结果不同的溢出面,源码未做处理。

两层摘要压缩 ​

layer1:确定性压缩(无 LLM) ​

职责就是上一节的落盘 + 预览替换。它解决的是「廉价且必然浪费的 token」——工具原始输出(grep 全仓结果、大文件 cat),并且不丢信息(内容在磁盘上,路径写在上下文里)。它是每轮都跑的预防性动作,而非阈值触发的响应式动作。

layer2:LLM 摘要压缩 ​

摘要 prompt 模板(summary_prompt.go,两阶段 + 9 段结构):

You are summarizing a coding agent conversation. Output in two phases.

## Phase 1: Analysis (will be discarded)
Write your analysis draft inside <analysis> tags. Think through:
- What was the user trying to accomplish?
- What key decisions were made?
- What files were modified or read?
- What errors were encountered and how were they fixed?
- What is the current state of work?

## Phase 2: Formal Summary (will be kept)
Write the formal summary inside <summary> tags. Follow these 9 sections exactly:

## 1 主要请求和意图
## 2 关键技术概念
## 3 文件和代码段
## 4 错误和修复
## 5 问题解决过程
## 6 所有用户消息原文
## 7 待办任务
## 8 当前工作(最详细)
## 9 可能的下一步

IMPORTANT:
- Do NOT call any tools. Output plain text only.
- In section 6, preserve every user message in its original language, in chronological order.
- Section 8 should be the most detailed — describe exactly what is being worked on right now and at which step the work stopped.
- Write in the same language as the user's messages.

三个设计点值得单独记:① <analysis> 思维草稿区被显式标注为「将被丢弃」,让模型先推理再总结,但 ExtractSummary 只取 <summary> 内容,草稿不进上下文(只消耗 summary 请求的 output token,由 SummaryReserve=20000 预留);② 第 6 段要求逐字保留所有 user 消息原文——这是对「用户意图是最不可再生信息」的直觉建模,也是对中文/多语言措辞的保护;③ 第 8 段强制「最详细」并显式要求说明「停在哪一步」,这是为「恢复后继续干活」优化,而不是为了「回顾」。

序列化与提取:

go
// serializeConversation 把对话扁平化为可读文本。
// - user/assistant 消息: role: <content>
// - assistant 工具调用: [call <name> id=<id>]
// - tool 消息: [result id=<id> isError=<bool>] <content>
go
func ExtractSummary(raw string) string {
	start := strings.Index(raw, "<summary>")
	if start == -1 {
		return raw
	}
	start += len("<summary>")

	end := strings.Index(raw[start:], "</summary>")
	if end == -1 {
		return raw
	}

	return strings.TrimSpace(raw[start : start+end])
}

ExtractSummary 找不到标签就整段降级使用(return raw),这是「宁可塞进草稿也不要丢摘要」的容错取向。

摘要请求本身:不传工具(Tools: nil,注释「摘要不传工具」),只消费 ev.Text;usage 被丢弃。若首轮摘要请求就 PTL,进入 ptlRetry:

go
// 前 3 次:每次丢最旧的 1 组
// 之后:按 20% 比例丢(至少 1 组)
// 直到成功或全部丢光
func ptlRetry(ctx context.Context, in ManageInput, msgs []llm.Message) (string, error) {
	groups := groupByUserTurn(msgs)
	for retry := 0; retry < ptlRetryLimit && len(groups) > 1; retry++ { ... groups = groups[1:] ... }
	for len(groups) > 1 {
		drop := int(math.Ceil(float64(len(groups)) * ptlDropPercentage))
		...
		groups = groups[drop:]
	}
	return "", context.DeadlineExceeded // sentinel: 全部丢光
}

分组单位是 groupByUserTurn(「用户消息 → 后续 assistant/tool 往返」为 1 组),保证丢的是完整回合而不是半截工具对。全部丢光后用 context.DeadlineExceeded 作 sentinel 错误返回。

压缩后如何重建历史 ​

runSummary 是完整流程,含一个快照一致性设计:

go
func runSummary(ctx context.Context, in ManageInput) ([]llm.Message, error) {
	oldMsgs := in.Conv.Messages()

	// 入口拍快照,整个 runSummary 生命周期只用这一份
	recoverySnapshot := in.Recovery.Snapshot()

	summaryText, err := summarizeOnce(ctx, in, oldMsgs)
	if err != nil {
		summaryText, err = ptlRetry(ctx, in, oldMsgs)
		if err != nil {
			return nil, err
		}
	}

	recoveryText := BuildRecoveryAttachment(recoverySnapshot, in.ToolDefs)

	combinedContent := "## 历史会话摘要\n" + summaryText + "\n\n" + recoveryText
	summaryAndRecovery := llm.Message{Role: llm.RoleUser, Content: combinedContent}

	recentTail := pickRecentTail(oldMsgs)
	return joinAfterSummary(summaryAndRecovery, recentTail), nil
}

新历史 = 1 条 user 消息(摘要 + 恢复附件) + 近期原文尾部:

  • pickRecentTail 从尾部累加 len(Content)+Σlen(ToolCalls.Input)+Σlen(ToolResults.Content),直到 estTokens >= recentKeepTokens(10000) 且 count >= recentKeepMessages(5) 才停(注释称「择宽」语义,即两个下界都满足);然后做配对修正:若切点落在 RoleTool 上,向前找带 ToolCalls 的 assistant,避免出现「有 tool_result 没有 tool_use」的非法序列。
  • joinAfterSummary 处理角色衔接:防御性地丢掉开头的 tool 消息;若近期原文以 user 开头,插入一条 assistant 占位 (已加载上下文摘要与恢复信息。请继续。),避免出现连续两条 user(多数 provider 对此报错或语义降级)。

恢复附件(recovery.go)——摘要之外的第二重保险 ​

go
func BuildRecoveryAttachment(snapshot []FileReadRecord, toolDefs []llm.ToolDefinition) string {
	// 第一段:最近读过的文件快照  ## 最近读过的文件
	// 第二段:当前可用工具列表    ## 当前可用工具
	// 第三段:边界提示            boundaryNotice
}
  • 第一段取 recoveryFileLimit=5 条,RecoveryState.Snapshot() 已按 Timestamp.After 倒序;每条渲染 ### <path> + [read at] RFC3339 + 内容(超过 5000*3.5=17500 字节截断并标记 (content truncated))。
  • 数据来源是 agent.recordFileReads:只在 calls[i].Name == "read_file"、且对应结果非 IsError 时,把不带行号前缀的纯净内容写入 RecoveryState(键为绝对路径,filepath.Abs 归一化)。
  • 第二段用 json.Marshal(d.InputSchema) 渲染工具名 + 描述 + schema。BuildRecoveryAttachment 的注释强调「toolDefs 必须与下一次 Stream 请求的 Request.Tools 来自同一引用」——因为 plan mode 下工具集会被裁剪成 ReadOnlyDefinitions(),附件必须反映真实的当下工具集。
  • 第三段 boundaryNotice 是防幻觉护栏:
go
const boundaryNotice = `## 重要提示

请注意:以上摘要仅用于提供上下文脉络。如果你需要获取文件的完整内容、确切的错误信息、或用户的原始措辞,请使用文件读取工具重新读取对应路径。不要依据摘要中的描述做代码推断或猜测。`

三层防幻觉是一致的设计语言:layer1 预览(「不要凭头部预览猜测全文」)+ 摘要(需要原文就重读)+ 边界提示(同义重复一次)。摘要会丢信息,本项目用「重读工具 + 明确指令」而不是「追求摘要无损」来解决。

compact 标记如何写入 JSONL,恢复时如何加载 ​

写入侧靠 Conversation 的回调,main.go 用 conversation.NewWithHooks(writer.OnAppend(modelName), writer.OnReplace()) 绑定:

go
func (w *Writer) OnReplace() func([]llm.Message) {
	return func(msgs []llm.Message) {
		_ = w.WriteCompactMarker()
		_ = w.AppendAll(msgs)
	}
}

而 Conversation.ReplaceMessages 无条件触发 onReplace:

go
	c.messages = newSlice
	c.mu.Unlock()

	if c.onReplace != nil {
		c.onReplace(msgs)
	}

⇒ 重要推论(读源码得出的真实行为):manageAuto 每轮都调用 in.Conv.ReplaceMessages(layer1Out),即使 layer1 什么也没改,也会写下一条 {"type":"compact","ts":...} 标记 + 整份历史快照。所以 JSONL 实际是**「带检查点快照的追加日志」**,文件体积随「轮数 × 历史长度」增长(AppendAll 每条消息还各自 file.Sync(),即 N 次 fsync)。这是本项目最值得优化的性能/体积问题,收益点在 ReplaceMessages 前做「内容是否真的变了」的比较(例如比较 len 或替换账本是否新增 seenIds)。


压缩失败与熔断(recovery.go / state.go) ​

注意:recovery.go 负责的是「摘要后的恢复附件」(Recovery Attachment,与 RecoveryState/FileReadRecord 配对),不是失败恢复;失败处理与熔断全在 state.go 的 AutoCompactTrackingState 与 layer2.go 的计数点。这是命名上容易被误解的地方。

状态机与状态对象(state.go) ​

state.go 提供四个会话级状态对象,全部带 sync.Mutex:

  1. SessionContext(SessionID / SessionDir / SpillDir):进程启动一次生成;ID 格式 YYYYMMDD-HHMMSS-xxxx,随机部分优先 crypto/rand.Read 取 2 字节转 4 位十六进制,失败时降级 rand.Int(rand.Reader, 1<<16)。ParseSessionTime 只解析前 15 位 YYYYMMDD-HHMMSS(time.ParseInLocation(..., time.Local)),旧格式返回 error(因此被 ListSessions/CleanExpired 跳过)。
  2. ContentReplacementState:seenIds map[string]struct{} + replacements map[string]string,语义是单调不可翻转账本——「同一 id 一旦进入 seenIds 就不可翻转」,决策三态 kept / replaced / skip。
  3. AutoCompactTrackingState:唯一字段 ConsecutiveFailures int,三方法 RecordSuccess()(清零)/ RecordFailure()(自增)/ Tripped()(>= 3)。
  4. RecoveryState:files map[string]FileReadRecord,写侧 RecordFile、读侧 Snapshot()(按时间倒序副本),与 NewSessionContext 的 SpillDir 无耦合。

严格地说,这不是一个显式 enum 状态机,而是**「计数器 + 布尔闸门」的隐式状态机**:正常态(failures ∈ [0,2])→ 熔断态(failures ≥ 3);RecordSuccess 是唯一的复位边。

连续失败如何处理 ​

计数发生在 AutoCompact(不是 ForceCompact):

go
// AutoCompact 自动摘要:成功后清零失败计数;整轮失败累加失败计数。
func AutoCompact(ctx context.Context, in ManageInput) ([]llm.Message, int64, int64, error) {
	beforeTok := in.EstimatedToken

	newMsgs, err := runSummary(ctx, in)
	if err != nil {
		in.AutoTracking.RecordFailure()
		return nil, beforeTok, 0, err
	}

	in.AutoTracking.RecordSuccess()
	afterTok := EstimateTokens(0, newMsgs, 0)
	return newMsgs, beforeTok, afterTok, nil
}

// ForceCompact 手动/紧急摘要:不走熔断器,失败不计入熔断计数。
func ForceCompact(ctx context.Context, in ManageInput) (...) {
	...
}

关键设计:熔断只作用于自动路径。手动 /compact 与紧急压缩(ErrPromptTooLong)永远尝试,因为「用户显式要求」和「请求已经发不出去」这两种情境下,放弃压缩等于放弃会话。

如何避免无限压缩 ​

四道闸门叠加:

  1. 熔断闸门:manageAuto 第 d 步 if estTokens < int64(threshold) || in.AutoTracking.Tripped() { out.AfterTokens = estTokens; return out, nil } —— 熔断后自动路径退化为「只做 layer1」,不再尝试 LLM 摘要,也就不可能每轮发起一次注定失败的摘要请求。
  2. 小窗口闸门:ContextWindow <= SummaryReserve+AutoSafetyMargin(≤33000)直接不做自动 layer2,避免「为摘要预留的空间比窗口还大」导致死循环。
  3. 摘要请求自限:ptlRetry 丢组直到 len(groups) > 1 不成立,最后返回 sentinel error,保证摘要请求的 PTL 不会无限重试;丢组的下界是「至少保留 1 组」。
  4. 紧急路径单次闸门:emergencyRetried 布尔量保证每个 iteration 最多一次紧急压缩重试;重试前用 cw - ManualSafetyMargin(3000) 做有效性判定,判定失败就抛出原始错误结束,绝不反复重试。

需要指出的真实缺口:ConsecutiveFailures 只存在于内存(SessionRuntime.AutoTracking),不持久化,进程重启即清零;且熔断后没有向用户提示(manageAuto 只是静默 return),TUI 只有在 willSummarize 为真时才发 CompactEvent,因此「压缩一直被熔断」这件事对用户是隐形的——源码未体现相关通知。


会话持久化(JSONL) ​

写入格式 ​

go
type Entry struct {
	Type        string           `json:"type,omitempty"` // "compact" 或空
	Role        string           `json:"role,omitempty"` // "user" / "assistant" / "tool"
	Content     string           `json:"content,omitempty"`
	ToolCalls   []llm.ToolCall   `json:"tool_calls,omitempty"`
	ToolResults []llm.ToolResult `json:"tool_results,omitempty"`
	Timestamp   int64            `json:"ts"`
	Model       string           `json:"model,omitempty"` // 仅首条消息
}
  • 一行一个 JSON 对象(json.Encoder.Encode 自带换行,且 \n 会被转义,因此每条消息严格占一行,这是坏行跳过策略成立的前提)。
  • ts 为秒级 time.Now().Unix()。
  • model 只在首条消息上写(Append(msg, model, isFirst),OnAppend 用一个闭包变量 isFirst 跟踪),list.go 的 readFirstUser 就靠它回填会话列表的 model 标签。
  • WriteCompactMarker 用匿名 struct 只写 {"type":"compact","ts":...} 两个字段。
  • 打开模式:新建 O_CREATE|O_APPEND|O_WRONLY,恢复 OpenWriter 用 O_APPEND|O_WRONLY(不带 O_CREATE,即恢复要求文件已存在);权限 0o644,目录 0o755。

fsync 策略 ​

Append 与 WriteCompactMarker 结尾都是 return w.file.Sync()——逐条 fsync。这是「崩溃/断电后 JSONL 仍可完整解析」的强保证,代价是每条消息一次磁盘同步;配合上一节的「每轮 AppendAll 全量快照」,写放大非常明显。所有写入都在 w.mu.Lock() 保护下串行(sync.Mutex),且 Append 的锁只覆盖 encode+sync,不含上层决策。

坏行处理 ​

go
	dec := json.NewDecoder(f)
	for dec.More() {
		var entry Entry
		if err := dec.Decode(&entry); err != nil {
			// 跳过坏行,继续
			continue
		}
		if entry.Type == "compact" {
			entries = nil // 清空,从 compact 之后重新开始
			continue
		}
		entries = append(entries, entry)
	}

是流式 json.Decoder + dec.More(),不是 bufio.Scanner 按行切分。这带来一个真实边界:单条消息损坏(例如被截断)后,Decoder 的错误恢复点是「下一个能否解析出值的字节」,可能吞掉紧随其后的那条记录,即 continue 只是「尽力而为」。要严格按行容错需要改成 Scanner 逐行 json.Unmarshal。LoadSessionRaw 用同样的循环,但不做 compact 过滤(供测试)。

compact 标记与恢复语义 ​

LoadSession 的注释就是契约:「从最后一个 compact 标记之后加载,跳过坏行,截断孤立工具调用」。因为 OnReplace 先写标记再写整份历史,所以「标记之后的第一条」永远是摘要重写后的完整历史(摘要 user 消息 + 近期原文)——恢复时天然拿到压缩后的视角,且不需要重放/重算压缩决策(ContentReplacementState 不持久化,恢复后是空账本,IsSeen 全 false,下一轮 layer1 会按字节规则重新决策;好在 spill 文件仍在磁盘上,spillSingle 幂等,不会重复写入,会产生相同的预览路径,因此结果可复现)。

TruncateOrphanedToolCalls 只处理最末尾一种情况:

go
	last := msgs[len(msgs)-1]
	if last.Role == llm.RoleAssistant && len(last.ToolCalls) > 0 {
		// 最后一条是带工具调用的 assistant,没有后续 tool 结果 → 截断
		return msgs[:len(msgs)-1]
	}

即「崩溃在工具执行中」的场景:assistant 已落盘、tool 结果未落盘。只截断最后一条,历史中间出现孤立调用(理论上不该发生,因为 ts 顺序写入)源码未处理。

token 超限自动压缩(恢复路径) ​

tui/resume.go 的 doResumeSession:加载后马上 est := estimateTokens(msgs),若 est > int64(cw-8000) 则在临时 Conversation(conversation.NewFromMessages(msgs, nil, nil),不挂 writer)上跑 RunForceCompact,成功后把 tempConv.Messages() 当作恢复内容——避免「刚恢复就立刻撞 PTL」。这里的 cw-8000 是恢复路径专用的硬编码余量,与 const.go 里的常量不共享,属于重复定义。

时间跨度提醒 ​

go
		// 检查时间跨度(超过 6 小时追加提醒)
		if elapsed := time.Since(info.ModifiedAt); elapsed > 6*time.Hour {
			reminder := fmt.Sprintf("[系统提示] 本会话已暂停 %s。部分上下文可能已过时,如需最新信息请重新读取相关文件。", formatDuration(elapsed))
			msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: reminder})
		}

ModifiedAt 来自 SessionInfo(JSONL 文件 ModTime),阈值 6*time.Hour 硬编码在 TUI 层。提醒以一条 user 消息注入历史尾部,与摘要提示语同构(都是「告知模型信息可能过期 + 给出补救动作」)。顺序细节:这条提醒在 RunForceCompact 之后追加,所以它会被写入恢复后的新 JSONL(NewFromMessages 之后由 writer 追加)。

info.ID 解析出的 ParseSessionTime 与 ModifiedAt(文件 mtime)是两个不同时间源:清理用 dim 名里的时间,提醒用 mtime——源码未体现二者不一致时的取舍逻辑。

30 天清理 ​

调用点在 cmd/mewcode/main.go,后台 goroutine、启动即跑、不阻塞 TUI:

go
	sessionsDir := filepath.Join(root, ".mewcode", "sessions")
	go func() {
		if err := session.CleanExpired(sessionsDir, 30*24*time.Hour); err != nil {
			fmt.Fprintf(os.Stderr, "[session] 过期会话清理失败: %v\n", err)
		}
	}()

清理实现:只认新格式 ID(compact.ParseSessionTime 失败即 continue),now.Sub(t) > maxAge 就 os.RemoveAll(dirPath),失败只打 stderr 不返回错误(函数最终 return nil)。三个已知局限:① 旧格式目录永远不被清理(无主垃圾);② 只有时间维度,没有「保留最近 N 个会话」或「总体积上限」策略;③ 没有删除确认/回收站/软删除,RemoveAll 直删。另外清理只覆盖 <workspace>/.mewcode/sessions,用户级目录(~/.mewcode/sessions,若存在)源码未体现清理逻辑。

会话列表 ​

ListSessions 只返回「目录名为新格式 ID」且「存在 conversation.jsonl」的会话,按 fi.ModTime() 倒序;readFirstUser 顺序扫描 JSONL 找第一条 role=user(title 按 rune 截断到 50 + ...,空则 (空会话))并顺带取 model,两者都拿到就 break 提前退出。Description() 渲染为 相对时间 · model · 体积(just now / N min ago / N hours ago / N days ago,体积 B/KB/MB)。


自动笔记系统(memory) ​

四类笔记与两级作用域(types.go) ​

go
const (
	TypeUserPreference     NoteType = "user_preference"
	TypeCorrectionFeedback NoteType = "correction_feedback"
	TypeProjectKnowledge   NoteType = "project_knowledge"
	TypeReferenceMaterial  NoteType = "reference_material"
)

四类是按「信息的可复用范围与来源」切分,而不是按主题切分:user_preference(跨项目的个人偏好)、correction_feedback(用户的负反馈,本质是「未来的行为约束」)、project_knowledge(本仓库技术栈/架构约定)、reference_material(外部链接/文档)。两级作用域由 Manager 的两个 Store 承载:project = <workspace>/.mewcode/memory/,user = ~/.mewcode/memory/。

文件名规则 create 时是 a.Type + "_" + a.Slug + ".md"(如 project_knowledge_api_conventions.md),frontmatter 为 type/title/created/updated + 正文。

触发时机(agent.go) ​

go
			// 无工具调用:自然完成
			if len(calls) == 0 {
				final := ensureFinal(ch, text)
				conv.AddAssistant(final)

				// 记忆更新触发(每 5 轮或显式请求)
				if a.memMgr != nil && a.runtime != nil {
					turnCount := a.runtime.IncTurn()
					recentMsgs := extractRecentTurn(conv)
					if turnCount%5 == 0 || hasMemorySignal(recentMsgs) {
						a.memMgr.UpdateAsync(ctx, recentMsgs)
					}
				}

两个触发条件:每 5 轮(turnCount%5 == 0,turnCount 来自 runtime.IncTurn())或显式信号词:

go
var memorySignalKeywords = []string{"记住", "记忆", "别忘", "remember", "memo"}

extractRecentTurn 取「最后一条 user 消息 → 结尾」这一段作为 recentMsgs。触发点只在无工具调用的自然结束分支,工具循环中途不会触发。

异步 LLM 更新(UpdateAsync) ​

go
func (m *Manager) UpdateAsync(ctx context.Context, recentMsgs []llm.Message) {
	go func() {
		m.mu.Lock()
		defer m.mu.Unlock()
		if m.provider == nil { return }
		indexText := m.LoadIndex()
		sysPrompt := buildMemoryUpdatePrompt(indexText)
		...
		req := llm.Request{
			Messages: msgs,                 // 最近一轮对话序列化成一条 user 消息
			System:   llm.System{Stable: sysPrompt},
			// 不传工具定义(F38)
		}
		stream := m.provider.Stream(ctx, req)
		...
		text = extractJSON(text)
		var actions []UpdateAction
		if err := json.Unmarshal([]byte(text), &actions); err != nil { ... return }
		for _, a := range actions {
			switch a.Level {
			case "project": m.projectStore.Apply([]UpdateAction{a})
			case "user":    m.userStore.Apply([]UpdateAction{a})
			default:        fmt.Fprintf(os.Stderr, "[memory] 未知笔记级别: %s\n", a.Level)
			}
		}
	}()
}

工程要点:

  • 不传工具(源码注释标注 F38),因为这是纯文本决策任务,传工具会诱导模型调工具而非输出 JSON。
  • m.mu 覆盖整个 goroutine,包括网络流式读取 → 更新之间严格串行,避免两个并发更新把 MEMORY.md 写花;但也意味着「上一轮更新没结束、这一轮又触发」时新触发会排队等待而不是合并或丢弃。上下文用的是触发点的 ctx(一次 Run 的 ctx),若该 Run 因用户中断而 cancel,记忆更新会被一起取消,源码未体现独立的超时/后台 ctx(例如 context.WithoutCancel)。
  • 逐条 Apply([]UpdateAction{a}):每次只传一个 action,于是每次都会 EnsureDir() 并各自加锁;等价于把批处理拆成 N 次串行调用,store.Apply 的批量形参在 manager 里没被用满。
  • JSON 提取是朴素括号裁剪:extractJSON 找第一个 [ 和最后一个 ] 后切片;注释说「可能被 markdown 代码块包裹」,但实现对「正文里含 [」的响应不鲁棒(例如模型先解释一句再给数组,或数组后面还有别的文字)。解析失败只打 stderr 并 return,不会重试,也不会反馈给模型修正。
  • 完全没有 panic recover:goroutine 内若发生 panic 会杀掉整个进程(os.WriteFile 等虽有错误返回,但 map/切片越界类问题不会兜住)。这是异步旁路代码的典型风险点。

去重策略 ​

去重发生在 prompt 层而不是代码层,prompt.go 的操作规则第 5 条:

5. 去重:如果多个笔记涵盖相同主题,优先合并而非创建新笔记

并且把现有索引一并注入(if existingIndex != "" { prompt += "\n\n## 现有笔记索引\n" + existingIndex }),让模型在「已知有什么」的前提下决定 create/update/delete。代码层只有两个弱保障:

  • updateIndexLine 按 strings.Contains(line, filename) 命中则替换该行(取首个命中),未命中则追加;
  • removeIndexLine 按 strings.Contains(line, filename) 过滤掉所有命中行。

⇒ 真实缺口:Store.create 是 os.WriteFile(filePath, ...)(覆盖)+ appendIndex(无条件追加一行),没有「文件已存在则退化为 update」的保护。若模型对同一题材换了 slug 或用重复 slug 做 create,就会出现「文件被覆盖 + MEMORY.md 出现重复行」,甚至(因为 updateIndexLine 只替换首个命中)索引与文件逐渐失配。此外匹配用 Contains 而非精确的文件名子串定位,理论上会被标题里恰好含相同词的行误伤。

索引注入系统提示的格式与体积 ​

索引文件是 MEMORY.md,每行格式(appendIndex):

go
	line := fmt.Sprintf("- [%s] %s — %s\n", noteType, title, desc)

其中 desc = extractOneLine(content)(正文首个非空行,超 80 字节截断为前 77 + ...)。

Manager.LoadIndex 合并两级并两级都做体积封顶:

go
	result := strings.Join(parts, "\n")
	if len(result) > maxIndexSize {         // 25 * 1024 = 25600 字节
		result = result[:maxIndexSize]
		// 找到最后一个换行符处截断
		if lastNL := strings.LastIndexByte(result, '\n'); lastNL > 0 {
			result = result[:lastNL]
		}
		result += "\n(index truncated)"
	}

顺序是项目级在前、用户级在后;截断按字节切到最后一个换行(保证不切碎一行),并追加 (index truncated) 让模型知道索引不完整。len() 依旧是字节口径,中文索引实际可用字符量更少。

注入位置:main.go 启动时 memoryText := memMgr.LoadIndex() → tui.New(..., memoryText, ...) → agent.WithMemoryText(memoryText) → prompt.BuildSystemPrompt(a.instructionText, a.memoryText, skillsCatalogText),在 OptionalModules 里作为 长期记忆(Priority 100,排在 自定义指令(80)、可用 Skill 列表(90) 之后)注入,AssembleSystem 按 Priority 升序以 "\n\n" 连接,且空内容槽自动跳过。注释明确这套排序是「稳定以保证跨调用逐字节一致(N1 缓存确定性)」——为 prompt cache 命中率服务。

一个真实限制:memoryText 在进程启动时读取一次,之后由 UpdateAsync 新写入的笔记要重启进程才会进入系统提示(除非另有刷新路径;TUI 中 memoryText 只在 New() 时赋值,commands.go:48-51 的 /memory 类命令只调 ListFiles() 列文件,源码未体现热更新)。这是本项目「写了但立刻用不上」的可见缺口。


项目指令文件(instructions) ​

三层加载(优先级从高到低) ​

go
	paths := []struct {
		path     string
		boundary string // 路径逃逸检测的根边界
	}{
		{filepath.Join(l.projectRoot, "MEWCODE.md"), l.projectRoot},
		{filepath.Join(l.projectRoot, ".mewcode", "MEWCODE.md"), l.projectRoot},
	}
	if l.userHome != "" {
		paths = append(paths, struct {
			path     string
			boundary string
		}{filepath.Join(l.userHome, ".mewcode", "MEWCODE.md"), filepath.Join(l.userHome, ".mewcode")})
	}

按 ① 项目根 MEWCODE.md ② 项目 .mewcode/MEWCODE.md ③ 用户 ~/.mewcode/MEWCODE.md 顺序加载,strings.Join(parts, "\n\n") 拼接——高优先级在前,即越靠近项目的约定越靠前(与 CLAUDE.md 体系一致)。每层用全新的 make(map[string]struct{}) 作为 visited 集合,因此层与层之间互不影响;任一文件读取失败(例如不存在)静默 continue,Load() 恒返回 nil error(错误通道形同虚设)。

注入侧是 prompt.OptionalModules 的 自定义指令(Priority 80),在 长期记忆(100) 与 可用工具 环境文本之前,属于稳定前缀的一部分,利于缓存。

@include 的四道防护(loadFile) ​

go
	// 环路检测
	if _, ok := visited[absPath]; ok {
		return fmt.Sprintf("<!-- @include 检测到环路,已跳过: %s -->", path), nil
	}
	visited[absPath] = struct{}{}

	// 深度限制
	if depth > maxIncludeDepth {
		return fmt.Sprintf("<!-- @include 超过最大嵌套深度,已跳过: %s -->", path), nil
	}

	// 路径逃逸检测(必须在解析为绝对路径之后)
	if !strings.HasPrefix(absPath, boundary) {
		return fmt.Sprintf("<!-- @include 路径超出允许范围,已跳过: %s -->", path), nil
	}
  • 深度限制 maxIncludeDepth = 5,从 MEWCODE.md 算第 1 层,depth+1 递归传递。超限不报错,替换成一行 HTML 注释(Markdown 渲染时不可见,但模型能看到,属于「可观测的降级」)。
  • 环路检测:visited 已记录绝对路径(filepath.Abs 归一化),重复出现即返回注释。注意两点:① 检查发生在「标记 visited」之后但在读取之前,所以菱形包含(A→B、A→C、B→D、C→D)里的 D 会被误报为环路并跳过;② visited 只管「已展开过的文件」,同一文件第二次出现不会重复展开——这既是防环手段,也是抑制 include 爆炸的设计选择(宁可少展开一次,也不允许指数级膨胀)。
  • 路径逃逸防护:absPath 必须先 filepath.Abs 再与 boundary 比较(注释强调顺序),@include 的相对路径用 filepath.Join(filepath.Dir(absPath), includePath) 解析,因此 ../ 逃逸会被前缀检查拦下。但这是字符串前缀比较而非路径感知比较:若 boundary = /work/proj,则 /work/proj-evil/secret.md 也会通过(HasPrefix 为真)。生产级写法应是 filepath.Rel + 检查结果不以 .. 开头,或 strings.HasPrefix(absPath, boundary + string(os.PathSeparator))。这是本项目 instructions 模块最实质的安全弱点。
  • 二进制检测:读文件后检查前 512 字节是否含 \x00:
go
	if len(data) > 0 {
		checkLen := len(data)
		if checkLen > 512 {
			checkLen = 512
		}
		for _, b := range data[:checkLen] {
			if b == 0 {
				return fmt.Sprintf("<!-- @include 文件为二进制格式,已跳过: %s -->", path), nil
			}
		}
	}

注意检测只针对 @include 的目标文件,顶层三个 MEWCODE.md 自身不检测(但都被当文本读入)。另外 @include 的识别是行级前缀匹配:strings.HasPrefix(trimmed, "@include ")(trimmed 已 TrimSpace),意味着行内出现 @include(如代码块里、行中间)不会触发,且路径里的尾随空格会被 TrimSpace 去掉,而带引号的路径(@include "a b.md")不会被去引号——源码未体现引号处理。所有 loadFile 的返回值最终 strings.Join(result, "\n"),非 include 行原样保留。


设计决策与权衡 ​

  1. 决策:唯一入口 ManageContext + 三种 TriggerKind,而非让每个调用点自己拼压缩逻辑。 为什么:上下文管理必须在「每次 Stream 之前」严格发生一次,散落实现极易出现「忘记 layer1」或「阈值算错」。集中后 hook 点也干净:EventPreCompact/EventPostCompact 只需包在 ManageContext 一次调用两侧(agent.go 用 willSummarize 预判并派发 hook 与 CompactEvent)。 替代方案:middleware/责任链式(每个 compaction 策略一个 handler);或事件驱动「监听 token 用量再压缩」。前者更可测试但抽象成本高,后者难以保证「压缩后立刻重估」。

  2. 决策:token 用「真实 usage 锚点 + 字符折算增量」,只用 math.Ceil(chars/3.5) 一个常数。 为什么:不想引入 tiktoken 类分词器依赖(Go 生态无官方等价物,且各 provider 分词器不同),而锚点让长会话的估算误差不随时间累积(每轮由真实 usage 归零一次)。 替代方案:① 引入 provider 的 count_tokens API(多一次网络往返、Anthropic-only);② 保守常数(如 2.0)换取更早压缩。代价是中文被低估(len 按字节)——这是当前实现最明确的可改进点。

  3. 决策:layer1 每轮无条件执行,且必须在阈值判断之前。 为什么:大工具结果(grep/cat 全仓)是确定性浪费,先挪走能避免「本来只需要丢两个大结果就够、却付出了 LLM 摘要的信息损失」。源码注释直说「layer1 节省的 token 需要反映在阈值判断里」。 替代方案:只在超阈值时才 layer1。省一点 CPU/IO,但会让大量会话多做一次不必要的摘要(信息损失不可逆)。

  4. 决策:替换决策写入 ContentReplacementState 账本,一旦决策不可翻转(DecideOnce)。 为什么:layer1 每轮重跑,如果决策不稳定(例如「这轮 spill、下轮又保留原文」),prompt 前缀会反复变化 → 击穿 prompt cache,且模型看到自相矛盾的历史。 替代方案:无状态、每轮按当前字节重算(简单,但无法保证历史稳定);或把决策持久化到 JSONL(本项目未做,重启后账本清零,靠 spill 幂等 + 相同规则重放得到一致结果)。

  5. 决策:摘要产物是「一条 user 消息」而不是 system 消息或 assistant 消息,并附三段恢复附件。 为什么:多数 provider 只允许一条 system(或在首部),中途插入 system 会破坏缓存与语义;user 消息是唯一「模型一定会读」的注入点。附件把「最近读过的文件 + 当前工具集 + 边界提示」一并带上,且用 BuildRecoveryAttachment 的注释锁定「toolDefs 必须与下一次请求同源」,避免 plan mode 下工具集不一致造成的幻觉。 替代方案:把摘要塞进 system 的第二块(Anthropic 支持 system 数组多块,但要额外适配);或用一个 tool_result 承载摘要(语义错位)。

  6. 决策:摘要 prompt 要求「所有 user 消息原文 + 第 8 段当前工作最详细」,并要求输出语言跟随用户。 为什么:用户意图(尤其措辞和约束条件)是最不可再生的资产,压缩后必须保持可追溯;第 8 段直指「恢复后继续干活」的核心场景,避免摘要成为事后复盘。 替代方案:自由格式摘要(省 token,但关键信息易丢);结构化 JSON 摘要(机器可读,但对模型是格式负担且容易触发解析失败,ExtractSummary 的两段式 HTML 标签方案在鲁棒性上更优)。

  7. 决策:<analysis> 草稿区 + <summary> 保留区的两阶段输出,草稿不进上下文。 为什么:给模型一个「推理空间」提升摘要质量,同时不让草稿污染上下文 token(只花 output token,由 SummaryReserve=20000 覆盖)。 替代方案:extended thinking(依赖 provider 特性,成本更高且不可迁移);一次成型(省 token,质量下降)。

  8. 决策:自动压缩带熔断(连续 3 次失败后放弃),手动/紧急压缩不带熔断。 为什么:自动路径每轮都可能重试,失败重试会导致「每轮一次注定失败的 LLM 调用 + 用户长时间等待」;而手动和紧急是「用户要求」与「请求已发不出去」这两种无法回避的场景,必须继续尝试。 替代方案:指数退避重试(更温和,但仍是每轮发起请求);全局禁用失败后的自动压缩(会让长会话永久卡在超限状态)。

  9. 决策:紧急压缩失败后不重试,直接抛原始 ErrPromptTooLong。 为什么:est2 >= cw - ManualSafetyMargin(3000) 说明连「压缩后的最小历史」都塞不下,继续重试无意义,立即失败让用户看到真实错误(同时 ensureAssistantTail 保证会话结构合法)。 替代方案:继续丢弃历史到能塞下为止(可能丢掉全部上下文,产生「失忆后瞎答」的更差体验)。

  10. 决策:JSONL 追加写 + compact 标记做检查点,而不是「就地重写文件」或 SQLite。 为什么:追加日志天然崩溃安全(配合逐条 fsync),标记让「压缩重写历史」用一次 O(1) 的 append 表达,恢复时只读最后一段;纯文本可 grep、可人工修复、零依赖。 替代方案:SQLite(需要 CGO 或纯 Go 驱动、二进制不可读);就地重写(崩溃窗口内文件可能损坏、需临时文件 + rename)。代价是文件体积膨胀(当前每轮全量快照)与多次 fsync 的 IO 成本。

  11. 决策:逐条 file.Sync() 而不是依赖 OS 缓冲。 为什么:终端 Agent 的会话极可能被 Ctrl-C / 崩溃 / 断电中断,会话是用户的长期资产(30 天保留),deferred Close() 不保证数据落盘。 替代方案:批量 flush + 定时 fsync(吞吐好,但丢最后若干条);只在 compact 标记时 fsync。当前是「安全优先」的极端选择,优化空间在于 AppendAll 里做成「一次 fsync 覆盖整批」。

  12. 决策:TruncateOrphanedToolCalls 只截断末尾孤立工具调用。 为什么:唯一现实来源是「工具执行中崩溃」,此时残缺必定出现在尾部;中间孤立的来源是文件损坏,属另一类问题。 替代方案:全量扫描修复配对(更完备,但可能掩盖真正的数据损坏,且需要更多规则处理 ToolCallID 对齐)。

  13. 决策:笔记更新异步、旁路、且不传工具、失败只打 stderr。 为什么:记忆是「用户主流程之外的价值」,绝不能因为它失败或变慢而阻塞对话主循环(聊天主路径已结束,但 UI 状态切换不能被拖住)。 替代方案:同步更新(用户能立即看到笔记生效,但每 5 轮增加一次完整 LLM 往返延迟);让主 Agent 自己调 save_memory 工具(更可控、可解释,但污染工具空间并诱导模型滥用工具)。本项目的异步方案以「索引热更新缺失」为代价。

  14. 决策:去重靠 prompt(把现有索引注入给「记忆管理助手」)+ 索引行级替换。 为什么:在 LLM 记忆场景里,语义去重(「这条和那条是同一件事」)本质是语义判断,代码规则做不到;把索引喂给模型是最低成本的语义去重。 替代方案:embedding 相似度去重(需要嵌入模型与向量库,项目当前无此依赖);严格 slug 撞车即 update(能防重复行,但挡不住换 slug 的重复语义)。

  15. 决策:索引注入 25KB 封顶并 (index truncated) 标注。 为什么:笔记会长期累积,索引直接进稳定系统前缀,必须有硬上限保护窗口预算和缓存前缀稳定性;标注截断让模型知道「可能还有没看到的笔记」。 替代方案:全量注入 + 定期由 LLM 汇总压缩索引(质量更好但引入额外 LLM 调用与不确定输出);按当前任务检索相关笔记(即 RAG 路线,本项目未做)。

  16. 决策:三层 MEWCODE.md 用「拼接」而不是「覆盖/合并优先级」。 为什么:项目约定与个人偏好在实践中是互补而非互相覆盖的关系,拼接让用户级偏好(如「中文回答」)始终有约束力;高优先级在前,冲突时后文更接近模型注意力的尾端……——注意这里源码只体现拼接顺序,冲突解决策略源码未体现(没有「同名键覆盖」之类的机制),完全依赖模型自行理解。 替代方案:TOML/YAML 配置式合并(确定性高、可校验,但表达力受限,写不了长段自然语言约定)。

  17. 决策:@include 的失败一律降级为 HTML 注释而不报错。 为什么:单条 include 失效不应让整份项目指令加载失败;注释形式对 Markdown 渲染不可见,但保留了可观测性(模型和用户都能看到「这里被跳过了」)。 替代方案:直接 return error(严格但脆弱);静默跳过什么都不留(不可观测,难排查)。

  18. 决策:SessionID 内嵌时间戳(YYYYMMDD-HHMMSS-xxxx),而不是 UUID。 为什么:目录名可排序、可人工识别、可在无元数据文件的情况下直接算过期时间(ParseSessionTime 就这么干的,省了一次 stat 与元数据一致性维护)。 替代方案:UUID + mtime 判断过期(更灵活,但清理逻辑要依赖文件系统时间,且恢复/迁移时易漂移);朴素递增序号(并发不安全)。代价是 ID 依赖本地时钟(时钟回拨会影响清理),且旧格式 ID 需要用「解析失败即跳过」来兼容。

  19. 决策:recoveryTokensPerFile = 5000 且最多 5 个文件,优先「最近读的」。 为什么:压缩后模型最容易犯的错是「凭记忆改文件」,把最近 truly 读过的文件原文(纯净、无行号前缀)塞回去,能显著降低瞎改概率;文件级截断 + 倒序保证预算可控(最多约 25k token 量级)。 替代方案:只留文件路径清单(省 token 但没用);按文件重要性排序(需要额外启发式,项目未做)。

  20. 决策:恢复会话时先「必要时压缩」再「注入时间跨度提醒」。 为什么:est > cw-8000 才 RunForceCompact,避免对短会话做无意义摘要;6 小时提醒以 user 消息追加以对抗「跨天恢复时模型对文件状态过度自信」。 替代方案:恢复即压缩(简单但浪费);不做时间提醒(模型会接着几小时前的假设继续改代码)。


面试官可能追问(20 条) ​

Q1:MewCode 的 token 是怎么估算的?为什么不直接用 tiktoken? A:compact.EstimateTokens(anchor, allMsgs, anchorMsgLen) = anchor + ceil(messageChars(allMsgs[anchorMsgLen:]) / 3.5),其中 anchor 由 compact.UsageAnchor(usage) 得到(InputTokens+OutputTokens+CacheWrite+CacheRead),而 anchorMsgLen 是设锚点时的 conv.Len()。messageChars 累加 len(Content)、len(ToolCalls[i].Input)、len(ToolResults[i].Content)。不用分词器是因为 Go 侧没有可靠的跨 provider 分词器,且锚点式估算让误差不累积(每轮被真实 usage 归零)。代价:len() 是字节数,中文被低估(3 字节/汉字 ÷ 3.5 ≈ 0.86 token/字)。

Q2:自动压缩的精确触发线是多少?为什么这么算? A:const.go 里 SummaryReserve=20000、AutoSafetyMargin=13000,manageAuto 中 threshold := ContextWindow - SummaryReserve - AutoSafetyMargin,200k 窗口即 167000 token。两个常量的作用不同:SummaryReserve 是给摘要请求的输出预留(摘要 prompt 要求 9 段,输出会很长),AutoSafetyMargin 是覆盖估算误差与单轮波动(比如一次大工具结果)。另外 ContextWindow <= 33000 时直接跳过自动摘要(sanity check)。

Q3:SummaryReserve 和 ManualSafetyMargin 有什么区别? A:AutoSafetyMargin=13000 用于自动触发(manageAuto 的 threshold);ManualSafetyMargin=3000 只在紧急路径用(agent.go:紧急压缩后 est2 >= cw - ManualSafetyMargin 就判定失败抛错)。语义差异是:自动路径要预防「将来会超」,所以余量大;手动/紧急路径只关心「现在这一次请求能不能塞下」,所以余量小到只覆盖请求开销。

Q4:layer1 的溢出判定具体是怎样的?为什么要按大小倒序? A:needSpill := it.size > singleResultLimit(50000) || remaining > messageAggregateLimit(200000),remaining 初始为「本消息内所有未决策 ToolResult 的字节和」。倒序排序后配合 remaining -= it.size,等价于贪心地先挪走最大的几个,直到剩余聚合量 ≤ 200KB。倒序的好处是决策单调:先处理的一定被 spill,后处理的不受前面顺序影响(如果正序,小结果先被保留可能就让聚合量一直超限)。

Q5:DecideOnce 为什么要设计成「一次性」的? A:因为 layer1 每轮都重跑,而 OffloadAndSnip 不允许历史抖动(否则同一段的预览/原文来回切换)。state.go 的注释写明「同一 id 一旦进入 seenIds 就不可翻转」,三态是 kept(写 seenIds)/replaced(写 seenIds + replacements)/skip(不写账本,可重试)。它把「查账本 → 决策 → 写账本」放进同一个 sync.Mutex 临界区,避免并发路径重复落盘。

Q6:OffloadAndSnip 里为什么同一个 DecideOnce 被调用两次? A:第一次调用执行 spill 与写账本(返回 preview 但被丢弃),第二次调用借「已 Seen 短路」把账本里的 preview 取出来赋给 tr.Content。行为正确、幂等,但可读性差;更直接的写法是用第一次返回值赋值。这是源码里明显可重构的一处。

Q7:落盘的文件名是什么?有什么风险? A:filepath.Join(session.SpillDir, toolUseID),SpillDir = <workspace>/.mewcode/sessions/<SessionID>/tool-results。写入是幂等的(os.Stat 存在即返回)。风险是文件名直接来自 tool_use_id 而没有清洗(源码未体现 filepath.Base 或字符白名单),依赖上游 provider 生成的 ID 是安全字符串。

Q8:压缩后历史是怎么重建的?为什么要有 joinAfterSummary? A:runSummary 产出 [摘要 user 消息] + [pickRecentTail 得到的近期原文]。pickRecentTail 从尾部累加字符,直到 estTokens>=10000(recentKeepTokens) 且 count>=5(recentKeepMessages),再做「配对修正」:切点若是 RoleTool,向前推到带 ToolCalls 的 assistant。joinAfterSummary 处理两件事:丢掉开头的 tool 消息(防御非法序列),若近期原文首条是 user 则插入 assistant 占位 (已加载上下文摘要与恢复信息。请继续。),避免连续两条 user。

Q9:ptlRetry 的策略是什么?最后怎么收场? A:摘要 prompt 本身超长时,先用 groupByUserTurn 把历史切成「以 user 起头的回合组」,前 ptlRetryLimit=3 次每次丢最旧的 1 组;之后按 ptlDropPercentage=0.2 比例丢(math.Ceil,至少 1 组)。循环条件都是 len(groups) > 1,即至少保留 1 组;全丢完仍失败就返回 sentinel context.DeadlineExceeded,由上层转成错误。

Q10:熔断是怎么实现的?为什么不做进 ForceCompact? A:AutoCompactTrackingState{ConsecutiveFailures},RecordFailure() 自增、RecordSuccess() 清零、Tripped() 判断 >= maxConsecutiveAutoCompactFailures(3)。AutoCompact 在整轮 runSummary 失败时 RecordFailure(),成功时 RecordSuccess();ForceCompact 的注释明确「不走熔断器,失败不计入熔断计数」。原因是自动路径会每轮重试(必须熔断止损),而手动 /compact 是用户显式请求、紧急压缩是请求已发不出去,都不能放弃。

Q11:熔断后会发生什么?用户能感知吗? A:manageAuto 里 if estTokens < threshold || in.AutoTracking.Tripped() { out.AfterTokens = estTokens; return out, nil }——熔断后退化为「只做 layer1」,不报错、不通知。上层只在预估会超阈值时发 CompactEvent,所以「一直熔断」对用户静默。且 ConsecutiveFailures 不持久化,重启清零;源码未体现熔断通知或用户可见的降级提示。这是可改进点。

Q12:JSONL 里的 compact 标记是什么?什么时候写? A:Writer.WriteCompactMarker() 写 {"type":"compact","ts":...}(匿名 struct,只有两个字段)。它由 Writer.OnReplace() 触发:先写标记,再 AppendAll(msgs) 写整份历史。而 Conversation.ReplaceMessages 是无条件回调 onReplace,manageAuto 每轮都调 ReplaceMessages(layer1Out) ——所以实际行为是每轮都写一条标记 + 全量快照,JSONL 等价于「检查点日志」,体积随轮数×历史长度增长。这是读代码才能发现的性能点。

Q13:恢复会话时为什么不会重复触发压缩? A:LoadSession 从最后一个 compact 标记之后开始收集 entry(if entry.Type == "compact" { entries = nil }),因此拿到的已经是压缩后的历史(摘要 user 消息 + 近期原文),不需要重放压缩决策。ContentReplacementState 不持久化,恢复后账本为空,但 spill 文件还在磁盘且 spillSingle 幂等,重新决策会得到同样的路径与预览文本。

Q14:坏行是怎么处理的?有什么隐患? A:load.go 用流式 json.NewDecoder(f) + for dec.More(),Decode 出错就 continue。隐患是没有按行切分(不是 bufio.Scanner);一条被截断的记录会让 Decoder 停在不确定位置,理论上可能连带吞掉紧随其后的记录。由于 json.Encoder 会转义 \n,每条消息严格一行,正常情况这个策略够用。

Q15:孤立工具调用是怎么截断的?为什么只截断尾部? A:TruncateOrphanedToolCalls:if last.Role == llm.RoleAssistant && len(last.ToolCalls) > 0 { return msgs[:len(msgs)-1] }。唯一现实来源是「工具执行到一半进程死亡」,残片必然在尾部;中间的孤立调用只会来自文件损坏,属另一类问题,源码未处理。

Q16:会话清理策略是什么?有什么坑? A:main.go 启动时后台 goroutine 调 session.CleanExpired(sessionsDir, 30*24*time.Hour)。CleanExpired 靠 compact.ParseSessionTime(id) 解析目录名前 15 位 YYYYMMDD-HHMMSS,超过 maxAge 就 os.RemoveAll。坑有三:旧格式 ID 解析失败被跳过(永不清理);只按时间,没有「保留最近 N 个」或体积上限;直接 RemoveAll 无回收站。

Q17:记忆更新在什么时机触发?为什么是异步? A:agent.go 在「无工具调用、自然完成」分支里 turnCount := a.runtime.IncTurn(),当 turnCount%5 == 0 或 hasMemorySignal(recentMsgs) 时调 m.memMgr.UpdateAsync(ctx, extractRecentTurn(conv))。信号词是 {"记住","记忆","别忘","remember","memo"}。异步是为了不阻塞主流程(它要发起一次完整 LLM 往返)。代价:ctx 是本次 Run 的 ctx,用户中断会连带取消记忆更新,且 memoryText 只在进程启动时读一次,新笔记重启才进系统提示。

Q18:记忆的四类笔记和两级存储分别是什么?索引怎么进 prompt? A:类型是 user_preference / correction_feedback / project_knowledge / reference_material;级别是 project(<workspace>/.mewcode/memory/)与 user(~/.mewcode/memory/)。MEMORY.md 每行 - [type] title — desc(desc 是正文首行,超 80 字节截断)。Manager.LoadIndex 项目级在前、用户级在后拼接,超过 maxIndexSize = 25*1024 就截到最后一个换行并追加 (index truncated);注入为系统提示的 长期记忆 模块(Priority 100,排在自定义指令 80 与 Skill 列表 90 之后),排序稳定以服务 prompt 缓存。

Q19:记忆去重怎么做的?有没有漏洞? A:主要在 prompt 层:buildMemoryUpdatePrompt 把现有索引喂给「记忆管理助手」,并明文要求「如果多个笔记涵盖相同主题,优先合并而非创建新笔记」(规则 5),操作限定为 create/update/delete。代码层只有弱保障:updateIndexLine/removeIndexLine 用 strings.Contains(line, filename) 匹配行。漏洞:Store.create 直接 os.WriteFile(覆盖)后无条件 appendIndex,没有「文件已存在则退化为 update」的保护,重复 slug 会造成索引重复行;且 Contains 不是精确匹配。

Q20:@include 有哪些防护?最弱的一环在哪? A:四道:maxIncludeDepth = 5 的深度限制;visited(绝对路径集合)环路检测;filepath.Abs 之后与 boundary 比较的路径逃逸检测;读取后前 512 字节含 \x00 的二进制检测。失败一律降级为 <!-- @include ... 已跳过 --> 注释。最弱一环是逃逸检测用 strings.HasPrefix(absPath, boundary) 做字符串前缀比较:boundary=/work/proj 时 /work/proj-evil/x.md 会误判为合法;应改为比较 boundary + string(os.PathSeparator) 前缀或用 filepath.Rel 后检查是否以 .. 开头。另外 visited 会让菱形包含中的公共文件被误报为「环路」而跳过(每个顶层文件独立一份 visited)。


企业级对应方案 ​

1. 与 Claude Code 的 /compact 与 auto-compact 对比 ​

Claude Code 同样有「手动 /compact + 接近窗口上限时自动压缩」的两级结构,并且同样把「最近读过的文件」在压缩后重新注入(其 /compact 后模型仍能引用文件内容)——本项目在结构上是同构的,但有几处工程差异:① Claude Code 的阈值与预留通常按模型窗口比例动态计算并带可配置项,本项目是硬编码常量(SummaryReserve=20000、AutoSafetyMargin=13000),切换 32k/128k 窗口时只有 ContextWindow <= 33000 一条兜底;② 本项目额外做了 TriggerEmergency(ErrPromptTooLong 触发的兜底压缩),这是 Claude Code 未公开强调的一层;③ 本项目压缩后不重放工具调用,只重建「摘要 + 近期原文」,而 Claude Code 的压缩更像「重新生成一份对话摘要作为新起点」。生产化补齐点:把 SummaryReserve/AutoSafetyMargin 改为按窗口比例 + 配置项;把 /compact 的 before/after token 数与失败原因做成用户可见反馈(现有 CompactEvent 已有 Before/After/Err 字段,缺的是 UI 呈现与「熔断中」的显式提示)。

2. 与 LangChain / LangGraph 的 memory 抽象对比 ​

LangChain 把记忆拆成 ConversationBufferMemory / SummaryMemory / SummaryBufferMemory(= 摘要 + 保留最近 N 轮,正是本项目 layer2 的形态)/ 向量检索记忆;LangGraph 进一步用「checkpointer + 状态图」把会话状态持久化(SqliteSaver/PostgresSaver)并支持 human-in-the-loop 与时间旅行回溯。本项目的对应物是:pickRecentTail(SummaryBuffer) + RecoveryState(文件快照) + JSONL 检查点(≈checkpointer),但没有图状态、没有时间旅行(不能回到某个 compact 点之外的任意状态,只能回到最后一个标记)、没有多线程/多分支(fork)语义。生产化补齐点:JSONL 检查点已具备「按标记回放」的基础,可低成本加上「列出所有 compact 检查点并从中分叉新会话」(对应 LangGraph 的 fork/rewind),这对「压缩后走偏了,想从压缩前重来」非常有价值。

3. 与 Mem0 / Zep / Letta(MemGPT)等记忆系统对比 ​

Mem0 的做法是「抽取事实 → 向量化 → 冲突消解(ADD/UPDATE/DELETE/NOOP)」;Zep 用「对话摘要 + 实体/关系图(Graphiti)+ 时间有效性标注」;Letta/MemGPT 把记忆做成「分层内存 + 分页换入换出」,让 Agent 自己用工具操作内存(core memory / archival memory / recall memory)。本项目 memory 包与 Mem0 最接近(同样是 LLM 输出 create/update/delete 的 JSON 操作列表),差异在于:① 无向量检索,全量索引塞进系统提示(因此必须 25KB 截断),事实召回靠「模型自己翻索引 + 读文件」,没有语义相似度检索;② 无冲突消解与时间有效性,去重只靠 prompt 里一句「优先合并」,create 还会覆盖同名文件并产生重复索引行;③ 无「记忆自我管理」工具,Agent 不能主动查询/写记忆(只有旁路异步写入),而 MemGPT 路线把记忆操作完全交给模型;④ 置信度/来源/衰减策略源码未体现。生产化补齐点:在 Store 上加「写入前查同名/近义」的保护、给 Note 加 source_session_id 与 last_used_at(支持衰减与清理)、把索引注入从「全量」改成「关键词 + 向量 top-k」,即可从「静态提示注入」升级为「检索式记忆」。

4. RAG 式长期记忆 vs 本项目的「提示注入式记忆」 ​

RAG 长期记忆的范式是「外部存储 + 检索器 + 重排 + 拼上下文」,优点是可容纳海量记忆而不吃窗口,缺点是检索失败即失忆、需要 embedder/向量库/一致性维护。本项目选择「索引(≤25KB)直接进系统提示」,换来的是:零依赖、可 grep、人工可编辑、模型一定能看到「有哪些记忆」,且天然命中 prompt cache(索引在稳定前缀里)。代价是容量天花板与「全量注入」的注意力稀释。互补而非替代:合理演进是「索引仍全量注入(它本身就是目录),但正文改为按需读取」——事实上本项目已经隐含这个模式(buildPreview 里就是「给路径 + 让模型用文件读取工具」),只需把同一思路搬到 memory:索引里写文件路径,让 Agent 用 read_file 读正文,即可把有效记忆容量从 25KB 提升到不受限,且不引入任何新依赖。

5. 对齐 Anthropic context engineering 最佳实践 ​

Anthropic 的上下文工程主张:① 把上下文当作有限资源、做「注意力预算」管理;② 用 compaction(摘要)处理长历史;③ 用 structured note-taking / 外部文件(memory)把状态写到上下文之外;④ 用 sub-agent 隔离探索性上下文;⑤ 保持 prompt 前缀稳定以命中缓存;⑥ 用明确的「信息可能过期」提示对抗幻觉。 本项目几乎逐条命中:②=layer2 摘要,③=SpillDir 落盘 + memory 笔记 + RecoveryState,④=subagent/task 模块(另属他章),⑤=AssembleSystem 注释明说「排序稳定以保证跨调用逐字节一致(N1 缓存确定性)」,⑥=boundaryNotice + layer1 预览的反猜测文案 + 恢复时的 6 小时时间跨度提醒。差距在于「注意力预算」的可观测性:Anthropic 生态强调用 token 用量反馈驱动决策,而本项目 CompactEvent 已经带了 Before/After,却没有把「layer1 节省了多少、摘要花了多少、离阈值还有多少」持续暴露给用户(TUI 只在 willSummarize 时展示),也没有 token 用量随时间的可视化。

6. 生产化待补齐清单(按 ROI 排序) ​

  1. 消掉「每轮全量快照」:manageAuto 中比较 layer1 是否真的产生新决策(例如 ContentReplacementState 暴露 newlySeen 计数),仅在变化时 ReplaceMessages,可同时解决 JSONL 体积膨胀与每轮 N 次 fsync 的问题。
  2. AppendAll 批量化 fsync:整批写完只 Sync() 一次(或在 compact 标记处 sync),把 O(N) 次磁盘同步降为 O(1)。
  3. 中文 token 估算校准:messageChars 改为按 rune 加权(CJK 计权更高),或对 estimateCharsPerToken 做按语言自适应的取值;这是当前最可能引发 prompt_too_long 的系统性偏差。
  4. 熔断可观测 + 状态外置:把 ConsecutiveFailures 与最近一次压缩成败写入 SessionRuntime 之外的持久位置(例如会话目录下的 compact.state.json),并在 TUI 明确提示「自动压缩已停用,请手动 /compact」。
  5. memory 热更新 + 写入防护:UpdateAsync 完成后刷新 Agent 持有的 memoryText(或把索引改为每次请求前从 Store 读并带 mtime 缓存),并给 Store.create 加「同名文件存在 → 自动转 update」的保护。
  6. instructions 逃逸检测改为路径感知:strings.HasPrefix(absPath, boundary) → 分隔符感知或 filepath.Rel 校验;同时考虑对顶层 MEWCODE.md 也做二进制检测与体积上限。
  7. 清理策略多元化:CleanExpired 之外增加「保留最近 N 个会话」与「会话目录总大小上限」,并处理旧格式目录的迁移/清理。

附:本笔记未能在源码中确认的点(明确标注) ​

  • provider 侧 usage 字段语义(InputTokens 是否含 cache 部分)在不同协议实现中的归一化:源码未体现统一处理,UsageAnchor 直接求和。
  • 压缩被熔断后的用户可见反馈、熔断状态的持久化:源码未体现。
  • memory 索引在运行期热刷新:源码未体现(memoryText 在 main.go 启动时读一次并经 agent.WithMemoryText 注入;commands.go 的列表命令只调 ListFiles())。
  • @include 路径带引号/含空格的处理、顶层 MEWCODE.md 的体积上限:源码未体现。
  • 旧格式 session 目录(<unix_ts>-<hex>)的迁移或清理路径:源码未体现(ListSessions/CleanExpired 一律跳过)。

持续学习,持续构建。