seren-memory 1.9.1__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.
- seren_memory/__init__.py +25 -0
- seren_memory/__main__.py +42 -0
- seren_memory/_version.py +24 -0
- seren_memory/app.py +548 -0
- seren_memory/collections.py +739 -0
- seren_memory/config.py +161 -0
- seren_memory/consolidator/__init__.py +2 -0
- seren_memory/consolidator/service.py +791 -0
- seren_memory/mcp/__init__.py +13 -0
- seren_memory/mcp/server.py +190 -0
- seren_memory/mcp/tools.py +548 -0
- seren_memory/models/__init__.py +12 -0
- seren_memory/models/schemas.py +354 -0
- seren_memory/routes/__init__.py +1 -0
- seren_memory/routes/long.py +64 -0
- seren_memory/routes/near.py +56 -0
- seren_memory/routes/search.py +104 -0
- seren_memory/routes/short.py +36 -0
- seren_memory/viewer/halls.html +715 -0
- seren_memory-1.9.1.dist-info/METADATA +21 -0
- seren_memory-1.9.1.dist-info/RECORD +24 -0
- seren_memory-1.9.1.dist-info/WHEEL +5 -0
- seren_memory-1.9.1.dist-info/entry_points.txt +2 -0
- seren_memory-1.9.1.dist-info/top_level.txt +1 -0
seren_memory/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SerenMemory - three-tier LLM memory with consolidation.
|
|
3
|
+
|
|
4
|
+
The Halls of Memory: ShortTerm (working), NearTerm (open loops), LongTerm
|
|
5
|
+
(consolidated). A small "consolidator" model does the dream-work of
|
|
6
|
+
promoting what matters and letting the rest go.
|
|
7
|
+
|
|
8
|
+
Standalone. Bring your own LLM (any OpenAI-compatible endpoint). Configure
|
|
9
|
+
a couple of values and you've got a memory system that matters.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
14
|
+
|
|
15
|
+
# Version flows from the git tag via setuptools-scm (written to _version.py
|
|
16
|
+
# at build time and recorded in the installed package's metadata). No manual
|
|
17
|
+
# bump, no sed step. The fallback only fires in a bare source checkout that
|
|
18
|
+
# was never installed; do `pip install -e .` to resolve it.
|
|
19
|
+
try:
|
|
20
|
+
__version__: str = _pkg_version("seren-memory")
|
|
21
|
+
except PackageNotFoundError:
|
|
22
|
+
__version__ = "0.0.0.dev"
|
|
23
|
+
|
|
24
|
+
from .app import create_app # noqa: F401,E402
|
|
25
|
+
from .config import load_config, MemoryConfig # noqa: F401,E402
|
seren_memory/__main__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry point: python -m seren_memory [--config path]
|
|
3
|
+
|
|
4
|
+
Boots the FastAPI app with uvicorn using the resolved config.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
from .app import create_app
|
|
13
|
+
from .config import load_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="seren_memory",
|
|
19
|
+
description="SerenMemory - three-tier LLM memory with consolidation.")
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--config", "-c", default=None,
|
|
22
|
+
help="Path to seren-memory.yaml (default: ./seren-memory.yaml or "
|
|
23
|
+
"$SEREN_MEMORY_CONFIG, falling back to built-in defaults).")
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
|
|
26
|
+
cfg = load_config(args.config)
|
|
27
|
+
app = create_app(cfg)
|
|
28
|
+
|
|
29
|
+
print(f"[seren-memory] listening on {cfg.server.host}:{cfg.server.port}")
|
|
30
|
+
print(f"[seren-memory] auth: "
|
|
31
|
+
f"{'enabled' if cfg.server.bearer_token else 'DISABLED (no token)'}")
|
|
32
|
+
|
|
33
|
+
uvicorn.run(
|
|
34
|
+
app,
|
|
35
|
+
host=cfg.server.host,
|
|
36
|
+
port=cfg.server.port,
|
|
37
|
+
log_level="info",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
main()
|
seren_memory/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.9.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 9, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
seren_memory/app.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""
|
|
2
|
+
seren_memory.app
|
|
3
|
+
════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
The FastAPI application. Wires the store, routes, optional bearer auth, and
|
|
6
|
+
the background consolidation loop.
|
|
7
|
+
|
|
8
|
+
ENDPOINTS:
|
|
9
|
+
GET / - service info + tier counts
|
|
10
|
+
GET /health - liveness
|
|
11
|
+
POST /short - add working memory (free write)
|
|
12
|
+
GET /short - list (debug)
|
|
13
|
+
DELETE /short/{id} - remove (free)
|
|
14
|
+
POST /near - add open loop (free write)
|
|
15
|
+
GET /near - list (debug)
|
|
16
|
+
POST /near/{id}/complete - mark intent done
|
|
17
|
+
DELETE /near/{id} - abandon intent (free)
|
|
18
|
+
GET /long - list (read-open)
|
|
19
|
+
POST /long/{id}/forget - flag for consolidator (the Lacuna gate)
|
|
20
|
+
POST /search - unified ranked recall
|
|
21
|
+
GET /consolidator/status - last run, recent runs, counts, config
|
|
22
|
+
POST /consolidate/run - trigger consolidation now (manual / external mode)
|
|
23
|
+
POST /consolidate/wake - restart the background loop if it died (thread mode)
|
|
24
|
+
POST /brief - submit a daily brief (steers consolidation)
|
|
25
|
+
GET /brief - list recent briefs (debug / viewer)
|
|
26
|
+
POST /short/{id}/preserve - mark for verbatim promotion (next cycle)
|
|
27
|
+
POST /short/{id}/promote - immediate verbatim promotion (skip cycle)
|
|
28
|
+
GET /drafts - list consolidator drafts (model review queue)
|
|
29
|
+
GET /drafts/{id}/chain - all attempts for a cluster (for comparison)
|
|
30
|
+
POST /drafts/{id}/approve - commit draft to long-term, archive shorts
|
|
31
|
+
POST /drafts/{id}/reject - send critique; triggers redraft or requires_selection
|
|
32
|
+
POST /drafts/{id}/select - commit best attempt when chain is requires_selection
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import hmac
|
|
38
|
+
import time
|
|
39
|
+
from contextlib import asynccontextmanager, AsyncExitStack
|
|
40
|
+
from typing import Optional
|
|
41
|
+
|
|
42
|
+
from fastapi import FastAPI, Body, Request, HTTPException
|
|
43
|
+
from fastapi.responses import JSONResponse, FileResponse
|
|
44
|
+
|
|
45
|
+
from .config import MemoryConfig, load_config
|
|
46
|
+
from .collections import MemoryStore
|
|
47
|
+
from .consolidator import Consolidator
|
|
48
|
+
from .models.schemas import DailyBrief
|
|
49
|
+
from .routes import short as short_routes
|
|
50
|
+
from .routes import near as near_routes
|
|
51
|
+
from .routes import long as long_routes
|
|
52
|
+
from .routes import search as search_routes
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Single source of truth for the version we report. Prefer the actually-
|
|
56
|
+
# installed wheel's metadata (the setuptools-scm value baked at build time);
|
|
57
|
+
# fall back to the package __version__ for an editable/dev checkout where the
|
|
58
|
+
# dist metadata may be absent or stale. This kills the old drift where app.py
|
|
59
|
+
# hardcoded a literal that the release process never touched.
|
|
60
|
+
try:
|
|
61
|
+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
62
|
+
try:
|
|
63
|
+
APP_VERSION = _pkg_version("seren-memory")
|
|
64
|
+
except PackageNotFoundError:
|
|
65
|
+
from . import __version__ as APP_VERSION
|
|
66
|
+
except Exception: # noqa: BLE001 - never let version lookup break startup
|
|
67
|
+
APP_VERSION = "0+unknown"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_app(config: MemoryConfig | None = None, embedding_function=None,
|
|
71
|
+
_allow_store_reset: bool = False) -> FastAPI:
|
|
72
|
+
cfg = config or load_config()
|
|
73
|
+
|
|
74
|
+
@asynccontextmanager
|
|
75
|
+
async def lifespan(app: FastAPI):
|
|
76
|
+
# -- Startup --
|
|
77
|
+
store = MemoryStore(cfg, embedding_function=embedding_function,
|
|
78
|
+
_allow_reset=_allow_store_reset)
|
|
79
|
+
app.state.store = store
|
|
80
|
+
app.state.config = cfg
|
|
81
|
+
app.state.consolidator = Consolidator(store, cfg)
|
|
82
|
+
print(f"[seren-memory] store ready at {cfg.resolved_persist_dir()}")
|
|
83
|
+
print(f"[seren-memory] tiers: {store.counts()}")
|
|
84
|
+
|
|
85
|
+
# -- Optional MCP server --
|
|
86
|
+
# Mounted ONLY if the [mcp] extras are installed. The import is
|
|
87
|
+
# inside the try block so a missing `mcp` package falls back to
|
|
88
|
+
# pure-HTTP mode without crashing startup. One install option
|
|
89
|
+
# (`pip install seren-memory[mcp]`) enables the MCP route at /mcp
|
|
90
|
+
# alongside the existing HTTP API - same process, same port, same
|
|
91
|
+
# config, one sec-approval surface.
|
|
92
|
+
try:
|
|
93
|
+
from .mcp.server import mount_mcp_routes
|
|
94
|
+
mcp_server = mount_mcp_routes(app)
|
|
95
|
+
except ImportError as exc:
|
|
96
|
+
# `mcp` package not installed - pure HTTP mode. Quiet by
|
|
97
|
+
# design: this is the default install path, not an error.
|
|
98
|
+
mcp_server = None
|
|
99
|
+
print(f"[seren-memory] MCP extras not installed; HTTP-only mode ({exc})")
|
|
100
|
+
except Exception as exc: # noqa: BLE001
|
|
101
|
+
# SDK installed but mount failed (version drift, transport
|
|
102
|
+
# mismatch, etc.) - log loudly but don't crash the service.
|
|
103
|
+
# HTTP API stays up; operator can investigate.
|
|
104
|
+
mcp_server = None
|
|
105
|
+
print(f"[seren-memory] MCP mount failed: {exc!r} - continuing without MCP")
|
|
106
|
+
|
|
107
|
+
# Background consolidation loop (thread mode only). External mode
|
|
108
|
+
# expects something to POST /consolidate/run on a schedule instead.
|
|
109
|
+
stop_event = None
|
|
110
|
+
if cfg.consolidator.enabled and cfg.consolidator.mode == "thread":
|
|
111
|
+
stop_event = asyncio.Event()
|
|
112
|
+
app.state._stop_event = stop_event
|
|
113
|
+
|
|
114
|
+
async def consolidation_loop():
|
|
115
|
+
interval = cfg.consolidator.interval_seconds
|
|
116
|
+
print(f"[seren-memory] consolidation loop active (every {interval}s)")
|
|
117
|
+
# Warmup delay so first run doesn't collide with startup.
|
|
118
|
+
try:
|
|
119
|
+
await asyncio.wait_for(stop_event.wait(), timeout=60)
|
|
120
|
+
return # stopped during warmup
|
|
121
|
+
except asyncio.TimeoutError:
|
|
122
|
+
pass
|
|
123
|
+
while not stop_event.is_set():
|
|
124
|
+
try:
|
|
125
|
+
# Run the (synchronous) consolidation off the event loop.
|
|
126
|
+
await asyncio.to_thread(app.state.consolidator.run_once)
|
|
127
|
+
except Exception as e: # noqa: BLE001
|
|
128
|
+
from .consolidator.service import ConsolidatorBusy
|
|
129
|
+
if isinstance(e, ConsolidatorBusy):
|
|
130
|
+
print(f"[seren-memory] scheduled tick skipped: {e}")
|
|
131
|
+
else:
|
|
132
|
+
print(f"[seren-memory] consolidation error: {e}")
|
|
133
|
+
try:
|
|
134
|
+
await asyncio.wait_for(stop_event.wait(), timeout=interval)
|
|
135
|
+
except asyncio.TimeoutError:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def _start_loop():
|
|
139
|
+
t = asyncio.create_task(consolidation_loop())
|
|
140
|
+
app.state._consolidation_task = t
|
|
141
|
+
return t
|
|
142
|
+
|
|
143
|
+
_start_loop()
|
|
144
|
+
# Expose the loop starter for the wake endpoint.
|
|
145
|
+
app.state._start_consolidation_loop = _start_loop
|
|
146
|
+
|
|
147
|
+
# -- Run the MCP session manager's task group (Bug 2 fix) --
|
|
148
|
+
# The streamable-HTTP transport keeps its anyio task group alive in
|
|
149
|
+
# session_manager.run(); without entering it here every MCP request
|
|
150
|
+
# 500s with "Task group is not initialized". A mounted sub-app's own
|
|
151
|
+
# lifespan does NOT fire under Starlette, so this is the only place
|
|
152
|
+
# it can live. AsyncExitStack makes HTTP-only mode (mcp_server is
|
|
153
|
+
# None) a clean no-op - we enter nothing and just yield.
|
|
154
|
+
async with AsyncExitStack() as _mcp_stack:
|
|
155
|
+
session_manager = getattr(mcp_server, "session_manager", None)
|
|
156
|
+
if session_manager is not None:
|
|
157
|
+
await _mcp_stack.enter_async_context(session_manager.run())
|
|
158
|
+
print("[seren-memory] MCP session manager running")
|
|
159
|
+
yield
|
|
160
|
+
|
|
161
|
+
# -- Shutdown --
|
|
162
|
+
if stop_event is not None:
|
|
163
|
+
stop_event.set()
|
|
164
|
+
task = getattr(app.state, "_consolidation_task", None)
|
|
165
|
+
if task:
|
|
166
|
+
try:
|
|
167
|
+
await task
|
|
168
|
+
except Exception: # noqa: BLE001
|
|
169
|
+
pass
|
|
170
|
+
# Release the ChromaDB client so SQLite handles are closed cleanly.
|
|
171
|
+
try:
|
|
172
|
+
app.state.store.close()
|
|
173
|
+
except Exception: # noqa: BLE001
|
|
174
|
+
pass
|
|
175
|
+
print("[seren-memory] shut down")
|
|
176
|
+
|
|
177
|
+
app = FastAPI(
|
|
178
|
+
title="SerenMemory",
|
|
179
|
+
description="Three-tier LLM memory with consolidation. The Halls of Memory.",
|
|
180
|
+
version=APP_VERSION,
|
|
181
|
+
lifespan=lifespan,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# -- Optional bearer auth --
|
|
185
|
+
# Same trusted-LAN posture as the rest of Seren: if a token is set,
|
|
186
|
+
# enforce it on everything except / and /health. If empty, no auth.
|
|
187
|
+
@app.middleware("http")
|
|
188
|
+
async def bearer_auth(request: Request, call_next):
|
|
189
|
+
token = cfg.server.bearer_token
|
|
190
|
+
if token:
|
|
191
|
+
# /viewer serves the HTML shell - it must be public so the page
|
|
192
|
+
# loads even when a token is set. The viewer's own JS fetch calls
|
|
193
|
+
# hit the API routes (/short, /long, /drafts, etc.) which ARE
|
|
194
|
+
# protected, so the data is still gated behind the token.
|
|
195
|
+
public = request.url.path in ("/", "/health", "/viewer")
|
|
196
|
+
if not public:
|
|
197
|
+
auth = request.headers.get("authorization", "")
|
|
198
|
+
# Constant-time compare so the 401 path doesn't leak how many
|
|
199
|
+
# leading bytes of the token matched (stdlib hmac, same as the
|
|
200
|
+
# agent's auth). Encode to bytes so non-ASCII can't raise.
|
|
201
|
+
expected = f"Bearer {token}"
|
|
202
|
+
if not hmac.compare_digest(auth.encode("utf-8"),
|
|
203
|
+
expected.encode("utf-8")):
|
|
204
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
205
|
+
return await call_next(request)
|
|
206
|
+
|
|
207
|
+
# -- Info routes --
|
|
208
|
+
@app.get("/")
|
|
209
|
+
async def root(request: Request):
|
|
210
|
+
store = request.app.state.store
|
|
211
|
+
return {
|
|
212
|
+
"service": "SerenMemory",
|
|
213
|
+
"version": APP_VERSION,
|
|
214
|
+
"tiers": store.counts(),
|
|
215
|
+
"consolidator": {
|
|
216
|
+
"enabled": cfg.consolidator.enabled,
|
|
217
|
+
"mode": cfg.consolidator.mode,
|
|
218
|
+
"interval_seconds": cfg.consolidator.interval_seconds,
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@app.get("/health")
|
|
223
|
+
async def health():
|
|
224
|
+
return {"ok": True, "ts": time.time()}
|
|
225
|
+
|
|
226
|
+
# -- The Halls viewer --
|
|
227
|
+
# Serves the introspection UI same-origin. This isn't just a debug tool -
|
|
228
|
+
# it's the window into the consolidator: are memories clustering sensibly,
|
|
229
|
+
# is near-term filling with stale loops, did a fact actually supersede the
|
|
230
|
+
# old one. Serving it FROM the memory service (rather than opening the
|
|
231
|
+
# file:// directly) also kills the CORS problem - the viewer's fetch calls
|
|
232
|
+
# are same-origin, so the browser doesn't block them.
|
|
233
|
+
@app.get("/viewer")
|
|
234
|
+
async def viewer():
|
|
235
|
+
# halls.html ships INSIDE the package (seren_memory/viewer/halls.html)
|
|
236
|
+
# so it travels with the wheel - Path(__file__).parent is the package
|
|
237
|
+
# dir whether running from a dev checkout or an installed site-packages.
|
|
238
|
+
from pathlib import Path
|
|
239
|
+
pkg_dir = Path(__file__).resolve().parent
|
|
240
|
+
candidates = [
|
|
241
|
+
pkg_dir / "viewer" / "halls.html", # in-package (installed + dev)
|
|
242
|
+
pkg_dir.parent / "viewer" / "halls.html", # repo-root (older dev layout)
|
|
243
|
+
]
|
|
244
|
+
html_path = next((p for p in candidates if p.is_file()), None)
|
|
245
|
+
if html_path is None:
|
|
246
|
+
return JSONResponse(
|
|
247
|
+
{"error": "viewer not found",
|
|
248
|
+
"hint": "halls.html missing from the package; reinstall or grab it from the repo"},
|
|
249
|
+
status_code=404)
|
|
250
|
+
return FileResponse(html_path, media_type="text/html")
|
|
251
|
+
|
|
252
|
+
# -- Consolidator operational status --
|
|
253
|
+
@app.get("/consolidator/status")
|
|
254
|
+
async def consolidator_status(request: Request):
|
|
255
|
+
"""Operational snapshot: when did the consolidator last run, how did
|
|
256
|
+
it go, what's the current cluster state. Backs the MCP
|
|
257
|
+
get_consolidator_status tool and the Halls viewer's operational
|
|
258
|
+
panel. last_run is null if the consolidator has never run on this
|
|
259
|
+
deployment.
|
|
260
|
+
"""
|
|
261
|
+
store = request.app.state.store
|
|
262
|
+
return {
|
|
263
|
+
"last_run": store.get_latest_run(),
|
|
264
|
+
"recent_runs": store.get_recent_runs(limit=10),
|
|
265
|
+
"latest_brief": store.get_latest_brief(),
|
|
266
|
+
"counts": store.counts(),
|
|
267
|
+
"config": {
|
|
268
|
+
"enabled": cfg.consolidator.enabled,
|
|
269
|
+
"mode": cfg.consolidator.mode,
|
|
270
|
+
"interval_seconds": cfg.consolidator.interval_seconds,
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# -- Brief submission --
|
|
275
|
+
@app.post("/brief")
|
|
276
|
+
async def submit_brief(request: Request, brief: DailyBrief = Body(...)):
|
|
277
|
+
"""Submit a daily brief. The main model calls this at the end of a
|
|
278
|
+
cycle. The consolidator reads the latest brief to steer its next
|
|
279
|
+
run. The brief is also itself a long-term candidate (a record of
|
|
280
|
+
'what we worked on')."""
|
|
281
|
+
store = request.app.state.store
|
|
282
|
+
saved = store.add_brief(brief)
|
|
283
|
+
return {"ok": True, "id": saved.id}
|
|
284
|
+
|
|
285
|
+
@app.get("/brief")
|
|
286
|
+
async def list_briefs(request: Request, limit: int = 20):
|
|
287
|
+
"""List the most recent briefs, newest first. Backs the Halls
|
|
288
|
+
viewer's brief panel - lets you see steering history alongside
|
|
289
|
+
the tier collections."""
|
|
290
|
+
store = request.app.state.store
|
|
291
|
+
rows = store.get_recent_briefs(limit=limit)
|
|
292
|
+
return {"entries": rows, "count": len(rows)}
|
|
293
|
+
|
|
294
|
+
# -- Consolidator drafts (model review queue) --
|
|
295
|
+
@app.get("/drafts")
|
|
296
|
+
async def list_drafts(request: Request, limit: int = 20,
|
|
297
|
+
status: Optional[str] = None):
|
|
298
|
+
"""List consolidator drafts. Defaults to all statuses, newest first;
|
|
299
|
+
pass status=pending for the active review queue, approved/rejected
|
|
300
|
+
for history, or requires_selection when the redraft budget ran out.
|
|
301
|
+
|
|
302
|
+
Each draft carries source_short_ids (the cluster evidence), attempt
|
|
303
|
+
(1-based position in its redraft chain), cluster_id (shared across
|
|
304
|
+
all attempts for one cluster), and previous_draft_ids so the model
|
|
305
|
+
can compare the full chain before selecting.
|
|
306
|
+
"""
|
|
307
|
+
store = request.app.state.store
|
|
308
|
+
rows = store.get_recent_drafts(limit=limit, status=status)
|
|
309
|
+
return {"entries": rows, "count": len(rows)}
|
|
310
|
+
|
|
311
|
+
@app.get("/drafts/{draft_id}/chain")
|
|
312
|
+
async def get_draft_chain(request: Request, draft_id: str):
|
|
313
|
+
"""Return all synthesis attempts for the same cluster as draft_id,
|
|
314
|
+
ordered by attempt number (ascending). Use this to compare every
|
|
315
|
+
draft in a chain before selecting the best one via /select.
|
|
316
|
+
Returns 404 if draft_id is not found.
|
|
317
|
+
"""
|
|
318
|
+
store = request.app.state.store
|
|
319
|
+
row = store._get_draft_row(draft_id)
|
|
320
|
+
if not row:
|
|
321
|
+
raise HTTPException(404, f"no draft '{draft_id}'")
|
|
322
|
+
cluster_id = row["metadata"].get("cluster_id", draft_id)
|
|
323
|
+
chain = store.get_drafts_by_cluster(cluster_id)
|
|
324
|
+
return {"cluster_id": cluster_id, "attempts": chain, "count": len(chain)}
|
|
325
|
+
|
|
326
|
+
@app.post("/drafts/{draft_id}/approve")
|
|
327
|
+
async def approve_draft(request: Request, draft_id: str,
|
|
328
|
+
body: Optional[dict] = Body(None)):
|
|
329
|
+
"""Approve a pending draft. Commits the synthesis to long-term and
|
|
330
|
+
archives the source shorts to pruned. Optional 'note' in body is
|
|
331
|
+
recorded with the approval.
|
|
332
|
+
|
|
333
|
+
Returns 404 if draft missing, 409 if already reviewed.
|
|
334
|
+
"""
|
|
335
|
+
store = request.app.state.store
|
|
336
|
+
note = (body or {}).get("note")
|
|
337
|
+
result = store.approve_draft(draft_id, note=note)
|
|
338
|
+
if result is None:
|
|
339
|
+
existing = store._get_draft_row(draft_id)
|
|
340
|
+
if not existing:
|
|
341
|
+
raise HTTPException(404, f"no draft '{draft_id}'")
|
|
342
|
+
raise HTTPException(409,
|
|
343
|
+
f"draft '{draft_id}' already {existing['metadata'].get('status')}")
|
|
344
|
+
return {
|
|
345
|
+
"ok": True,
|
|
346
|
+
"draft_id": draft_id,
|
|
347
|
+
"long_term_id": result["long_term_id"],
|
|
348
|
+
"shorts_archived": result["shorts_archived"],
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
@app.post("/drafts/{draft_id}/reject")
|
|
352
|
+
async def reject_draft(request: Request, draft_id: str, body: dict = Body(...)):
|
|
353
|
+
"""Reject a pending draft with a critique. The consolidator will
|
|
354
|
+
produce a new synthesis incorporating the critique (up to
|
|
355
|
+
max_redraft_attempts total tries). Once the limit is exhausted the
|
|
356
|
+
chain flips to requires_selection and the model must POST /select.
|
|
357
|
+
|
|
358
|
+
Body: {"critique": "<why this synthesis is wrong/incomplete>"}
|
|
359
|
+
Legacy key 'reason' is accepted as an alias.
|
|
360
|
+
|
|
361
|
+
Returns 400 if no critique, 404 if draft missing, 409 if already
|
|
362
|
+
reviewed. Response includes action ('redrafted' or
|
|
363
|
+
'requires_selection') and, when redrafting, the new draft_id.
|
|
364
|
+
"""
|
|
365
|
+
critique = (body or {}).get("critique") or (body or {}).get("reason", "")
|
|
366
|
+
critique = critique.strip() if critique else ""
|
|
367
|
+
if not critique:
|
|
368
|
+
raise HTTPException(400, "a 'critique' is required to reject a draft")
|
|
369
|
+
store = request.app.state.store
|
|
370
|
+
cluster_meta = store.reject_draft(draft_id, critique)
|
|
371
|
+
if cluster_meta is None:
|
|
372
|
+
existing = store._get_draft_row(draft_id)
|
|
373
|
+
if not existing:
|
|
374
|
+
raise HTTPException(404, f"no draft '{draft_id}'")
|
|
375
|
+
raise HTTPException(409,
|
|
376
|
+
f"draft '{draft_id}' already {existing['metadata'].get('status')}")
|
|
377
|
+
|
|
378
|
+
# Trigger redraft (or flip to requires_selection) on the consolidator.
|
|
379
|
+
consolidator = request.app.state.consolidator
|
|
380
|
+
redraft_result = await asyncio.to_thread(
|
|
381
|
+
consolidator.redraft_cluster,
|
|
382
|
+
cluster_id=cluster_meta["cluster_id"],
|
|
383
|
+
rejected_draft_id=draft_id,
|
|
384
|
+
critique=critique,
|
|
385
|
+
attempt=cluster_meta["attempt"],
|
|
386
|
+
source_short_ids=cluster_meta["source_short_ids"],
|
|
387
|
+
brief_id_used=cluster_meta["brief_id_used"],
|
|
388
|
+
topic=cluster_meta["topic"],
|
|
389
|
+
evidence_count=cluster_meta["evidence_count"],
|
|
390
|
+
)
|
|
391
|
+
if redraft_result is None:
|
|
392
|
+
return {
|
|
393
|
+
"ok": True, "draft_id": draft_id,
|
|
394
|
+
"action": "rejected", "critique": critique,
|
|
395
|
+
"warning": "redraft synthesis failed; cluster stays in pool",
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
"ok": True,
|
|
399
|
+
"draft_id": draft_id,
|
|
400
|
+
"action": redraft_result["action"],
|
|
401
|
+
"critique": critique,
|
|
402
|
+
"new_draft_id": redraft_result.get("draft_id"),
|
|
403
|
+
"attempt": redraft_result["attempt"],
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
@app.post("/drafts/{draft_id}/select")
|
|
407
|
+
async def select_draft(request: Request, draft_id: str,
|
|
408
|
+
body: Optional[dict] = Body(None)):
|
|
409
|
+
"""Commit the best attempt from a requires_selection chain to
|
|
410
|
+
long-term. The selected draft is approved and all sibling attempts
|
|
411
|
+
are marked rejected. Source shorts are archived to pruned.
|
|
412
|
+
|
|
413
|
+
Body (optional):
|
|
414
|
+
{
|
|
415
|
+
"edited_content": "<revised text to commit instead of the draft>",
|
|
416
|
+
"note": "<freeform note on the selection>"
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
edited_content is the editor's safety valve: when all redraft
|
|
420
|
+
attempts are unsatisfactory, the editor picks the best of the chain
|
|
421
|
+
and can revise it before commit. If edited_content is omitted (or
|
|
422
|
+
None), the draft commits as-is. The original draft's content is
|
|
423
|
+
preserved on the draft row (and copied into the long-term entry's
|
|
424
|
+
extra dict) so the audit trail stays intact - we can always answer
|
|
425
|
+
"what did the consolidator originally synthesize" even after edit.
|
|
426
|
+
|
|
427
|
+
Edit is only available on this path (not on approve), which keeps
|
|
428
|
+
the iteration loop discipline: if you want to tweak during the loop,
|
|
429
|
+
reject with a critique and let the consolidator re-synthesize. Edit
|
|
430
|
+
is the terminal-state release valve, not a shortcut.
|
|
431
|
+
|
|
432
|
+
Call GET /drafts/{id}/chain first to compare all attempts, then
|
|
433
|
+
POST /select on the one judged best (optionally with edits).
|
|
434
|
+
|
|
435
|
+
Returns 400 if edited_content is provided as blank/whitespace,
|
|
436
|
+
404 if draft missing, 409 if not in requires_selection state.
|
|
437
|
+
Response includes 'edited' (bool) and 'edit_delta_chars' (int) so
|
|
438
|
+
the operator can see the magnitude of any revision at a glance.
|
|
439
|
+
"""
|
|
440
|
+
body = body or {}
|
|
441
|
+
edited_content = body.get("edited_content")
|
|
442
|
+
note = body.get("note")
|
|
443
|
+
|
|
444
|
+
# Empty edits are a bug, not "no edit". Reject explicitly so the
|
|
445
|
+
# editor can't accidentally commit a blank long-term entry.
|
|
446
|
+
if edited_content is not None:
|
|
447
|
+
if not isinstance(edited_content, str) or not edited_content.strip():
|
|
448
|
+
raise HTTPException(
|
|
449
|
+
400,
|
|
450
|
+
"edited_content must be a non-empty string; omit the field "
|
|
451
|
+
"to commit the draft as-is")
|
|
452
|
+
|
|
453
|
+
store = request.app.state.store
|
|
454
|
+
result = store.select_draft(draft_id, note=note,
|
|
455
|
+
edited_content=edited_content)
|
|
456
|
+
if result is None:
|
|
457
|
+
existing = store._get_draft_row(draft_id)
|
|
458
|
+
if not existing:
|
|
459
|
+
raise HTTPException(404, f"no draft '{draft_id}'")
|
|
460
|
+
status = existing["metadata"].get("status")
|
|
461
|
+
raise HTTPException(409,
|
|
462
|
+
f"draft '{draft_id}' is '{status}', not requires_selection")
|
|
463
|
+
return {
|
|
464
|
+
"ok": True,
|
|
465
|
+
"draft_id": draft_id,
|
|
466
|
+
"long_term_id": result["long_term_id"],
|
|
467
|
+
"shorts_archived": result["shorts_archived"],
|
|
468
|
+
"edited": result["edited"],
|
|
469
|
+
"edit_delta_chars": result["edit_delta_chars"],
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
# -- Short-term agency endpoints (preserve_verbatim + promote_memory) --
|
|
473
|
+
@app.post("/short/{entry_id}/preserve")
|
|
474
|
+
async def preserve_short_verbatim(request: Request, entry_id: str):
|
|
475
|
+
"""Mark a short-term entry to be promoted VERBATIM (no synthesis,
|
|
476
|
+
no fusion) on the next consolidator cycle. Also pins it so it
|
|
477
|
+
survives aging until the cycle runs. Returns 404 if not found."""
|
|
478
|
+
store = request.app.state.store
|
|
479
|
+
ok = store.update_short_metadata(entry_id, {"verbatim": True, "pinned": True})
|
|
480
|
+
if not ok:
|
|
481
|
+
raise HTTPException(
|
|
482
|
+
status_code=404,
|
|
483
|
+
detail=f"short-term entry '{entry_id}' not found")
|
|
484
|
+
return {"ok": True, "id": entry_id, "verbatim": True, "pinned": True}
|
|
485
|
+
|
|
486
|
+
@app.post("/short/{entry_id}/promote")
|
|
487
|
+
async def promote_short_immediately(request: Request, entry_id: str):
|
|
488
|
+
"""Immediately move a short-term entry to long-term verbatim,
|
|
489
|
+
bypassing the consolidator cycle entirely. The 'I know this is
|
|
490
|
+
durable, don't make me wait' override. Returns 404 if not found."""
|
|
491
|
+
store = request.app.state.store
|
|
492
|
+
long_id = store.promote_short_to_long(entry_id)
|
|
493
|
+
if long_id is None:
|
|
494
|
+
raise HTTPException(
|
|
495
|
+
status_code=404,
|
|
496
|
+
detail=f"short-term entry '{entry_id}' not found")
|
|
497
|
+
return {"ok": True, "long_term_id": long_id, "removed_short_id": entry_id}
|
|
498
|
+
|
|
499
|
+
# -- Manual consolidation trigger + wake --
|
|
500
|
+
@app.post("/consolidate/run")
|
|
501
|
+
async def consolidate_now(request: Request):
|
|
502
|
+
"""Run a consolidation pass right now. Used in 'external' mode (a
|
|
503
|
+
cron/systemd timer POSTs here) or for manual testing. Runs the
|
|
504
|
+
synchronous consolidation in a thread so we don't block the loop.
|
|
505
|
+
|
|
506
|
+
Returns 409 if a scheduled run is already in progress.
|
|
507
|
+
"""
|
|
508
|
+
from .consolidator.service import ConsolidatorBusy
|
|
509
|
+
try:
|
|
510
|
+
report = await asyncio.to_thread(
|
|
511
|
+
request.app.state.consolidator.run_once)
|
|
512
|
+
except ConsolidatorBusy as exc:
|
|
513
|
+
raise HTTPException(status_code=409, detail=str(exc))
|
|
514
|
+
return {"ok": True, "report": report}
|
|
515
|
+
|
|
516
|
+
@app.post("/consolidate/wake")
|
|
517
|
+
async def wake_consolidator(request: Request):
|
|
518
|
+
"""Restart the background consolidation loop if it has died or is not
|
|
519
|
+
running. No-op when mode is 'external' (there is no loop to wake).
|
|
520
|
+
Returns 'already_running' if the task is still alive, 'woken' if it
|
|
521
|
+
was restarted, or 'not_applicable' in external mode.
|
|
522
|
+
"""
|
|
523
|
+
cfg = request.app.state.config
|
|
524
|
+
if not cfg.consolidator.enabled or cfg.consolidator.mode != "thread":
|
|
525
|
+
return {"ok": True, "status": "not_applicable",
|
|
526
|
+
"detail": "consolidator is in external mode; POST /consolidate/run to trigger"}
|
|
527
|
+
|
|
528
|
+
task: Optional[asyncio.Task] = getattr(request.app.state,
|
|
529
|
+
"_consolidation_task", None)
|
|
530
|
+
if task is not None and not task.done():
|
|
531
|
+
return {"ok": True, "status": "already_running"}
|
|
532
|
+
|
|
533
|
+
starter = getattr(request.app.state, "_start_consolidation_loop", None)
|
|
534
|
+
if starter is None:
|
|
535
|
+
return {"ok": False, "status": "error",
|
|
536
|
+
"detail": "loop starter not available; restart the service"}
|
|
537
|
+
|
|
538
|
+
starter()
|
|
539
|
+
return {"ok": True, "status": "woken",
|
|
540
|
+
"detail": "background consolidation loop restarted"}
|
|
541
|
+
|
|
542
|
+
# -- Tier routes --
|
|
543
|
+
app.include_router(short_routes.router)
|
|
544
|
+
app.include_router(near_routes.router)
|
|
545
|
+
app.include_router(long_routes.router)
|
|
546
|
+
app.include_router(search_routes.router)
|
|
547
|
+
|
|
548
|
+
return app
|