dejavu
§ 00 · Source-level Observatory

deja-vu agent memory layer

deja-vu 不是另一个向量数据库,而是一个零依赖 Go 二进制:它直接读取 Claude Code、Codex、opencode、Cursor、Gemini CLI、Grok Build 等本地会话日志,构建本地倒排索引,并通过 CLI/MCP/auto-recall 让 Agent 在重新调试前先检索过去的解法。

★304
Stars
8
Harnesses
Go
Core
7ms
Warm search
定位 — 它把已经存在的 agent session log 变成可搜索、可脱敏、可同步、可通过 MCP 调用的本地记忆层。
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

源码的核心分工很清楚: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 提供稳定低成本结果。

架构模式
本地日志解析器 + crash-safe 倒排索引 + CLI/MCP 双入口。
核心抽象
Session、Message、Record、Manifest、SyncRecord 五个结构串起历史、索引和同步。
安全策略
索引写入、share、sync export 都重新跑 redaction。
下游依赖
Claude Code / Codex / opencode / Cursor 等 agent harness 的本地 transcript。

▸为什么不是普通 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 文件。

深度阅读 · 索引与检索
§ 01 · INDEX PIPELINE

索引管线:从多源日志到倒排桶

索引入口先检查 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,避免父子会话重复污染索引。

internal/index/index.go · L487-521Go
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。

internal/index/index.go · L356-443Go
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}
深度阅读 · 索引与检索
§ 02 · SEARCH & MCP

检索和 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 推到前面。

internal/search/search.go · L41-107Go
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 确认“之前怎么修的”。

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, ""
深度阅读 · MCP 与自动回忆
§ 03 · REDACTION & SYNC

脱敏与同步:记忆能流动,但不能裸奔

安全边界在这个项目里不是文档口号。索引写入时 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 先过滤大多数无密钥文本,降低正则成本。

internal/redact/redact.go · L25-103Go
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 去重,避免同时间戳消息被误判。

internal/index/sync.go · L41-119Go
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}
深度阅读 · 同步与安全
§ 04 · TESTS & RISKS

测试覆盖与风险边界

▸覆盖面

仓库测试覆盖到 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 工作流的第一层记忆。

Generated · 2026-07-17Source · vshulcz/deja-vu← 全部分析