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.
- ouroboros/__init__.py +36 -0
- ouroboros/__main__.py +27 -0
- ouroboros/api/__init__.py +11 -0
- ouroboros/api/middleware/__init__.py +4 -0
- ouroboros/api/routes/__init__.py +5 -0
- ouroboros/api/server.py +553 -0
- ouroboros/cli/__init__.py +23 -0
- ouroboros/cli/main.py +455 -0
- ouroboros/config/__init__.py +29 -0
- ouroboros/config/defaults.py +69 -0
- ouroboros/config/loader.py +103 -0
- ouroboros/config/schema.py +161 -0
- ouroboros/core/__init__.py +7 -0
- ouroboros/core/assembler.py +195 -0
- ouroboros/core/chain_of_thought.py +112 -0
- ouroboros/core/clustering.py +80 -0
- ouroboros/core/concept.py +223 -0
- ouroboros/core/config.py +292 -0
- ouroboros/core/crystal.py +804 -0
- ouroboros/core/curiosity.py +172 -0
- ouroboros/core/dream.py +134 -0
- ouroboros/core/entity.py +176 -0
- ouroboros/core/expectation.py +84 -0
- ouroboros/core/generative.py +110 -0
- ouroboros/core/humor.py +98 -0
- ouroboros/core/immune.py +124 -0
- ouroboros/core/inference.py +740 -0
- ouroboros/core/math_engine.py +215 -0
- ouroboros/core/narrative.py +134 -0
- ouroboros/core/other_mind.py +176 -0
- ouroboros/core/parser.py +806 -0
- ouroboros/core/quality.py +132 -0
- ouroboros/core/query_analyzer.py +163 -0
- ouroboros/core/ranker.py +154 -0
- ouroboros/core/resonance.py +153 -0
- ouroboros/core/synthesizer.py +102 -0
- ouroboros/core/tension.py +242 -0
- ouroboros/core/utils.py +140 -0
- ouroboros/core/working_memory.py +135 -0
- ouroboros/llm/__init__.py +30 -0
- ouroboros/llm/anthropic.py +108 -0
- ouroboros/llm/base.py +105 -0
- ouroboros/llm/factory.py +66 -0
- ouroboros/llm/gemini.py +108 -0
- ouroboros/llm/llamacpp.py +122 -0
- ouroboros/llm/ollama.py +127 -0
- ouroboros/llm/openai.py +118 -0
- ouroboros/llm/vllm.py +117 -0
- ouroboros/mind.py +1318 -0
- ouroboros/privacy/__init__.py +17 -0
- ouroboros/privacy/bridge.py +244 -0
- ouroboros/py.typed +1 -0
- ouroboros/storage/__init__.py +31 -0
- ouroboros/storage/backends.py +345 -0
- ouroboros/version.py +9 -0
- ouroboros_memory-1.0.0.dist-info/METADATA +436 -0
- ouroboros_memory-1.0.0.dist-info/RECORD +61 -0
- ouroboros_memory-1.0.0.dist-info/WHEEL +4 -0
- ouroboros_memory-1.0.0.dist-info/entry_points.txt +2 -0
- ouroboros_memory-1.0.0.dist-info/licenses/LICENSE +26 -0
- 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"]
|
ouroboros/api/server.py
ADDED
|
@@ -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"]
|