MCP 与自动回忆 深度解析
这一页拆 stdio MCP server、recall 工具、recall_context digest,以及 Codex/opencode 自动注入上下文的实现边界。
MCP 入口
MCP server 由 CLI 的 cmd/deja/main.go:108 进入,具体实现从 cmd/deja/mcp.go:27 开始。它用 line-delimited JSON 读取请求,cmd/deja/mcp.go:29 扩大 scanner buffer,cmd/deja/mcp.go:46 把请求交给 handleMCP,保持 stdio server 足够小。
27func serveMCP(r io.Reader, w io.Writer) error { 28 s := bufio.NewScanner(r) 29 // MCP messages are line-delimited JSON here. Allow large, but bounded, client lines. 30 s.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) 31 enc := json.NewEncoder(w) 32 for s.Scan() { 33 line := strings.TrimSpace(s.Text()) 34 if line == "" { 35 continue 36 } 37 var req rpcRequest 38 if err := json.Unmarshal([]byte(line), &req); err != nil { 39 writeRPCError(enc, nil, -32700, "parse error") 40 continue 41 } 42 if req.ID == nil { 43 // Notification. Do not reply. 44 continue 45 } 46 result, code, msg := handleMCP(req) 47 if code != 0 { 48 writeRPCError(enc, req.ID, code, msg) 49 continue 50 } 51 if err := enc.Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result}); err != nil { 52 return err 53 } 54 } 55 if err := s.Err(); err != nil { 56 // Oversized or malformed client frames are reported as JSON-RPC parse errors, 57 // then the stdio server exits gracefully instead of silently hard-stopping. 58 writeRPCError(enc, nil, -32700, "parse error") 59 if os.Getenv("DEJA_DEBUG") == "1" { 60 fmt.Fprintf(os.Stderr, "deja mcp scanner error: %v\n", err) 61 } 62 } 63 return nil 64}
工具注册与调用
▸tools/list
cmd/deja/mcp.go:74 返回工具清单,cmd/deja/mcp.go:77 暴露 recall,cmd/deja/mcp.go:82 暴露 recall_context。描述明确要求 Agent 在 debug 或重新实现前先查旧 session,这让记忆成为工作流里的默认动作,而不是用户手动 grep。
66func handleMCP(req rpcRequest) (any, int, string) { 67 switch req.Method { 68 case "initialize": 69 return map[string]any{ 70 "protocolVersion": mcpProtocolVersion, 71 "capabilities": map[string]any{"tools": map[string]any{}}, 72 "serverInfo": map[string]any{"name": "deja", "version": version}, 73 }, 0, "" 74 case "tools/list": 75 return map[string]any{"tools": []map[string]any{ 76 { 77 "name": "recall", 78 "description": "Search past coding-agent sessions (Claude Code, Codex, opencode, aider, Gemini CLI, Cursor, Antigravity, Grok Build) indexed on this machine and return the best matches as dense text under ~4KB. Use before debugging or re-implementing something: prior sessions often contain the exact fix, error message, or command. Query works best with specific tokens — an error string, function name, or flag.", 79 "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"query": map[string]any{"type": "string", "description": "Search terms; specific tokens (error strings, function names, flags) match best. Multiple words are ANDed."}, "harness": map[string]any{"type": "string", "description": "Optional filter: claude, codex, opencode, aider, gemini, cursor, antigravity or grok."}, "limit": map[string]any{"type": "number", "description": "Max sessions to return (default 5)."}}, "required": []string{"query"}}, 80 }, 81 { 82 "name": "recall_context", 83 "description": "Return a markdown digest (~8KB) of the single best-matching prior session — the full problem/solution arc, not just snippets. Use after recall points at a session and more detail is needed.", 84 "inputSchema": map[string]any{"type": "object", "properties": map[string]any{"query": map[string]any{"type": "string", "description": "Search terms identifying the session to digest."}}, "required": []string{"query"}}, 85 }, 86 }}, 0, "" 87 case "tools/call": 88 var p struct { 89 Name string `json:"name"` 90 Arguments json.RawMessage `json:"arguments"` 91 } 92 if err := json.Unmarshal(req.Params, &p); err != nil { 93 return nil, -32602, "invalid params" 94 } 95 text, err := callMCPTool(p.Name, p.Arguments) 96 if err != nil { 97 return nil, -32602, err.Error() 98 } 99 return toolText(text), 0, ""
▸tools/call
调用分发在 cmd/deja/mcp.go:113,recall 分支在 cmd/deja/mcp.go:115 校验 query,在 cmd/deja/mcp.go:127 限制输出预算;recall_context 分支在 cmd/deja/mcp.go:132 只返回最佳 session 的 digest。两者区分“找候选”和“读完整上下文”。
113func callMCPTool(name string, raw json.RawMessage) (string, error) { 114 switch name { 115 case "recall": 116 var a struct { 117 Query string `json:"query"` 118 Harness string `json:"harness"` 119 Limit int `json:"limit"` 120 } 121 if err := json.Unmarshal(raw, &a); err != nil { 122 return "", err 123 } 124 if strings.TrimSpace(a.Query) == "" { 125 return "", fmt.Errorf("query required") 126 } 127 text, err := recallText(a.Query, a.Harness, a.Limit, 4096) 128 if err == nil { 129 usage.Record(index.DefaultDir(), usage.KindRecall, len(text)) 130 } 131 return text, err 132 case "recall_context": 133 var a struct { 134 Query string `json:"query"` 135 } 136 if err := json.Unmarshal(raw, &a); err != nil { 137 return "", err 138 } 139 if strings.TrimSpace(a.Query) == "" { 140 return "", fmt.Errorf("query required") 141 } 142 text, err := recallContext(a.Query) 143 if err == nil { 144 usage.Record(index.DefaultDir(), usage.KindContext, len(text)) 145 } 146 return text, err 147 default: 148 return "", fmt.Errorf("unknown tool %q", name) 149 } 150}
▸预算裁剪
recallText 在 cmd/deja/mcp.go:152 设默认 limit,cmd/deja/mcp.go:160 复用 SearchWithRecovery,cmd/deja/mcp.go:174 逐条写摘要,cmd/deja/mcp.go:190 用 trimUTF8 控制输出不破坏字符边界。这个细节让 MCP 工具对上下文预算友好。
152func recallText(q, harness string, limit, budget int) (string, error) { 153 if limit <= 0 { 154 limit = 5 155 } 156 o := search.Options{Query: q, Harness: harness, All: true} 157 if err := index.EnsureForSearch(index.DefaultDir(), o, false, mcpProgress()); err != nil { 158 return "", err 159 } 160 ss, err := index.SearchWithRecovery(index.DefaultDir(), o, mcpProgress()) 161 if err != nil { 162 return "", err 163 } 164 hits, err := search.Run(ss, o) 165 if err != nil { 166 return "", err 167 } 168 if len(hits) == 0 { 169 return fmt.Sprintf("No prior deja sessions matched %q.", q), nil 170 } 171 if len(hits) > limit { 172 hits = hits[:limit] 173 } 174 var b strings.Builder 175 fmt.Fprintf(&b, "deja recall for %q (%d match(es))\n", q, len(hits)) 176 for i, h := range hits { 177 fmt.Fprintf(&b, "\n%d. [%s] %s · %s · %d matches", i+1, h.Session.Harness, h.Session.Project, h.Session.ID, h.Count) 178 if !h.Session.Updated.IsZero() { 179 fmt.Fprintf(&b, " · updated %s", h.Session.Updated.Format("2006-01-02")) 180 } 181 fmt.Fprintln(&b) 182 for _, sn := range h.Snippets { 183 fmt.Fprintf(&b, "- %s\n", sn) 184 } 185 if b.Len() >= budget { 186 break 187 } 188 } 189 out := b.String() 190 if len(out) > budget { 191 out = trimUTF8(out, budget) 192 } 193 return out, nil 194}
自动注入上下文
Codex hook 安装在 cmd/deja/install_auto.go:15,它读取 cmd/deja/install_auto.go:17 的 hooks.json,保留现有 hooks,并在 cmd/deja/install_auto.go:45 到 cmd/deja/install_auto.go:48 合并 SessionStart command。opencode 没有同类 hook,所以 cmd/deja/install_auto.go:81 生成插件,在 cmd/deja/install_auto.go:105 的 system transform 阶段注入。
11// Codex (v0.114+) ships lifecycle hooks with the same JSON contract as 12// Claude Code, so the existing `deja hook-context` output works unchanged. 13// We merge one SessionStart entry into ~/.codex/hooks.json and leave 14// everything else in the file alone. 15func installCodexHooks(exe string, uninstall bool) (installResult, error) { 16 h, _ := os.UserHomeDir() 17 path := filepath.Join(h, ".codex", "hooks.json") 18 old, _ := os.ReadFile(path) 19 var root map[string]any 20 if len(bytes.TrimSpace(old)) == 0 { 21 root = map[string]any{} 22 } else if err := json.Unmarshal(old, &root); err != nil { 23 return installResult{}, err 24 } 25 cmd := exe + " hook-context" 26 hooks, _ := root["hooks"].(map[string]any) 27 if hooks == nil { 28 hooks = map[string]any{} 29 root["hooks"] = hooks 30 } 31 entries, _ := hooks["SessionStart"].([]any) 32 var kept []any 33 found := false 34 for _, entryAny := range entries { 35 entry, _ := entryAny.(map[string]any) 36 if entry != nil && entryHasCommand(entry, cmd) { 37 found = true 38 if uninstall { 39 continue 40 } 41 } 42 kept = append(kept, entryAny) 43 } 44 if !uninstall && !found { 45 kept = append(kept, map[string]any{ 46 "matcher": "startup|resume", 47 "hooks": []any{map[string]any{"type": "command", "command": cmd, "timeout": 10}}, 48 }) 49 } 50 if len(kept) == 0 { 51 delete(hooks, "SessionStart") 52 } else { 53 hooks["SessionStart"] = kept 54 } 55 if len(hooks) == 0 { 56 delete(root, "hooks") 57 } 58 next, err := json.MarshalIndent(root, "", " ") 59 if err != nil { 60 return installResult{}, err 61 } 62 next = append(next, '\n') 63 a, err := writeIfChanged(path, old, next) 64 return installResult{Path: path, Action: a}, err 65}
边界
cmd/deja/mcp.go:124要求 query 非空,避免 Agent 发起无约束 recall。cmd/deja/mcp.go:227只有 DEJA_DEBUG 才输出进度,默认不污染 MCP stdout。cmd/deja/install_auto.go:113的 opencode 插件把 memory 当可选上下文,失败时不阻断会话。