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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
evictions are staged as **proposals** a human reviews; an unreviewed proposal
**expires** after 30 days (default) rather than auto-applying — silence is
never consent.
- **Console Review page** — the memory console gains a `Review` page: the
pending-proposal queue grouped by entity with per-kind before/after
evidence, Approve / Keep / Edit-then-approve / Promote verbs, batch-approve
per entity, and a run-maintenance button (dry-run by default, apply behind
a confirm). Backed by five auth-gated console routes
(`/views/{ns}/review/*`, `/views/{ns}/maintenance/*`) that reach the engine
only through the gateway; double-decides surface as HTTP 409 with the
compare-and-set outcome passed through, so the console and Claude Code can
review the same queue without double-applying. Also closes the four WP10a
carry-in cleanups (summarizer budget-check ordering, CLI missing-namespace
guard, `Store.path` on the ABC, tool-docstring apply/auto-spawn asymmetry).
- **Conversational review in Claude Code** — four MCP tools
(`memory_maintenance_run`, dry-run by default; `memory_maintenance_status`,
model-free; `memory_review_queue`; `memory_review_decide`) on the core
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ console MCP surfaces. Set `LM_MAINT_AUTO=1` to opt into a background auto-run
(safe band only) on the first tool call of a stale namespace; it is off by
default.

**Or click through it in the console.** The memory console ships a **Review**
page: the same queue grouped by entity, before/after evidence per proposal
(both texts + cosine for near-duplicates, sources + proposed text for
summaries, score evidence for evictions), with Approve / Keep /
Edit-then-approve / Promote verbs, batch-approve per entity, and a
run-maintenance button (dry-run by default; apply sits behind a confirm). Both
frontends drive the same proposal store with compare-and-set decisions, so
deciding in one place shows up as "already decided" in the other instead of
double-applying.

**The safety story in one paragraph.** Nothing is ever deleted — maintenance
only appends, retires (the same `superseded_by` flip ordinary supersession
uses), or demotes to a cold tier, so your full history stays queryable as-of any
Expand Down
5 changes: 5 additions & 0 deletions console/src/lean_memory_console/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .events import EventLog
from .routes.data import build_data_router
from .routes.mcp import build_mcp_mount
from .routes.review import build_review_router
from .routes.views import build_views_router

# Loopback hostnames accepted in local mode (DNS-rebinding guard). Exact-match
Expand Down Expand Up @@ -106,6 +107,10 @@ async def _security(request: Request, call_next):
return response

app.include_router(build_views_router())
# /views/*/review + /views/*/maintenance — per-route auth-gated, same prefix
# as views; registered before the static catch-all so its greedy "/" mount
# never shadows these paths.
app.include_router(build_review_router())
# /v1/* are POST handlers reading app.state.gateway; gate the whole router
# with the same auth dependency (local token / docker bearer).
app.include_router(
Expand Down
4 changes: 3 additions & 1 deletion console/src/lean_memory_console/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ async def memory_maintenance_run(
) -> dict[str, Any]:
"""Run one sleep-time maintenance pass (§6.3). DRY-RUN by default (apply=False):
computes the would-do report with zero writes. apply=True runs the auto band
and stages proposals. Symmetric with the CLI. Returns the run summary."""
and stages proposals. Symmetric with the CLI. NOTE: the LM_MAINT_AUTO
auto-spawn path runs `--apply --auto-only` (auto band only, no proposals) —
only interactive apply=True grows the review queue. Returns the run summary."""
return await gateway.maintain(namespace, apply=apply)

@mcp.tool()
Expand Down
115 changes: 115 additions & 0 deletions console/src/lean_memory_console/routes/review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""/views/*/review + /views/*/maintenance — the maintenance review-path router.

Every route proxies a single ``EngineGateway`` maintenance method (WP10a): the
engine is never opened directly here. Existence + reserved-namespace guarding is
identical to the read-path router (``_ns_db``), so a review call against a
nonexistent namespace 404s rather than creating the ``.db`` as a side effect.
"""

from __future__ import annotations

from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel

from ..config import is_reserved_namespace, ns_db_path


class DecideBody(BaseModel):
decision: str
edited_text: str | None = None


class PromoteBody(BaseModel):
fact_id: str


class RunBody(BaseModel):
apply: bool = False


# The lifecycle CAS reports a re-decided proposal as an outcome, not an
# exception (lifecycle.py:_already_decided) — surface those as 409 so the UI can
# refresh the row, with the informative body passed through verbatim.
_ALREADY_DECIDED = frozenset({"already_decided", "already_applied"})


def _ns_db(request: Request, namespace: str):
if is_reserved_namespace(namespace):
raise HTTPException(status_code=404, detail="unknown namespace")
config = request.app.state.config
path = ns_db_path(config.data_root, namespace)
if not path.exists():
raise HTTPException(status_code=404, detail="unknown namespace")
return path


def build_review_router() -> APIRouter:
# Imported here (not at module top) to break the app<->routes import cycle,
# exactly as build_views_router does: app.py imports this builder last, so
# require_auth is already defined by the time create_app calls it.
from ..app import require_auth

router = APIRouter(prefix="/views")

@router.get(
"/{namespace}/review/queue", dependencies=[Depends(require_auth)]
)
async def review_queue(
request: Request,
namespace: str,
kind: str | None = None,
limit: int = 20,
):
_ns_db(request, namespace)
gateway = request.app.state.gateway
return await gateway.review_queue(namespace, kind=kind, limit=limit)

@router.post(
"/{namespace}/review/{proposal_id}/decide",
dependencies=[Depends(require_auth)],
)
async def decide(
request: Request,
namespace: str,
proposal_id: str,
body: DecideBody,
):
_ns_db(request, namespace)
gateway = request.app.state.gateway
result = await gateway.decide(
namespace, proposal_id, body.decision,
edited_text=body.edited_text,
)
if result.get("outcome") in _ALREADY_DECIDED:
return JSONResponse(result, status_code=409)
return result

@router.post(
"/{namespace}/review/promote", dependencies=[Depends(require_auth)]
)
async def promote(request: Request, namespace: str, body: PromoteBody):
_ns_db(request, namespace)
gateway = request.app.state.gateway
return await gateway.promote(namespace, body.fact_id)

@router.get(
"/{namespace}/maintenance/status",
dependencies=[Depends(require_auth)],
)
async def maintenance_status(request: Request, namespace: str):
_ns_db(request, namespace)
gateway = request.app.state.gateway
return await gateway.maintenance_status(namespace)

@router.post(
"/{namespace}/maintenance/run", dependencies=[Depends(require_auth)]
)
async def maintenance_run(
request: Request, namespace: str, body: RunBody
):
_ns_db(request, namespace)
gateway = request.app.state.gateway
return await gateway.maintain(namespace, apply=body.apply)

return router
Loading
Loading