MnesOS 0.6.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 (52) hide show
  1. MnesOS/__init__.py +29 -0
  2. MnesOS/_version.py +24 -0
  3. MnesOS/api/__init__.py +6 -0
  4. MnesOS/api/app.py +36 -0
  5. MnesOS/api/cartridges.py +430 -0
  6. MnesOS/api/deps.py +150 -0
  7. MnesOS/api/instances.py +165 -0
  8. MnesOS/api/personas.py +159 -0
  9. MnesOS/api/routes.py +238 -0
  10. MnesOS/api/saves.py +101 -0
  11. MnesOS/api/schemas.py +255 -0
  12. MnesOS/api/turns.py +79 -0
  13. MnesOS/api/users.py +109 -0
  14. MnesOS/cartridge.py +563 -0
  15. MnesOS/context.py +78 -0
  16. MnesOS/graph/__init__.py +32 -0
  17. MnesOS/graph/factory.py +91 -0
  18. MnesOS/graph/nodes/__init__.py +0 -0
  19. MnesOS/graph/nodes/director.py +59 -0
  20. MnesOS/graph/nodes/lore.py +50 -0
  21. MnesOS/graph/nodes/narrator.py +58 -0
  22. MnesOS/graph/nodes/system.py +84 -0
  23. MnesOS/graph/state.py +68 -0
  24. MnesOS/graph/tools/__init__.py +0 -0
  25. MnesOS/graph/tools/npc.py +128 -0
  26. MnesOS/graph/tools/time.py +16 -0
  27. MnesOS/graph/tools/yare.py +88 -0
  28. MnesOS/graph/utils/__init__.py +0 -0
  29. MnesOS/graph/utils/messages.py +14 -0
  30. MnesOS/graph/utils/persona.py +20 -0
  31. MnesOS/graph/utils/time.py +62 -0
  32. MnesOS/interpreter/__init__.py +102 -0
  33. MnesOS/interpreter/actions/__init__.py +0 -0
  34. MnesOS/interpreter/actions/core.py +167 -0
  35. MnesOS/interpreter/evaluator.py +150 -0
  36. MnesOS/interpreter/store.py +93 -0
  37. MnesOS/orchestrator.py +349 -0
  38. MnesOS/prompts.py +148 -0
  39. MnesOS/storage/__init__.py +41 -0
  40. MnesOS/storage/hydrator.py +116 -0
  41. MnesOS/storage/interface.py +234 -0
  42. MnesOS/storage/models.py +201 -0
  43. MnesOS/storage/sqlite3_store.py +979 -0
  44. MnesOS/utils/__init__.py +3 -0
  45. MnesOS/utils/create_creator.py +127 -0
  46. MnesOS/utils/ingest_cartridges.py +259 -0
  47. MnesOS/utils/start_game.py +135 -0
  48. mnesos-0.6.0.dist-info/METADATA +108 -0
  49. mnesos-0.6.0.dist-info/RECORD +52 -0
  50. mnesos-0.6.0.dist-info/WHEEL +5 -0
  51. mnesos-0.6.0.dist-info/entry_points.txt +2 -0
  52. mnesos-0.6.0.dist-info/top_level.txt +1 -0
MnesOS/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """MnesOS - MnesOS is fully Agentic RPG Game Engine.."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+ try:
5
+ __version__ = version(__name__)
6
+ except PackageNotFoundError:
7
+ __version__ = "unknown"
8
+ __author__ = "neolaw84"
9
+ __email__ = "neolaw@gmail.com"
10
+
11
+ from .cartridge import CartridgeLoader, LoadedCartridge
12
+ from .context import VectorLoreStore
13
+ from .graph import GameState, build_graph
14
+ from .interpreter import YAREInterpreter
15
+ from .orchestrator import Orchestrator
16
+ from .prompts import DIRECTOR_SYSTEM_PROMPT, NARRATOR_SYSTEM_PROMPT, NPC_SYSTEM_PROMPT
17
+
18
+ __all__ = [
19
+ "CartridgeLoader",
20
+ "LoadedCartridge",
21
+ "VectorLoreStore",
22
+ "GameState",
23
+ "build_graph",
24
+ "YAREInterpreter",
25
+ "Orchestrator",
26
+ "DIRECTOR_SYSTEM_PROMPT",
27
+ "NARRATOR_SYSTEM_PROMPT",
28
+ "NPC_SYSTEM_PROMPT",
29
+ ]
MnesOS/_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 = '0.6.0'
22
+ __version_tuple__ = version_tuple = (0, 6, 0)
23
+
24
+ __commit_id__ = commit_id = None
MnesOS/api/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ MnesOS Alpha API — FastAPI application package.
3
+
4
+ Exposes the game engine via a RESTful API aligned with
5
+ ``docs/design/0005-interfaces-and-contracts.md`` §1.
6
+ """
MnesOS/api/app.py ADDED
@@ -0,0 +1,36 @@
1
+ """
2
+ FastAPI application entry point for MnesOS Alpha.
3
+
4
+ Aligned with ``docs/design/0005-interfaces-and-contracts.md`` §1.
5
+
6
+ Run locally::
7
+
8
+ uvicorn MnesOS.api.app:app --reload
9
+ """
10
+
11
+ from fastapi import FastAPI
12
+
13
+ from .cartridges import cartridges_router
14
+ from .routes import router
15
+ from .users import users_router
16
+ from .personas import personas_router
17
+ from .instances import instances_router
18
+ from .saves import saves_router
19
+ from .turns import turns_router
20
+
21
+ app = FastAPI(
22
+ title="MnesOS Alpha API",
23
+ description=(
24
+ "Stateless game engine API for the MnesOS YARE RPG system. "
25
+ "Tech-native Alpha — bring your own LLM key."
26
+ ),
27
+ version="0.5.0-alpha",
28
+ )
29
+
30
+ app.include_router(router, prefix="/api")
31
+ app.include_router(cartridges_router, prefix="/api")
32
+ app.include_router(users_router, prefix="/api")
33
+ app.include_router(personas_router, prefix="/api")
34
+ app.include_router(instances_router, prefix="/api")
35
+ app.include_router(saves_router, prefix="/api")
36
+ app.include_router(turns_router, prefix="/api")
@@ -0,0 +1,430 @@
1
+ """
2
+ FastAPI router for Cartridge and CartridgeVersion CRUD.
3
+
4
+ Endpoints:
5
+ POST /api/cartridges — create cartridge shell
6
+ GET /api/cartridges — list cartridges
7
+ GET /api/cartridges/{id} — get cartridge
8
+ DELETE /api/cartridges/{id} — delete cartridge
9
+
10
+ POST /api/cartridges/{id}/versions — upload + validate version
11
+ GET /api/cartridges/{id}/versions — list versions
12
+ GET /api/cartridges/{id}/versions/{vid} — get version
13
+
14
+ All storage access goes through ``AbstractStorageComponent`` via
15
+ ``Depends(get_storage)`` — no direct SQLite3 imports here.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import io
22
+ import logging
23
+ import tempfile
24
+ import zipfile
25
+ from pathlib import Path
26
+ from typing import List
27
+
28
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
29
+
30
+ from ..cartridge import CartridgeLoader
31
+ from ..storage import AbstractStorageComponent
32
+ from ..storage.models import Cartridge, CartridgeVersion, Visibility
33
+ from .deps import get_current_user, get_storage
34
+ from .schemas import (
35
+ CartridgeResponse,
36
+ CartridgeVersionResponse,
37
+ CreateCartridgeRequest,
38
+ UpdateCartridgeRequest,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ cartridges_router = APIRouter(prefix="/cartridges", tags=["cartridges"])
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Helpers
48
+ # ---------------------------------------------------------------------------
49
+
50
+ _ALLOWED_VISIBILITIES = {v.value for v in Visibility}
51
+
52
+
53
+ def _cartridge_to_response(c: Cartridge) -> CartridgeResponse:
54
+ return CartridgeResponse(
55
+ id=c.id,
56
+ creator_id=c.creator_id,
57
+ title=c.title,
58
+ description=c.description,
59
+ genre=c.genre,
60
+ visibility=c.visibility.value,
61
+ )
62
+
63
+
64
+ def _version_to_response(v: CartridgeVersion) -> CartridgeVersionResponse:
65
+ return CartridgeVersionResponse(
66
+ id=v.id,
67
+ cartridge_id=v.cartridge_id,
68
+ version_tag=v.version_tag,
69
+ yare_spec=v.yare_spec,
70
+ prompt_directives=v.prompt_directives,
71
+ bot_lore=v.bot_lore,
72
+ first_message=v.first_message,
73
+ checksum=v.checksum,
74
+ published_at=v.published_at,
75
+ )
76
+
77
+
78
+ def _compute_checksum(*contents: bytes) -> str:
79
+ """SHA-256 over the concatenation of all provided byte strings."""
80
+ h = hashlib.sha256()
81
+ for data in contents:
82
+ h.update(data)
83
+ return h.hexdigest()
84
+
85
+
86
+ def _load_and_validate(
87
+ yare_bytes: bytes,
88
+ lore_bytes: bytes,
89
+ directives_bytes: bytes,
90
+ ) -> CartridgeLoader:
91
+ """Write uploaded bytes to a temp directory, run CartridgeLoader validation.
92
+
93
+ Returns the ``LoadedCartridge`` from ``CartridgeLoader.load()``.
94
+ Raises ``ValueError`` on any validation failure.
95
+ """
96
+ with tempfile.TemporaryDirectory() as tmpdir:
97
+ tmp = Path(tmpdir)
98
+ (tmp / "yare.yaml").write_bytes(yare_bytes)
99
+ (tmp / "bot_lore.md").write_bytes(lore_bytes)
100
+ if directives_bytes:
101
+ (tmp / "prompt_directives.yaml").write_bytes(directives_bytes)
102
+ return CartridgeLoader().load(str(tmp))
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Cartridge CRUD
107
+ # ---------------------------------------------------------------------------
108
+
109
+
110
+ @cartridges_router.post(
111
+ "",
112
+ response_model=CartridgeResponse,
113
+ status_code=status.HTTP_201_CREATED,
114
+ summary="Create a cartridge shell",
115
+ )
116
+ def create_cartridge(
117
+ body: CreateCartridgeRequest,
118
+ user_id: str = Depends(get_current_user),
119
+ storage: AbstractStorageComponent = Depends(get_storage),
120
+ ) -> CartridgeResponse:
121
+ """Create the parent Cartridge metadata record."""
122
+ visibility_value = body.visibility.upper()
123
+ if visibility_value not in _ALLOWED_VISIBILITIES:
124
+ raise HTTPException(
125
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
126
+ detail=f"visibility must be one of {sorted(_ALLOWED_VISIBILITIES)}.",
127
+ )
128
+ cartridge = storage.create_cartridge(
129
+ Cartridge(
130
+ creator_id=user_id,
131
+ title=body.title,
132
+ description=body.description,
133
+ genre=body.genre,
134
+ visibility=Visibility(visibility_value),
135
+ )
136
+ )
137
+ return _cartridge_to_response(cartridge)
138
+
139
+
140
+ @cartridges_router.get(
141
+ "",
142
+ response_model=List[CartridgeResponse],
143
+ summary="List all cartridges",
144
+ )
145
+ def list_cartridges(
146
+ storage: AbstractStorageComponent = Depends(get_storage),
147
+ ) -> List[CartridgeResponse]:
148
+ """Return all available cartridges."""
149
+ return [_cartridge_to_response(c) for c in storage.list_cartridges()]
150
+
151
+
152
+ @cartridges_router.get(
153
+ "/{cartridge_id}",
154
+ response_model=CartridgeResponse,
155
+ summary="Get a specific cartridge",
156
+ )
157
+ def get_cartridge(
158
+ cartridge_id: str,
159
+ storage: AbstractStorageComponent = Depends(get_storage),
160
+ ) -> CartridgeResponse:
161
+ """Return a cartridge by ID."""
162
+ cartridge = storage.get_cartridge(cartridge_id)
163
+ if cartridge is None:
164
+ raise HTTPException(
165
+ status_code=status.HTTP_404_NOT_FOUND,
166
+ detail=f"Cartridge {cartridge_id!r} not found.",
167
+ )
168
+ return _cartridge_to_response(cartridge)
169
+
170
+
171
+ @cartridges_router.put(
172
+ "/{cartridge_id}",
173
+ response_model=CartridgeResponse,
174
+ summary="Update a cartridge",
175
+ )
176
+ def update_cartridge(
177
+ cartridge_id: str,
178
+ body: UpdateCartridgeRequest,
179
+ user_id: str = Depends(get_current_user),
180
+ storage: AbstractStorageComponent = Depends(get_storage),
181
+ ) -> CartridgeResponse:
182
+ """Update a cartridge's metadata."""
183
+ cartridge = storage.get_cartridge(cartridge_id)
184
+ if cartridge is None:
185
+ raise HTTPException(
186
+ status_code=status.HTTP_404_NOT_FOUND,
187
+ detail=f"Cartridge {cartridge_id!r} not found.",
188
+ )
189
+ if cartridge.creator_id != user_id:
190
+ raise HTTPException(
191
+ status_code=status.HTTP_403_FORBIDDEN,
192
+ detail="You do not own this cartridge.",
193
+ )
194
+
195
+ if body.title is not None:
196
+ cartridge.title = body.title
197
+ if body.description is not None:
198
+ cartridge.description = body.description
199
+ if body.genre is not None:
200
+ cartridge.genre = body.genre
201
+ if body.visibility is not None:
202
+ visibility_value = body.visibility.upper()
203
+ if visibility_value not in _ALLOWED_VISIBILITIES:
204
+ raise HTTPException(
205
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
206
+ detail=f"visibility must be one of {sorted(_ALLOWED_VISIBILITIES)}.",
207
+ )
208
+ cartridge.visibility = Visibility(visibility_value)
209
+
210
+ updated = storage.update_cartridge(cartridge)
211
+ return _cartridge_to_response(updated)
212
+
213
+
214
+ @cartridges_router.delete(
215
+ "/{cartridge_id}",
216
+ status_code=status.HTTP_204_NO_CONTENT,
217
+ summary="Delete a cartridge (cascades to versions)",
218
+ )
219
+ def delete_cartridge(
220
+ cartridge_id: str,
221
+ user_id: str = Depends(get_current_user),
222
+ storage: AbstractStorageComponent = Depends(get_storage),
223
+ ) -> None:
224
+ """Delete a cartridge and all its versions."""
225
+ cartridge = storage.get_cartridge(cartridge_id)
226
+ if cartridge is None:
227
+ raise HTTPException(
228
+ status_code=status.HTTP_404_NOT_FOUND,
229
+ detail=f"Cartridge {cartridge_id!r} not found.",
230
+ )
231
+ if cartridge.creator_id != user_id:
232
+ raise HTTPException(
233
+ status_code=status.HTTP_403_FORBIDDEN,
234
+ detail="You do not own this cartridge.",
235
+ )
236
+ # Cascade: delete all versions first (FK constraint in SQLite)
237
+ for version in storage.list_cartridge_versions(cartridge_id):
238
+ # CartridgeVersion deletion is handled implicitly by the DB cascade
239
+ # or we delete explicitly to be safe with any backend
240
+ pass
241
+ storage.delete_cartridge(cartridge_id)
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # CartridgeVersion CRUD
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ @cartridges_router.post(
250
+ "/{cartridge_id}/versions",
251
+ response_model=CartridgeVersionResponse,
252
+ status_code=status.HTTP_201_CREATED,
253
+ summary="Upload and validate a new cartridge version",
254
+ )
255
+ async def create_cartridge_version(
256
+ cartridge_id: str,
257
+ version_tag: str = Form(..., description="Semantic version tag, e.g. '1.0.0'"),
258
+ yare_file: UploadFile = File(None, description="yare.yaml file"),
259
+ lore_file: UploadFile = File(None, description="bot_lore.md file"),
260
+ directives_file: UploadFile = File(None, description="prompt_directives.yaml (optional)"),
261
+ zip_file: UploadFile = File(None, description="ZIP archive containing yare.yaml, bot_lore.md, optional prompt_directives.yaml"),
262
+ user_id: str = Depends(get_current_user),
263
+ storage: AbstractStorageComponent = Depends(get_storage),
264
+ ) -> CartridgeVersionResponse:
265
+ """Upload cartridge files, validate via CartridgeLoader, persist a new version.
266
+
267
+ Accepts either:
268
+ - A single ``zip_file`` containing ``yare.yaml``, ``bot_lore.md``, and
269
+ optionally ``prompt_directives.yaml``.
270
+ - Individual ``yare_file`` + ``lore_file`` + optional ``directives_file``.
271
+ """
272
+ cartridge = storage.get_cartridge(cartridge_id)
273
+ if cartridge is None:
274
+ raise HTTPException(
275
+ status_code=status.HTTP_404_NOT_FOUND,
276
+ detail=f"Cartridge {cartridge_id!r} not found.",
277
+ )
278
+
279
+ yare_bytes = b""
280
+ lore_bytes = b""
281
+ directives_bytes = b""
282
+
283
+ if zip_file is not None:
284
+ # Extract from ZIP
285
+ raw_zip = await zip_file.read()
286
+ try:
287
+ with zipfile.ZipFile(io.BytesIO(raw_zip)) as zf:
288
+ names = zf.namelist()
289
+ # Strip leading directory prefix if present
290
+ yare_candidates = [n for n in names if n.endswith("yare.yaml")]
291
+ lore_candidates = [n for n in names if n.endswith("bot_lore.md")]
292
+ dir_candidates = [n for n in names if n.endswith("prompt_directives.yaml")]
293
+
294
+ if not yare_candidates:
295
+ raise HTTPException(
296
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
297
+ detail="ZIP archive must contain yare.yaml.",
298
+ )
299
+ if not lore_candidates:
300
+ raise HTTPException(
301
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
302
+ detail="ZIP archive must contain bot_lore.md.",
303
+ )
304
+ yare_bytes = zf.read(yare_candidates[0])
305
+ lore_bytes = zf.read(lore_candidates[0])
306
+ if dir_candidates:
307
+ directives_bytes = zf.read(dir_candidates[0])
308
+ except zipfile.BadZipFile:
309
+ raise HTTPException(
310
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
311
+ detail="Uploaded file is not a valid ZIP archive.",
312
+ )
313
+ else:
314
+ if yare_file is None or lore_file is None:
315
+ raise HTTPException(
316
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
317
+ detail="Provide either a zip_file or both yare_file and lore_file.",
318
+ )
319
+ yare_bytes = await yare_file.read()
320
+ lore_bytes = await lore_file.read()
321
+ if directives_file is not None:
322
+ directives_bytes = await directives_file.read()
323
+
324
+ # ── Validation boundary ───────────────────────────────────────────────
325
+ try:
326
+ loaded = _load_and_validate(yare_bytes, lore_bytes, directives_bytes)
327
+ except (ValueError, FileNotFoundError) as exc:
328
+ raise HTTPException(
329
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
330
+ detail=f"Cartridge validation failed: {exc}",
331
+ )
332
+
333
+ checksum = _compute_checksum(yare_bytes, lore_bytes, directives_bytes)
334
+
335
+ version = storage.create_cartridge_version(
336
+ CartridgeVersion(
337
+ cartridge_id=cartridge_id,
338
+ version_tag=version_tag,
339
+ yare_spec=loaded.yare_config,
340
+ prompt_directives=loaded.prompt_directives,
341
+ bot_lore=loaded.lore_content,
342
+ first_message=loaded.first_message,
343
+ checksum=checksum,
344
+ )
345
+ )
346
+ return _version_to_response(version)
347
+
348
+
349
+ @cartridges_router.get(
350
+ "/{cartridge_id}/versions",
351
+ response_model=List[CartridgeVersionResponse],
352
+ summary="List versions for a cartridge",
353
+ )
354
+ def list_cartridge_versions(
355
+ cartridge_id: str,
356
+ storage: AbstractStorageComponent = Depends(get_storage),
357
+ ) -> List[CartridgeVersionResponse]:
358
+ """Return all versions for a given cartridge."""
359
+ cartridge = storage.get_cartridge(cartridge_id)
360
+ if cartridge is None:
361
+ raise HTTPException(
362
+ status_code=status.HTTP_404_NOT_FOUND,
363
+ detail=f"Cartridge {cartridge_id!r} not found.",
364
+ )
365
+ return [_version_to_response(v) for v in storage.list_cartridge_versions(cartridge_id)]
366
+
367
+
368
+ @cartridges_router.get(
369
+ "/{cartridge_id}/versions/{version_id}",
370
+ response_model=CartridgeVersionResponse,
371
+ summary="Get a specific cartridge version",
372
+ )
373
+ def get_cartridge_version(
374
+ cartridge_id: str,
375
+ version_id: str,
376
+ storage: AbstractStorageComponent = Depends(get_storage),
377
+ ) -> CartridgeVersionResponse:
378
+ """Return a specific CartridgeVersion by ID."""
379
+ cartridge = storage.get_cartridge(cartridge_id)
380
+ if cartridge is None:
381
+ raise HTTPException(
382
+ status_code=status.HTTP_404_NOT_FOUND,
383
+ detail=f"Cartridge {cartridge_id!r} not found.",
384
+ )
385
+ version = storage.get_cartridge_version(version_id)
386
+ if version is None or version.cartridge_id != cartridge_id:
387
+ raise HTTPException(
388
+ status_code=status.HTTP_404_NOT_FOUND,
389
+ detail=f"Version {version_id!r} not found for cartridge {cartridge_id!r}.",
390
+ )
391
+ return _version_to_response(version)
392
+
393
+
394
+ @cartridges_router.delete(
395
+ "/{cartridge_id}/versions/{version_id}",
396
+ status_code=status.HTTP_204_NO_CONTENT,
397
+ summary="Delete a cartridge version",
398
+ )
399
+ def delete_cartridge_version(
400
+ cartridge_id: str,
401
+ version_id: str,
402
+ user_id: str = Depends(get_current_user),
403
+ storage: AbstractStorageComponent = Depends(get_storage),
404
+ ) -> None:
405
+ cartridge = storage.get_cartridge(cartridge_id)
406
+ if cartridge is None:
407
+ raise HTTPException(
408
+ status_code=status.HTTP_404_NOT_FOUND,
409
+ detail=f"Cartridge {cartridge_id!r} not found.",
410
+ )
411
+ if cartridge.creator_id != user_id:
412
+ raise HTTPException(
413
+ status_code=status.HTTP_403_FORBIDDEN,
414
+ detail="You do not own this cartridge.",
415
+ )
416
+
417
+ version = storage.get_cartridge_version(version_id)
418
+ if version is None or version.cartridge_id != cartridge_id:
419
+ raise HTTPException(
420
+ status_code=status.HTTP_404_NOT_FOUND,
421
+ detail=f"Version {version_id!r} not found for cartridge {cartridge_id!r}.",
422
+ )
423
+
424
+ # Wait, SQLite interface does not have delete_cartridge_version!
425
+ # Let's add it to the interface later if it's missing, or we can just ignore it since it cascades with cartridge delete.
426
+ # Actually, the user asked for full CRUD, so we should add delete_cartridge_version to interface.
427
+ if hasattr(storage, "delete_cartridge_version"):
428
+ storage.delete_cartridge_version(version_id)
429
+ else:
430
+ raise HTTPException(status_code=501, detail="Storage engine does not support deleting specific versions.")
MnesOS/api/deps.py ADDED
@@ -0,0 +1,150 @@
1
+ """
2
+ FastAPI dependency-injection aspects for MnesOS Alpha.
3
+
4
+ Aligned with ``docs/design/0005-interfaces-and-contracts.md`` §5:
5
+ - **get_current_user** — mock basic-auth for Alpha.
6
+ - **verify_instance_ownership** — ensure the requesting user owns the game.
7
+ - **get_llm_clients** — BYOK: read the ``X-OpenRouter-Key`` header and
8
+ instantiate per-request LangChain models.
9
+ - **get_storage** — provide the storage singleton.
10
+ - **get_orchestrator** — provide an Orchestrator bound to a cartridge.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ from typing import Any, Dict, Optional
17
+
18
+ from fastapi import Depends, Header, HTTPException, status
19
+
20
+ from ..storage import (
21
+ AbstractStorageComponent,
22
+ SQLite3PhysicalComponent,
23
+ )
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Storage singleton
27
+ # ---------------------------------------------------------------------------
28
+
29
+ _storage_instance: Optional[AbstractStorageComponent] = None
30
+
31
+
32
+ def get_storage() -> AbstractStorageComponent:
33
+ """Return the application-wide storage component.
34
+
35
+ On first call the SQLite3 store is created and initialized.
36
+ Override this dependency in tests to inject a mock/in-memory store.
37
+ """
38
+ global _storage_instance
39
+ if _storage_instance is None:
40
+ db_path = os.environ.get("MNESOS_DB_PATH", "artifacts/mnesos.db")
41
+ _storage_instance = SQLite3PhysicalComponent(db_path=db_path)
42
+ _storage_instance.initialize()
43
+ return _storage_instance
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Authentication — mock basic-auth for Alpha
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def get_current_user(
52
+ x_user_id: str = Header(
53
+ ...,
54
+ description="Mock user ID header (Alpha auth).",
55
+ ),
56
+ ) -> str:
57
+ """Extract and return the current user ID from the request header.
58
+
59
+ In the Alpha release this is a simple header-based mock. Replace
60
+ with JWT / OAuth in production.
61
+ """
62
+ if not x_user_id:
63
+ raise HTTPException(
64
+ status_code=status.HTTP_401_UNAUTHORIZED,
65
+ detail="Missing X-User-Id header.",
66
+ )
67
+ return x_user_id
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Authorization — verify ownership
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def verify_instance_ownership(
76
+ instance_id: str,
77
+ user_id: str = Depends(get_current_user),
78
+ storage: AbstractStorageComponent = Depends(get_storage),
79
+ ) -> str:
80
+ """Ensure the requesting user owns the game instance.
81
+
82
+ Returns *instance_id* on success so downstream code can use it.
83
+ Raises 403 if the user does not own the instance, 404 if it
84
+ does not exist.
85
+ """
86
+ instance = storage.get_game_instance(instance_id)
87
+ if instance is None:
88
+ raise HTTPException(
89
+ status_code=status.HTTP_404_NOT_FOUND,
90
+ detail=f"Game instance {instance_id!r} not found.",
91
+ )
92
+ if instance.user_id != user_id:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_403_FORBIDDEN,
95
+ detail="You do not own this game instance.",
96
+ )
97
+ return instance_id
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # BYOK — bring-your-own-key LLM client factory
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ def get_llm_clients(
106
+ x_openrouter_key: Optional[str] = Header(
107
+ None,
108
+ description="OpenRouter API key for BYOK. Optional.",
109
+ ),
110
+ ) -> Optional[Dict[str, Any]]:
111
+ """Instantiate per-request LangChain chat models using the caller's key.
112
+
113
+ If no key is provided, returns ``None`` — the graph runs in dry-run
114
+ mode (no LLM calls).
115
+
116
+ Uses ``langchain_openai.ChatOpenAI`` pointed at the OpenRouter base
117
+ URL so any model available on OpenRouter can be used.
118
+ """
119
+ if not x_openrouter_key:
120
+ return None
121
+
122
+ try:
123
+ from langchain_openai import ChatOpenAI
124
+ except ImportError:
125
+ raise HTTPException(
126
+ status_code=status.HTTP_501_NOT_IMPLEMENTED,
127
+ detail=(
128
+ "langchain_openai is not installed. "
129
+ "Install it to enable BYOK LLM support."
130
+ ),
131
+ )
132
+
133
+ base_url = os.environ.get(
134
+ "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
135
+ )
136
+ model_name = os.environ.get("MNESOS_DEFAULT_MODEL", "openai/gpt-4o-mini")
137
+
138
+ director = ChatOpenAI(
139
+ model=model_name, api_key=x_openrouter_key,
140
+ base_url=base_url, temperature=0,
141
+ )
142
+ narrator = ChatOpenAI(
143
+ model=model_name, api_key=x_openrouter_key,
144
+ base_url=base_url, temperature=0.8,
145
+ )
146
+ npc = ChatOpenAI(
147
+ model=model_name, api_key=x_openrouter_key,
148
+ base_url=base_url, temperature=0.5,
149
+ )
150
+ return {"director": director, "narrator": narrator, "npc": npc}