Initial v0.1: memwalk CLI on top of memba

- Config in ~/.memwalk/config.toml (typer init writes it)
- Sources: git log walker + bash history parser with secret/noise filters
- Ingest orchestrator feeds events into a memba Session and saves
  ~/.memwalk/current.memb via the rewritten Session.chat() that preserves
  loaded state (raw eval+sample, no KV-cache reset)
- Daily snapshot rotation under ~/.memwalk/snapshots/
- CLI commands: init, update, ask, standup, status, prune

Validated on Nemotron-3-Nano-4B Q4_K_M end-to-end: ingest 304 events
(303 commits + 1 shell session) in 2.8s on GPU, recall in fresh process
returns accurate per-project summaries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-16 13:40:46 +03:00
co-authored by Claude Opus 4.7
commit b873144b69
11 changed files with 885 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
# Build
build/
dist/
# IDE
.vscode/
.idea/
*.swp
.DS_Store
# Claude Code
.claude/
# Local state files (the whole point is they're per-user)
*.memb
.memwalk/
+69
View File
@@ -0,0 +1,69 @@
# memwalk
> Walk through your work memory.
A local-first CLI that watches your git activity (and optionally your shell
history) and feeds it into a Mamba-based LLM via persistent state. You can
then ask in plain English what you were doing last week, why you started that
branch, or generate a standup from yesterday's commits — without your data
ever leaving the machine.
Built on **[memba](https://github.com/emil28092005/Memba)** for state
persistence and **[NVIDIA Nemotron-3-Nano-4B](https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF)**
(or any other GGUF SSM/hybrid model) for inference.
## Status
v0.1 — alpha. Works end-to-end on Linux for the maintainer; APIs and on-disk
format may change.
## Install
```bash
# Until memba is on PyPI, install both editable from local clones:
pip install -e /path/to/Memba
pip install -e /path/to/memwalk
```
Make sure you have a GGUF Mamba-2 or hybrid model. Recommended:
```bash
hf download nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF \
NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf \
--local-dir ~/.memwalk/models
```
## Quickstart
```bash
memwalk init # interactive setup
memwalk update # ingest last 30 days of git+bash
memwalk standup # auto-generate daily standup
memwalk ask "What was I working on last week?"
memwalk status
```
## What it actually does
`memwalk update` walks your configured git repos and (optionally) your bash
history, formats new events into a readable activity block, and feeds that
into the SSM model. The model's hidden state — a fixed ~85 MB blob — is
saved to `~/.memwalk/current.memb` via memba.
`memwalk ask` and `memwalk standup` load that state and query it. The model
recalls themes, projects, and trajectory across processes and reboots.
## Layout
```
~/.memwalk/
├── config.toml
├── current.memb ← rolling state
├── last_update.txt
└── snapshots/
└── 2026-05-16.memb ← daily snapshot before each update
```
## License
MIT.
+3
View File
@@ -0,0 +1,3 @@
"""memwalk — semantic recall of your work via local SSM models."""
__version__ = "0.1.0"
+187
View File
@@ -0,0 +1,187 @@
"""memwalk CLI — typer entry point."""
from __future__ import annotations
from datetime import datetime, timedelta
from pathlib import Path
import typer
from rich.console import Console
from rich.prompt import Prompt
from rich.table import Table
from . import __version__
from .config import (
DEFAULT_MODEL_HINT, HOME_DIR, BashConfig, Config, GitConfig,
load_config, read_last_update, write_config,
)
from .ingest import open_session, query, update
from .snapshot import prune_old
from .sources import bash as bash_src
from .sources import git as git_src
cli = typer.Typer(name="memwalk", help="Walk through your work memory.",
add_completion=False)
console = Console()
# ── init ──────────────────────────────────────────────────────────
@cli.command()
def init(
model: str = typer.Option(None, "--model", help="Path to GGUF model"),
scan_path: list[str] = typer.Option(None, "--scan",
help="Directory to scan for git repos (repeatable)"),
no_bash: bool = typer.Option(False, "--no-bash", help="Disable bash history"),
force: bool = typer.Option(False, "--force", "-f",
help="Overwrite existing config"),
) -> None:
"""Interactive (or flag-driven) setup. Writes ~/.memwalk/config.toml."""
config_path = HOME_DIR / "config.toml"
if config_path.exists() and not force:
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
console.print("Use --force to overwrite, or edit the file by hand.")
raise typer.Exit(1)
console.print(f"[bold cyan]memwalk init v{__version__}[/bold cyan]\n")
# Model
if model is None:
console.print(DEFAULT_MODEL_HINT + "\n")
model = Prompt.ask("Path to GGUF model")
model_p = Path(model).expanduser()
if not model_p.exists():
console.print(f"[yellow]warning: {model_p} doesn't exist yet[/yellow]")
# Scan paths
if scan_path:
scan_paths = [Path(p).expanduser() for p in scan_path]
else:
default = str(Path.home() / "Desktop/Coding")
raw = Prompt.ask(
"Directories to scan for git repos (comma-separated)",
default=default,
)
scan_paths = [Path(p.strip()).expanduser() for p in raw.split(",") if p.strip()]
cfg = Config(
model_path=model_p,
git=GitConfig(scan_paths=scan_paths),
bash=BashConfig(enabled=not no_bash),
)
write_config(cfg)
console.print(f"\n[green]✓[/green] Wrote {cfg.config_path}")
console.print(f"[green]✓[/green] State dir: {cfg.state_dir}")
console.print(f"\nNext: [bold]memwalk update[/bold] to ingest the last 30 days")
# ── update ────────────────────────────────────────────────────────
@cli.command(name="update")
def update_cmd(
verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
"""Ingest new git+bash events since last update."""
cfg = load_config()
with console.status("Ingesting…"):
result = update(cfg, verbose=verbose)
if result["ingested"] == 0:
console.print(f"[dim]No new activity since {result['since'].strftime('%Y-%m-%d %H:%M')}[/dim]")
return
console.print(
f"[green]✓[/green] Ingested {result['ingested']} events "
f"([cyan]{result['git']}[/cyan] commits + "
f"[cyan]{result['bash']}[/cyan] shell sessions) "
f"in {result['elapsed_s']:.1f}s"
)
console.print(f" Window: {result['since'].strftime('%Y-%m-%d %H:%M')} → "
f"{result['until'].strftime('%Y-%m-%d %H:%M')}")
console.print(f" State : {result['state_size']:,} bytes"
+ (" (daily snapshot taken)" if result["snapshotted"] else ""))
# ── ask ───────────────────────────────────────────────────────────
@cli.command()
def ask(
question: str = typer.Argument(..., help="What to ask the model"),
max_tokens: int = typer.Option(400, "--max-tokens"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
"""Query the current state."""
cfg = load_config()
with console.status("Thinking…"):
answer = query(cfg, question, max_tokens=max_tokens, verbose=verbose)
console.print(answer)
# ── standup ───────────────────────────────────────────────────────
@cli.command()
def standup(
verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
"""Generate a brief 'what I did + what's next' summary from recent activity."""
cfg = load_config()
q = (
"Generate my daily standup notes. Cover: what I worked on yesterday "
"(grouped by project), what I plan today based on the trajectory, and "
"any blockers visible in the activity. Be concise — bullet points, "
"no preamble."
)
with console.status("Thinking…"):
answer = query(cfg, q, max_tokens=500, verbose=verbose)
console.print("[bold cyan]Standup:[/bold cyan]\n")
console.print(answer)
# ── status ────────────────────────────────────────────────────────
@cli.command()
def status() -> None:
"""Show config and state info."""
try:
cfg = load_config()
except FileNotFoundError as e:
console.print(f"[red]{e}[/red]")
raise typer.Exit(1)
last = read_last_update(cfg)
table = Table(show_header=False, box=None)
table.add_row("[bold]config[/bold]", str(cfg.config_path))
table.add_row("[bold]model[/bold]", str(cfg.model_path))
table.add_row("[bold]state[/bold]",
f"{cfg.state_path} ({cfg.state_path.stat().st_size:,} B)"
if cfg.state_path.exists() else f"{cfg.state_path} (none)")
table.add_row("[bold]scan paths[/bold]", ", ".join(str(p) for p in cfg.git.scan_paths))
table.add_row("[bold]bash[/bold]", "on" if cfg.bash.enabled else "off")
table.add_row("[bold]last update[/bold]",
last.strftime("%Y-%m-%d %H:%M") if last else "never")
if cfg.snapshots_dir.exists():
snaps = sorted(cfg.snapshots_dir.glob("*.memb"))
table.add_row("[bold]snapshots[/bold]",
f"{len(snaps)} " + (f"(latest {snaps[-1].stem})" if snaps else ""))
console.print(table)
# ── prune ─────────────────────────────────────────────────────────
@cli.command()
def prune(
keep_days: int = typer.Option(90, "--keep-days", help="Snapshots older than this are deleted"),
) -> None:
"""Delete old daily snapshots."""
cfg = load_config()
n = prune_old(cfg, keep_days=keep_days)
console.print(f"[dim]Deleted {n} snapshots older than {keep_days} days[/dim]")
def main() -> None:
cli()
if __name__ == "__main__":
main()
+146
View File
@@ -0,0 +1,146 @@
"""Config & state-tracking for memwalk.
Layout:
~/.memwalk/
config.toml # user settings
last_update.txt # ISO timestamp of last successful update
current.memb # rolling state file
snapshots/
2026-05-16.memb
...
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
HOME_DIR = Path.home() / ".memwalk"
DEFAULT_MODEL_HINT = (
"Recommended: NVIDIA Nemotron-3-Nano-4B (hybrid Mamba+Transformer).\n"
"Download with: hf download nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF "
"NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf --local-dir ~/.memwalk/models"
)
@dataclass
class GitConfig:
scan_paths: list[Path] = field(default_factory=list)
bootstrap_days: int = 30
@dataclass
class BashConfig:
enabled: bool = True
history_file: Path = Path.home() / ".bash_history"
session_gap_min: int = 30
@dataclass
class Config:
model_path: Path
n_gpu_layers: int = -1
n_ctx: int = 8192
state_dir: Path = HOME_DIR
git: GitConfig = field(default_factory=GitConfig)
bash: BashConfig = field(default_factory=BashConfig)
@property
def config_path(self) -> Path:
return HOME_DIR / "config.toml"
@property
def state_path(self) -> Path:
return self.state_dir / "current.memb"
@property
def snapshots_dir(self) -> Path:
return self.state_dir / "snapshots"
@property
def last_update_path(self) -> Path:
return self.state_dir / "last_update.txt"
def load_config() -> Config:
"""Load config.toml from ~/.memwalk/ or raise FileNotFoundError."""
path = HOME_DIR / "config.toml"
if not path.exists():
raise FileNotFoundError(
f"No config at {path}. Run `memwalk init` first."
)
with path.open("rb") as f:
data = tomllib.load(f)
model_path = Path(data["model_path"]).expanduser()
git_cfg = data.get("git", {})
bash_cfg = data.get("bash", {})
return Config(
model_path=model_path,
n_gpu_layers=int(data.get("n_gpu_layers", -1)),
n_ctx=int(data.get("n_ctx", 8192)),
state_dir=Path(data.get("state_dir", HOME_DIR)).expanduser(),
git=GitConfig(
scan_paths=[Path(p).expanduser() for p in git_cfg.get("scan_paths", [])],
bootstrap_days=int(git_cfg.get("bootstrap_days", 30)),
),
bash=BashConfig(
enabled=bool(bash_cfg.get("enabled", True)),
history_file=Path(bash_cfg.get("history_file",
Path.home() / ".bash_history")).expanduser(),
session_gap_min=int(bash_cfg.get("session_gap_min", 30)),
),
)
def write_config(cfg: Config) -> None:
"""Persist config to ~/.memwalk/config.toml using a hand-rolled writer
(Python stdlib has no TOML writer until 3.13+)."""
HOME_DIR.mkdir(parents=True, exist_ok=True)
cfg.state_dir.mkdir(parents=True, exist_ok=True)
cfg.snapshots_dir.mkdir(parents=True, exist_ok=True)
lines = [
"# memwalk config — edit by hand or rerun `memwalk init`",
f'model_path = "{cfg.model_path}"',
f"n_gpu_layers = {cfg.n_gpu_layers}",
f"n_ctx = {cfg.n_ctx}",
f'state_dir = "{cfg.state_dir}"',
"",
"[git]",
"scan_paths = [",
*[f' "{p}",' for p in cfg.git.scan_paths],
"]",
f"bootstrap_days = {cfg.git.bootstrap_days}",
"",
"[bash]",
f"enabled = {str(cfg.bash.enabled).lower()}",
f'history_file = "{cfg.bash.history_file}"',
f"session_gap_min = {cfg.bash.session_gap_min}",
]
cfg.config_path.write_text("\n".join(lines) + "\n")
def read_last_update(cfg: Config) -> datetime | None:
path = cfg.last_update_path
if not path.exists():
return None
try:
return datetime.fromisoformat(path.read_text().strip())
except ValueError:
return None
def write_last_update(cfg: Config, ts: datetime) -> None:
cfg.last_update_path.write_text(ts.isoformat())
+108
View File
@@ -0,0 +1,108 @@
"""Orchestrate: pull events from sources, format, feed to a memba Session."""
from __future__ import annotations
import time
from datetime import datetime, timedelta
from pathlib import Path
from memba import Session
from .config import Config, read_last_update, write_last_update
from .snapshot import maybe_snapshot
from .sources import Event, bash as bash_src, git as git_src
def open_session(cfg: Config, *, verbose: bool = False) -> Session:
"""Open a memba Session pointing at memwalk's rolling state file."""
return Session(
model_path=str(cfg.model_path),
session_id="current",
state_dir=str(cfg.state_dir),
n_gpu_layers=cfg.n_gpu_layers,
n_ctx=cfg.n_ctx,
chat_format="chatml",
verbose=verbose,
)
def collect_all(cfg: Config, since: datetime) -> dict[str, list[Event]]:
"""Pull events from every enabled source."""
out: dict[str, list[Event]] = {}
if cfg.git.scan_paths:
out["git"] = git_src.collect(cfg.git.scan_paths, since)
if cfg.bash.enabled:
out["bash"] = bash_src.collect(
cfg.bash.history_file, since, cfg.bash.session_gap_min
)
return out
def format_block(events_by_source: dict[str, list[Event]],
since: datetime, until: datetime) -> str:
"""Build a single human-readable text block for ingestion."""
chunks: list[str] = [
f"=== Activity from {since.date()} to {until.date()} ==="
]
if events_by_source.get("git"):
chunks.append(git_src.format_block(events_by_source["git"]))
if events_by_source.get("bash"):
chunks.append(bash_src.format_block(events_by_source["bash"]))
return "\n\n".join(chunks)
def update(cfg: Config, *, verbose: bool = False) -> dict:
"""Ingest new events into the state and return a small summary dict."""
now = datetime.now()
since = read_last_update(cfg)
if since is None:
# First run: bootstrap window
since = now - timedelta(days=cfg.git.bootstrap_days)
events = collect_all(cfg, since)
n_git = len(events.get("git", []))
n_bash = len(events.get("bash", []))
if n_git + n_bash == 0:
return {"ingested": 0, "git": 0, "bash": 0, "since": since, "until": now}
block = format_block(events, since, now)
# Rotate daily snapshot BEFORE mutating state
snapshotted = maybe_snapshot(cfg)
sess = open_session(cfg, verbose=verbose)
prompt = (
"I will now feed you a summary of my recent work activity. "
"Read it and incorporate into your understanding of what I've been "
"doing. Reply with just 'noted'.\n\n" + block
)
t0 = time.time()
ack = sess.chat(prompt, max_tokens=20)
elapsed = time.time() - t0
sess.save()
write_last_update(cfg, now)
return {
"ingested": n_git + n_bash,
"git": n_git,
"bash": n_bash,
"since": since,
"until": now,
"ack": ack,
"elapsed_s": elapsed,
"snapshotted": snapshotted,
"state_size": sess.state_size,
}
def query(cfg: Config, question: str, max_tokens: int = 400,
verbose: bool = False) -> str:
"""Load the current state and ask a question."""
if not cfg.state_path.exists():
raise FileNotFoundError("No state yet — run `memwalk update` first.")
sess = open_session(cfg, verbose=verbose)
return sess.chat(question, max_tokens=max_tokens)
+36
View File
@@ -0,0 +1,36 @@
"""Daily snapshot rotation for the rolling state file."""
from __future__ import annotations
import shutil
from datetime import datetime, timedelta
from .config import Config
def maybe_snapshot(cfg: Config) -> bool:
"""If current.memb exists and no snapshot for today, copy it. Returns True if snapshotted."""
if not cfg.state_path.exists():
return False
today = datetime.now().strftime("%Y-%m-%d")
target = cfg.snapshots_dir / f"{today}.memb"
if target.exists():
return False
cfg.snapshots_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(cfg.state_path, target)
return True
def prune_old(cfg: Config, keep_days: int = 90) -> int:
"""Delete snapshots older than @keep_days. Returns number deleted."""
if not cfg.snapshots_dir.exists():
return 0
cutoff = (datetime.now() - timedelta(days=keep_days)).strftime("%Y-%m-%d")
deleted = 0
for snap in cfg.snapshots_dir.glob("*.memb"):
if snap.stem < cutoff:
snap.unlink()
deleted += 1
return deleted
+13
View File
@@ -0,0 +1,13 @@
"""Event sources — each module exposes `collect(since: datetime) -> list[Event]`."""
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class Event:
"""A timestamped, human-readable activity record."""
source: str # "git", "bash", "obsidian", ...
ts: datetime
summary: str # one-line description used in ingestion blocks
detail: str = "" # optional longer body
+153
View File
@@ -0,0 +1,153 @@
"""
Bash history source.
Parses ~/.bash_history, handling the optional HISTTIMEFORMAT prefix
(`#<unix_ts>\\n<command>` blocks). Filters out noise (short / dupe / common
navigation / secret-looking lines) and groups remaining commands into
sessions separated by a configurable idle gap.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from . import Event
# Commands too short or too common to be worth remembering
_SKIP_EXACT = {
"ls", "ll", "la", "l", "cd", "cd -", "pwd", "clear", "exit", "fg", "bg",
"jobs", "history", "reset",
}
_SKIP_PREFIX = ("ls ", "cd ", "cat ", "less ", "tail ", "head ", "echo ",
"which ", "whereis ", "type ", "man ", "help ")
# Lines that look like they leak credentials — never ingest
_SECRET_PATTERNS = [
re.compile(r"(?i)(password|passwd|secret|api[_-]?key|access[_-]?token|bearer)\s*[=:]"),
re.compile(r"(?i)\b(aws|gcp|gh|github|hf|huggingface|openai|anthropic)[_-]?(token|key)"),
re.compile(r"(?i)sk-[a-z0-9-]{20,}"), # OpenAI/Anthropic API keys
re.compile(r"(?i)ghp_[a-z0-9]{30,}"), # GitHub PATs
]
def _is_noise(cmd: str) -> bool:
if len(cmd) < 3:
return True
if cmd in _SKIP_EXACT:
return True
for p in _SKIP_PREFIX:
if cmd.startswith(p) and len(cmd) < 25:
return True
return False
def _looks_secret(cmd: str) -> bool:
return any(p.search(cmd) for p in _SECRET_PATTERNS)
@dataclass
class _Raw:
ts: datetime | None
cmd: str
def _parse_history(path: Path) -> list[_Raw]:
"""Read history file, returning entries with timestamps where available."""
if not path.exists():
return []
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
entries: list[_Raw] = []
pending_ts: datetime | None = None
for line in lines:
if not line:
continue
if line.startswith("#") and line[1:].strip().isdigit():
# HISTTIMEFORMAT marker
try:
pending_ts = datetime.fromtimestamp(int(line[1:].strip()))
except (ValueError, OSError):
pending_ts = None
continue
entries.append(_Raw(ts=pending_ts, cmd=line.strip()))
pending_ts = None
return entries
def collect(
history_file: Path,
since: datetime,
session_gap_min: int = 30,
) -> list[Event]:
"""Return cleaned bash events newer than @since, grouped into sessions."""
raw = _parse_history(history_file)
if not raw:
return []
# Filter
clean: list[_Raw] = []
prev_cmd: str | None = None
for entry in raw:
cmd = entry.cmd
if not cmd or cmd.startswith("#"):
continue
if _is_noise(cmd) or _looks_secret(cmd):
continue
if cmd == prev_cmd:
continue
if entry.ts is not None and entry.ts < since:
continue
clean.append(entry)
prev_cmd = cmd
if not clean:
return []
# Group by session (gap > session_gap_min minutes starts a new one).
# Commands without timestamps are attributed to the previous session.
events: list[Event] = []
session: list[_Raw] = []
last_ts: datetime | None = None
fallback_ts = since # used when entries have no timestamps
def flush_session(items: list[_Raw]) -> None:
if not items:
return
ts = next((it.ts for it in items if it.ts is not None), fallback_ts)
commands = [it.cmd for it in items]
summary = f"shell session ({len(commands)} cmds): {commands[0][:60]}"
detail = "\n".join(f" $ {c}" for c in commands[:30])
if len(commands) > 30:
detail += f"\n … and {len(commands)-30} more"
events.append(Event(source="bash", ts=ts, summary=summary, detail=detail))
gap = session_gap_min * 60
for entry in clean:
if entry.ts is not None and last_ts is not None:
if (entry.ts - last_ts).total_seconds() > gap:
flush_session(session)
session = []
session.append(entry)
if entry.ts is not None:
last_ts = entry.ts
flush_session(session)
return events
def format_block(events: list[Event]) -> str:
if not events:
return ""
parts = ["shell activity:"]
for e in events:
date = e.ts.strftime("%Y-%m-%d %H:%M")
parts.append(f"\n [{date}] {e.summary}")
if e.detail:
parts.append(e.detail)
return "\n".join(parts)
+92
View File
@@ -0,0 +1,92 @@
"""Git event source — walks configured paths for repos and collects commits."""
from __future__ import annotations
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from . import Event
def find_repos(roots: list[Path]) -> list[Path]:
"""Return all distinct git repositories under the given roots (non-recursive
one level deep, plus the root itself if it's a repo)."""
repos: list[Path] = []
seen: set[Path] = set()
for root in roots:
if not root.exists():
continue
candidates: list[Path] = []
if (root / ".git").exists():
candidates.append(root)
for entry in root.iterdir():
if entry.is_dir() and (entry / ".git").exists():
candidates.append(entry)
for c in candidates:
real = c.resolve()
if real not in seen:
seen.add(real)
repos.append(c)
return repos
def collect(roots: list[Path], since: datetime) -> list[Event]:
"""Collect commits across @roots that landed after @since."""
since_iso = since.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
events: list[Event] = []
for repo in find_repos(roots):
try:
out = subprocess.check_output(
[
"git", "-C", str(repo), "log",
f"--since={since_iso}",
"--no-merges",
"--date=iso-strict",
"--pretty=format:%H%x09%ad%x09%an%x09%s",
],
text=True, stderr=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
continue
for line in out.splitlines():
parts = line.split("\t", 3)
if len(parts) != 4:
continue
sha, iso, author, subject = parts
try:
ts = datetime.fromisoformat(iso)
except ValueError:
continue
events.append(Event(
source="git",
ts=ts,
summary=f"[{repo.name}] {subject}",
detail=f"commit {sha[:10]} by {author}",
))
return events
def format_block(events: list[Event]) -> str:
"""Group commits by repo (extracted from `[name] subject`) into a readable block."""
if not events:
return ""
by_repo: dict[str, list[Event]] = {}
for e in events:
# Strip leading "[repo] " prefix to recover repo name
repo = e.summary.split("]", 1)[0].lstrip("[") if e.summary.startswith("[") else "unknown"
by_repo.setdefault(repo, []).append(e)
parts: list[str] = ["git activity:"]
for repo, items in sorted(by_repo.items()):
items.sort(key=lambda x: x.ts)
parts.append(f"\n {repo} ({len(items)} commits):")
for e in items:
date = e.ts.strftime("%Y-%m-%d")
subject = e.summary.split("] ", 1)[-1]
parts.append(f" {date} {subject}")
return "\n".join(parts)
+52
View File
@@ -0,0 +1,52 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "memwalk"
version = "0.1.0"
description = "Walk through your work memory — semantic recall of git, shell and notes via local SSM models"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
keywords = ["llm", "memory", "ssm", "mamba", "nemotron", "memba", "personal-ai", "cli"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
]
dependencies = [
"memba>=0.1.0",
"typer>=0.9.0",
"rich>=13.0.0",
"tomli>=2.0;python_version<'3.11'",
]
[project.optional-dependencies]
dev = ["pytest>=7.0", "ruff", "mypy"]
[project.scripts]
memwalk = "memwalk.cli:main"
[project.urls]
Homepage = "https://github.com/emil28092005/memwalk"
Repository = "https://github.com/emil28092005/memwalk"
[tool.setuptools.packages.find]
include = ["memwalk*"]
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
ignore = ["E501"]