dejavu
Nested detail page

MCP 与自动回忆 深度解析

这一页拆 stdio MCP server、recall 工具、recall_context digest,以及 Codex/opencode 自动注入上下文的实现边界。

§ 00 · IMPORT CHAIN

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 足够小。

cmd/deja/mcp.go · L27-64Go
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}
§ 01 · TOOLS

工具注册与调用

▸tools/list

cmd/deja/mcp.go:74 返回工具清单,cmd/deja/mcp.go:77 暴露 recall,cmd/deja/mcp.go:82 暴露 recall_context。描述明确要求 Agent 在 debug 或重新实现前先查旧 session,这让记忆成为工作流里的默认动作,而不是用户手动 grep。

cmd/deja/mcp.go · L66-99Go
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。两者区分“找候选”和“读完整上下文”。

cmd/deja/mcp.go · L113-150Go
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 工具对上下文预算友好。

cmd/deja/mcp.go · L152-194Go
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}
§ 02 · AUTO

自动注入上下文

SessionStart hook -> deja hook-context -> recent project memory -> system prompt / plugin context

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 阶段注入。

cmd/deja/install_auto.go · L11-65Go
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}
§ 03 · EDGE

边界

  • 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 当可选上下文,失败时不阻断会话。
Generated · 2026-07-17Source · vshulcz/deja-vu← 返回 deja-vu 主页面