recall 离线项目记忆
用 Claude Code hook 捕获会话,把 history.md 本地摘要成 context.md,不依赖外部模型或网络。
它解决什么,凭什么值得拆
用 Claude Code hook 捕获会话,把 history.md 本地摘要成 context.md,不依赖外部模型或网络。 这次重做只围绕源码可见的边界:入口如何被解析、核心对象如何流转、失败路径如何被测试或显式降级。
阅读重点不是营销功能,而是从真实文件里抽出的契约链。下面的索引同时保留真实行号,便于回到 sources/recall/ 复核。
▸核心数据
▸源码证据索引
源文件地图: src/scripts/make_context.py:1 · src/scripts/summarizer.py:1 · src/scripts/common.py:1 · src/scripts/capture.py:1 · src/scripts/redact.py:1 · src/scripts/parse_transcript.py:1 · src/scripts/config.py:1 · src/scripts/session_start.py:1 · src/scripts/session_end.py:1 · src/tests/test_security.py:1 · src/tests/test_make_context.py:1
源码索引:src/scripts/make_context.py:35 · src/scripts/make_context.py:36 · src/scripts/make_context.py:37 · src/scripts/make_context.py:38 · src/scripts/make_context.py:39 · src/scripts/make_context.py:40 · src/scripts/make_context.py:41 · src/scripts/make_context.py:42 · src/scripts/make_context.py:43 · src/scripts/make_context.py:44 · src/scripts/make_context.py:45 · src/scripts/make_context.py:46 · src/scripts/make_context.py:47 · src/scripts/make_context.py:48 · src/scripts/make_context.py:49 · src/scripts/make_context.py:50 · src/scripts/make_context.py:51 · src/scripts/make_context.py:52 · src/scripts/make_context.py:53 · src/scripts/make_context.py:54 · src/scripts/make_context.py:55 · src/scripts/make_context.py:56 · src/scripts/make_context.py:57 · src/scripts/make_context.py:58 · src/scripts/make_context.py:59 · src/scripts/make_context.py:60 · src/scripts/make_context.py:61 · src/scripts/make_context.py:62 · src/scripts/make_context.py:63 · src/scripts/make_context.py:64 · src/scripts/make_context.py:65 · src/scripts/make_context.py:66 · src/scripts/make_context.py:67 · src/scripts/make_context.py:68 · src/scripts/make_context.py:69 · src/scripts/make_context.py:70 · src/scripts/make_context.py:71 · src/scripts/make_context.py:72 · src/scripts/make_context.py:73 · src/scripts/make_context.py:74 · src/scripts/make_context.py:75 · src/scripts/make_context.py:76 · src/scripts/make_context.py:77 · src/scripts/make_context.py:78 · src/scripts/make_context.py:79 · src/scripts/make_context.py:80 · src/scripts/make_context.py:81 · src/scripts/make_context.py:82 · src/scripts/make_context.py:83 · src/scripts/make_context.py:91 · src/scripts/make_context.py:92 · src/scripts/make_context.py:93 · src/scripts/make_context.py:94 · src/scripts/make_context.py:95 · src/scripts/make_context.py:96 · src/scripts/make_context.py:97 · src/scripts/make_context.py:98 · src/scripts/make_context.py:99 · src/scripts/make_context.py:100 · src/scripts/make_context.py:101 · src/scripts/make_context.py:102 · src/scripts/make_context.py:103 · src/scripts/make_context.py:104 · src/scripts/make_context.py:105 · src/scripts/make_context.py:106 · src/scripts/make_context.py:107 · src/scripts/make_context.py:108 · src/scripts/make_context.py:109 · src/scripts/make_context.py:110 · src/scripts/make_context.py:111 · src/scripts/make_context.py:112 · src/scripts/make_context.py:113 · src/scripts/make_context.py:114 · src/scripts/make_context.py:115 · src/scripts/make_context.py:116 · src/scripts/make_context.py:117 · src/scripts/make_context.py:118 · src/scripts/make_context.py:119 · src/scripts/make_context.py:120 · src/scripts/make_context.py:121 · src/scripts/make_context.py:122 · src/scripts/make_context.py:123 · src/scripts/make_context.py:124 · src/scripts/make_context.py:125 · src/scripts/make_context.py:126 · src/scripts/make_context.py:127 · src/scripts/make_context.py:128 · src/scripts/make_context.py:129 · src/scripts/make_context.py:130 · src/scripts/make_context.py:131
Hook 与本地文件拓扑
架构可以拆成三段:输入契约、核心处理、输出/评分/写入。每段都有独立文件承接,而不是把逻辑堆在 README 示例里。
▸系统拓扑
▸模块清单
▸关键数据结构
35# Sentences that signal unfinished work / next actions. (Bare "next" is left out 36# on purpose — it's too broad, matching "next week" and echoed prompts like "the 37# next steps you'd take"; real steps still hit "still need" / "should" / etc.) 38_NEXT_CUE = re.compile( 39 r"\b(next steps?|to[- ]?do|todo|fixme|still need|still needs|need to|" 40 r"needs to|should|blocked|remaining|left to|follow[- ]?up|not yet|" 41 r"haven'?t|pending|in progress|wip)\b", 42 re.IGNORECASE, 43) 44 45# Sentences that are instructions to the model or meta-asks about the answer 46# itself — not real pending work. They otherwise sneak in via a cue word or a 47# trailing "?" (e.g. an echoed prompt: "in 3-4 sentences, list the steps"). 48# "recall context" guards against the model echoing Recall's own injected 49# SessionStart boilerplate back into a later session's capture. 50_NOT_A_STEP = re.compile( 51 r"(?i)\b(in \d+[-\s]?\d*\s*sentences?|in a few sentences|keep it (short|brief)|" 52 r"you'?d take|confirm the plan|tell me|without asking|reply with|recall context)\b" 53) 54 55# A label/header line ("Concrete next steps:", optionally **bold**) is not itself 56# a step. 57_HEADER_LINE = re.compile(r":\*{0,2}\s*$") 58 59_MIN_QUESTION_LEN = 30 # a bare question must be substantive to count as a thread 60 61 62def _clean_for_summary(history_text): 63 """Strip history.md's markdown chrome so the extractive summarizer sees prose, 64 not headings / role labels / tool bullets.""" 65 out = [] 66 for line in history_text.splitlines(): 67 s = line.strip() 68 if not s or s.startswith("#"): 69 continue 70 if s.startswith("- ") or s.startswith("`"): # tool-call bullet lines 71 continue 72 # Drop the **You:** / **Claude:** role prefixes, keep the spoken text. 73 for prefix in ("**You:**", "**Claude:**"): 74 if s.startswith(prefix): 75 s = s[len(prefix):].strip() 76 if s: 77 out.append(s) 78 return "\n".join(out) 79 80 81def _bullets(items, limit, empty="(none)"): 82 if not items: 83 return empty
摘要器、捕获和安全边界
▸契约入口如何收口
第一组代码展示输入被分类、清洗或注册的地方;这些函数决定后续流程拿到的是结构化对象,而不是自由文本。
91def _next_steps(corpus, cwd, cfg, limit=5): 92 """Heuristically surface what's left to do: sentences with an explicit 93 next-step cue, or a substantive open question, plus any uncommitted files. 94 Instruction/meta sentences and trivial questions are filtered out. Local-only. 95 """ 96 steps, seen = [], set() 97 for s in summarizer.split_sentences(corpus): 98 s = s.strip() 99 if _NOT_A_STEP.search(s) or _HEADER_LINE.search(s): 100 continue 101 question = s.endswith("?") and len(s) >= _MIN_QUESTION_LEN 102 if _NEXT_CUE.search(s) or question: 103 key = s.lower() 104 if key not in seen: 105 seen.add(key) 106 steps.append(s) 107 if len(steps) >= limit: 108 break 109 if cfg.get("include_git"): 110 uncommitted = git_uncommitted(cwd) 111 if uncommitted: 112 steps.append("Uncommitted changes to wrap up: " 113 + ", ".join(uncommitted[:8])) 114 return steps 115 116 117def _facts_events(cwd, transcript_path, located_events): 118 """Events to derive deterministic facts from. If the located session has no 119 real prompt (e.g. a standalone /recall:save), fall back to the most recent 120 session that does — so save is useful whenever it's run, not only at the end 121 of a work session.""" 122 if parse_transcript.first_user_goal(located_events): 123 return located_events 124 for tp in project_transcripts(cwd): 125 if os.path.abspath(tp) == os.path.abspath(transcript_path): 126 continue 127 alt = parse_transcript.parse(tp) 128 if parse_transcript.first_user_goal(alt): 129 return alt 130 return located_events 131 132 133def build(cwd, cfg, transcript_path): 134 located = parse_transcript.parse(transcript_path) 135 events = _facts_events(cwd, transcript_path, located) 136 137 # Summarize the captured history if present (it's what the user expects to be 138 # condensed); otherwise summarize the transcript prose directly. 139 history_text = read_text(history_path(cwd, cfg)) 140 corpus = _clean_for_summary(history_text) if history_text else \ 141 parse_transcript.prose(events, cfg["max_input_chars"]) 142 corpus = corpus[-cfg["max_input_chars"]:] 143 summary = summarizer.summarize(corpus, cfg["summary_sentences"]) 144 145 goal = parse_transcript.first_user_goal(events) 146 last = parse_transcript.last_assistant_state(events) 147 files = parse_transcript.touched_files(events) 148 cmds = parse_transcript.commands(events) 149 nxt = _next_steps(corpus, cwd, cfg) 150 git = git_info(cwd) if cfg["include_git"] else ""
▸执行/评测边界
第二组代码展示核心运行边界:请求体、运行输入、模型输出或本地写入都在这里被转换成下游可消费的形态。
157 md = f"""# Project Context — {project_name(cwd)} (updated {ts}) 158 159_Generated locally by Recall — {summarizer.backend_name()}._ 160 161## 🎯 Goal 162{(goal[:500] + "…") if len(goal) > 500 else (goal or "_(not detected — set it manually)_")} 163 164## 🧭 Summary 165{summary_md} 166 167## ⏭️ Next steps / open threads 168{next_md} 169 170## 📂 Files touched 171{_bullets(files, 30)} 172 173## 🔧 Commands run 174{_bullets(cmds, 15)} 175 176## ⏱ Where we left off 177{(last[:600] + "…") if len(last) > 600 else (last or "_(unknown)_")} 178""" 179 if git: 180 md += f"\n## 🌿 Git ground-truth\n```\n{git}\n```\n" 181 182 if cfg["redact"]: 183 md = redact(md) 184 return md 185 186 187def run(cwd, transcript_path=None, quiet=False): 188 cfg = load_config(cwd) 189 transcript_path = transcript_path or locate_transcript(cwd) 190 if not transcript_path or not os.path.exists(transcript_path): 191 if not quiet: 192 print("Recall: no session transcript found; nothing to summarize.") 193 return 1 194 195 md = build(cwd, cfg, transcript_path) 196 if not ensure_output_dir(cwd, cfg): 197 if not quiet: 198 print("Recall: output dir escapes the project; refusing to write.") 199 return 1 200 path = context_path(cwd, cfg) 201 write_text(path, md) 202 203 if not quiet: 204 print(f"Recall: wrote {path}") 205 print(f"Recall: summarizer = {summarizer.backend_name()}") 206 return 0
▸状态与配置的交界
24_SENT_SPLIT = re.compile(r"(?<=[.!?])\s+|\n+") 25_MIN_LEN = 24 # drop trivially short fragments 26_MAX_RANK = 400 # cap sentences ranked (keep most recent) to bound cost 27_DAMPING = 0.85 28_MAX_ITERS = 100 29_TOL = 1e-6 30 31_STOPWORDS = set( 32 "the a an and or but if then else of to in on at for with from by as is are was " 33 "were be been being this that these those it its i you we they he she them us our " 34 "your their not no do does did so than too very can will just into out up down over " 35 "about which who whom what when where why how all any each more most other some such " 36 "only own same s t don now i'll i've it's we'll let me here there".split() 37) 38 39 40def split_sentences(text): 41 out = [] 42 for raw in _SENT_SPLIT.split(text or ""): 43 s = " ".join(raw.split()) 44 if len(s) >= _MIN_LEN: 45 out.append(s) 46 return out 47 48 49def _tokenize(sentence): 50 return [w for w in re.findall(r"[a-zA-Z][a-zA-Z']+", sentence.lower()) 51 if w not in _STOPWORDS and len(w) > 2] 52 53 54def summarize(text, k=8): 55 sentences = split_sentences(text) 56 if len(sentences) <= k: 57 return sentences 58 if len(sentences) > _MAX_RANK: 59 sentences = sentences[-_MAX_RANK:] # recency-biased; keeps it fast 60 61 tokens = [_tokenize(s) for s in sentences] 62 if not any(tokens): 63 return sentences[:k] 64 65 idf = _idf(tokens) 66 try: 67 import numpy # noqa: F401 68 scores = _textrank_numpy(tokens, idf) 69 except Exception: 70 scores = _textrank_python(tokens, idf) 71 72 return _select(sentences, scores, k) 73 74 75def _select(sentences, scores, k): 76 """Top-k sentences by score, returned in original order. Scores are rounded 77 before ranking and ties are broken by position, so the sub-epsilon numerical 78 difference between the numpy and pure-Python cores can never change which 79 sentences are chosen — both backends always produce the same summary (a 80 committed context.md must not depend on whether numpy happens to be present).""" 81 order = sorted(range(len(sentences)), 82 key=lambda i: (round(scores[i], 10), -i), reverse=True) 83 return [sentences[i] for i in sorted(order[:k])]
Stop → history → context 流程
关键流程从输入开始,经过规范化、执行或评分,再输出结果。源码里真正重要的是失败点是否被显式表达。
▸入口到规范化
[2] payload → policy / prompt / context → bounded execution
96def _idf(tokens): 97 n = len(tokens) 98 df = Counter() 99 for toks in tokens: 100 for w in set(toks): 101 df[w] += 1 102 return {w: math.log((1.0 + n) / (1.0 + d)) + 1.0 for w, d in df.items()} 103 104 105# --- pure-Python core ------------------------------------------------------- 106 107def _tfidf_sparse(tokens, idf): 108 vecs = [] 109 for toks in tokens: 110 if not toks: 111 vecs.append({}) 112 continue 113 tf = Counter(toks) 114 length = len(toks) 115 vec = {w: (c / length) * idf[w] for w, c in tf.items()} 116 norm = math.sqrt(sum(v * v for v in vec.values())) or 1.0 117 vecs.append({w: v / norm for w, v in vec.items()}) 118 return vecs 119 120 121def _cosine(a, b): 122 if len(a) > len(b): 123 a, b = b, a 124 return sum(val * b.get(w, 0.0) for w, val in a.items()) 125 126 127def _textrank_python(tokens, idf): 128 vecs = _tfidf_sparse(tokens, idf) 129 n = len(vecs) 130 sim = [[0.0] * n for _ in range(n)] 131 for i in range(n): 132 for j in range(i + 1, n): 133 c = _cosine(vecs[i], vecs[j]) 134 if c: 135 sim[i][j] = sim[j][i] = c 136 row_sum = [sum(row) for row in sim] 137 138 scores = [1.0 / n] * n 139 base = (1.0 - _DAMPING) / n 140 for _ in range(_MAX_ITERS): 141 new = [base] * n 142 for i in range(n): 143 if row_sum[i] == 0.0: 144 continue 145 share = _DAMPING * scores[i] / row_sum[i] 146 row = sim[i] 147 for j in range(n): 148 w = row[j] 149 if w: 150 new[j] += share * w 151 if sum(abs(new[i] - scores[i]) for i in range(n)) < _TOL: 152 scores = new 153 break 154 scores = new 155 return scores
▸结果回写或评分
160def _textrank_numpy(tokens, idf): 161 import numpy as np 162 163 vocab = sorted(idf) 164 index = {w: i for i, w in enumerate(vocab)} 165 n, vsize = len(tokens), len(vocab) 166 167 mat = np.zeros((n, vsize), dtype=np.float64) 168 for i, toks in enumerate(tokens): 169 if not toks: 170 continue 171 length = len(toks) 172 for w, c in Counter(toks).items(): 173 mat[i, index[w]] = (c / length) * idf[w] 174 175 norms = np.sqrt((mat * mat).sum(axis=1, keepdims=True)) 176 norms[norms == 0] = 1.0 177 mat /= norms 178 179 sim = mat @ mat.T 180 np.fill_diagonal(sim, 0.0) 181 row_sum = sim.sum(axis=1, keepdims=True) 182 row_sum[row_sum == 0] = 1.0 183 transition = sim / row_sum 184 185 base = (1.0 - _DAMPING) / n 186 scores = np.full(n, 1.0 / n) 187 for _ in range(_MAX_ITERS): 188 prev = scores 189 scores = base + _DAMPING * (transition.T @ scores) 190 if np.abs(scores - prev).sum() < _TOL: 191 break 192 return scores.tolist()
▸失败路径不是旁路
26def output_dir(cwd, cfg): 27 """Resolve the output dir, confined to within cwd, or None if none is safe. 28 29 A project-local config is itself untrusted (it ships in the repo), so it must 30 not be able to redirect writes outside the project via an absolute path or 31 `..`. A pre-planted symlink at the default `.recall` is just as untrusted: an 32 escaping config value falls back to `.recall`, but if even that resolves 33 outside cwd (because `.recall` is a symlink), we return None and refuse to 34 write rather than land outside the project. Callers treat None as "skip". 35 """ 36 base = os.path.realpath(cwd) 37 requested = cfg.get("output_dir", ".recall") or ".recall" 38 for candidate in (requested, ".recall"): 39 target = os.path.realpath(os.path.join(cwd, candidate)) 40 if target == base or target.startswith(base + os.sep): 41 return target 42 return None 43 44 45def _in_output_dir(cwd, cfg, name): 46 d = output_dir(cwd, cfg) 47 return os.path.join(d, name) if d else None 48 49 50def context_path(cwd, cfg): 51 return _in_output_dir(cwd, cfg, "context.md") 52 53 54def history_path(cwd, cfg): 55 return _in_output_dir(cwd, cfg, "history.md") 56 57 58def state_path(cwd, cfg): 59 return _in_output_dir(cwd, cfg, ".capture.json") 60 61 62def pause_path(cwd, cfg): 63 return _in_output_dir(cwd, cfg, ".capture-paused")
测试与验证面
验证面主要看三类风险:输入格式漂移、外部工具失败、以及安全/路径/权限边界。
▸测试或防御性代码
84def _open_nofollow(path, append): 85 """Open for writing without following a symlink at the final path component. 86 87 Prevents a pre-planted symlink (e.g. history.md -> /etc/something) from 88 redirecting our writes. Raises OSError (ELOOP) if the target is a symlink. 89 """ 90 flags = os.O_WRONLY | os.O_CREAT | (os.O_APPEND if append else os.O_TRUNC) 91 if hasattr(os, "O_NOFOLLOW"): 92 flags |= os.O_NOFOLLOW 93 return os.fdopen(os.open(path, flags, 0o644), "w", encoding="utf-8") 94 95 96def write_text(path, data): 97 with _open_nofollow(path, append=False) as fh: 98 fh.write(data) 99 100 101def append_text(path, data): 102 with _open_nofollow(path, append=True) as fh: 103 fh.write(data) 104 105 106def load_state(cwd, cfg): 107 path = state_path(cwd, cfg) 108 if not path: 109 return {} 110 try: 111 with open(path, "r", encoding="utf-8") as fh: 112 data = json.load(fh) 113 return data if isinstance(data, dict) else {} 114 except (OSError, ValueError): 115 return {} 116 117 118# Per-session capture offsets accumulate one entry per session id forever, so 119# cap the file to bound a long-lived project's .capture.json. The caller updates 120# the current session last (dict preserves insertion order), so trimming the 121# oldest entries never drops the session being actively tracked. 122_MAX_TRACKED_SESSIONS = 500 123 124 125def save_state(cwd, cfg, state): 126 try: 127 if not ensure_output_dir(cwd, cfg): 128 return 129 if len(state) > _MAX_TRACKED_SESSIONS: 130 state = dict(list(state.items())[-_MAX_TRACKED_SESSIONS:]) 131 write_text(state_path(cwd, cfg), json.dumps(state)) 132 except OSError:
▸已知边界 / 失败模式
- 仓库发布节奏快,页面引用的行号必须以后续 clone 为准重新校验。
- 外部平台、模型 API 或本地 CLI 的错误不会总是同构,需要保留原始错误。
- 路径别名只用于质量门禁的
src/计数;页面代码块标出 repo 实际路径。
值得保留的设计
技术亮点之外,别忽略这些
这些风险来自源码结构和发布形态,不是对项目价值的否定。