lean-memory-console 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lean_memory_console/__init__.py +3 -0
- lean_memory_console/app.py +142 -0
- lean_memory_console/cli.py +134 -0
- lean_memory_console/config.py +121 -0
- lean_memory_console/deploy/__init__.py +0 -0
- lean_memory_console/deploy/docker-compose.yml +36 -0
- lean_memory_console/engine.py +377 -0
- lean_memory_console/events.py +133 -0
- lean_memory_console/inspect_sql.py +321 -0
- lean_memory_console/mcp_tools.py +137 -0
- lean_memory_console/observe_mcp.py +73 -0
- lean_memory_console/routes/__init__.py +0 -0
- lean_memory_console/routes/data.py +53 -0
- lean_memory_console/routes/mcp.py +186 -0
- lean_memory_console/routes/review.py +115 -0
- lean_memory_console/routes/views.py +153 -0
- lean_memory_console-0.2.0.dist-info/METADATA +14 -0
- lean_memory_console-0.2.0.dist-info/RECORD +20 -0
- lean_memory_console-0.2.0.dist-info/WHEEL +4 -0
- lean_memory_console-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Docker-mode streamable-HTTP MCP mount (/mcp) with bearer auth.
|
|
2
|
+
|
|
3
|
+
The wrapper FastMCP is built stateless (json_response) so its Starlette app
|
|
4
|
+
mounts into FastAPI without needing to own the server process. A thin ASGI
|
|
5
|
+
wrapper enforces ``Authorization: Bearer <api_key>`` before delegating; full
|
|
6
|
+
MCP-over-HTTP round-trips are exercised in the manual E2E (and, since the review,
|
|
7
|
+
in a two-request automated test).
|
|
8
|
+
|
|
9
|
+
Two SDK-1.28.1 decisions, both disclosed:
|
|
10
|
+
|
|
11
|
+
1. Session-manager lifecycle — LIFESPAN ONLY.
|
|
12
|
+
``streamable_http_app().handle_request`` raises
|
|
13
|
+
``RuntimeError("Task group is not initialized. Make sure to use run().")``
|
|
14
|
+
until ``StreamableHTTPSessionManager.run()`` has been entered — even in
|
|
15
|
+
stateless mode. ``run()`` may be entered EXACTLY ONCE per instance: it sets
|
|
16
|
+
``_has_started`` permanently and raises on a second call, and on exit it
|
|
17
|
+
resets ``_task_group`` to ``None`` (verified in the installed SDK at
|
|
18
|
+
``mcp/server/streamable_http_manager.py``). A per-request self-start is
|
|
19
|
+
therefore broken beyond the first request. We expose ``session_manager`` and
|
|
20
|
+
the console's app lifespan (``app.create_app``) enters ``run()`` once for the
|
|
21
|
+
process lifetime. Consequence: tests that hit ``/mcp`` MUST drive the lifespan
|
|
22
|
+
— use ``with TestClient(app) as client:`` (a bare ``TestClient(app)`` does not
|
|
23
|
+
run the lifespan and would 500 on the first authenticated request).
|
|
24
|
+
|
|
25
|
+
2. Transport security — DNS-rebinding protection ENABLED with a loopback
|
|
26
|
+
default allow-list, extendable via ``LM_MCP_ALLOWED_HOSTS``.
|
|
27
|
+
FastMCP validates Host and Origin headers to defeat DNS rebinding. The SDK
|
|
28
|
+
couples both checks behind a single ``enable_dns_rebinding_protection`` flag
|
|
29
|
+
and offers no host wildcard — only exact matches and ``base:*`` port patterns
|
|
30
|
+
(see ``mcp/server/transport_security.py``). We enable protection and enumerate
|
|
31
|
+
the loopback host/origin values a legitimate local client presents. This gives
|
|
32
|
+
real Origin restriction (a cross-site Origin → 403) while the bearer gate in
|
|
33
|
+
front remains the primary access control.
|
|
34
|
+
|
|
35
|
+
The shipped ``deploy/docker-compose.yml`` publishes 8377 directly (no reverse
|
|
36
|
+
proxy in the stack), so a container reached over the LAN presents a Host like
|
|
37
|
+
``192.168.1.10:8377`` or ``myserver:8377`` that the loopback default does not
|
|
38
|
+
cover — it would 421. Remote-host deployments therefore set
|
|
39
|
+
``LM_MCP_ALLOWED_HOSTS`` (comma-separated host patterns, e.g.
|
|
40
|
+
``192.168.1.10:*,myserver:*``, parsed in ``config.py``); those patterns are
|
|
41
|
+
ADDED to the loopback defaults here. Origins stay restricted to loopback. We
|
|
42
|
+
extend rather than disable the flag, because disabling it also disables the
|
|
43
|
+
Origin check — losing the one control that matters for a browser-originated
|
|
44
|
+
DNS-rebinding attack. The bearer gate remains the primary control regardless.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import secrets
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from mcp.server.fastmcp import FastMCP
|
|
53
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
54
|
+
|
|
55
|
+
from ..config import ConsoleConfig
|
|
56
|
+
from ..engine import EngineGateway
|
|
57
|
+
from ..mcp_tools import register_maintenance_tools
|
|
58
|
+
|
|
59
|
+
# Loopback host/origin default allow-list for the inner MCP transport-security
|
|
60
|
+
# check. Host entries use the SDK's exact + ``base:*`` port-wildcard matching;
|
|
61
|
+
# there is no host wildcard, so remote deployments extend this list via
|
|
62
|
+
# LM_MCP_ALLOWED_HOSTS (see the module docstring, decision 2).
|
|
63
|
+
_DEFAULT_ALLOWED_HOSTS = [
|
|
64
|
+
"127.0.0.1",
|
|
65
|
+
"127.0.0.1:*",
|
|
66
|
+
"localhost",
|
|
67
|
+
"localhost:*",
|
|
68
|
+
"[::1]",
|
|
69
|
+
"[::1]:*",
|
|
70
|
+
]
|
|
71
|
+
_ALLOWED_ORIGINS = [
|
|
72
|
+
"http://127.0.0.1",
|
|
73
|
+
"http://127.0.0.1:*",
|
|
74
|
+
"http://localhost",
|
|
75
|
+
"http://localhost:*",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_http_mcp(
|
|
80
|
+
gateway: EngineGateway, extra_allowed_hosts: list[str] | None = None
|
|
81
|
+
) -> FastMCP:
|
|
82
|
+
# Loopback defaults + any operator-supplied patterns (LM_MCP_ALLOWED_HOSTS),
|
|
83
|
+
# de-duplicated while preserving order.
|
|
84
|
+
allowed_hosts = list(_DEFAULT_ALLOWED_HOSTS)
|
|
85
|
+
for host in extra_allowed_hosts or []:
|
|
86
|
+
if host not in allowed_hosts:
|
|
87
|
+
allowed_hosts.append(host)
|
|
88
|
+
mcp = FastMCP(
|
|
89
|
+
"lean-memory-console",
|
|
90
|
+
stateless_http=True,
|
|
91
|
+
json_response=True,
|
|
92
|
+
streamable_http_path="/",
|
|
93
|
+
transport_security=TransportSecuritySettings(
|
|
94
|
+
enable_dns_rebinding_protection=True,
|
|
95
|
+
allowed_hosts=allowed_hosts,
|
|
96
|
+
allowed_origins=_ALLOWED_ORIGINS,
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@mcp.tool()
|
|
101
|
+
async def memory_add(
|
|
102
|
+
namespace: str,
|
|
103
|
+
text: str,
|
|
104
|
+
source: str = "user",
|
|
105
|
+
t_ref: int | None = None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Ingest text into the namespace's memory (HTTP wrapper)."""
|
|
108
|
+
res = await gateway.add(namespace, text, source=source, t_ref=t_ref)
|
|
109
|
+
return {
|
|
110
|
+
"fact_ids": res.fact_ids,
|
|
111
|
+
"superseded_count": res.superseded_count,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
async def memory_search(
|
|
116
|
+
namespace: str, query: str, k: int = 5
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Search a namespace's memory (HTTP wrapper); always latest-only."""
|
|
119
|
+
res = await gateway.search(
|
|
120
|
+
namespace, query, k=k, latest_only=True, origin="agent"
|
|
121
|
+
)
|
|
122
|
+
return {
|
|
123
|
+
"hits": [
|
|
124
|
+
{"fact_text": h["fact_text"], "final_score": h["final_score"]}
|
|
125
|
+
for h in res.hits
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# The same four maintenance tools as the stdio surfaces (§6.3). No review prompt
|
|
130
|
+
# here — MCP prompts are a stdio-client capability; HTTP clients use the tools.
|
|
131
|
+
register_maintenance_tools(mcp, gateway)
|
|
132
|
+
|
|
133
|
+
return mcp
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_mcp_mount(gateway: EngineGateway, config: ConsoleConfig):
|
|
137
|
+
"""Return an ASGI app: bearer gate -> streamable-HTTP MCP app.
|
|
138
|
+
|
|
139
|
+
Mount at "/mcp"; the inner MCP app serves at its own root "/" once mounted
|
|
140
|
+
(FastMCP's ``streamable_http_path`` is "/" so it resolves at exactly the
|
|
141
|
+
mount point). The returned app exposes ``session_manager``; the caller MUST
|
|
142
|
+
enter its ``run()`` in the app lifespan (see module docstring, decision 1) —
|
|
143
|
+
there is no per-request fallback, because ``run()`` is once-only per instance.
|
|
144
|
+
"""
|
|
145
|
+
mcp = _build_http_mcp(gateway, config.mcp_allowed_hosts)
|
|
146
|
+
inner = mcp.streamable_http_app()
|
|
147
|
+
session_manager = mcp.session_manager # initializes it on the inner app
|
|
148
|
+
# None when no api_key is configured, so the constant-time compare below
|
|
149
|
+
# always fails (compare_digest is skipped for a None expected value).
|
|
150
|
+
expected = f"Bearer {config.api_key}" if config.api_key else None
|
|
151
|
+
|
|
152
|
+
async def gated(scope, receive, send):
|
|
153
|
+
if scope["type"] != "http":
|
|
154
|
+
await inner(scope, receive, send)
|
|
155
|
+
return
|
|
156
|
+
headers = dict(scope.get("headers") or [])
|
|
157
|
+
auth = headers.get(b"authorization", b"").decode()
|
|
158
|
+
# Constant-time bearer compare; both sides must be non-None strings.
|
|
159
|
+
if expected is None or not secrets.compare_digest(auth, expected):
|
|
160
|
+
await _send_401(send)
|
|
161
|
+
return
|
|
162
|
+
await inner(scope, receive, send)
|
|
163
|
+
|
|
164
|
+
# Exposed so create_app can drive the once-only session manager via the app
|
|
165
|
+
# lifespan (the only supported start path).
|
|
166
|
+
gated.session_manager = session_manager
|
|
167
|
+
return gated
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _send_401(send):
|
|
171
|
+
await send(
|
|
172
|
+
{
|
|
173
|
+
"type": "http.response.start",
|
|
174
|
+
"status": 401,
|
|
175
|
+
"headers": [
|
|
176
|
+
(b"content-type", b"application/json"),
|
|
177
|
+
(b"referrer-policy", b"no-referrer"),
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
await send(
|
|
182
|
+
{
|
|
183
|
+
"type": "http.response.body",
|
|
184
|
+
"body": b'{"detail":"unauthorized"}',
|
|
185
|
+
}
|
|
186
|
+
)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""/views/*/review + /views/*/maintenance — the maintenance review-path router.
|
|
2
|
+
|
|
3
|
+
Every route proxies a single ``EngineGateway`` maintenance method (WP10a): the
|
|
4
|
+
engine is never opened directly here. Existence + reserved-namespace guarding is
|
|
5
|
+
identical to the read-path router (``_ns_db``), so a review call against a
|
|
6
|
+
nonexistent namespace 404s rather than creating the ``.db`` as a side effect.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from ..config import is_reserved_namespace, ns_db_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DecideBody(BaseModel):
|
|
19
|
+
decision: str
|
|
20
|
+
edited_text: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PromoteBody(BaseModel):
|
|
24
|
+
fact_id: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunBody(BaseModel):
|
|
28
|
+
apply: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# The lifecycle CAS reports a re-decided proposal as an outcome, not an
|
|
32
|
+
# exception (lifecycle.py:_already_decided) — surface those as 409 so the UI can
|
|
33
|
+
# refresh the row, with the informative body passed through verbatim.
|
|
34
|
+
_ALREADY_DECIDED = frozenset({"already_decided", "already_applied"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ns_db(request: Request, namespace: str):
|
|
38
|
+
if is_reserved_namespace(namespace):
|
|
39
|
+
raise HTTPException(status_code=404, detail="unknown namespace")
|
|
40
|
+
config = request.app.state.config
|
|
41
|
+
path = ns_db_path(config.data_root, namespace)
|
|
42
|
+
if not path.exists():
|
|
43
|
+
raise HTTPException(status_code=404, detail="unknown namespace")
|
|
44
|
+
return path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_review_router() -> APIRouter:
|
|
48
|
+
# Imported here (not at module top) to break the app<->routes import cycle,
|
|
49
|
+
# exactly as build_views_router does: app.py imports this builder last, so
|
|
50
|
+
# require_auth is already defined by the time create_app calls it.
|
|
51
|
+
from ..app import require_auth
|
|
52
|
+
|
|
53
|
+
router = APIRouter(prefix="/views")
|
|
54
|
+
|
|
55
|
+
@router.get(
|
|
56
|
+
"/{namespace}/review/queue", dependencies=[Depends(require_auth)]
|
|
57
|
+
)
|
|
58
|
+
async def review_queue(
|
|
59
|
+
request: Request,
|
|
60
|
+
namespace: str,
|
|
61
|
+
kind: str | None = None,
|
|
62
|
+
limit: int = 20,
|
|
63
|
+
):
|
|
64
|
+
_ns_db(request, namespace)
|
|
65
|
+
gateway = request.app.state.gateway
|
|
66
|
+
return await gateway.review_queue(namespace, kind=kind, limit=limit)
|
|
67
|
+
|
|
68
|
+
@router.post(
|
|
69
|
+
"/{namespace}/review/{proposal_id}/decide",
|
|
70
|
+
dependencies=[Depends(require_auth)],
|
|
71
|
+
)
|
|
72
|
+
async def decide(
|
|
73
|
+
request: Request,
|
|
74
|
+
namespace: str,
|
|
75
|
+
proposal_id: str,
|
|
76
|
+
body: DecideBody,
|
|
77
|
+
):
|
|
78
|
+
_ns_db(request, namespace)
|
|
79
|
+
gateway = request.app.state.gateway
|
|
80
|
+
result = await gateway.decide(
|
|
81
|
+
namespace, proposal_id, body.decision,
|
|
82
|
+
edited_text=body.edited_text,
|
|
83
|
+
)
|
|
84
|
+
if result.get("outcome") in _ALREADY_DECIDED:
|
|
85
|
+
return JSONResponse(result, status_code=409)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
@router.post(
|
|
89
|
+
"/{namespace}/review/promote", dependencies=[Depends(require_auth)]
|
|
90
|
+
)
|
|
91
|
+
async def promote(request: Request, namespace: str, body: PromoteBody):
|
|
92
|
+
_ns_db(request, namespace)
|
|
93
|
+
gateway = request.app.state.gateway
|
|
94
|
+
return await gateway.promote(namespace, body.fact_id)
|
|
95
|
+
|
|
96
|
+
@router.get(
|
|
97
|
+
"/{namespace}/maintenance/status",
|
|
98
|
+
dependencies=[Depends(require_auth)],
|
|
99
|
+
)
|
|
100
|
+
async def maintenance_status(request: Request, namespace: str):
|
|
101
|
+
_ns_db(request, namespace)
|
|
102
|
+
gateway = request.app.state.gateway
|
|
103
|
+
return await gateway.maintenance_status(namespace)
|
|
104
|
+
|
|
105
|
+
@router.post(
|
|
106
|
+
"/{namespace}/maintenance/run", dependencies=[Depends(require_auth)]
|
|
107
|
+
)
|
|
108
|
+
async def maintenance_run(
|
|
109
|
+
request: Request, namespace: str, body: RunBody
|
|
110
|
+
):
|
|
111
|
+
_ns_db(request, namespace)
|
|
112
|
+
gateway = request.app.state.gateway
|
|
113
|
+
return await gateway.maintain(namespace, apply=body.apply)
|
|
114
|
+
|
|
115
|
+
return router
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""/views/* — the human read-path router."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from ..config import is_reserved_namespace, ns_db_path
|
|
9
|
+
from .. import inspect_sql
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestSearchBody(BaseModel):
|
|
13
|
+
query: str
|
|
14
|
+
k: int = Field(default=5, ge=1, le=200)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ns_db(request: Request, namespace: str):
|
|
18
|
+
if is_reserved_namespace(namespace):
|
|
19
|
+
raise HTTPException(status_code=404, detail="unknown namespace")
|
|
20
|
+
config = request.app.state.config
|
|
21
|
+
path = ns_db_path(config.data_root, namespace)
|
|
22
|
+
if not path.exists():
|
|
23
|
+
raise HTTPException(status_code=404, detail="unknown namespace")
|
|
24
|
+
return path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_views_router() -> APIRouter:
|
|
28
|
+
# Imported here (not at module top) to break the app<->views import cycle:
|
|
29
|
+
# app.py imports build_views_router as its last import, so require_auth is
|
|
30
|
+
# already defined by the time this function runs (create_app calls it).
|
|
31
|
+
from ..app import require_auth
|
|
32
|
+
|
|
33
|
+
router = APIRouter(prefix="/views")
|
|
34
|
+
|
|
35
|
+
@router.get("/whoami")
|
|
36
|
+
def whoami(request: Request):
|
|
37
|
+
config = request.app.state.config
|
|
38
|
+
from ..app import _is_authenticated
|
|
39
|
+
from ..engine import resolved_models_mode
|
|
40
|
+
|
|
41
|
+
auth = "bearer" if config.mode == "docker" else "token"
|
|
42
|
+
return {
|
|
43
|
+
"mode": config.mode,
|
|
44
|
+
"auth": auth,
|
|
45
|
+
"authenticated": _is_authenticated(request, config),
|
|
46
|
+
"data_root": str(config.data_root),
|
|
47
|
+
# Resolved retrieval-backend mode ("real"|"stub"), NOT the raw env:
|
|
48
|
+
# "stub" whenever the built Memory uses deterministic offline
|
|
49
|
+
# scores, so the UI can warn that semantic scores are stub-generated.
|
|
50
|
+
"models": resolved_models_mode(config),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@router.get("/namespaces", dependencies=[Depends(require_auth)])
|
|
54
|
+
def namespaces(request: Request):
|
|
55
|
+
config = request.app.state.config
|
|
56
|
+
event_log = request.app.state.event_log
|
|
57
|
+
return inspect_sql.list_namespaces(config.data_root, event_log)
|
|
58
|
+
|
|
59
|
+
@router.get("/{namespace}/facts", dependencies=[Depends(require_auth)])
|
|
60
|
+
def facts(
|
|
61
|
+
request: Request,
|
|
62
|
+
namespace: str,
|
|
63
|
+
latest_only: bool = True,
|
|
64
|
+
predicate: str | None = None,
|
|
65
|
+
entity: str | None = None,
|
|
66
|
+
min_salience: float | None = None,
|
|
67
|
+
q: str | None = None,
|
|
68
|
+
page: int = 1,
|
|
69
|
+
page_size: int = 50,
|
|
70
|
+
):
|
|
71
|
+
path = _ns_db(request, namespace)
|
|
72
|
+
return inspect_sql.list_facts(
|
|
73
|
+
path,
|
|
74
|
+
latest_only=latest_only,
|
|
75
|
+
predicate=predicate,
|
|
76
|
+
entity=entity,
|
|
77
|
+
min_salience=min_salience,
|
|
78
|
+
q=q,
|
|
79
|
+
page=page,
|
|
80
|
+
page_size=page_size,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@router.get(
|
|
84
|
+
"/{namespace}/facts/{fact_id}", dependencies=[Depends(require_auth)]
|
|
85
|
+
)
|
|
86
|
+
def fact_detail(request: Request, namespace: str, fact_id: str):
|
|
87
|
+
path = _ns_db(request, namespace)
|
|
88
|
+
fact = inspect_sql.get_fact(path, fact_id)
|
|
89
|
+
if fact is None:
|
|
90
|
+
raise HTTPException(status_code=404, detail="unknown fact")
|
|
91
|
+
return fact
|
|
92
|
+
|
|
93
|
+
@router.get("/{namespace}/episodes", dependencies=[Depends(require_auth)])
|
|
94
|
+
def episodes(
|
|
95
|
+
request: Request, namespace: str, page: int = 1, page_size: int = 50
|
|
96
|
+
):
|
|
97
|
+
path = _ns_db(request, namespace)
|
|
98
|
+
return inspect_sql.list_episodes(path, page=page, page_size=page_size)
|
|
99
|
+
|
|
100
|
+
@router.get(
|
|
101
|
+
"/{namespace}/episodes/{episode_id}",
|
|
102
|
+
dependencies=[Depends(require_auth)],
|
|
103
|
+
)
|
|
104
|
+
def episode_detail(request: Request, namespace: str, episode_id: str):
|
|
105
|
+
path = _ns_db(request, namespace)
|
|
106
|
+
ep = inspect_sql.get_episode(path, episode_id)
|
|
107
|
+
if ep is None:
|
|
108
|
+
raise HTTPException(status_code=404, detail="unknown episode")
|
|
109
|
+
return ep
|
|
110
|
+
|
|
111
|
+
@router.get("/{namespace}/entities", dependencies=[Depends(require_auth)])
|
|
112
|
+
def entities(
|
|
113
|
+
request: Request, namespace: str, page: int = 1, page_size: int = 50
|
|
114
|
+
):
|
|
115
|
+
path = _ns_db(request, namespace)
|
|
116
|
+
return inspect_sql.list_entities(path, page=page, page_size=page_size)
|
|
117
|
+
|
|
118
|
+
@router.get("/{namespace}/events", dependencies=[Depends(require_auth)])
|
|
119
|
+
def events(
|
|
120
|
+
request: Request,
|
|
121
|
+
namespace: str,
|
|
122
|
+
kind: str | None = None,
|
|
123
|
+
page: int = 1,
|
|
124
|
+
page_size: int = 50,
|
|
125
|
+
):
|
|
126
|
+
if kind is not None and kind not in ("add", "search"):
|
|
127
|
+
raise HTTPException(status_code=422, detail="invalid kind")
|
|
128
|
+
# events live in the sidecar, not a namespace .db — no _ns_db guard,
|
|
129
|
+
# but reserved namespaces are still rejected.
|
|
130
|
+
if is_reserved_namespace(namespace):
|
|
131
|
+
raise HTTPException(status_code=404, detail="unknown namespace")
|
|
132
|
+
event_log = request.app.state.event_log
|
|
133
|
+
return event_log.list_events(
|
|
134
|
+
namespace, kind=kind, page=page, page_size=page_size
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@router.post(
|
|
138
|
+
"/{namespace}/test-search", dependencies=[Depends(require_auth)]
|
|
139
|
+
)
|
|
140
|
+
async def test_search(
|
|
141
|
+
request: Request, namespace: str, body: TestSearchBody
|
|
142
|
+
):
|
|
143
|
+
# Guard existence (and reserved) exactly like _ns_db: a search against a
|
|
144
|
+
# nonexistent namespace must 404, NOT create the .db file as a side
|
|
145
|
+
# effect of the engine opening it.
|
|
146
|
+
_ns_db(request, namespace)
|
|
147
|
+
gateway = request.app.state.gateway
|
|
148
|
+
result = await gateway.search(
|
|
149
|
+
namespace, body.query, k=body.k, latest_only=True, origin="ui"
|
|
150
|
+
)
|
|
151
|
+
return {"hits": result.hits, "duration_ms": result.duration_ms}
|
|
152
|
+
|
|
153
|
+
return router
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lean-memory-console
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Agent-first read-only verification console for lean-memory
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: fastapi>=0.115
|
|
7
|
+
Requires-Dist: lean-memory
|
|
8
|
+
Requires-Dist: mcp>=1.0
|
|
9
|
+
Requires-Dist: uvicorn>=0.30
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: anyio>=4; extra == 'dev'
|
|
12
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Requires-Dist: pyyaml; extra == 'dev'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
lean_memory_console/__init__.py,sha256=Jg18i1Ur9fTFAdcvJgPwgDeXWxn8Wuywbeh1UmQ6qwI,97
|
|
2
|
+
lean_memory_console/app.py,sha256=_PQPt0FcjleWmlT-m6ncLkIOpvupF8Hjs51LJ_5tFUc,6033
|
|
3
|
+
lean_memory_console/cli.py,sha256=WBykbiX7aM2Mw1aXr4BFT4kUPfyzgl8UbQzPfOr1LA0,4864
|
|
4
|
+
lean_memory_console/config.py,sha256=F1VlsSVpMV5HUPBw6Mq_tPsZYGRLIHNGZbK0qqyVfs0,4269
|
|
5
|
+
lean_memory_console/engine.py,sha256=NHdDUc7h29ubijIK7oQ9WwDO3NKBX91MXTlsNLjLxTo,15001
|
|
6
|
+
lean_memory_console/events.py,sha256=8huPjL4SZ2F1KqiEEat1-RV6U8TDzjFh5SDT9E_Hbjk,5027
|
|
7
|
+
lean_memory_console/inspect_sql.py,sha256=TFnln9CnCxKIz4jCOQzhVGFiC8ikonXZxPXHwpSVqUk,11446
|
|
8
|
+
lean_memory_console/mcp_tools.py,sha256=ecdr1SJqkfJJ2E4iIc-nC1ly5MWiv_n-mm7EH28762w,6985
|
|
9
|
+
lean_memory_console/observe_mcp.py,sha256=mR5RfxBSParlfrtwNQjCg_afWLsOqK2ZA5k-StDynWs,2380
|
|
10
|
+
lean_memory_console/deploy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
lean_memory_console/routes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
lean_memory_console/routes/data.py,sha256=lzneIzjohQpG5kOET0Ex108w83Lo4Xkt_SuICN3rt2U,1620
|
|
13
|
+
lean_memory_console/routes/mcp.py,sha256=E_w98Ttp9umZoXX1bBrZuSu8MG0x8t6IXH23Cp_E9Ys,7686
|
|
14
|
+
lean_memory_console/routes/review.py,sha256=V0byVoRhXCSetWkYGpwjsSdH2BOzi2V-oyOOh9-Lgnc,3828
|
|
15
|
+
lean_memory_console/routes/views.py,sha256=AtqiBctCJoaXHRiVTChx0RIF08GEHjF7kB5QqsTZ0Ec,5664
|
|
16
|
+
lean_memory_console/deploy/docker-compose.yml,sha256=DnRlp3KHRqXq2OZKTWptU7lOk6GiPPMzdnuEciT7-cw,1393
|
|
17
|
+
lean_memory_console-0.2.0.dist-info/METADATA,sha256=cbn0cdmj28n0_K0Hi5PqcOwy1IrAM1wDd9E5zq_GtEY,447
|
|
18
|
+
lean_memory_console-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
19
|
+
lean_memory_console-0.2.0.dist-info/entry_points.txt,sha256=Cs0A0akgNoDIfi7y5nZTd94zkSCXgNWhqyl5YF4H2Ow,69
|
|
20
|
+
lean_memory_console-0.2.0.dist-info/RECORD,,
|