Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
60 changes: 55 additions & 5 deletions src/ai_memory_protocol/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -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()
Expand All @@ -330,18 +350,43 @@ 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:
print(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", "")
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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand All @@ -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 ---
Expand Down
95 changes: 84 additions & 11 deletions src/ai_memory_protocol/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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": [],
},
),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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]] = []
Expand All @@ -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))

Expand Down
2 changes: 1 addition & 1 deletion src/ai_memory_protocol/rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 10 additions & 10 deletions src/ai_memory_protocol/scaffold.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
Loading