ouroboros-memory 1.0.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.
Files changed (61) hide show
  1. ouroboros/__init__.py +36 -0
  2. ouroboros/__main__.py +27 -0
  3. ouroboros/api/__init__.py +11 -0
  4. ouroboros/api/middleware/__init__.py +4 -0
  5. ouroboros/api/routes/__init__.py +5 -0
  6. ouroboros/api/server.py +553 -0
  7. ouroboros/cli/__init__.py +23 -0
  8. ouroboros/cli/main.py +455 -0
  9. ouroboros/config/__init__.py +29 -0
  10. ouroboros/config/defaults.py +69 -0
  11. ouroboros/config/loader.py +103 -0
  12. ouroboros/config/schema.py +161 -0
  13. ouroboros/core/__init__.py +7 -0
  14. ouroboros/core/assembler.py +195 -0
  15. ouroboros/core/chain_of_thought.py +112 -0
  16. ouroboros/core/clustering.py +80 -0
  17. ouroboros/core/concept.py +223 -0
  18. ouroboros/core/config.py +292 -0
  19. ouroboros/core/crystal.py +804 -0
  20. ouroboros/core/curiosity.py +172 -0
  21. ouroboros/core/dream.py +134 -0
  22. ouroboros/core/entity.py +176 -0
  23. ouroboros/core/expectation.py +84 -0
  24. ouroboros/core/generative.py +110 -0
  25. ouroboros/core/humor.py +98 -0
  26. ouroboros/core/immune.py +124 -0
  27. ouroboros/core/inference.py +740 -0
  28. ouroboros/core/math_engine.py +215 -0
  29. ouroboros/core/narrative.py +134 -0
  30. ouroboros/core/other_mind.py +176 -0
  31. ouroboros/core/parser.py +806 -0
  32. ouroboros/core/quality.py +132 -0
  33. ouroboros/core/query_analyzer.py +163 -0
  34. ouroboros/core/ranker.py +154 -0
  35. ouroboros/core/resonance.py +153 -0
  36. ouroboros/core/synthesizer.py +102 -0
  37. ouroboros/core/tension.py +242 -0
  38. ouroboros/core/utils.py +140 -0
  39. ouroboros/core/working_memory.py +135 -0
  40. ouroboros/llm/__init__.py +30 -0
  41. ouroboros/llm/anthropic.py +108 -0
  42. ouroboros/llm/base.py +105 -0
  43. ouroboros/llm/factory.py +66 -0
  44. ouroboros/llm/gemini.py +108 -0
  45. ouroboros/llm/llamacpp.py +122 -0
  46. ouroboros/llm/ollama.py +127 -0
  47. ouroboros/llm/openai.py +118 -0
  48. ouroboros/llm/vllm.py +117 -0
  49. ouroboros/mind.py +1318 -0
  50. ouroboros/privacy/__init__.py +17 -0
  51. ouroboros/privacy/bridge.py +244 -0
  52. ouroboros/py.typed +1 -0
  53. ouroboros/storage/__init__.py +31 -0
  54. ouroboros/storage/backends.py +345 -0
  55. ouroboros/version.py +9 -0
  56. ouroboros_memory-1.0.0.dist-info/METADATA +436 -0
  57. ouroboros_memory-1.0.0.dist-info/RECORD +61 -0
  58. ouroboros_memory-1.0.0.dist-info/WHEEL +4 -0
  59. ouroboros_memory-1.0.0.dist-info/entry_points.txt +2 -0
  60. ouroboros_memory-1.0.0.dist-info/licenses/LICENSE +26 -0
  61. ouroboros_memory_README.md +371 -0
ouroboros/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Ouroboros: lifelong private memory for large language models.
2
+
3
+ This package is the public entry point. The heavy lifting is done by
4
+ submodules; importing :mod:`ouroboros` is cheap and side-effect free.
5
+
6
+ >>> from ouroboros import OuroborosMind, MindConfig
7
+ >>> mind = OuroborosMind()
8
+ >>> reply = mind.chat("My name is Tony. I like dark mode.")
9
+ >>> reply
10
+ 'Noted, Tony. I have crystallized your preference for dark mode.'
11
+
12
+ The full system architecture is documented in ``docs/architecture.md``.
13
+ """
14
+
15
+ from ouroboros.config.schema import MindConfig, PrivacyConfig
16
+ from ouroboros.mind import OuroborosMind
17
+ from ouroboros.version import __version__
18
+
19
+ __all__ = [
20
+ "MindConfig",
21
+ "OuroborosMind",
22
+ "PrivacyConfig",
23
+ "__version__",
24
+ ]
25
+
26
+
27
+ def main() -> None: # pragma: no cover - CLI entry point
28
+ """Lazy entry point for ``python -m ouroboros``.
29
+
30
+ The CLI module is imported lazily to keep ``import ouroboros``
31
+ cheap and to avoid forcing the optional Typer/Rich stack to be
32
+ installed in every environment that just wants the core engine.
33
+ """
34
+ from ouroboros.cli.main import app
35
+
36
+ app()
ouroboros/__main__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Allow ``python -m ouroboros`` to launch the CLI.
2
+
3
+ The actual CLI implementation lives in :mod:`ouroboros.cli.main` and
4
+ is imported lazily so the package is usable in environments where
5
+ Typer / Rich are not installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+
13
+ def main() -> None: # pragma: no cover - CLI entry point
14
+ """Entry point for ``python -m ouroboros``."""
15
+ try:
16
+ from ouroboros.cli.main import app
17
+ except ImportError as exc:
18
+ sys.stderr.write(
19
+ f"ouroboros: the CLI module is not available ({exc}).\n"
20
+ "Install the optional CLI dependencies to use the command line.\n"
21
+ )
22
+ sys.exit(1)
23
+ app()
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
@@ -0,0 +1,11 @@
1
+ """FastAPI server and WebSocket streaming for the Ouroboros HTTP API.
2
+
3
+ Use :func:`create_app` to build a :class:`fastapi.FastAPI` instance
4
+ and mount it on any ASGI server (uvicorn, hypercorn, ...).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ouroboros.api.server import ChatRequest, ChatResponse, RestoreRequest, create_app
10
+
11
+ __all__ = ["ChatRequest", "ChatResponse", "RestoreRequest", "create_app"]
@@ -0,0 +1,4 @@
1
+ """HTTP middleware: CORS, auth, logging, error handling.
2
+
3
+ The middleware modules are implemented in Phase 6.
4
+ """
@@ -0,0 +1,5 @@
1
+ """HTTP API route modules.
2
+
3
+ The route modules (``chat``, ``config``, ``memory``, ...) are
4
+ implemented in Phase 6. This package marker is empty for now.
5
+ """
@@ -0,0 +1,553 @@
1
+ """FastAPI server exposing the Ouroboros mind over HTTP.
2
+
3
+ The server exposes a REST surface and a WebSocket endpoint for
4
+ streaming chat. It is created via :func:`create_app` so tests can
5
+ mount the app on :class:`fastapi.testclient.TestClient` without having
6
+ to bind a real port.
7
+
8
+ REST endpoints
9
+ --------------
10
+
11
+ - ``GET /health`` - liveness probe.
12
+ - ``GET /version`` - installed version.
13
+ - ``GET /config`` - resolved :class:`MindConfig`.
14
+ - ``POST /config`` - update a dotted key in memory.
15
+ - ``GET /llm`` - current LLM provider info.
16
+ - ``GET /llm/models`` - list models known to the provider.
17
+ - ``POST /llm/switch`` - swap the active LLM at runtime.
18
+ - ``GET /llm/ping`` - liveness probe of the daemon.
19
+ - ``POST /chat`` - one-shot chat.
20
+ - ``WS /ws/chat`` - streaming chat.
21
+ - ``GET /inspect`` - status snapshot.
22
+ - ``POST /dream`` - run a single dream pass.
23
+ - ``GET /memory/concepts`` - paginated concept summaries.
24
+ - ``GET /memory/facts`` - paginated raw facts.
25
+ - ``POST /memory/search`` - top-K relevant concepts.
26
+ - ``GET /memory/narrative`` - recent narrative log.
27
+ - ``POST /memory/clear`` - reset the in-memory mind.
28
+ - ``POST /backup`` - download a JSON snapshot.
29
+ - ``POST /restore`` - restore from a JSON snapshot.
30
+
31
+ The application also serves the built Svelte GUI from
32
+ ``src/ouroboros/gui/dist`` when present.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import json
38
+ import os
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
43
+ from fastapi.middleware.cors import CORSMiddleware
44
+ from fastapi.responses import FileResponse, JSONResponse
45
+ from fastapi.staticfiles import StaticFiles
46
+ from pydantic import BaseModel, ConfigDict, Field
47
+
48
+ from ouroboros import __version__
49
+ from ouroboros.config import load_config, save_config
50
+ from ouroboros.mind import OuroborosMind
51
+ from ouroboros.storage import StorageManager
52
+
53
+ # ----------------------------------------------------------------------
54
+ # Request / response models
55
+ # ----------------------------------------------------------------------
56
+
57
+
58
+ class ChatRequest(BaseModel):
59
+ """Body for :http:post:`/chat`.
60
+
61
+ The documented contract uses ``message``; ``text`` is also
62
+ accepted for backwards compatibility with earlier clients.
63
+ """
64
+
65
+ model_config = ConfigDict(extra="forbid")
66
+
67
+ message: str | None = Field(default=None, min_length=1, max_length=8192)
68
+ text: str | None = Field(default=None, min_length=1, max_length=8192)
69
+
70
+
71
+ class ChatResponse(BaseModel):
72
+ """Body returned by :http:post:`/chat`.
73
+
74
+ Mirrors the documented contract in ``docs/user_guide.md`` §7.2:
75
+ a top-level ``reply`` field and a few provenance fields.
76
+ """
77
+
78
+ text: str
79
+ reply: str
80
+ context: list[str] = Field(default_factory=list)
81
+ facts_learned: int = 0
82
+ status: dict[str, Any] = Field(default_factory=dict)
83
+
84
+
85
+ class RestoreRequest(BaseModel):
86
+ """Body for :http:post:`/restore`."""
87
+
88
+ payload: dict[str, Any]
89
+
90
+
91
+ class LLMSwitchRequest(BaseModel):
92
+ """Body for :http:post:`/llm/switch`."""
93
+
94
+ model_config = ConfigDict(extra="forbid")
95
+
96
+ provider: str = Field(..., min_length=1, max_length=64)
97
+ model: str = Field(default="", max_length=256)
98
+ base_url: str = Field(default="", max_length=512)
99
+ api_key: str = Field(default="", max_length=512)
100
+ timeout: float = Field(default=60.0, gt=0.0, le=600.0)
101
+
102
+
103
+ class MemorySearchRequest(BaseModel):
104
+ """Body for :http:post:`/memory/search`."""
105
+
106
+ model_config = ConfigDict(extra="forbid")
107
+
108
+ query: str = Field(..., min_length=1, max_length=1024)
109
+ k: int = Field(default=8, ge=1, le=64)
110
+
111
+
112
+ class ConfigUpdateRequest(BaseModel):
113
+ """Body for :http:post:`/config`."""
114
+
115
+ model_config = ConfigDict(extra="forbid")
116
+
117
+ key: str = Field(..., min_length=1, max_length=128)
118
+ value: str = Field(..., max_length=4096)
119
+
120
+
121
+ # ----------------------------------------------------------------------
122
+ # App factory
123
+ # ----------------------------------------------------------------------
124
+
125
+
126
+ _GUI_DIST = Path(__file__).resolve().parent.parent / "gui" / "dist"
127
+
128
+
129
+ def _storage_path() -> Path:
130
+ cfg = load_config()
131
+ return Path(str(cfg.memory.persistence_path)).expanduser() / "mind.json"
132
+
133
+
134
+ def _build_mind(load_from_disk: bool = True) -> OuroborosMind:
135
+ """Construct a :class:`OuroborosMind` wired to the configured LLM.
136
+
137
+ Parameters
138
+ ----------
139
+ load_from_disk:
140
+ When ``True`` (the default) any persisted state at the
141
+ configured storage path is loaded into the mind before it is
142
+ returned. Tests set this to ``False`` to keep state isolated.
143
+ """
144
+ from ouroboros.llm import get_provider
145
+
146
+ cfg = load_config()
147
+ path = _storage_path()
148
+ manager = StorageManager(path=path)
149
+ mind: OuroborosMind
150
+ try:
151
+ provider_kwargs: dict[str, Any] = {
152
+ "model": cfg.llm.model,
153
+ "timeout": cfg.llm.timeout,
154
+ }
155
+ kind = cfg.llm.provider.value
156
+ if kind in ("ollama", "ollama-cloud", "local-ollama"):
157
+ provider_kwargs["host"] = cfg.llm.base_url
158
+ elif kind in ("openai", "gpt"):
159
+ provider_kwargs["base_url"] = cfg.llm.base_url
160
+ if cfg.llm.api_key:
161
+ provider_kwargs["api_key"] = cfg.llm.api_key
162
+ elif kind in ("vllm",):
163
+ provider_kwargs["base_url"] = cfg.llm.base_url
164
+ elif kind in ("anthropic", "claude", "gemini", "google"):
165
+ if cfg.llm.api_key:
166
+ provider_kwargs["api_key"] = cfg.llm.api_key
167
+ provider = get_provider(kind, **provider_kwargs)
168
+ mind = OuroborosMind(provider=provider)
169
+ except Exception:
170
+ mind = OuroborosMind()
171
+ # Apply privacy configuration.
172
+ if cfg.privacy.forbid_cloud or cfg.privacy.level.value == "MAXIMUM":
173
+ mind.set_privacy(cfg.privacy)
174
+ if load_from_disk and manager.exists():
175
+ payload = manager.load()
176
+ if payload is not None:
177
+ mind.load_state(payload)
178
+ return mind
179
+
180
+
181
+ def _save_mind(mind: OuroborosMind) -> None:
182
+ path = _storage_path()
183
+ manager = StorageManager(path=path)
184
+ manager.save_mind(mind)
185
+
186
+
187
+ def _set_dotted(d: dict[str, Any], dotted: str, value: str) -> bool:
188
+ """Set ``d['a']['b'] = coerced(value)`` for a dotted key path."""
189
+ parts = dotted.split(".")
190
+ cur: Any = d
191
+ for part in parts[:-1]:
192
+ if part not in cur or not isinstance(cur[part], dict):
193
+ cur[part] = {}
194
+ cur = cur[part]
195
+ last = parts[-1]
196
+ coerced: Any = value
197
+ if value.lower() in ("true", "false"):
198
+ coerced = value.lower() == "true"
199
+ else:
200
+ try:
201
+ coerced = int(value)
202
+ except ValueError:
203
+ try:
204
+ coerced = float(value)
205
+ except ValueError:
206
+ pass
207
+ cur[last] = coerced
208
+ return True
209
+
210
+
211
+ def _llm_list_models(mind: OuroborosMind) -> list[str]:
212
+ """Return a list of model names available for the active provider."""
213
+ provider = mind._provider # type: ignore[attr-defined]
214
+ if provider is None:
215
+ return []
216
+ list_fn = getattr(provider, "list_models", None)
217
+ if callable(list_fn):
218
+ try:
219
+ models = list_fn()
220
+ except Exception:
221
+ return []
222
+ return [str(m) for m in models if m]
223
+ return [str(getattr(provider, "model", ""))] if getattr(
224
+ provider, "model", ""
225
+ ) else []
226
+
227
+
228
+ def create_app() -> FastAPI:
229
+ """Build the FastAPI application.
230
+
231
+ The returned app holds a single :class:`OuroborosMind` instance
232
+ per process. For multi-worker deployments each worker would have
233
+ its own mind; production users would put a single-worker
234
+ deployment behind a reverse proxy.
235
+ """
236
+ cfg = load_config()
237
+ app = FastAPI(
238
+ title="Ouroboros Mind",
239
+ version=__version__,
240
+ description="Lifelong private memory for large language models.",
241
+ )
242
+ app.add_middleware(
243
+ CORSMiddleware,
244
+ allow_origins=cfg.server.cors_origins or ["*"],
245
+ allow_credentials=True,
246
+ allow_methods=["*"],
247
+ allow_headers=["*"],
248
+ )
249
+
250
+ # ---- health & meta ------------------------------------------------
251
+
252
+ @app.get("/health", tags=["meta"])
253
+ def health() -> dict[str, str]:
254
+ return {"status": "ok", "version": __version__}
255
+
256
+ @app.get("/version", tags=["meta"])
257
+ def version() -> dict[str, str]:
258
+ return {"version": __version__}
259
+
260
+ # ---- config ------------------------------------------------------
261
+
262
+ @app.get("/config", tags=["config"])
263
+ def get_config() -> dict[str, Any]:
264
+ return load_config().model_dump(mode="json")
265
+
266
+ @app.post("/config", tags=["config"])
267
+ def update_config(req: ConfigUpdateRequest) -> dict[str, Any]:
268
+ cfg = load_config()
269
+ data: dict[str, Any] = cfg.model_dump()
270
+ _set_dotted(data, req.key, req.value)
271
+ cfg = type(cfg).model_validate(data)
272
+ save_config(cfg)
273
+ return {"status": "ok", "key": req.key, "value": req.value, "config": cfg.model_dump(mode="json")}
274
+
275
+ # ---- LLM ---------------------------------------------------------
276
+
277
+ @app.get("/llm", tags=["llm"])
278
+ def llm_info() -> dict[str, Any]:
279
+ mind = _build_mind()
280
+ info = mind.llm_info()
281
+ info["models"] = _llm_list_models(mind)
282
+ return info
283
+
284
+ @app.get("/llm/models", tags=["llm"])
285
+ def llm_models() -> list[str]:
286
+ mind = _build_mind()
287
+ return _llm_list_models(mind)
288
+
289
+ @app.get("/llm/ping", tags=["llm"])
290
+ def llm_ping() -> dict[str, Any]:
291
+ mind = _build_mind()
292
+ info = mind.llm_info()
293
+ return {"ok": bool(info.get("available")), "info": info}
294
+
295
+ @app.post("/llm/switch", tags=["llm"])
296
+ def llm_switch(req: LLMSwitchRequest) -> dict[str, Any]:
297
+ mind = _build_mind()
298
+ kwargs: dict[str, Any] = {"timeout": float(req.timeout)}
299
+ if req.base_url:
300
+ kwargs["base_url" if req.provider in ("openai", "vllm", "gpt") else "host"] = req.base_url
301
+ if req.api_key:
302
+ kwargs["api_key"] = req.api_key
303
+ if req.model:
304
+ kwargs["model"] = req.model
305
+ try:
306
+ provider = mind.switch_llm(req.provider, **kwargs)
307
+ except Exception as exc:
308
+ raise HTTPException(status_code=400, detail=f"could not switch LLM: {exc}") from exc
309
+ info = mind.llm_info()
310
+ info["models"] = _llm_list_models(mind)
311
+ return {
312
+ "status": "ok",
313
+ "provider": getattr(provider, "name", ""),
314
+ "model": getattr(provider, "model", ""),
315
+ "info": info,
316
+ }
317
+
318
+ # ---- chat --------------------------------------------------------
319
+
320
+ @app.post("/chat", response_model=ChatResponse, tags=["chat"])
321
+ def chat(req: ChatRequest) -> ChatResponse:
322
+ # Accept both ``message`` (documented) and ``text`` (legacy).
323
+ text = req.message or req.text
324
+ if not text:
325
+ raise HTTPException(status_code=422, detail="`message` (or `text`) is required")
326
+ mind = _build_mind()
327
+ facts_before = int(mind.status().get("facts", 0))
328
+ try:
329
+ reply = mind.chat(text)
330
+ finally:
331
+ _save_mind(mind)
332
+ snap = mind.status()
333
+ facts_after = int(snap.get("facts", facts_before))
334
+ # Capture the names of the highest-ranked concepts as a
335
+ # transparent "context" hint for callers that want to know
336
+ # which facts were used.
337
+ context: list[str] = []
338
+ try:
339
+ ranked = mind.ranker.rank(query=text, k=4)
340
+ context = [r.node.name for r in ranked]
341
+ except Exception:
342
+ context = []
343
+ return ChatResponse(
344
+ text=reply,
345
+ reply=reply,
346
+ context=context,
347
+ facts_learned=max(0, facts_after - facts_before),
348
+ status=snap,
349
+ )
350
+
351
+ # ---- inspect -----------------------------------------------------
352
+
353
+ @app.get("/inspect", tags=["meta"])
354
+ def inspect() -> dict[str, Any]:
355
+ mind = _build_mind()
356
+ return mind.status()
357
+
358
+ # ---- dream -------------------------------------------------------
359
+
360
+ @app.post("/dream", tags=["cognition"])
361
+ def dream() -> dict[str, Any]:
362
+ mind = _build_mind()
363
+ report = mind.dream()
364
+ try:
365
+ data = (
366
+ report.to_dict()
367
+ if hasattr(report, "to_dict")
368
+ else {"raw": str(report)}
369
+ )
370
+ except Exception:
371
+ data = {"raw": str(report)}
372
+ if "summary" not in data and hasattr(report, "summary"):
373
+ data["summary"] = report.summary()
374
+ if "facts_consolidated" not in data:
375
+ data["facts_consolidated"] = (
376
+ int(data.get("insights_logged", 0))
377
+ + int(data.get("contradictions_weakened", 0))
378
+ )
379
+ _save_mind(mind)
380
+ return data
381
+
382
+ # ---- memory browser ----------------------------------------------
383
+
384
+ @app.get("/memory/concepts", tags=["memory"])
385
+ def memory_concepts(limit: int = 200, offset: int = 0) -> dict[str, Any]:
386
+ mind = _build_mind()
387
+ return {
388
+ "items": mind.list_concepts(limit=limit, offset=offset),
389
+ "total": len(mind.crystal),
390
+ }
391
+
392
+ @app.get("/memory/facts", tags=["memory"])
393
+ def memory_facts(
394
+ limit: int = 200,
395
+ offset: int = 0,
396
+ subject: str | None = None,
397
+ relation: str | None = None,
398
+ ) -> dict[str, Any]:
399
+ mind = _build_mind()
400
+ items = mind.list_facts(
401
+ limit=limit, offset=offset, subject=subject, relation=relation
402
+ )
403
+ return {
404
+ "items": items,
405
+ "total": mind.crystal.total_facts(),
406
+ }
407
+
408
+ @app.post("/memory/search", tags=["memory"])
409
+ def memory_search(req: MemorySearchRequest) -> dict[str, Any]:
410
+ mind = _build_mind()
411
+ return {"items": mind.search_memory(req.query, k=req.k)}
412
+
413
+ @app.get("/memory/narrative", tags=["memory"])
414
+ def memory_narrative(limit: int = 50) -> dict[str, Any]:
415
+ mind = _build_mind()
416
+ return {"items": mind.narrative_log(limit=limit)}
417
+
418
+ @app.get("/memory/items", tags=["memory"])
419
+ def memory_items(
420
+ limit: int = 100, subject: str | None = None
421
+ ) -> dict[str, Any]:
422
+ """List the raw memory items (the verbatim text the user typed).
423
+
424
+ Newest first. This is the topic-agnostic mirror: every
425
+ utterance the LLM could draw from, regardless of whether the
426
+ parser produced a structured triple for it.
427
+ """
428
+ mind = _build_mind()
429
+ return {
430
+ "items": mind.list_memory_items(limit=limit, subject=subject),
431
+ "total": mind.crystal.total_memory_items(),
432
+ }
433
+
434
+ @app.post("/memory/items/search", tags=["memory"])
435
+ def memory_items_search(req: MemorySearchRequest) -> dict[str, Any]:
436
+ """Search the raw memory items by token overlap with a query."""
437
+ mind = _build_mind()
438
+ return {"items": mind.search_memory_items(req.query, k=req.k)}
439
+
440
+ @app.post("/memory/clear", tags=["memory"])
441
+ def memory_clear() -> dict[str, Any]:
442
+ mind = _build_mind(load_from_disk=False)
443
+ mind.clear()
444
+ _save_mind(mind)
445
+ return {"status": "ok", "status_snapshot": mind.status()}
446
+
447
+ # ---- backup / restore --------------------------------------------
448
+
449
+ @app.post("/backup", tags=["persistence"])
450
+ def backup(target: Path | None = None) -> dict[str, Any]:
451
+ """Return the snapshot.
452
+
453
+ Behaviour matches the documented contract in
454
+ ``docs/user_guide.md`` §7.2: the body is a JSON object with a
455
+ ``path`` and a ``size`` (in bytes). For convenience we also
456
+ include the full snapshot under ``payload`` so clients that
457
+ want to keep the data inline do not need a second call.
458
+ """
459
+ mind = _build_mind()
460
+ snap = mind.to_dict()
461
+ text = json.dumps(snap, ensure_ascii=False, default=str)
462
+ size = len(text.encode("utf-8"))
463
+ out_path = target or _storage_path()
464
+ out_path.parent.mkdir(parents=True, exist_ok=True)
465
+ out_path.write_text(text, encoding="utf-8")
466
+ return {
467
+ "path": str(out_path),
468
+ "size": size,
469
+ "payload": snap,
470
+ }
471
+
472
+ @app.post("/restore", tags=["persistence"])
473
+ def restore(req: RestoreRequest) -> dict[str, Any]:
474
+ payload = req.payload
475
+ if not isinstance(payload, dict):
476
+ raise HTTPException(status_code=400, detail="payload must be an object")
477
+ mind = _build_mind(load_from_disk=False)
478
+ try:
479
+ mind.load_state(payload)
480
+ except Exception as exc:
481
+ raise HTTPException(status_code=400, detail=f"could not load: {exc}") from exc
482
+ _save_mind(mind)
483
+ return {"status": "ok", **mind.status()}
484
+
485
+ # ---- websocket streaming ----------------------------------------
486
+
487
+ @app.websocket("/ws/chat")
488
+ async def ws_chat(ws: WebSocket) -> None:
489
+ await ws.accept()
490
+ try:
491
+ while True:
492
+ raw = await ws.receive_text()
493
+ try:
494
+ payload = json.loads(raw)
495
+ except json.JSONDecodeError:
496
+ payload = {"message": raw}
497
+ # Accept both ``message`` (documented) and ``text`` (legacy).
498
+ text = str(payload.get("message", "") or payload.get("text", ""))
499
+ if not text:
500
+ await ws.send_json({"error": "empty text"})
501
+ continue
502
+ mind = _build_mind()
503
+ reply = mind.chat(text)
504
+ _save_mind(mind)
505
+ await ws.send_json({"text": reply, "reply": reply, "status": mind.status()})
506
+ except WebSocketDisconnect:
507
+ return
508
+
509
+ # ---- GUI static assets -------------------------------------------
510
+
511
+ if _GUI_DIST.is_dir() and (_GUI_DIST / "index.html").is_file():
512
+ assets_dir = _GUI_DIST / "assets"
513
+
514
+ @app.get("/", include_in_schema=False)
515
+ def gui_index() -> Any:
516
+ return FileResponse(_GUI_DIST / "index.html")
517
+
518
+ @app.get("/favicon.svg", include_in_schema=False)
519
+ def gui_favicon() -> Any:
520
+ favicon = _GUI_DIST / "favicon.svg"
521
+ if favicon.is_file():
522
+ return FileResponse(favicon)
523
+ raise HTTPException(status_code=404)
524
+
525
+ if assets_dir.is_dir():
526
+ app.mount(
527
+ "/assets",
528
+ StaticFiles(directory=str(assets_dir), check_dir=False),
529
+ name="gui-assets",
530
+ )
531
+
532
+ @app.get("/{full_path:path}", include_in_schema=False)
533
+ def gui_spa(full_path: str) -> Any:
534
+ # Don't shadow API routes.
535
+ if full_path.startswith("api/") or full_path.startswith("ws/"):
536
+ raise HTTPException(status_code=404)
537
+ candidate = _GUI_DIST / full_path
538
+ if candidate.is_file():
539
+ return FileResponse(candidate)
540
+ return FileResponse(_GUI_DIST / "index.html")
541
+
542
+ return app
543
+
544
+
545
+ __all__ = [
546
+ "ChatRequest",
547
+ "ChatResponse",
548
+ "ConfigUpdateRequest",
549
+ "LLMSwitchRequest",
550
+ "MemorySearchRequest",
551
+ "RestoreRequest",
552
+ "create_app",
553
+ ]
@@ -0,0 +1,23 @@
1
+ """Command-line interface (Typer).
2
+
3
+ The CLI is the simplest way to run Ouroboros: it provides a terminal
4
+ REPL, a server launcher, an interactive configuration wizard, and
5
+ import/export/backup commands. The Typer application is exposed
6
+ through :data:`app`; the import is performed lazily to keep the
7
+ top-level ``import ouroboros`` cheap.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+
15
+ def __getattr__(name: str) -> Any: # pragma: no cover - simple dispatch
16
+ if name == "app":
17
+ from ouroboros.cli.main import app
18
+
19
+ return app
20
+ raise AttributeError(f"module 'ouroboros.cli' has no attribute {name!r}")
21
+
22
+
23
+ __all__ = ["app"]