Files
pancake fb3589b8b1 Implemented the CLI-first workflow.
Key improvements:

  - Native compile, merge, datasets, review, and clean commands.
  - Direct options for commands, learn, build, verify, pending, and propose; -- remains only for future escape-hatch options.
  - Training presets: qwen, minicpm5, and lfm25.
  - Unified review across memory, agentic, and legacy TSV queues.
  - Rich status showing dataset freshness, focused/workflow counts, GGUF paths, and whether retraining is needed.
  - Ollama and llama.cpp interactive chat backends.
  - CLI-first memory submission instructions.
  - Make remains available through r2ai-model make.
2026-07-12 13:58:22 +02:00

514 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""Collect human memory notes and export them as training rows."""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent
MEMORY_DIR = ROOT / "data" / "memory"
TOPICS_PATH = MEMORY_DIR / "topics.jsonl"
MEMORY_PATH = MEMORY_DIR / "memory.jsonl"
TRAINING_PATH = MEMORY_DIR / "verified.jsonl"
SYSTEM_PROMPT = (
"You are a radare2 assistant trained from human corrections. "
"Prefer the corrected human memory when answering."
)
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def stable_hash(*parts: object, length: int = 16) -> str:
data = "\n".join(str(part) for part in parts)
return hashlib.sha256(data.encode("utf-8")).hexdigest()[:length]
def read_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.is_file():
return []
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as f:
for lineno, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
except json.JSONDecodeError as exc:
raise SystemExit(f"invalid JSONL in {path}:{lineno}") from exc
if isinstance(item, dict):
rows.append(item)
return rows
def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
def append_jsonl(path: Path, row: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
def split_tags(value: str | None) -> list[str]:
if not value:
return []
tags: list[str] = []
for item in value.replace(";", ",").split(","):
tag = item.strip()
if tag and tag not in tags:
tags.append(tag)
return tags
def one_line(value: str) -> str:
return " ".join(value.strip().split())
def memory_messages(topic: str, highlight: str, details: str) -> list[dict[str, str]]:
answer = f"{highlight.strip()}\n\nDetails:\n{details.strip()}"
return [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"What did the human clarify about {topic.strip()}?"},
{"role": "assistant", "content": answer.strip()},
]
def make_memory_row(
topic: str,
highlight: str,
details: str,
tags: list[str],
source: str,
question: str = "",
) -> dict[str, Any]:
topic = one_line(topic)
highlight = one_line(highlight)
details = details.strip()
fingerprint = stable_hash(topic, highlight, details)
row_id = f"memory.{stable_hash(topic, fingerprint)}"
return {
"content_fingerprint": fingerprint,
"created_at": utc_now(),
"details": details,
"highlight": highlight,
"id": row_id,
"kind": "human_memory",
"messages": memory_messages(topic, highlight, details),
"question": one_line(question) if question else "",
"source": {"channel": source},
"status": "accepted",
"tags": tags,
"topic": topic,
}
def export_training_rows() -> int:
rows = []
seen: set[str] = set()
for row in read_jsonl(MEMORY_PATH):
if row.get("status") != "accepted":
continue
row_id = str(row.get("id", ""))
messages = row.get("messages")
if not row_id or row_id in seen or not isinstance(messages, list):
continue
seen.add(row_id)
rows.append(
{
"content_fingerprint": row.get("content_fingerprint", ""),
"id": row_id,
"kind": "human_memory",
"messages": messages,
"source_refs": ["data/memory/memory.jsonl"],
"tags": row.get("tags", []),
"topic": row.get("topic", ""),
}
)
write_jsonl(TRAINING_PATH, rows)
return len(rows)
def add_topic(args: argparse.Namespace) -> int:
topic = one_line(args.topic)
if not topic:
print("missing topic", file=sys.stderr)
return 1
question = one_line(args.question or topic)
rows = read_jsonl(TOPICS_PATH)
topic_id = f"topic.{stable_hash(topic, question)}"
for row in rows:
if row.get("id") == topic_id:
print(f"already queued {topic_id}")
return 0
row = {
"created_at": utc_now(),
"id": topic_id,
"question": question,
"source": {"channel": args.source},
"status": "pending",
"tags": split_tags(args.tags),
"topic": topic,
}
append_jsonl(TOPICS_PATH, row)
print(f"queued {topic_id}")
return 0
def remember(args: argparse.Namespace) -> int:
topic = one_line(args.topic)
highlight = one_line(args.highlight)
details = args.details
if details == "-":
details = sys.stdin.read()
details = (details or "").strip()
if not topic or not highlight or not details:
print("topic, highlight, and details are required", file=sys.stderr)
return 1
row = make_memory_row(
topic=topic,
highlight=highlight,
details=details,
tags=split_tags(args.tags),
source=args.source,
question=args.question or "",
)
existing = read_jsonl(MEMORY_PATH)
existing_ids = {str(item.get("id")) for item in existing}
if row["id"] in existing_ids:
print(f"already remembered {row['id']}")
else:
append_jsonl(MEMORY_PATH, row)
print(f"remembered {row['id']}")
count = export_training_rows()
print(f"exported {count} memory training rows to {TRAINING_PATH.relative_to(ROOT)}")
return 0
def print_topics(args: argparse.Namespace) -> int:
rows = read_jsonl(TOPICS_PATH)
if not args.all:
rows = [row for row in rows if row.get("status") == "pending"]
if not rows:
print("no memory topics")
return 0
for row in rows:
tags = ",".join(row.get("tags") or [])
suffix = f" tags={tags}" if tags else ""
print(f"{row.get('status', 'pending')} {row.get('id')} {row.get('topic')}{suffix}")
question = str(row.get("question") or "").strip()
if question and question != row.get("topic"):
print(f" {question}")
return 0
def pending_topics() -> list[dict[str, Any]]:
return [row for row in read_jsonl(TOPICS_PATH) if row.get("status") == "pending"]
def memory_answer_template(topic: dict[str, Any]) -> dict[str, Any]:
return {
"id": topic.get("id", ""),
"highlight": "one sentence with the corrected or clarified fact",
"details": "full explanation, examples, commands, caveats, and any context the model must learn",
"tags": topic.get("tags") or ["radare2", "memory"],
}
def agentic_memory_payload(topic: dict[str, Any], pending_count: int) -> dict[str, Any]:
template = memory_answer_template(topic)
return {
"status": "pending",
"id": topic.get("id", ""),
"pending_count": pending_count,
"question": topic.get("question") or topic.get("topic") or "",
"source": topic.get("source", {}),
"submit": {
"stdin": "r2ai-model answer < answer.json",
"file": "r2ai-model answer --file answer.json",
},
"tags": topic.get("tags") or [],
"topic": topic.get("topic", ""),
"answer_template": template,
"answer_format": template,
}
def print_agentic_memory(args: argparse.Namespace) -> int:
pending = pending_topics()
if not pending:
if args.format == "json":
print(json.dumps({"pending_count": 0, "status": "empty"}, ensure_ascii=False, indent=2))
else:
print("no pending memory topics")
return 0
current = pending[0]
payload = agentic_memory_payload(current, len(pending))
if args.format == "json":
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
print(f"Memory topic: {payload['topic']}")
print(f"Topic id: {payload['id']}")
print(f"Pending: {payload['pending_count']}")
tags = ",".join(payload.get("tags") or [])
if tags:
print(f"Tags: {tags}")
print("\nQuestion:")
print(payload["question"])
print("\nAnswer JSON template:")
print(json.dumps(payload["answer_template"], ensure_ascii=False, indent=2))
print("\nSubmit with stdin:")
print(" r2ai-model answer < answer.json")
print("\nSubmit with a file:")
print(" r2ai-model answer --file answer.json")
return 0
def load_answer_payloads(path: str) -> list[dict[str, Any]]:
if path in {"", "-"}:
raw = sys.stdin.read()
else:
raw = Path(path).read_text(encoding="utf-8")
raw = raw.strip()
if not raw:
raise SystemExit("empty memory answer payload")
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise SystemExit(f"memory answer payload must be JSON: {exc}") from exc
if isinstance(parsed, dict):
return [parsed]
if isinstance(parsed, list) and all(isinstance(item, dict) for item in parsed):
return parsed
raise SystemExit("memory answer payload must be a JSON object or list of objects")
def tags_from_payload(value: Any, fallback: list[str]) -> list[str]:
if isinstance(value, list):
return list(dict.fromkeys(str(item).strip() for item in value if str(item).strip()))
if isinstance(value, str):
tags = split_tags(value)
if tags:
return tags
return fallback
def source_from_payload(value: Any, fallback: str) -> str:
if isinstance(value, dict) and value.get("channel"):
return str(value.get("channel"))
if isinstance(value, str) and value.strip():
return value.strip()
return fallback
def remember_payload(payload: dict[str, Any], rows: list[dict[str, Any]], args: argparse.Namespace) -> str:
topic_id = str(payload.get("id") or payload.get("topic_id") or payload.get("memory_topic_id") or "").strip()
if not topic_id:
raise SystemExit("memory answer payload is missing id")
current = next((row for row in rows if row.get("id") == topic_id), None)
if current is None:
raise SystemExit(f"memory topic id not found: {topic_id}")
if current.get("status") == "answered" and current.get("memory_id"):
return str(current.get("memory_id"))
highlight = one_line(str(payload.get("highlight") or ""))
details_value = payload.get("details") or ""
if isinstance(details_value, list):
details = "\n".join(str(item) for item in details_value).strip()
else:
details = str(details_value).strip()
if not highlight or not details:
raise SystemExit("memory answer payload requires highlight and details")
tags = tags_from_payload(payload.get("tags"), list(current.get("tags") or ["radare2", "memory"]))
memory = make_memory_row(
topic=str(current.get("topic") or ""),
highlight=highlight,
details=details,
tags=tags,
source=source_from_payload(payload.get("source"), args.source),
question=str(current.get("question") or ""),
)
existing_ids = {str(row.get("id")) for row in read_jsonl(MEMORY_PATH)}
if memory["id"] not in existing_ids:
append_jsonl(MEMORY_PATH, memory)
current["status"] = "answered"
current["answered_at"] = utc_now()
current["memory_id"] = memory["id"]
return str(memory["id"])
def answer_file(args: argparse.Namespace) -> int:
payloads = load_answer_payloads(args.file)
rows = read_jsonl(TOPICS_PATH)
remembered: list[str] = []
for payload in payloads:
remembered.append(remember_payload(payload, rows, args))
write_jsonl(TOPICS_PATH, rows)
count = export_training_rows()
for memory_id in remembered:
print(f"remembered {memory_id}")
print(f"exported {count} memory training rows to {TRAINING_PATH.relative_to(ROOT)}")
return 0
def prompt_line(prompt: str, default: str = "") -> str:
if default:
value = input(f"{prompt} [{default}]: ").strip()
return value or default
return input(f"{prompt}: ").strip()
def prompt_details() -> str:
print("Details, finish with a single '.' line:")
lines: list[str] = []
while True:
try:
line = input()
except EOFError:
break
if line == ".":
break
lines.append(line)
return "\n".join(lines).strip()
def ask(args: argparse.Namespace) -> int:
rows = read_jsonl(TOPICS_PATH)
pending = [row for row in rows if row.get("status") == "pending"]
if not pending:
if not sys.stdin.isatty():
print("no pending memory topics")
return 0
print("No pending memory topics. Create one now or press enter to stop.")
topic = prompt_line("Topic")
if not topic:
return 0
add_args = argparse.Namespace(
topic=topic,
question=prompt_line("Question", topic),
tags=prompt_line("Tags", "radare2,memory"),
source=args.source,
)
add_topic(add_args)
rows = read_jsonl(TOPICS_PATH)
pending = [row for row in rows if row.get("status") == "pending"]
if not pending:
return 0
current = pending[0]
print(f"Topic: {current.get('topic')}")
print(f"Question: {current.get('question') or current.get('topic')}")
print("Commands: /skip keeps it pending, /drop removes it, /quit stops.")
highlight = prompt_line("Highlight")
if highlight == "/quit":
return 0
if highlight in {"/skip", ""}:
print("kept pending")
return 0
if highlight == "/drop":
current["status"] = "dropped"
current["answered_at"] = utc_now()
write_jsonl(TOPICS_PATH, rows)
print(f"dropped {current.get('id')}")
return 0
details = prompt_details()
if not details:
print("empty details, kept pending")
return 1
default_tags = ",".join(current.get("tags") or ["radare2", "memory"])
tags = split_tags(prompt_line("Tags", default_tags))
memory = make_memory_row(
topic=str(current.get("topic") or ""),
highlight=highlight,
details=details,
tags=tags,
source=args.source,
question=str(current.get("question") or ""),
)
existing_ids = {str(row.get("id")) for row in read_jsonl(MEMORY_PATH)}
if memory["id"] not in existing_ids:
append_jsonl(MEMORY_PATH, memory)
current["status"] = "answered"
current["answered_at"] = utc_now()
current["memory_id"] = memory["id"]
write_jsonl(TOPICS_PATH, rows)
count = export_training_rows()
print(f"remembered {memory['id']}")
print(f"exported {count} memory training rows to {TRAINING_PATH.relative_to(ROOT)}")
return 0
def export_command(args: argparse.Namespace) -> int:
count = export_training_rows()
print(f"exported {count} memory training rows to {TRAINING_PATH.relative_to(ROOT)}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command")
ask_parser = sub.add_parser("ask", help="answer the next pending topic interactively")
ask_parser.add_argument("--source", default="terminal", help="memory transport name")
ask_parser.set_defaults(func=ask)
add_parser = sub.add_parser("add-topic", help="queue a topic for human clarification")
add_parser.add_argument("topic")
add_parser.add_argument("--question", default="", help="specific question to ask")
add_parser.add_argument("--tags", default="", help="comma-separated tags")
add_parser.add_argument("--source", default="terminal", help="memory transport name")
add_parser.set_defaults(func=add_topic)
list_parser = sub.add_parser("list", help="list pending memory topics")
list_parser.add_argument("--all", action="store_true", help="include answered and dropped topics")
list_parser.set_defaults(func=print_topics)
next_parser = sub.add_parser("next", help="print the next pending topic and non-interactive answer template")
next_parser.add_argument("--format", choices=["text", "json"], default="text")
next_parser.set_defaults(func=print_agentic_memory)
answer_file_parser = sub.add_parser("answer-file", help="record one or more JSON memory answers from stdin or a file")
answer_file_parser.add_argument("--file", default="-", help="JSON answer file, or '-' for stdin")
answer_file_parser.add_argument("--source", default="agentic-memory", help="memory transport name")
answer_file_parser.set_defaults(func=answer_file)
remember_parser = sub.add_parser("remember", help="record a memory directly")
remember_parser.add_argument("--topic", required=True)
remember_parser.add_argument("--highlight", required=True)
remember_parser.add_argument("--details", required=True, help="details text, or '-' to read stdin")
remember_parser.add_argument("--question", default="", help="question this memory answers")
remember_parser.add_argument("--tags", default="", help="comma-separated tags")
remember_parser.add_argument("--source", default="terminal", help="memory transport name")
remember_parser.set_defaults(func=remember)
export_parser = sub.add_parser("export-training", help="export accepted memories to chat JSONL")
export_parser.set_defaults(func=export_command)
parser.set_defaults(func=ask, source="terminal")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())