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.
- MnesOS/__init__.py +29 -0
- MnesOS/_version.py +24 -0
- MnesOS/api/__init__.py +6 -0
- MnesOS/api/app.py +36 -0
- MnesOS/api/cartridges.py +430 -0
- MnesOS/api/deps.py +150 -0
- MnesOS/api/instances.py +165 -0
- MnesOS/api/personas.py +159 -0
- MnesOS/api/routes.py +238 -0
- MnesOS/api/saves.py +101 -0
- MnesOS/api/schemas.py +255 -0
- MnesOS/api/turns.py +79 -0
- MnesOS/api/users.py +109 -0
- MnesOS/cartridge.py +563 -0
- MnesOS/context.py +78 -0
- MnesOS/graph/__init__.py +32 -0
- MnesOS/graph/factory.py +91 -0
- MnesOS/graph/nodes/__init__.py +0 -0
- MnesOS/graph/nodes/director.py +59 -0
- MnesOS/graph/nodes/lore.py +50 -0
- MnesOS/graph/nodes/narrator.py +58 -0
- MnesOS/graph/nodes/system.py +84 -0
- MnesOS/graph/state.py +68 -0
- MnesOS/graph/tools/__init__.py +0 -0
- MnesOS/graph/tools/npc.py +128 -0
- MnesOS/graph/tools/time.py +16 -0
- MnesOS/graph/tools/yare.py +88 -0
- MnesOS/graph/utils/__init__.py +0 -0
- MnesOS/graph/utils/messages.py +14 -0
- MnesOS/graph/utils/persona.py +20 -0
- MnesOS/graph/utils/time.py +62 -0
- MnesOS/interpreter/__init__.py +102 -0
- MnesOS/interpreter/actions/__init__.py +0 -0
- MnesOS/interpreter/actions/core.py +167 -0
- MnesOS/interpreter/evaluator.py +150 -0
- MnesOS/interpreter/store.py +93 -0
- MnesOS/orchestrator.py +349 -0
- MnesOS/prompts.py +148 -0
- MnesOS/storage/__init__.py +41 -0
- MnesOS/storage/hydrator.py +116 -0
- MnesOS/storage/interface.py +234 -0
- MnesOS/storage/models.py +201 -0
- MnesOS/storage/sqlite3_store.py +979 -0
- MnesOS/utils/__init__.py +3 -0
- MnesOS/utils/create_creator.py +127 -0
- MnesOS/utils/ingest_cartridges.py +259 -0
- MnesOS/utils/start_game.py +135 -0
- mnesos-0.6.0.dist-info/METADATA +108 -0
- mnesos-0.6.0.dist-info/RECORD +52 -0
- mnesos-0.6.0.dist-info/WHEEL +5 -0
- mnesos-0.6.0.dist-info/entry_points.txt +2 -0
- 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
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")
|
MnesOS/api/cartridges.py
ADDED
|
@@ -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}
|