diff --git a/pyproject.toml b/pyproject.toml index 4b34266..13aaaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ai-memory-protocol" -version = "0.3.1" +version = "0.4.0" description = "AI Memory Protocol — versioned, graph-based memory for AI agents using Sphinx-Needs" readme = "README.md" license = { text = "Apache-2.0" } diff --git a/src/ai_memory_protocol/cli.py b/src/ai_memory_protocol/cli.py index c37ef18..3acbaf4 100644 --- a/src/ai_memory_protocol/cli.py +++ b/src/ai_memory_protocol/cli.py @@ -247,9 +247,27 @@ def cmd_update(args: argparse.Namespace) -> None: def cmd_deprecate(args: argparse.Namespace) -> None: """Mark a memory as deprecated.""" workspace = find_workspace(args.dir) - ok, msg = deprecate_in_rst(workspace, args.id, args.by) - print(msg) - if ok: + + ids_to_deprecate: list[str] = [] + if getattr(args, "ids", None): + ids_to_deprecate = [i.strip() for i in args.ids.split(",") if i.strip()] + elif args.id: + ids_to_deprecate = [args.id] + + if not ids_to_deprecate: + print("Error: provide an ID or --ids") + return + + success_count = 0 + for mid in ids_to_deprecate: + ok, msg = deprecate_in_rst(workspace, mid, args.by) + print(f" {mid}: {msg}") + if ok: + success_count += 1 + + if len(ids_to_deprecate) > 1: + print(f"\nDeprecated {success_count}/{len(ids_to_deprecate)} memories.") + if success_count > 0: print("Run 'memory rebuild' to update needs.json") @@ -310,6 +328,8 @@ def cmd_tags(args: argparse.Namespace) -> None: def cmd_stale(args: argparse.Namespace) -> None: """Show expired or review-overdue memories.""" + from datetime import timedelta + workspace = find_workspace(args.dir) needs = load_needs(workspace) today = date.today().isoformat() @@ -330,11 +350,32 @@ def cmd_stale(args: argparse.Namespace) -> None: print("No stale memories found.") return + renew = getattr(args, "renew", None) + if renew and renew > 0: + new_date = (date.today() + timedelta(days=renew)).isoformat() + all_stale = expired + review_due + count = 0 + for need in all_stale: + nid = need.get("id", "") + if nid: + ok, _msg = update_field_in_rst(workspace, nid, "review_after", new_date) + if ok: + count += 1 + print(f"Renewed review_after to {new_date} on {count}/{len(all_stale)} stale memories.") + print("Run 'memory rebuild' to update needs.json") + return + + show_body = getattr(args, "body", False) + if expired: print(f"## {len(expired)} EXPIRED memories\n") for need in sorted(expired, key=lambda n: n.get("expires_at", "")): exp = need.get("expires_at", "") print(f" [EXPIRED {exp}] {format_compact(need)}") + if show_body: + body_text = (need.get("description", "") or need.get("content", "")).strip() + if body_text: + print(f" > {body_text[:200].replace(chr(10), ' ')}") print() if review_due: @@ -342,6 +383,10 @@ def cmd_stale(args: argparse.Namespace) -> None: for need in sorted(review_due, key=lambda n: n.get("review_after", "")): ra = need.get("review_after", "") print(f" [REVIEW {ra}] {format_compact(need)}") + if show_body: + body_text = (need.get("description", "") or need.get("content", "")).strip() + if body_text: + print(f" > {body_text[:200].replace(chr(10), ' ')}") def cmd_rebuild(args: argparse.Namespace) -> None: @@ -477,7 +522,7 @@ def build_parser() -> argparse.ArgumentParser: p_add.add_argument("--relates", default="", help="Related memory IDs, comma-separated") p_add.add_argument("--supersedes", default="", help="IDs this supersedes, comma-separated") p_add.add_argument( - "--review-days", type=int, default=30, help="Days until review (default: 30)" + "--review-days", type=int, default=90, help="Days until review (default: 90)" ) p_add.add_argument("--dry-run", action="store_true", help="Print RST without writing") p_add.add_argument( @@ -546,7 +591,8 @@ def build_parser() -> argparse.ArgumentParser: # --- deprecate --- p_dep = sub.add_parser("deprecate", help="Mark a memory as deprecated") - p_dep.add_argument("id", help="Memory ID to deprecate") + p_dep.add_argument("id", nargs="?", help="Memory ID to deprecate") + p_dep.add_argument("--ids", help="Comma-separated IDs for batch deprecation") p_dep.add_argument("--by", help="ID of the superseding memory") p_dep.set_defaults(func=cmd_deprecate) @@ -561,6 +607,10 @@ def build_parser() -> argparse.ArgumentParser: # --- stale --- p_stale = sub.add_parser("stale", help="Show expired or review-overdue memories") + p_stale.add_argument("--body", action="store_true", help="Show body preview (first 200 chars)") + p_stale.add_argument( + "--renew", type=int, metavar="DAYS", help="Renew review_after on all stale by N days" + ) p_stale.set_defaults(func=cmd_stale) # --- rebuild --- diff --git a/src/ai_memory_protocol/mcp_server.py b/src/ai_memory_protocol/mcp_server.py index bff3a3b..a2180fb 100644 --- a/src/ai_memory_protocol/mcp_server.py +++ b/src/ai_memory_protocol/mcp_server.py @@ -261,8 +261,8 @@ def _build_tools() -> list: }, "review_days": { "type": "integer", - "description": "Days until review is due. Default 30.", - "default": 30, + "description": "Days until review is due. Default 90.", + "default": 90, }, "rebuild": { "type": "boolean", @@ -346,12 +346,21 @@ def _build_tools() -> list: "type": "string", "description": "Memory ID to deprecate.", }, + "ids": { + "type": "string", + "description": "Comma-separated memory IDs for batch deprecation.", + }, "by": { "type": "string", "description": "ID of the superseding memory.", }, + "rebuild": { + "type": "boolean", + "description": "Auto-rebuild needs.json after deprecating. Default true.", + "default": True, + }, }, - "required": ["id"], + "required": [], }, ), Tool( @@ -379,7 +388,17 @@ def _build_tools() -> list: ), inputSchema={ "type": "object", - "properties": {}, + "properties": { + "body": { + "type": "boolean", + "description": "Include truncated body text (first 200 chars) in output.", + "default": False, + }, + "renew_days": { + "type": "integer", + "description": "Renew review_after on all stale memories by N days from today.", + }, + }, "required": [], }, ), @@ -618,7 +637,7 @@ def _handle_add(args: dict[str, Any]) -> list[TextContent]: body=args.get("body", ""), relates=relates, supersedes=supersedes, - review_days=args.get("review_days", 30), + review_days=args.get("review_days", 90), ) target = append_to_rst(workspace, args["type"], directive) @@ -680,10 +699,34 @@ def _handle_update(args: dict[str, Any]) -> list[TextContent]: def _handle_deprecate(args: dict[str, Any]) -> list[TextContent]: workspace = _get_workspace() - ok, msg = deprecate_in_rst(workspace, args["id"], args.get("by")) - if ok: - msg += "\nRun memory_rebuild to update needs.json." - return _text_response(msg) + + ids_to_deprecate: list[str] = [] + if args.get("ids"): + ids_to_deprecate = [i.strip() for i in args["ids"].split(",") if i.strip()] + elif args.get("id"): + ids_to_deprecate = [args["id"]] + + if not ids_to_deprecate: + return _text_response("Error: provide 'id' or 'ids' parameter.") + + results: list[str] = [] + success_count = 0 + for mid in ids_to_deprecate: + ok, msg = deprecate_in_rst(workspace, mid, args.get("by")) + results.append(f" {mid}: {'OK' if ok else 'FAILED'} - {msg}") + if ok: + success_count += 1 + + summary = f"Deprecated {success_count}/{len(ids_to_deprecate)} memories." + results.insert(0, summary) + + if args.get("rebuild", True) and success_count > 0: + success, rebuild_msg = run_rebuild(workspace) + results.append(rebuild_msg) + elif success_count > 0: + results.append("Run memory_rebuild to update needs.json.") + + return _text_response("\n".join(results)) def _handle_tags(args: dict[str, Any]) -> list[TextContent]: @@ -724,6 +767,7 @@ def _handle_stale(args: dict[str, Any]) -> list[TextContent]: workspace = _get_workspace() needs = load_needs(workspace) today = date.today().isoformat() + show_body = args.get("body", False) expired: list[dict[str, Any]] = [] review_due: list[dict[str, Any]] = [] @@ -740,19 +784,48 @@ def _handle_stale(args: dict[str, Any]) -> list[TextContent]: if not expired and not review_due: return _text_response("No stale memories found.") + renew_days = args.get("renew_days") + if renew_days and renew_days > 0: + from datetime import timedelta as td + + new_date = (date.today() + td(days=renew_days)).isoformat() + count = 0 + all_stale = expired + review_due + for need in all_stale: + nid = need.get("id", "") + if nid: + ok, _msg = update_field_in_rst(workspace, nid, "review_after", new_date) + if ok: + count += 1 + result = f"Renewed review_after to {new_date} on {count}/{len(all_stale)} stale memories." + if count > 0: + success, rebuild_msg = run_rebuild(workspace) + result += f"\n{rebuild_msg}" + return _text_response(result) + lines: list[str] = [] if expired: lines.append(f"## {len(expired)} EXPIRED memories\n") for need in sorted(expired, key=lambda n: n.get("expires_at", "")): exp = need.get("expires_at", "") - lines.append(f" [EXPIRED {exp}] {format_compact(need)}") + line = f" [EXPIRED {exp}] {format_compact(need)}" + if show_body: + body_text = (need.get("description", "") or need.get("content", "")).strip() + if body_text: + line += f"\n > {body_text[:200].replace(chr(10), ' ')}" + lines.append(line) lines.append("") if review_due: lines.append(f"## {len(review_due)} memories overdue for review\n") for need in sorted(review_due, key=lambda n: n.get("review_after", "")): ra = need.get("review_after", "") - lines.append(f" [REVIEW {ra}] {format_compact(need)}") + line = f" [REVIEW {ra}] {format_compact(need)}" + if show_body: + body_text = (need.get("description", "") or need.get("content", "")).strip() + if body_text: + line += f"\n > {body_text[:200].replace(chr(10), ' ')}" + lines.append(line) return _text_response("\n".join(lines)) diff --git a/src/ai_memory_protocol/rst.py b/src/ai_memory_protocol/rst.py index f5b36d7..ffa09e8 100644 --- a/src/ai_memory_protocol/rst.py +++ b/src/ai_memory_protocol/rst.py @@ -41,7 +41,7 @@ def generate_rst_directive( supports: list[str] | None = None, depends: list[str] | None = None, supersedes: list[str] | None = None, - review_days: int = 30, + review_days: int = 90, ) -> str: """Generate a Sphinx-Needs RST directive string.""" nid = need_id or generate_id(mem_type, title) diff --git a/src/ai_memory_protocol/scaffold.py b/src/ai_memory_protocol/scaffold.py index f7a660c..570ed76 100644 --- a/src/ai_memory_protocol/scaffold.py +++ b/src/ai_memory_protocol/scaffold.py @@ -179,16 +179,16 @@ def _write(path: Path, content: str) -> None: # -------------------------------------------------------------------------- # 2. Extra metadata fields # -------------------------------------------------------------------------- -needs_extra_options = {{ - "source": {{"description": "Origin: URL, commit hash, ticket, conversation"}}, - "owner": {{"description": "Who is responsible for this memory"}}, - "confidence": {{"description": "Confidence level: low | medium | high"}}, - "scope": {{"description": "Applicability scope: global, repo:X, product:X"}}, - "created_at": {{"description": "ISO-8601 creation date"}}, - "updated_at": {{"description": "ISO-8601 last update date"}}, - "expires_at": {{"description": "ISO-8601 expiry date"}}, - "review_after": {{"description": "ISO-8601 date after which this memory needs review"}}, -}} +needs_extra_options = [ + "source", + "owner", + "confidence", + "scope", + "created_at", + "updated_at", + "expires_at", + "review_after", +] # -------------------------------------------------------------------------- # 3. Custom link types diff --git a/tests/conftest.py b/tests/conftest.py index 58b3a22..03af186 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,10 +36,10 @@ def tmp_workspace(tmp_path: Path) -> Path: ' {"directive": "q", "title": "Open Question", "prefix": "Q_", ' '"color": "#D9D2E9", "style": "node"},\n' "]\n" - "needs_extra_options = {\n" - ' "source": {}, "owner": {}, "confidence": {}, "scope": {},\n' - ' "created_at": {}, "updated_at": {}, "expires_at": {}, "review_after": {},\n' - "}\n" + "needs_extra_options = [\n" + ' "source", "owner", "confidence", "scope",\n' + ' "created_at", "updated_at", "expires_at", "review_after",\n' + "]\n" "needs_build_json = True\n" )