- README.MD: add original-source-code and nano-claude-code sections, update overview table (4 subprojects), add v3.0 news entry, expand comparison table with memory/multi-agent/skills dimensions - nano-claude-code v3.0: multi-agent package (multi_agent/), memory package (memory/), skill package (skill/) with built-in /commit and /review skills, context compression (compaction.py), tool registry plugin system, diff view, 17 slash commands, 18 built-in tools, 101 tests (~5000 lines total) - original-source-code/src: add raw TypeScript source tree (1884 files) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
145 lines
5.2 KiB
Python
145 lines
5.2 KiB
Python
"""Memory file scanning with mtime tracking and freshness/age helpers.
|
|
|
|
Mirrors the key ideas from Claude Code's memoryScan.ts and memoryAge.ts:
|
|
- Scan memory directories, sort newest-first
|
|
- Format a manifest for display or AI relevance selection
|
|
- Report memory age in human-readable form ("today", "3 days ago")
|
|
- Emit a staleness caveat for memories older than 1 day
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from .store import get_memory_dir, parse_frontmatter, INDEX_FILENAME
|
|
|
|
MAX_MEMORY_FILES = 200
|
|
|
|
|
|
# ── Data model ─────────────────────────────────────────────────────────────
|
|
|
|
@dataclass
|
|
class MemoryHeader:
|
|
"""Lightweight descriptor loaded from a memory file's frontmatter.
|
|
|
|
Attributes:
|
|
filename: basename of the .md file
|
|
file_path: absolute path
|
|
mtime_s: modification time (seconds since epoch)
|
|
description: value from frontmatter `description:` field
|
|
type: value from frontmatter `type:` field
|
|
scope: "user" or "project"
|
|
"""
|
|
filename: str
|
|
file_path: str
|
|
mtime_s: float
|
|
description: str
|
|
type: str
|
|
scope: str
|
|
|
|
|
|
# ── Scanning ───────────────────────────────────────────────────────────────
|
|
|
|
def scan_memory_dir(mem_dir: Path, scope: str) -> list[MemoryHeader]:
|
|
"""Scan a single memory directory and return headers sorted newest-first.
|
|
|
|
Reads only the frontmatter (first ~30 lines) for efficiency.
|
|
Silently skips unreadable files. Caps at MAX_MEMORY_FILES entries.
|
|
"""
|
|
if not mem_dir.is_dir():
|
|
return []
|
|
|
|
headers: list[MemoryHeader] = []
|
|
for fp in mem_dir.glob("*.md"):
|
|
if fp.name == INDEX_FILENAME:
|
|
continue
|
|
try:
|
|
stat = fp.stat()
|
|
# Read only the first 30 lines for frontmatter
|
|
lines = fp.read_text(errors="replace").splitlines()[:30]
|
|
snippet = "\n".join(lines)
|
|
meta, _ = parse_frontmatter(snippet)
|
|
headers.append(MemoryHeader(
|
|
filename=fp.name,
|
|
file_path=str(fp),
|
|
mtime_s=stat.st_mtime,
|
|
description=meta.get("description", ""),
|
|
type=meta.get("type", ""),
|
|
scope=scope,
|
|
))
|
|
except Exception:
|
|
continue
|
|
|
|
headers.sort(key=lambda h: h.mtime_s, reverse=True)
|
|
return headers[:MAX_MEMORY_FILES]
|
|
|
|
|
|
def scan_all_memories() -> list[MemoryHeader]:
|
|
"""Scan both user and project memory directories, merged newest-first."""
|
|
user_dir = get_memory_dir("user")
|
|
proj_dir = get_memory_dir("project")
|
|
|
|
user_headers = scan_memory_dir(user_dir, "user")
|
|
proj_headers = scan_memory_dir(proj_dir, "project")
|
|
|
|
combined = user_headers + proj_headers
|
|
combined.sort(key=lambda h: h.mtime_s, reverse=True)
|
|
return combined[:MAX_MEMORY_FILES]
|
|
|
|
|
|
# ── Age / freshness ────────────────────────────────────────────────────────
|
|
|
|
def memory_age_days(mtime_s: float) -> int:
|
|
"""Days since mtime_s (floor-rounded, clamped to 0 for future times)."""
|
|
return max(0, math.floor((time.time() - mtime_s) / 86_400))
|
|
|
|
|
|
def memory_age_str(mtime_s: float) -> str:
|
|
"""Human-readable age: 'today', 'yesterday', or 'N days ago'."""
|
|
d = memory_age_days(mtime_s)
|
|
if d == 0:
|
|
return "today"
|
|
if d == 1:
|
|
return "yesterday"
|
|
return f"{d} days ago"
|
|
|
|
|
|
def memory_freshness_text(mtime_s: float) -> str:
|
|
"""Staleness caveat for memories older than 1 day (empty string if fresh).
|
|
|
|
Motivated by user reports of stale code-state memories (file:line
|
|
citations to code that has since changed) being asserted as fact.
|
|
"""
|
|
d = memory_age_days(mtime_s)
|
|
if d <= 1:
|
|
return ""
|
|
return (
|
|
f"This memory is {d} days old. "
|
|
"Memories are point-in-time observations, not live state — "
|
|
"claims about code behavior or file:line citations may be outdated. "
|
|
"Verify against current code before asserting as fact."
|
|
)
|
|
|
|
|
|
# ── Manifest formatting ────────────────────────────────────────────────────
|
|
|
|
def format_memory_manifest(headers: list[MemoryHeader]) -> str:
|
|
"""Format a list of MemoryHeader as a text manifest.
|
|
|
|
Format per line: [type/scope] filename (age): description
|
|
Example:
|
|
[feedback/user] feedback_testing.md (3 days ago): Don't mock DB in tests
|
|
[project/project] project_freeze.md (today): Merge freeze until 2026-04-10
|
|
"""
|
|
lines = []
|
|
for h in headers:
|
|
tag = f"[{h.type}/{h.scope}]" if h.type else f"[{h.scope}]"
|
|
age = memory_age_str(h.mtime_s)
|
|
if h.description:
|
|
lines.append(f"- {tag} {h.filename} ({age}): {h.description}")
|
|
else:
|
|
lines.append(f"- {tag} {h.filename} ({age})")
|
|
return "\n".join(lines)
|