deja-vu agent memory layer
deja-vu 不是另一个向量数据库,而是一个零依赖 Go 二进制:它直接读取 Claude Code、Codex、opencode、Cursor、Gemini CLI、Grok Build 等本地会话日志,构建本地倒排索引,并通过 CLI/MCP/auto-recall 让 Agent 在重新调试前先检索过去的解法。
它解决什么,凭什么值得拆
源码的核心分工很清楚:CLI 路由集中在 cmd/deja/main.go:64,多 harness 加载从 cmd/deja/main.go:27 到 cmd/deja/main.go:53 串起 Claude、Codex、opencode、aider、Gemini、Cursor、Antigravity 和 Grok;真正索引入口在 internal/index/index.go:135 与 internal/index/index.go:152,查询再落到 internal/index/index.go:179。这说明 deja-vu 的架构不是把所有逻辑塞进一个 CLI,而是把“发现源、构建缓存、检索排序、输出上下文”分层拆开。
项目最有价值的地方在于它选择了确定性本地索引,而不是先上 embedding。internal/index/index.go:195 到 internal/index/index.go:225 使用 postings 交集、子串 fallback 和 record scan 三段检索路径;internal/search/search.go:41 到 internal/search/search.go:107 负责二次过滤、计数、排序和截断。这让它能在隐私敏感的 agent 日志上保持零网络路径,同时给 MCP recall 提供稳定低成本结果。
▸为什么不是普通 grep
普通 grep 每次都要扫原始日志,且很难把同一 session 的多条消息合并成可复用上下文;deja-vu 在 internal/index/index.go:356 重建索引时写入 records.bin,在 internal/index/index.go:381 并行产出 token postings,并在 internal/index/index.go:404 到 internal/index/index.go:418 对每条消息做去重、截断、脱敏和 token 推送。这个设计让查询阶段读缓存而不是反复解析所有 harness 文件。
索引管线:从多源日志到倒排桶
索引入口先检查 manifest 是否新鲜:internal/index/index.go:144 收集 current files,internal/index/index.go:145 读取 manifest,internal/index/index.go:146 同时校验文件状态和 records.bin 完整性。搜索专用入口 internal/index/index.go:152 还会在 internal/index/index.go:167 判断版本、scope 或 records 尾部是否损坏,必要时走 rebuild。这种缓存失效策略比“有文件就相信”更稳。
▸多 harness 加载器
加载器列表写死在 internal/index/index.go:487 到 internal/index/index.go:499,好处是新增 harness 的影响面非常明确。Codex 解析器从 internal/sources/codex.go:12 开始走 sessions 和 history 两条路径,Claude 解析器则在 internal/sources/claude.go:29 默认跳过 subagent transcript,避免父子会话重复污染索引。
487var harnessLoaders = []struct { 488 name string 489 load func() []model.Session 490}{ 491 {"claude", sources.LoadClaude}, 492 {"codex", sources.LoadCodex}, 493 {"opencode", sources.LoadOpencode}, 494 {"aider", sources.LoadAider}, 495 {"gemini", sources.LoadGemini}, 496 {"cursor", sources.LoadCursor}, 497 {"antigravity", sources.LoadAntigravity}, 498 {"grok", sources.LoadGrok}, 499} 500 501func load(h string) []model.Session { return loadProgress(h, nil) } 502 503// loadProgress narrates a full rebuild per harness: a cold pass over a large 504// corpus takes seconds and used to look hung. 505func loadProgress(h string, progress io.Writer) []model.Session { 506 var ss []model.Session 507 for _, hl := range harnessLoaders { 508 if h != "" && h != hl.name { 509 continue 510 } 511 got := hl.load() 512 ss = append(ss, got...) 513 if progress != nil && len(got) > 0 { 514 msgs := 0 515 for _, s := range got { 516 msgs += len(s.Messages) 517 } 518 fmt.Fprintf(progress, "deja: %s: %d sessions, %d messages\n", hl.name, len(got), msgs) 519 } 520 } 521 return ss
▸增量与 crash-safe
internal/index/index.go:228 到 internal/index/index.go:243 的 SearchWithRecovery 是很实用的失败处理:桶文件被 crash 截断时,它不会把错误丢给用户,而是触发一次强制 rebuild 后再查。重建时 internal/index/index.go:430 先写桶,internal/index/index.go:434 写 manifest,最后 internal/index/index.go:437 删除旧目录并 internal/index/index.go:438 rename 临时目录,尽量把中间态限制在 tmp。
356func rebuild(dir string, harness string, scope string, files map[string]FileState, progress io.Writer) error { 357 lastIngestFiles = len(files) 358 initialBuild := !HasManifest(dir) 359 writtenMessages := 0 360 imported := importedSessions(dir) 361 tmp := dir + ".tmp" 362 _ = os.RemoveAll(tmp) 363 if err := os.MkdirAll(filepath.Join(tmp, "buckets"), 0o755); err != nil { 364 return err 365 } 366 ss := loadProgress(harness, progress) 367 ss = append(ss, imported.sessions...) 368 m := Manifest{Version: version, Files: files, Sessions: map[string]SessionMeta{}, BuiltAt: time.Now(), Scope: scope, 369 ExportWatermarks: imported.watermarks, ImportedRecords: imported.dedupe} 370 recPath := filepath.Join(tmp, "records.bin") 371 rf, err := os.Create(recPath) 372 if err != nil { 373 return err 374 } 375 rw, err := newRecordWriter(rf) 376 if err != nil { 377 _ = rf.Close() 378 return err 379 } 380 seenMsgs := msgSeen{} 381 buckets, err := indexTextParallel(func(push func(tokenJob)) error { 382 for _, s := range ss { 383 key := s.Harness + ":" + s.ID 384 ord := uint32(0) 385 if old, ok := m.Sessions[key]; ok { 386 ord = old.Ord 387 if s.Started.IsZero() || (!old.Started.IsZero() && old.Started.Before(s.Started)) { 388 s.Started = old.Started 389 } 390 if old.Updated.After(s.Updated) { 391 s.Updated = old.Updated 392 } 393 if s.Project == "history" && old.Project != "" && old.Project != "history" { 394 s.Project = old.Project 395 } 396 if s.Title == "" { 397 s.Title = old.Title 398 } 399 } 400 if ord == 0 { 401 ord = nextSessionOrd(m.Sessions) 402 } 403 m.Sessions[key] = metaWithOrd(metaForSession(s), ord) 404 for _, msg := range s.Messages { 405 if seenMsgs.dup(key, msg.Role, msg.Time, msg.Text) { 406 continue 407 } 408 text := msg.Text 409 if len(text) > maxIndexedText { 410 text = text[:maxIndexedText] 411 } 412 text = redactForIngest(&m, s.Path, text) 413 off, err := rw.write(Record{Key: key, SourcePath: s.Path, Role: msg.Role, Text: text, Time: msg.Time}) 414 if err != nil { 415 return err 416 } 417 writtenMessages++ 418 push(tokenJob{text: text, offset: off, sid: m.Sessions[key].Ord}) 419 } 420 } 421 return nil 422 }) 423 if err != nil { 424 _ = rw.Close() 425 return err 426 } 427 if err := rw.Close(); err != nil { 428 return err 429 } 430 if err := writeBucketsConcurrent(filepath.Join(tmp, "buckets"), buckets); err != nil { 431 return err 432 } 433 setOpencodeLastUpdated(m.Files, m.Sessions) 434 if err := writeManifest(tmp, m); err != nil { 435 return err 436 } 437 _ = os.RemoveAll(dir) 438 if err := os.Rename(tmp, dir); err != nil { 439 return err 440 } 441 summarizeBuild(initialBuild, len(m.Sessions), writtenMessages, ss) 442 return nil 443}
检索和 MCP:给 Agent 的廉价 recall
CLI 查询走 cmd/deja/main.go:214 解析搜索参数,再通过 cmd/deja/main.go:218 确保索引,cmd/deja/main.go:221 执行 SearchWithRecovery,最后交给 internal/search/search.go:41 评分。MCP 路径则从 cmd/deja/mcp.go:27 建立 line-delimited JSON server,在 cmd/deja/mcp.go:74 暴露工具列表,在 cmd/deja/mcp.go:113 分发 tool call。
▸查询排序
搜索实现不是只返回第一处命中:internal/search/search.go:68 遍历消息,internal/search/search.go:85 累加命中,internal/search/search.go:93 用命中数和更新时间计算 score,internal/search/search.go:98 排序,internal/search/search.go:104 限制默认返回数量。这能把最近且相关的 session 推到前面。
41func Run(ss []model.Session, o Options) ([]Hit, error) { 42 var re *regexp.Regexp 43 qlow := strings.ToLower(o.Query) 44 qtoks := queryTokens(o.Query) 45 if o.Regex { 46 var err error 47 re, err = regexp.Compile("(?i)" + o.Query) 48 if err != nil { 49 return nil, err 50 } 51 } 52 cut := time.Time{} 53 if o.Since > 0 { 54 cut = time.Now().Add(-o.Since) 55 } 56 var hits []Hit 57 for _, s := range mergeSessions(ss) { 58 if o.Harness != "" && s.Harness != o.Harness { 59 continue 60 } 61 if o.Project != "" && !strings.Contains(strings.ToLower(s.Project), strings.ToLower(o.Project)) { 62 continue 63 } 64 if !cut.IsZero() && s.Updated.Before(cut) { 65 continue 66 } 67 h := Hit{Session: s} 68 for _, m := range s.Messages { 69 if o.Role != "" && m.Role != o.Role { 70 continue 71 } 72 c := 0 73 if re != nil { 74 c = len(re.FindAllStringIndex(m.Text, -1)) 75 } else { 76 low := strings.ToLower(m.Text) 77 if len(qtoks) <= 1 { 78 if strings.Contains(low, qlow) { 79 c = strings.Count(low, qlow) 80 } 81 } else { 82 c = countAllTokens(low, qtoks) 83 } 84 } 85 if c > 0 { 86 h.Count += c 87 if len(h.Snippets) < 3 { 88 h.Snippets = append(h.Snippets, snippet(m.Text, o.Query, re)) 89 } 90 } 91 } 92 if h.Count > 0 { 93 age := time.Since(s.Updated).Hours() / 24 94 h.Score = float64(h.Count) * 1000 / (1 + age) 95 hits = append(hits, h) 96 } 97 } 98 sort.Slice(hits, func(i, j int) bool { 99 if hits[i].Score == hits[j].Score { 100 return hits[i].Session.Updated.After(hits[j].Session.Updated) 101 } 102 return hits[i].Score > hits[j].Score 103 }) 104 if !o.All && len(hits) > 15 { 105 hits = hits[:15] 106 } 107 return hits, nil
▸MCP recall 的上下文预算
MCP recall 在 cmd/deja/mcp.go:152 设定默认 limit,cmd/deja/mcp.go:156 构造搜索选项,cmd/deja/mcp.go:171 截取最多 limit 条,cmd/deja/mcp.go:190 按预算裁剪 UTF-8 输出。recall_context 则在 cmd/deja/mcp.go:206 走单个最佳 session 的 digest,适合 Agent 确认“之前怎么修的”。
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, ""
脱敏与同步:记忆能流动,但不能裸奔
安全边界在这个项目里不是文档口号。索引写入时 internal/index/index.go:412 调用 redaction,share 输出在 cmd/deja/share.go:82 到 cmd/deja/share.go:86 对整篇 digest 再脱敏,sync export 在 internal/index/sync.go:80 对每条记录再次 redact。三处重复处理会牺牲一点性能,但换来更强的默认安全。
▸凭证识别
脱敏规则覆盖 AWS、通用 key/value、Bearer、PEM、provider token、JWT 和 URL credential:正则集中在 internal/redact/redact.go:25 到 internal/redact/redact.go:42,执行流程在 internal/redact/redact.go:64 到 internal/redact/redact.go:102。它还用 internal/redact/redact.go:50 和 internal/redact/redact.go:53 的 hints 先过滤大多数无密钥文本,降低正则成本。
25var ( 26 awsAccessKeyRE = regexp.MustCompile(`A(?:KIA|SIA)[0-9A-Z]{16}`) 27 awsSecretRE = regexp.MustCompile(`(?i)\b(aws[_-]?secret[_-]?access[_-]?key)(\s*['"]?\s*[:=]\s*)(['"]?)([A-Za-z0-9/+=_-]{32,})(['"]?)`) 28 // The key may be embedded in a larger identifier (ANTHROPIC_API_KEY, 29 // x-api-key) and, in JSON, a closing quote can sit between the key and the 30 // delimiter ("api_key": "..."). Tolerate both so env-var and JSON forms are 31 // caught, not just a bare `api_key=`. 32 genericKVRE = regexp.MustCompile(`(?i)\b([\w.-]{0,64}?(?:api[_-]?key|secret|token|passwd|password|authorization))(\s*['"]?\s*[:=]\s*)(['"]?)([A-Za-z0-9/+=._-]{16,})(['"]?)`) 33 bearerRE = regexp.MustCompile(`(?i)\b(Bearer)(\s+)([A-Za-z0-9._~+/=-]{16,})`) 34 pemPrivateRE = regexp.MustCompile(`(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----`) 35 // Provider prefixes. sk- allows internal hyphens/underscores so modern 36 // hyphenated formats (sk-ant-…, sk-proj-…) are covered, not just legacy 37 // sk-<alnum> keys. 38 providerRE = regexp.MustCompile(`\b(gh[opsur]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9_-]{20,}|gsk_[A-Za-z0-9]{20,}|xai-[A-Za-z0-9-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{30,}|xox[bpcs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,})\b`) 39 jwtRE = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b`) 40 // Password is greedy so a password containing '@' (user:p@ss@host) splits on 41 // the last '@' and is redacted whole, not just up to the first '@'. 42 connURLRE = regexp.MustCompile(`\b([A-Za-z][A-Za-z0-9+.-]*://)([^\s/@:]+):([^\s]+)@([^\s]+)`) // scheme://user:pass@host 43) 44 45func Disabled() bool { return os.Getenv("DEJA_NO_REDACT") == "1" } 46 47// kvHints are the substrings genericKVRE can anchor on; providerHints the 48// literal prefixes of providerRE. Checking them first keeps the regexes off 49// the vast majority of messages, which contain no credentials at all. 50var kvHints = []string{"key", "secret", "token", "passw", "authorization"} 51 52// "github_pat_" is listed on its own: "gh" is not a substring of "github". 53var providerHints = []string{"gh", "github_pat_", "glpat-", "sk_", "rk_", "sk-", "gsk_", "xai-", "hf_", "npm_", "xox", "AIza"} 54 55func containsAnyFold(s string, hints []string) bool { 56 for _, h := range hints { 57 if strings.Contains(s, h) { 58 return true 59 } 60 } 61 return false 62} 63 64func Text(s string) (string, Counts) { 65 counts := Counts{} 66 if Disabled() || s == "" { 67 return s, counts 68 } 69 lower := strings.ToLower(s) 70 if strings.Contains(s, "-----BEGIN") { 71 s = replaceWhole(s, pemPrivateRE, "private-key", counts) 72 } 73 if strings.Contains(s, "://") { 74 s = replaceSubmatch(s, connURLRE, "url-credentials", counts, func(m []string) string { 75 return m[1] + m[2] + ":[redacted:url-credentials]@" + m[4] 76 }) 77 } 78 if strings.Contains(lower, "aws") { 79 s = replaceSubmatch(s, awsSecretRE, "aws-secret", counts, func(m []string) string { 80 return m[1] + m[2] + m[3] + "[redacted:aws-secret]" + closingQuote(m[3], m[5]) 81 }) 82 } 83 if strings.Contains(s, "AKIA") || strings.Contains(s, "ASIA") { 84 s = replaceWhole(s, awsAccessKeyRE, "aws-access-key", counts) 85 } 86 if strings.Contains(lower, "bearer") { 87 s = replaceSubmatch(s, bearerRE, "bearer-token", counts, func(m []string) string { 88 return m[1] + m[2] + "[redacted:bearer-token]" 89 }) 90 } 91 if strings.Contains(s, "eyJ") { 92 s = replaceWhole(s, jwtRE, "jwt", counts) 93 } 94 if containsAnyFold(lower, kvHints) { 95 s = replaceSubmatch(s, genericKVRE, "credential", counts, func(m []string) string { 96 return m[1] + m[2] + m[3] + "[redacted:credential]" + closingQuote(m[3], m[5]) 97 }) 98 } 99 if containsAnyFold(s, providerHints) { 100 s = replaceProvider(s, counts) 101 } 102 return s, counts 103}
▸同步模型
同步命令入口在 cmd/deja/sync.go:11 到 cmd/deja/sync.go:56,导出前会在 cmd/deja/sync.go:32 确保索引可搜索;底层 internal/index/sync.go:41 使用 source watermark 产出 append-only JSONL,导入端在 internal/index/sync.go:160 到 internal/index/sync.go:183 用 legacy key 加 text hash 去重,避免同时间戳消息被误判。
41func exportRecords(dir, outDir string, full bool) (int, error) { 42 if dir == "" { 43 dir = DefaultDir() 44 } 45 unlock, err := lockDir(dir) 46 if err != nil { 47 return 0, err 48 } 49 defer unlock() 50 m, err := readManifest(dir) 51 if err != nil { 52 return 0, err 53 } 54 if err := os.MkdirAll(outDir, 0o755); err != nil { 55 return 0, err 56 } 57 if m.ExportWatermarks == nil { 58 m.ExportWatermarks = map[string]int64{} 59 } 60 bySource := map[string][]SyncRecord{} 61 nextWatermarks := map[string]int64{} 62 for k, v := range m.ExportWatermarks { 63 nextWatermarks[k] = v 64 } 65 err = eachRecord(filepath.Join(dir, "records.bin"), func(r Record) { 66 if r.SourcePath == syncImportPath { 67 return 68 } 69 source := r.SourcePath 70 if source == "" { 71 source = r.Key 72 } 73 if !full && !r.Time.IsZero() && r.Time.UnixNano() <= m.ExportWatermarks[source] { 74 return 75 } 76 meta, ok := m.Sessions[r.Key] 77 if !ok { 78 return 79 } 80 text, _ := redact.Text(r.Text) 81 rec := SyncRecord{Harness: meta.Harness, SessionID: meta.ID, Project: meta.Project, Role: r.Role, Text: text, Time: r.Time} 82 bySource[source] = append(bySource[source], rec) 83 if r.Time.UnixNano() > nextWatermarks[source] { 84 nextWatermarks[source] = r.Time.UnixNano() 85 } 86 }) 87 if err != nil { 88 return 0, err 89 } 90 total := 0 91 for source, recs := range bySource { 92 if len(recs) == 0 { 93 continue 94 } 95 name := fmt.Sprintf("deja-sync-%s-%d.jsonl", shortHash(source), time.Now().UnixNano()) 96 f, err := os.Create(filepath.Join(outDir, name)) 97 if err != nil { 98 return total, err 99 } 100 enc := json.NewEncoder(f) 101 for _, rec := range recs { 102 if err := enc.Encode(rec); err != nil { 103 _ = f.Close() 104 return total, err 105 } 106 total++ 107 } 108 if err := f.Close(); err != nil { 109 return total, err 110 } 111 } 112 for source, wm := range nextWatermarks { 113 m.ExportWatermarks[source] = wm 114 } 115 if err := writeManifest(dir, m); err != nil { 116 return total, err 117 } 118 return total, nil 119}
测试覆盖与风险边界
▸覆盖面
仓库测试覆盖到 parser、index crash recovery、dedupe、sync persistence、redaction、MCP、auto install 和 SSH sync。比如 internal/sources/util.go:91 的 JSONL offset scanner、internal/sources/util.go:133 的并发 parseFiles、cmd/deja/install_auto.go:15 的 Codex hooks 合并、cmd/deja/sync_ssh.go:48 的 ssh push 都有对应测试文件。
▸风险
- 纯 lexical 检索对同义表达不敏感,
internal/index/index.go:205的子串 fallback 能缓解“code/opencode”类问题,但不能替代语义检索。 - 索引只脱敏缓存和导出,不会修改原始 harness 日志;这一点由 README 说明,源码上也能从
internal/redact/redact.go:45的 opt-out 看出使用者仍需管理原始数据。 - 同步依赖用户选择的目录或 SSH hop,
cmd/deja/sync_ssh.go:70到cmd/deja/sync_ssh.go:81会创建远端临时目录并 scp,运维失败模式需要用户理解。
▸结论
deja-vu 的强项是“低摩擦、零服务、本地优先”。它不是最大而全的 memory platform,而是把 agent 已经写到磁盘上的历史变成一个可靠 recall primitive:cmd/deja/main.go:108 给 MCP,cmd/deja/main.go:148 给 ctx,cmd/deja/main.go:139 给 share,cmd/deja/main.go:145 给 sync。这个边界小但清晰,所以适合作为个人或团队 agent 工作流的第一层记忆。