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.
@@ -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
@@ -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()
@@ -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