Files
emilandClaude Opus 4.7 75c9ee4576 Add memba MVP: C++ core, Python SDK, CLI, examples, experiments
C++ core (libmemba.so):
- include/memba/state.h — C API (state_new/free/save/load/get_size)
- src/state.cpp — MEMB file format: magic, version, SHA-256 model_id,
  CRC-32, opaque llama_state_*_data() blob
- src/cli.cpp — minimal demo binary with greedy sampler
- CMakeLists.txt + build.sh with llama.cpp submodule, CUDA auto-detect

Python SDK (memba):
- core.py — file I/O via llama-cpp-python's exposed C functions,
  unwraps _LlamaContext to access raw context pointer (≥0.3.x)
- session.py — high-level Session with auto-save/load, ChatML wrapper
  for instruct models, raw mode for base models
- cli.py — typer-based: chat (REPL), run (one-shot), list, rm, info

Examples:
- 01_basic_save_load.py, 02_chat_session.py

Experiments (throwaway POCs documenting product-direction findings):
- recall_poc.py — git log → state → cross-process query
- mood_poc.py — batch sentiment trajectory, Mamba vs Transformer
- mood_stream_poc.py, mood_batch_poc.py — variants
- diag_saveload.py — minimal save/load isolation test
- README.md documents the headline finding: save/load is byte-identical,
  but Falcon-Mamba-7B-Instruct does not retain facts across conversation
  turns even in-process — limits viable products to single-prompt analysis
  and persona priming.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 12:48:37 +03:00

258 lines
9.3 KiB
Python

"""
memba CLI — typer-based entry point.
Sub-commands
------------
memba chat --model <gguf> --session <name> [--gpu-layers N] → REPL
memba run --model <gguf> --prompt <text> [--save-state <f>] → one-shot
memba list [--state-dir <dir>] → list sessions
memba rm <session> [--state-dir <dir>] → delete session
memba info <session> [--state-dir <dir>] → show metadata
"""
from __future__ import annotations
import struct
import sys
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from rich.prompt import Prompt
from . import core
from .session import Session
app = Console()
cli = typer.Typer(
name="memba",
help="Persistent memory layer for SSM-based LLMs (Falcon-Mamba, Zamba).",
add_completion=False,
)
console = Console()
_DEFAULT_STATE_DIR = "~/.memba/states"
# ── Helpers ────────────────────────────────────────────────────────
def _state_dir_path(state_dir: str) -> Path:
return Path(state_dir).expanduser()
def _list_sessions(state_dir: Path) -> list[Path]:
if not state_dir.exists():
return []
return sorted(state_dir.glob("*.memb"))
def _parse_memb_header(path: Path) -> dict:
"""Return header fields from a .memb file without loading the full blob."""
HEADER_FMT = "<4sI64sIIQ"
HEADER_SIZE = struct.calcsize(HEADER_FMT)
raw = path.read_bytes()
if len(raw) < HEADER_SIZE:
return {}
magic, version, model_id, n_ctx, _, data_size = struct.unpack_from(HEADER_FMT, raw)
return {
"magic": magic,
"version": version,
"model_id": model_id.rstrip(b"\x00").decode("ascii", errors="replace"),
"n_ctx": n_ctx,
"data_size": data_size,
"file_size": path.stat().st_size,
}
# ── chat — interactive REPL ────────────────────────────────────────
@cli.command()
def chat(
model: str = typer.Option(..., "--model", "-m", help="Path to GGUF model"),
session: str = typer.Option("default", "--session", "-s", help="Session name"),
gpu_layers: int = typer.Option(0, "--gpu-layers", "-g", help="GPU layers (0=CPU, -1=all)"),
n_ctx: int = typer.Option(4096, "--n-ctx", help="Context size"),
state_dir: str = typer.Option(_DEFAULT_STATE_DIR, "--state-dir"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
max_tokens: int = typer.Option(512, "--max-tokens", help="Max tokens per turn"),
chat_format: str = typer.Option("chatml", "--chat-format",
help="Prompt template: chatml (instruct) | raw (base)"),
) -> None:
"""Interactive REPL with auto-save on exit (Ctrl-C or /exit)."""
console.print(f"[bold cyan]memba[/bold cyan] — session [green]{session!r}[/green]")
console.print(f"Model : [dim]{model}[/dim]")
console.print(f"GPU : {gpu_layers} layers Format: [magenta]{chat_format}[/magenta]")
console.print("Type [bold]/save[/bold] to checkpoint, [bold]/exit[/bold] or Ctrl-C to quit.\n")
with console.status("Loading model…"):
sess = Session(
model_path=model,
session_id=session,
state_dir=state_dir,
n_gpu_layers=gpu_layers,
n_ctx=n_ctx,
verbose=verbose,
chat_format=chat_format,
)
console.print(f"[dim]State size: {sess.state_size:,} bytes[/dim]\n")
try:
while True:
try:
user_input = Prompt.ask("[bold]You[/bold]")
except (EOFError, KeyboardInterrupt):
break
if not user_input.strip():
continue
if user_input.strip() == "/exit":
break
if user_input.strip() == "/save":
path = sess.save()
console.print(f"[dim]Saved → {path}[/dim]")
continue
with console.status("Thinking…"):
reply = sess.chat(user_input, max_tokens=max_tokens)
console.print(f"[bold green]Assistant[/bold green]: {reply}\n")
except KeyboardInterrupt:
pass
console.print("\n[dim]Saving state…[/dim]")
path = sess.save()
console.print(f"[bold]Session saved:[/bold] {path}")
# ── run — one-shot with optional save/load ─────────────────────────
@cli.command()
def run(
model: str = typer.Option(..., "--model", "-m", help="Path to GGUF model"),
prompt: str = typer.Option(..., "--prompt", "-p", help="Prompt text"),
save_state: Optional[str] = typer.Option(None, "--save-state", help="Write state to this path"),
load_state: Optional[str] = typer.Option(None, "--load-state", help="Load state from this path"),
gpu_layers: int = typer.Option(0, "--gpu-layers", "-g"),
n_ctx: int = typer.Option(4096, "--n-ctx"),
max_tokens: int = typer.Option(512, "--max-tokens"),
verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
"""One-shot generation with optional state save/load."""
from llama_cpp import Llama
with console.status("Loading model…"):
llama = Llama(
model_path=model,
n_ctx=n_ctx,
n_gpu_layers=gpu_layers,
verbose=verbose,
)
if load_state:
with console.status(f"Loading state from {load_state}…"):
core.load_state(llama, model, load_state)
console.print(f"[dim]State loaded: {load_state}[/dim]", file=sys.stderr)
result = llama(prompt, max_tokens=max_tokens, echo=False)
print(result["choices"][0]["text"])
if save_state:
core.save_state(llama, model, save_state)
console.print(f"[dim]State saved: {save_state}[/dim]", file=sys.stderr)
# ── list ───────────────────────────────────────────────────────────
@cli.command(name="list")
def list_sessions(
state_dir: str = typer.Option(_DEFAULT_STATE_DIR, "--state-dir"),
) -> None:
"""List all saved sessions."""
sdir = _state_dir_path(state_dir)
files = _list_sessions(sdir)
if not files:
console.print(f"[dim]No sessions in {sdir}[/dim]")
return
table = Table(title=f"Sessions in {sdir}", show_lines=False)
table.add_column("Session", style="bold cyan")
table.add_column("State (B)", justify="right")
table.add_column("File (B)", justify="right")
table.add_column("model_id", style="dim", no_wrap=True)
for f in files:
h = _parse_memb_header(f)
table.add_row(
f.stem,
f"{h.get('data_size', '?'):,}" if isinstance(h.get('data_size'), int) else "?",
f"{h.get('file_size', '?'):,}" if isinstance(h.get('file_size'), int) else "?",
h.get("model_id", "?")[:16] + "…",
)
console.print(table)
# ── rm ─────────────────────────────────────────────────────────────
@cli.command()
def rm(
session: str = typer.Argument(..., help="Session name to remove"),
state_dir: str = typer.Option(_DEFAULT_STATE_DIR, "--state-dir"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
) -> None:
"""Delete a saved session."""
sdir = _state_dir_path(state_dir)
path = sdir / f"{session}.memb"
if not path.exists():
console.print(f"[red]Session not found:[/red] {path}")
raise typer.Exit(1)
if not yes:
confirmed = typer.confirm(f"Delete {path}?")
if not confirmed:
raise typer.Abort()
path.unlink()
console.print(f"[dim]Deleted:[/dim] {path}")
# ── info ───────────────────────────────────────────────────────────
@cli.command()
def info(
session: str = typer.Argument(..., help="Session name"),
state_dir: str = typer.Option(_DEFAULT_STATE_DIR, "--state-dir"),
) -> None:
"""Show metadata stored in a session's state file."""
sdir = _state_dir_path(state_dir)
path = sdir / f"{session}.memb"
if not path.exists():
console.print(f"[red]Session not found:[/red] {path}")
raise typer.Exit(1)
h = _parse_memb_header(path)
if not h:
console.print("[red]Cannot parse header (truncated file?)[/red]")
raise typer.Exit(1)
console.print(f"[bold]Session:[/bold] {session}")
console.print(f" File : {path}")
console.print(f" Magic : {h['magic']!r}")
console.print(f" Version : {h['version']}")
console.print(f" model_id: {h['model_id']}")
console.print(f" n_ctx : {h['n_ctx']}")
console.print(f" State : {h['data_size']:,} bytes")
console.print(f" File : {h['file_size']:,} bytes")
def main() -> None:
cli()
if __name__ == "__main__":
main()