Files
memwalk/memwalk/corpus.py
T
emilandClaude Opus 4.7 fe5642e0f2 v0.2: full repurpose — codebase exploration with per-directory cache
BREAKING. memwalk is no longer a personal-activity recorder; it's
an AI tool for asking questions about any codebase, with cached
SSM state per directory.

What's gone
-----------
- sources/git.py, sources/bash.py — personal activity collectors
- snapshot.py — daily snapshot rotation
- ingest.py — orchestration tied to git+bash use case
- standup / update CLI commands
- All v0.1 config keys (scan_paths, bash settings, bootstrap_days)

What's new
----------
- corpus.py        — walk a codebase, filter source files, build a single
                     ingest-ready text block with a stable manifest hash
                     for cache invalidation.
- cache.py         — per-directory cached state + sidecar metadata JSON.
                     Cache key = sha256(abs_path)[:16]; freshness check =
                     manifest hash over (rel_path, size, mtime_ns).
- engine.py        — shared digest/ask orchestration used by both CLI
                     and MCP server.
- cli.py           — init, digest, ask, list, drop, status, mcp.
- mcp_server.py    — tools: digest, ask, list_caches, drop_cache, status.
- config.py        — drastically simplified (just model+inference defaults).

The MCP server still ships as `memwalk mcp` and works the same way with
Claude Code / opencode.

Validated on memwalk's own source: digest in ~4s, ask answers in ~3s
(model+state load) including "list CLI commands", "where is cache
stored, what filename pattern", "how does cache invalidation work" —
all accurate down to specific details (sha256 length, file extensions,
metadata field semantics).

memba dep installed via git URL until both packages reach PyPI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 14:52:43 +03:00

125 lines
4.2 KiB
Python

"""Codebase walker — produces a single ingest-ready text block + a stable
manifest hash for cache invalidation."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
# Source-ish file extensions we read by default. Override with --extensions.
DEFAULT_INCLUDE_SUFFIXES: frozenset[str] = frozenset({
".py", ".pyi",
".c", ".h", ".cpp", ".hpp", ".cc", ".cxx",
".rs", ".go", ".java", ".kt", ".scala", ".swift",
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte",
".rb", ".php", ".cs", ".fs", ".ex", ".exs", ".erl", ".clj", ".cljs",
".sh", ".bash", ".zsh", ".fish", ".ps1",
".toml", ".yaml", ".yml", ".json", ".xml", ".ini", ".cfg", ".conf",
".md", ".rst", ".txt",
".sql", ".graphql", ".proto",
".dockerfile", ".tf", ".hcl",
})
# Directories we never descend into.
DEFAULT_EXCLUDE_DIRS: frozenset[str] = frozenset({
".git", ".hg", ".svn",
"__pycache__", "node_modules", "vendor", "third_party",
".venv", "venv", "env", ".env",
"build", "dist", "target", "out", "bin", "obj",
".next", ".nuxt", ".cache",
".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
"coverage", ".coverage", "htmlcov",
".idea", ".vscode",
"llama.cpp", # common vendored ML dep — too big
})
# Glob patterns for files we always skip.
DEFAULT_EXCLUDE_PATTERNS: tuple[str, ...] = (
"*.gguf", "*.safetensors", "*.bin", "*.onnx", "*.pt", "*.pth",
"*.so", "*.so.*", "*.dylib", "*.dll",
"*.o", "*.a", "*.obj", "*.exe",
"*.pyc", "*.pyo",
"*.memb",
"package-lock.json", "yarn.lock", "Cargo.lock", "uv.lock",
"poetry.lock", "Pipfile.lock", "*.lock",
)
DEFAULT_MAX_FILE_BYTES: int = 64 * 1024
@dataclass(frozen=True, slots=True)
class CorpusFile:
rel_path: str # path relative to corpus root, forward slashes
bytes: int
mtime_ns: int
text: str # file content (UTF-8, replaced on errors)
def collect_files(
root: Path,
*,
include_suffixes: frozenset[str] = DEFAULT_INCLUDE_SUFFIXES,
exclude_dirs: frozenset[str] = DEFAULT_EXCLUDE_DIRS,
exclude_patterns: tuple[str, ...] = DEFAULT_EXCLUDE_PATTERNS,
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
) -> list[CorpusFile]:
"""Walk @root, return CorpusFile entries sorted by relative path."""
out: list[CorpusFile] = []
for p in root.rglob("*"):
if not p.is_file():
continue
if any(part in exclude_dirs for part in p.parts):
continue
if p.suffix and p.suffix not in include_suffixes:
continue
if not p.suffix and p.name.lower() not in {"dockerfile", "makefile"}:
continue
if any(p.match(pat) for pat in exclude_patterns):
continue
try:
st = p.stat()
except OSError:
continue
if st.st_size > max_file_bytes:
continue
try:
text = p.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
rel = p.relative_to(root).as_posix()
out.append(CorpusFile(
rel_path=rel, bytes=st.st_size, mtime_ns=st.st_mtime_ns, text=text,
))
out.sort(key=lambda f: f.rel_path)
return out
def build_corpus(root: Path, files: list[CorpusFile]) -> str:
"""Format files into a single text block with a manifest at the top."""
if not files:
return ""
n_chars = sum(len(f.text) for f in files)
header = (
f"=== CODEBASE: {root.name} ===\n"
f"{len(files)} files, {n_chars:,} characters total.\n\n"
f"File manifest:\n"
+ "\n".join(f" {f.rel_path}" for f in files)
+ "\n"
)
bodies = "\n".join(
f"\n=== {f.rel_path} ({f.bytes} bytes) ===\n{f.text}"
for f in files
)
return header + bodies
def manifest_hash(files: list[CorpusFile]) -> str:
"""Stable SHA-256 over (rel_path, size, mtime_ns) tuples.
Changes whenever any included file is added, removed, or modified.
Returns 16 hex chars (enough for cache key uniqueness, easy to log)."""
h = hashlib.sha256()
for f in files:
h.update(f"{f.rel_path}\0{f.bytes}\0{f.mtime_ns}\n".encode("utf-8"))
return h.hexdigest()[:16]