From e56e9779ec4340fba700e5103a4aa2d867b7580f Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 17 May 2026 23:01:54 +0200 Subject: [PATCH] feat(memory): add JSON index and runtime memory_lookup/read/write tools Gives Pyra an active memory brain: memory_index.json tracks summary + keywords per file (like an inode table), and three built-in tools let the AI look up, read, and overwrite memory mid-session. write_memory accepts summary/keywords; update_index() merges the JSON index without losing existing metadata. Co-Authored-By: Claude Sonnet 4.6 --- src/pyra/chat/session.py | 81 ++++++++++++++++++++++++++++++++++++++- src/pyra/memory/index.py | 48 +++++++++++++++++++++-- src/pyra/memory/reader.py | 29 ++++++++++++++ src/pyra/memory/writer.py | 12 +++++- 4 files changed, 163 insertions(+), 7 deletions(-) diff --git a/src/pyra/chat/session.py b/src/pyra/chat/session.py index 2dc3c52..c03e93b 100644 --- a/src/pyra/chat/session.py +++ b/src/pyra/chat/session.py @@ -17,7 +17,8 @@ from pyra.chat.renderer import ( from pyra.chat.planner import TaskPlanner from pyra.config.manager import load_config from pyra.config.schema import PyraConfig -from pyra.memory.reader import list_memories +from pyra.memory.reader import list_memories, lookup_memories, read_memory +from pyra.memory.writer import write_memory from pyra.plugins.base import Tool from pyra.plugins.executor import ToolExecutor from pyra.plugins.registry import PluginRegistry @@ -36,6 +37,32 @@ _STATIC_COMMANDS = { } +def _handle_memory_lookup(query: str) -> str: + results = lookup_memories(query) + if not results: + return f"No memory entries found matching '{query}'." + lines = [ + f"- {r['file']}: {r['summary']} (keywords: {', '.join(r['keywords'])})" + for r in results + ] + return "\n".join(lines) + + +def _handle_memory_read(file: str) -> str: + try: + return read_memory(file) + except (FileNotFoundError, PermissionError) as exc: + return f"Error: {exc}" + + +def _handle_memory_write(file: str, content: str, summary: str, keywords: list) -> str: + try: + write_memory(file, content, summary=summary, keywords=list(keywords)) + return f"Memory saved: {file}" + except (ValueError, PermissionError) as exc: + return f"Error: {exc}" + + def start_chat() -> None: try: cfg = load_config() @@ -77,6 +104,58 @@ def start_chat() -> None: handler=planner.make_tool_handler(), requires_approval=False, )) + registry.register_builtin(Tool( + name="memory_lookup", + description=( + "Search the memory index by keyword or topic. " + "Always call this BEFORE memory_write to check whether a matching entry already exists." + ), + parameters={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Keyword or topic to search for."}, + }, + "required": ["query"], + }, + handler=_handle_memory_lookup, + requires_approval=False, + )) + registry.register_builtin(Tool( + name="memory_read", + description="Read the full content of a memory file by its relative path (e.g. 'user/profile.md').", + parameters={ + "type": "object", + "properties": { + "file": {"type": "string", "description": "Relative path to the memory file."}, + }, + "required": ["file"], + }, + handler=_handle_memory_read, + requires_approval=False, + )) + registry.register_builtin(Tool( + name="memory_write", + description=( + "Write or overwrite a memory file. Always call memory_lookup first to avoid duplicates. " + "If an existing file covers the same topic, read it first and merge the content." + ), + parameters={ + "type": "object", + "properties": { + "file": {"type": "string", "description": "Relative path, e.g. 'user/profile.md' or 'knowledge/python_tips.md'."}, + "content": {"type": "string", "description": "Full Markdown content to write."}, + "summary": {"type": "string", "description": "One-sentence summary of what this memory file stores."}, + "keywords": { + "type": "array", + "items": {"type": "string"}, + "description": "Keywords for index lookup (3–8 terms).", + }, + }, + "required": ["file", "content", "summary", "keywords"], + }, + handler=_handle_memory_write, + requires_approval=False, + )) history = ConversationHistory(cfg, registry) session: PromptSession = PromptSession( diff --git a/src/pyra/memory/index.py b/src/pyra/memory/index.py index 470a2ca..a877e38 100644 --- a/src/pyra/memory/index.py +++ b/src/pyra/memory/index.py @@ -1,22 +1,51 @@ import datetime +import json from pathlib import Path from pyra.memory import _MEMORY_ROOT from pyra.utils.paths import safe_chmod _INDEX_FILE = _MEMORY_ROOT / "MEMORY_INDEX.md" +_JSON_INDEX_FILE = _MEMORY_ROOT / "memory_index.json" + +_EXCLUDED = {"MEMORY_INDEX.md", "memory_index.json"} + + +def _load_json_index() -> dict: + if not _JSON_INDEX_FILE.exists(): + return {} + try: + return json.loads(_JSON_INDEX_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return {} def update_index() -> None: - files = sorted(_MEMORY_ROOT.rglob("*.md")) - files = [f for f in files if f.name != "MEMORY_INDEX.md"] + existing = _load_json_index() + files = sorted(_MEMORY_ROOT.rglob("*.md")) + files = [f for f in files if f.name not in _EXCLUDED] + + new_json: dict = {} rows: list[str] = [] + for f in files: rel = f.relative_to(_MEMORY_ROOT) + rel_key = rel.as_posix() category = rel.parts[0] if len(rel.parts) > 1 else "root" - mtime = datetime.datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M") - rows.append(f"| {rel} | {category} | {mtime} |") + mtime = datetime.datetime.fromtimestamp(f.stat().st_mtime) + mtime_str = mtime.strftime("%Y-%m-%d %H:%M") + + prev = existing.get(rel_key, {}) + new_json[rel_key] = { + "summary": prev.get("summary", ""), + "keywords": prev.get("keywords", []), + "modified": mtime.isoformat(timespec="seconds"), + } + rows.append(f"| {rel} | {category} | {mtime_str} |") + + _JSON_INDEX_FILE.write_text(json.dumps(new_json, indent=2)) + safe_chmod(_JSON_INDEX_FILE, 0o600) table = "\n".join(rows) if rows else "| _(no memory files)_ | — | — |" content = ( @@ -28,3 +57,14 @@ def update_index() -> None: ) _INDEX_FILE.write_text(content) safe_chmod(_INDEX_FILE, 0o600) + + +def update_json_entry(rel_path: str, summary: str, keywords: list[str]) -> None: + """Update the summary and keywords for one entry in the JSON index.""" + index = _load_json_index() + entry = index.get(rel_path, {}) + entry["summary"] = summary + entry["keywords"] = keywords + index[rel_path] = entry + _JSON_INDEX_FILE.write_text(json.dumps(index, indent=2)) + safe_chmod(_JSON_INDEX_FILE, 0o600) diff --git a/src/pyra/memory/reader.py b/src/pyra/memory/reader.py index a0d78c0..8afe501 100644 --- a/src/pyra/memory/reader.py +++ b/src/pyra/memory/reader.py @@ -1,10 +1,13 @@ import datetime +import json from dataclasses import dataclass from pathlib import Path from pyra.memory import _MEMORY_ROOT from pyra.security.boundaries import assert_safe_path +_JSON_INDEX_FILE = _MEMORY_ROOT / "memory_index.json" + @dataclass class MemoryFile: @@ -49,6 +52,32 @@ def read_memory(name: str) -> str: return path.read_text() +def read_index() -> dict: + """Return memory_index.json contents, or {} if missing or corrupt.""" + if not _JSON_INDEX_FILE.exists(): + return {} + try: + return json.loads(_JSON_INDEX_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + +def lookup_memories(query: str) -> list[dict]: + """Case-insensitive substring search over summary text and keywords.""" + q = query.lower() + results: list[dict] = [] + for rel_path, entry in read_index().items(): + summary = entry.get("summary", "").lower() + keywords = [k.lower() for k in entry.get("keywords", [])] + if q in summary or any(q in k or k in q for k in keywords): + results.append({ + "file": rel_path, + "summary": entry.get("summary", ""), + "keywords": entry.get("keywords", []), + }) + return results + + def load_context_for_session() -> str: memories = list_memories() if not memories: diff --git a/src/pyra/memory/writer.py b/src/pyra/memory/writer.py index 6c2739a..168c43c 100644 --- a/src/pyra/memory/writer.py +++ b/src/pyra/memory/writer.py @@ -1,7 +1,7 @@ from pathlib import Path from pyra.memory import _MEMORY_ROOT -from pyra.memory.index import update_index +from pyra.memory.index import update_index, update_json_entry from pyra.security.boundaries import assert_safe_path from pyra.utils.paths import safe_chmod @@ -20,12 +20,20 @@ def _resolve_and_validate(name: str) -> Path: return path -def write_memory(name: str, content: str) -> Path: +def write_memory( + name: str, + content: str, + summary: str = "", + keywords: list[str] | None = None, +) -> Path: path = _resolve_and_validate(name) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) safe_chmod(path, 0o600) update_index() + if summary or keywords: + rel_key = path.relative_to(_MEMORY_ROOT).as_posix() + update_json_entry(rel_key, summary, keywords or []) return path