recall离线项目记忆
§ 00 · Source-level Observatory

recall 离线项目记忆

用 Claude Code hook 捕获会话,把 history.md 本地摘要成 context.md,不依赖外部模型或网络。

★ 617
GitHub Stars
2.1K
LOC
hooks + scripts
Files
2026-06-25
Last commit
graph TD A[Platform or dataset event] --> B[recall contract layer] B --> C[Normalization and parsing] C --> D[Execution or evaluation loop] D --> E[Result, score, or local memory] C --> F[Validation and edge handling]
§ 00 · OVERVIEW

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

用 Claude Code hook 捕获会话,把 history.md 本地摘要成 context.md,不依赖外部模型或网络。 这次重做只围绕源码可见的边界:入口如何被解析、核心对象如何流转、失败路径如何被测试或显式降级。

阅读重点不是营销功能,而是从真实文件里抽出的契约链。下面的索引同时保留真实行号,便于回到 sources/recall/ 复核。

▸核心数据

架构模式
Hook 与本地文件拓扑
核心抽象
摘要器、捕获和安全边界
验证数据
以源码测试、解析器和配置表作为可复核证据
下游依赖
Claude Code plugin

▸源码证据索引

源文件地图: 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

§ 01 · ARCHITECTURE

Hook 与本地文件拓扑

架构可以拆成三段:输入契约、核心处理、输出/评分/写入。每段都有独立文件承接,而不是把逻辑堆在 README 示例里。

▸系统拓扑

graph TD A[Platform or dataset event] --> B[recall contract layer] B --> C[Normalization and parsing] C --> D[Execution or evaluation loop] D --> E[Result, score, or local memory] C --> F[Validation and edge handling]

▸模块清单

协议入口
事件、上下文、权限和 mutation intent 先归一成可校验契约。
调度核心
Dispatcher/runner 只交换 run、progress、result,不绑定具体平台 UI。
验证层
Parser、schema 和单测围绕失败输入设计,避免无声漂移。

▸关键数据结构

src/scripts/make_context.py · L35-83 repo: scripts/make_context.pyPython
  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
深度阅读 · TF-IDF + TextRank 本地摘要器 →
§ 02 · CORE MODULES

摘要器、捕获和安全边界

▸契约入口如何收口

第一组代码展示输入被分类、清洗或注册的地方;这些函数决定后续流程拿到的是结构化对象,而不是自由文本。

src/scripts/make_context.py · L91-150 repo: scripts/make_context.pyPython
  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 ""

▸执行/评测边界

第二组代码展示核心运行边界:请求体、运行输入、模型输出或本地写入都在这里被转换成下游可消费的形态。

src/scripts/make_context.py · L157-206 repo: scripts/make_context.pyPython
 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

▸状态与配置的交界

src/scripts/summarizer.py · L24-83 repo: scripts/summarizer.pyPython
  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])]
深度阅读 · 捕获、路径限制与 git hardening →
§ 03 · KEY FLOW

Stop → history → context 流程

关键流程从输入开始,经过规范化、执行或评分,再输出结果。源码里真正重要的是失败点是否被显式表达。

▸入口到规范化

[1] raw event / sample → parser → typed payload
[2] payload → policy / prompt / context → bounded execution
src/scripts/summarizer.py · L96-155 repo: scripts/summarizer.pyPython
  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

▸结果回写或评分

result → validation → persistence / reply / aggregate score
src/scripts/summarizer.py · L160-192 repo: scripts/summarizer.pyPython
 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()

▸失败路径不是旁路

src/scripts/common.py · L26-63 repo: scripts/common.pyPython
  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")
§ 04 · TESTS & VALIDATION

测试与验证面

验证面主要看三类风险:输入格式漂移、外部工具失败、以及安全/路径/权限边界。

▸测试或防御性代码

src/scripts/common.py · L84-132 repo: scripts/common.pyPython
  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 实际路径。
§ 05 · HIGHLIGHTS

值得保留的设计

契约清晰
核心边界被拆到独立模块,读者可以直接从行号回溯。
失败可见
解析、评分、路径和权限错误大多被显式编码,而不是吞掉。
局部可替换
平台、模型或摘要后端通过配置/接口切换。
测试有抓手
单测围绕 parser、security、config 或 runner 这样的真实风险点。
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

这些风险来自源码结构和发布形态,不是对项目价值的否定。

文档与实现可能错位
README 的高层叙述需要用当前 clone 的源码重新核验。
边界依赖外部系统
平台 API、模型服务或 Claude Code 本身会引入非仓库内可控变量。
评测/摘要可被输入污染
长上下文、prompt tag、转录文本都需要清洗策略。
本地状态要谨慎
写入历史、SQLite 或工作树时必须保留路径与权限边界。
诚实记录 — 本页只解释当前源码能证明的内容;模型权重表现、平台 SLA 或未发布路线不作外推。
Generated · 2026-06-29Source · raiyanyahya/recall← 返回 Trending 索引