From fbd20588371ddd5bc3b17c504b2d2649330b4b4b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 2 Apr 2026 15:20:51 +0200 Subject: [PATCH 1/7] feat: increase default review_days from 30 to 90 --- src/ai_memory_protocol/cli.py | 2 +- src/ai_memory_protocol/mcp_server.py | 6 +++--- src/ai_memory_protocol/rst.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ai_memory_protocol/cli.py b/src/ai_memory_protocol/cli.py index c37ef18..31b7a18 100644 --- a/src/ai_memory_protocol/cli.py +++ b/src/ai_memory_protocol/cli.py @@ -477,7 +477,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( diff --git a/src/ai_memory_protocol/mcp_server.py b/src/ai_memory_protocol/mcp_server.py index bff3a3b..e497e6c 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", @@ -618,7 +618,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) 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) From 91bb14f1a47bde5d19eeaedee1ad19c454670e00 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 2 Apr 2026 15:25:24 +0200 Subject: [PATCH 2/7] feat: add rebuild option to memory_deprecate MCP tool --- src/ai_memory_protocol/mcp_server.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/ai_memory_protocol/mcp_server.py b/src/ai_memory_protocol/mcp_server.py index e497e6c..b5ab4d4 100644 --- a/src/ai_memory_protocol/mcp_server.py +++ b/src/ai_memory_protocol/mcp_server.py @@ -350,6 +350,11 @@ def _build_tools() -> list: "type": "string", "description": "ID of the superseding memory.", }, + "rebuild": { + "type": "boolean", + "description": "Auto-rebuild needs.json after deprecating. Default true.", + "default": True, + }, }, "required": ["id"], }, @@ -681,7 +686,13 @@ 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: + if not ok: + return _text_response(msg) + + if args.get("rebuild", True): + success, rebuild_msg = run_rebuild(workspace) + msg += f"\n{rebuild_msg}" + else: msg += "\nRun memory_rebuild to update needs.json." return _text_response(msg) From dbed2c3cba1813dc06419e6ba84aac1b61be1119 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 2 Apr 2026 15:27:38 +0200 Subject: [PATCH 3/7] feat: batch deprecate, stale --body/--renew --- src/ai_memory_protocol/cli.py | 54 ++++++++++++++++-- src/ai_memory_protocol/mcp_server.py | 82 ++++++++++++++++++++++++---- 2 files changed, 119 insertions(+), 17 deletions(-) diff --git a/src/ai_memory_protocol/cli.py b/src/ai_memory_protocol/cli.py index 31b7a18..71bd66b 100644 --- a/src/ai_memory_protocol/cli.py +++ b/src/ai_memory_protocol/cli.py @@ -247,10 +247,26 @@ 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: - print("Run 'memory rebuild' to update needs.json") + + 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] + else: + 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.") + print("Run 'memory rebuild' to update needs.json") def cmd_review(args: argparse.Namespace) -> None: @@ -310,6 +326,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 +348,31 @@ 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 and need.get("content"): + body_preview = need["content"][:200].replace("\n", " ") + print(f" > {body_preview}") print() if review_due: @@ -342,6 +380,9 @@ 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 and need.get("content"): + body_preview = need["content"][:200].replace("\n", " ") + print(f" > {body_preview}") def cmd_rebuild(args: argparse.Namespace) -> None: @@ -546,7 +587,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 +603,8 @@ 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 b5ab4d4..3f3c1ad 100644 --- a/src/ai_memory_protocol/mcp_server.py +++ b/src/ai_memory_protocol/mcp_server.py @@ -346,6 +346,10 @@ 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.", @@ -356,7 +360,7 @@ def _build_tools() -> list: "default": True, }, }, - "required": ["id"], + "required": [], }, ), Tool( @@ -384,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": [], }, ), @@ -685,16 +699,33 @@ 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 not ok: - return _text_response(msg) - if args.get("rebuild", True): - success, rebuild_msg = run_rebuild(workspace) - msg += f"\n{rebuild_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"]] else: - msg += "\nRun memory_rebuild to update needs.json." - return _text_response(msg) + 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]: @@ -735,6 +766,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]] = [] @@ -751,19 +783,45 @@ 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 and need.get("content"): + body_preview = need["content"][:200].replace("\n", " ") + line += f"\n > {body_preview}" + 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 and need.get("content"): + body_preview = need["content"][:200].replace("\n", " ") + line += f"\n > {body_preview}" + lines.append(line) return _text_response("\n".join(lines)) From f2e20e719c24792a1481218a20987b274c73d1ff Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 2 Apr 2026 16:03:36 +0200 Subject: [PATCH 4/7] style: fix ruff formatting in cli.py and mcp_server.py --- src/ai_memory_protocol/cli.py | 4 +++- src/ai_memory_protocol/mcp_server.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ai_memory_protocol/cli.py b/src/ai_memory_protocol/cli.py index 71bd66b..19583f2 100644 --- a/src/ai_memory_protocol/cli.py +++ b/src/ai_memory_protocol/cli.py @@ -604,7 +604,9 @@ 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.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 3f3c1ad..3b8a849 100644 --- a/src/ai_memory_protocol/mcp_server.py +++ b/src/ai_memory_protocol/mcp_server.py @@ -786,6 +786,7 @@ def _handle_stale(args: dict[str, Any]) -> list[TextContent]: 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 From 37e64827d02728f41945a923e53d13dba04d3a7f Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 2 Apr 2026 17:07:01 +0200 Subject: [PATCH 5/7] fix: address Copilot review - body preview uses description field, validate empty ids, conditional rebuild message --- src/ai_memory_protocol/cli.py | 20 ++++++++++++-------- src/ai_memory_protocol/mcp_server.py | 17 ++++++++++------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/ai_memory_protocol/cli.py b/src/ai_memory_protocol/cli.py index 19583f2..3acbaf4 100644 --- a/src/ai_memory_protocol/cli.py +++ b/src/ai_memory_protocol/cli.py @@ -253,7 +253,8 @@ def cmd_deprecate(args: argparse.Namespace) -> None: ids_to_deprecate = [i.strip() for i in args.ids.split(",") if i.strip()] elif args.id: ids_to_deprecate = [args.id] - else: + + if not ids_to_deprecate: print("Error: provide an ID or --ids") return @@ -266,7 +267,8 @@ def cmd_deprecate(args: argparse.Namespace) -> None: if len(ids_to_deprecate) > 1: print(f"\nDeprecated {success_count}/{len(ids_to_deprecate)} memories.") - print("Run 'memory rebuild' to update needs.json") + if success_count > 0: + print("Run 'memory rebuild' to update needs.json") def cmd_review(args: argparse.Namespace) -> None: @@ -370,9 +372,10 @@ def cmd_stale(args: argparse.Namespace) -> None: 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 and need.get("content"): - body_preview = need["content"][:200].replace("\n", " ") - print(f" > {body_preview}") + 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: @@ -380,9 +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 and need.get("content"): - body_preview = need["content"][:200].replace("\n", " ") - print(f" > {body_preview}") + 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: diff --git a/src/ai_memory_protocol/mcp_server.py b/src/ai_memory_protocol/mcp_server.py index 3b8a849..a2180fb 100644 --- a/src/ai_memory_protocol/mcp_server.py +++ b/src/ai_memory_protocol/mcp_server.py @@ -705,7 +705,8 @@ def _handle_deprecate(args: dict[str, Any]) -> list[TextContent]: ids_to_deprecate = [i.strip() for i in args["ids"].split(",") if i.strip()] elif args.get("id"): ids_to_deprecate = [args["id"]] - else: + + if not ids_to_deprecate: return _text_response("Error: provide 'id' or 'ids' parameter.") results: list[str] = [] @@ -808,9 +809,10 @@ def _handle_stale(args: dict[str, Any]) -> list[TextContent]: for need in sorted(expired, key=lambda n: n.get("expires_at", "")): exp = need.get("expires_at", "") line = f" [EXPIRED {exp}] {format_compact(need)}" - if show_body and need.get("content"): - body_preview = need["content"][:200].replace("\n", " ") - line += f"\n > {body_preview}" + 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("") @@ -819,9 +821,10 @@ def _handle_stale(args: dict[str, Any]) -> list[TextContent]: for need in sorted(review_due, key=lambda n: n.get("review_after", "")): ra = need.get("review_after", "") line = f" [REVIEW {ra}] {format_compact(need)}" - if show_body and need.get("content"): - body_preview = need["content"][:200].replace("\n", " ") - line += f"\n > {body_preview}" + 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)) From fe9be917f4e1d31b78d9403cd7a6fe78e8a4cae3 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 2 Apr 2026 17:10:01 +0200 Subject: [PATCH 6/7] chore: bump version to 0.4.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } From 8069ca770371fca8250c728e0849223fb32958ca Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Thu, 2 Apr 2026 17:22:17 +0200 Subject: [PATCH 7/7] fix: update needs_extra_options to list format for sphinx-needs 8.0 compatibility --- src/ai_memory_protocol/scaffold.py | 20 ++++++++++---------- tests/conftest.py | 8 ++++---- 2 files changed, 14 insertions(+), 14 deletions(-) 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" )