contextos-memory-runtime 1.0.0rc2__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 (93) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/__main__.py +6 -0
  3. contextos/api/__init__.py +1 -0
  4. contextos/api/routes/__init__.py +1 -0
  5. contextos/api/routes/desktop.py +322 -0
  6. contextos/api/routes/ingest.py +17 -0
  7. contextos/api/routes/memories.py +84 -0
  8. contextos/api/routes/models.py +81 -0
  9. contextos/api/routes/retrieval.py +89 -0
  10. contextos/api/routes/system.py +216 -0
  11. contextos/api/server.py +195 -0
  12. contextos/benchmarks/__init__.py +1 -0
  13. contextos/benchmarks/compilation.py +245 -0
  14. contextos/benchmarks/connectors.py +423 -0
  15. contextos/benchmarks/explainability.py +103 -0
  16. contextos/benchmarks/final.py +406 -0
  17. contextos/benchmarks/graph.py +310 -0
  18. contextos/benchmarks/graph_adversarial.py +525 -0
  19. contextos/benchmarks/mcp.py +324 -0
  20. contextos/benchmarks/model_routing.py +203 -0
  21. contextos/benchmarks/optimization.py +305 -0
  22. contextos/benchmarks/rescue_integration.py +127 -0
  23. contextos/benchmarks/retrieval.py +266 -0
  24. contextos/benchmarks/temporal.py +377 -0
  25. contextos/benchmarks/temporal_hotpath.py +76 -0
  26. contextos/benchmarks/terminal.py +62 -0
  27. contextos/cli/__init__.py +1 -0
  28. contextos/cli/app.py +932 -0
  29. contextos/cli/dashboard.py +174 -0
  30. contextos/cli/formatters.py +299 -0
  31. contextos/config/__init__.py +1 -0
  32. contextos/config/settings.py +160 -0
  33. contextos/connectors/__init__.py +6 -0
  34. contextos/connectors/fake.py +11 -0
  35. contextos/connectors/json_import.py +125 -0
  36. contextos/connectors/local_files.py +102 -0
  37. contextos/connectors/manager.py +293 -0
  38. contextos/connectors/models.py +62 -0
  39. contextos/connectors/protocols.py +11 -0
  40. contextos/core/__init__.py +103 -0
  41. contextos/core/enums.py +489 -0
  42. contextos/core/exceptions.py +293 -0
  43. contextos/core/models.py +1147 -0
  44. contextos/core/protocols.py +549 -0
  45. contextos/daemon/__init__.py +1 -0
  46. contextos/daemon/manager.py +510 -0
  47. contextos/daemon/state.py +127 -0
  48. contextos/daemon/wiring.py +296 -0
  49. contextos/demo.py +217 -0
  50. contextos/embedding/__init__.py +1 -0
  51. contextos/embedding/deterministic.py +76 -0
  52. contextos/embedding/sentence_transformers.py +80 -0
  53. contextos/mcp/__init__.py +5 -0
  54. contextos/mcp/server.py +269 -0
  55. contextos/providers/__init__.py +13 -0
  56. contextos/providers/fake.py +217 -0
  57. contextos/providers/ollama.py +297 -0
  58. contextos/providers/openai_compatible.py +337 -0
  59. contextos/services/__init__.py +1 -0
  60. contextos/services/compilation.py +535 -0
  61. contextos/services/explainability.py +553 -0
  62. contextos/services/extraction.py +311 -0
  63. contextos/services/graph.py +524 -0
  64. contextos/services/graph_retrieval.py +143 -0
  65. contextos/services/ingestion.py +143 -0
  66. contextos/services/inspection.py +174 -0
  67. contextos/services/memory.py +291 -0
  68. contextos/services/model_service.py +409 -0
  69. contextos/services/optimization.py +426 -0
  70. contextos/services/privacy.py +331 -0
  71. contextos/services/retrieval.py +302 -0
  72. contextos/services/retrieval_index.py +88 -0
  73. contextos/services/router.py +302 -0
  74. contextos/services/secret_scanner.py +207 -0
  75. contextos/services/telemetry_query.py +102 -0
  76. contextos/services/temporal.py +500 -0
  77. contextos/services/token_counter.py +222 -0
  78. contextos/storage/__init__.py +1 -0
  79. contextos/storage/connector_repo.py +67 -0
  80. contextos/storage/database.py +497 -0
  81. contextos/storage/event_repo.py +137 -0
  82. contextos/storage/graph_repo.py +228 -0
  83. contextos/storage/lexical/__init__.py +1 -0
  84. contextos/storage/lexical/bm25.py +134 -0
  85. contextos/storage/memory_repo.py +589 -0
  86. contextos/storage/relation_repo.py +80 -0
  87. contextos/storage/telemetry_repo.py +481 -0
  88. contextos/storage/vector/__init__.py +1 -0
  89. contextos/storage/vector/in_memory.py +162 -0
  90. contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
  91. contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
  92. contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
  93. contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
contextos/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ContextOS — Local-first personal AI memory runtime."""
2
+
3
+ __version__ = "1.0.0rc2"
contextos/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """ContextOS entry point: python -m contextos."""
2
+
3
+ from contextos.cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1 @@
1
+ """API package for ContextOS."""
@@ -0,0 +1 @@
1
+ """API routes package for ContextOS."""
@@ -0,0 +1,322 @@
1
+ """Bounded, privacy-conscious terminal product endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import time
8
+ from datetime import UTC, datetime, timedelta
9
+ from typing import Any, Literal
10
+ from uuid import UUID # noqa: TC003 -- FastAPI resolves UUID route parameters at runtime.
11
+
12
+ from fastapi import APIRouter, HTTPException, Query
13
+ from pydantic import BaseModel, Field
14
+
15
+ from contextos.api.server import get_service
16
+ from contextos.core.enums import MemoryStatus, SourceRole
17
+ from contextos.core.models import IngestRequest, MemoryFilters, ModelCapabilities
18
+
19
+ router = APIRouter(tags=["terminal"])
20
+ _model_cache: tuple[float, object, list[dict[str, Any]]] | None = None
21
+
22
+
23
+ def _public_label(value: object) -> str:
24
+ """Keep identifiers useful while refusing credential, URI and path shapes."""
25
+ from contextos.services.explainability import safe_text
26
+ clean = safe_text(value, 160)
27
+ if re.search(
28
+ r"(?i)(?:[a-z]:[\\/]|\\\\|[a-z][a-z0-9+.-]*://|"
29
+ r"\b(?:api[_-]?key|password|secret|bearer|authorization|cookie|credential)\b|"
30
+ r"sk-[a-z0-9_-]{8,}|(?:^|\s)/(?:[^/\s]+/)+)", clean,
31
+ ):
32
+ return "[REDACTED]"
33
+ return clean
34
+
35
+
36
+ def _measurement_label(source: str | None) -> str:
37
+ if source in {"provider_reported", "tokenizer_counted"}:
38
+ return "MEASURED"
39
+ if source == "approximated":
40
+ return "APPROXIMATED"
41
+ return "UNKNOWN"
42
+
43
+
44
+ class RememberRequest(BaseModel):
45
+ text: str = Field(min_length=1, max_length=10_000, repr=False)
46
+
47
+
48
+ @router.get("/dashboard")
49
+ async def dashboard(
50
+ model: str | None = Query(default=None, max_length=200),
51
+ provider: str | None = Query(default=None, max_length=200),
52
+ period: Literal["all", "today", "week"] = "all",
53
+ ) -> dict[str, Any]:
54
+ repo = get_service("memory_repo")
55
+ telemetry = get_service("telemetry_query")
56
+ connectors = get_service("connectors")
57
+ states = {
58
+ state.connector_id: state
59
+ for state in await get_service("connector_repo").list_states()
60
+ }
61
+ tracked_cursor = await get_service("database").connection().execute(
62
+ "SELECT connector_id, COUNT(*) FROM connector_items WHERE deleted = 0 GROUP BY connector_id"
63
+ )
64
+ tracked_items = {row[0]: row[1] for row in await tracked_cursor.fetchall()}
65
+ now = datetime.now(UTC)
66
+ start = (
67
+ datetime(now.year, now.month, now.day, tzinfo=UTC) if period == "today"
68
+ else now - timedelta(days=7) if period == "week" else None
69
+ )
70
+ recent = await telemetry.list_recent(limit=8, model_id=model, provider_id=provider, start=start)
71
+ summary = await telemetry.summary_range(start=start, provider_id=provider,
72
+ model_id=model, success_only=True)
73
+ breakdown = await telemetry.provider_model_breakdown(start, provider, model)
74
+ summary_data = summary.model_dump(mode="json")
75
+ for group in ("by_provider", "by_model"):
76
+ summary_data[group] = {
77
+ _public_label(key): value for key, value in summary_data[group].items()
78
+ }
79
+ for row in breakdown:
80
+ row["provider"] = _public_label(row["provider"])
81
+ row["model"] = _public_label(row["model"])
82
+ row["context_tokenizer"] = _public_label(row["context_tokenizer"])
83
+ global _model_cache
84
+ providers = get_service("providers")
85
+ if (
86
+ _model_cache is None
87
+ or _model_cache[1] is not providers
88
+ or time.monotonic() >= _model_cache[0]
89
+ ):
90
+ async def discover(provider: Any) -> list[ModelCapabilities]:
91
+ try:
92
+ inventory = await asyncio.wait_for(provider.list_models(), timeout=1.0)
93
+ if not isinstance(inventory, list):
94
+ return []
95
+ return [item for item in inventory if isinstance(item, ModelCapabilities)]
96
+ except Exception:
97
+ return []
98
+ inventories = await asyncio.gather(*(discover(p) for p in providers.values()))
99
+ discovered = [
100
+ {"provider": _public_label(item.provider_id), "model": _public_label(item.model_id),
101
+ "local": item.local, "enabled": item.enabled,
102
+ "simulated": item.provider_id == "fake"}
103
+ for inventory in inventories for item in inventory
104
+ ][:50]
105
+ _model_cache = (time.monotonic() + 10.0, providers, discovered)
106
+ discovered = _model_cache[2]
107
+ mcp = get_service("settings").mcp
108
+ return {
109
+ "memories": {
110
+ "active": await repo.count(MemoryFilters(status=MemoryStatus.ACTIVE)),
111
+ "historical": await repo.count(MemoryFilters(status=MemoryStatus.HISTORICAL)),
112
+ "expired": await repo.count(MemoryFilters(status=MemoryStatus.EXPIRED)),
113
+ },
114
+ "temporal": {
115
+ "superseded": await repo.count(MemoryFilters(status=MemoryStatus.SUPERSEDED)),
116
+ "contradicted": await repo.count(MemoryFilters(status=MemoryStatus.CONTRADICTED)),
117
+ },
118
+ "graph": await graph_stats(),
119
+ "mcp": {"enabled": mcp.enabled, "transport": mcp.transport if mcp.enabled else None,
120
+ "read": mcp.allow_read if mcp.enabled else False,
121
+ "write": mcp.allow_write if mcp.enabled else False},
122
+ "connectors": [
123
+ {"id": _public_label(cid), "status": states[cid].status if cid in states else "idle",
124
+ "enabled": states[cid].enabled if cid in states else True,
125
+ "error_code": states[cid].error_code if cid in states else None,
126
+ "type": _public_label(states[cid].connector_type) if cid in states else "unknown",
127
+ "tracked_items": tracked_items.get(cid, 0),
128
+ "last_success_at": states[cid].last_success_at if cid in states else None,
129
+ "last_attempt_at": states[cid].last_attempt_at if cid in states else None,
130
+ "cursor_present": bool(states[cid].cursor) if cid in states else False}
131
+ for cid in connectors.list_connectors()
132
+ ],
133
+ "models": [
134
+ item for item in discovered
135
+ if (model is None or item["model"] == _public_label(model))
136
+ and (provider is None or item["provider"] == _public_label(provider))
137
+ ],
138
+ "summary": summary_data,
139
+ "provider_models": breakdown,
140
+ "period": period,
141
+ "context_measurement_bases": [
142
+ {"source": row["source"], "tokenizer": _public_label(row["tokenizer"])}
143
+ for row in await telemetry.context_measurement_bases(model, provider, start)
144
+ ],
145
+ "recent": [
146
+ {
147
+ "id": str(row.invocation_id), "timestamp": row.timestamp.isoformat(),
148
+ "provider": _public_label(row.provider_id), "model": _public_label(row.model_id),
149
+ "status": row.status,
150
+ "preflight_input_tokens": row.preflight_input_tokens,
151
+ "provider_input_tokens": row.provider_input_tokens,
152
+ "provider_output_tokens": row.provider_output_tokens,
153
+ "provider_measurement_source": row.token_measurement_source.value,
154
+ "provider_measurement_label": _measurement_label(row.token_measurement_source.value),
155
+ "context_measurement_source": (row.context_token_measurement_source.value
156
+ if row.context_token_measurement_source else "unknown"),
157
+ "context_measurement_label": _measurement_label(
158
+ row.context_token_measurement_source.value
159
+ if row.context_token_measurement_source else None
160
+ ),
161
+ "context_tokenizer": _public_label(row.context_tokenizer),
162
+ "candidate_context_tokens": row.candidate_context_tokens,
163
+ "compiled_context_tokens": row.compiled_context_tokens,
164
+ "context_tokens_avoided": row.context_tokens_avoided,
165
+ "lexical_candidate_count": row.lexical_candidate_count,
166
+ "dense_candidate_count": row.dense_candidate_count,
167
+ "selected_memory_count": row.selected_memory_count,
168
+ "graph_expanded_count": row.graph_expanded_count,
169
+ "retrieval_ms": row.retrieval_ms, "compilation_ms": row.compilation_ms,
170
+ "provider_ms": row.provider_latency_ms, "error_code": _public_label(row.error_code)}
171
+ for row in recent
172
+ ],
173
+ }
174
+
175
+
176
+ @router.get("/graph/stats")
177
+ async def graph_stats() -> dict[str, Any]:
178
+ """Read projection counts without rebuilding or exposing node labels."""
179
+ repo = get_service("graph_repo")
180
+ nodes, edges, supports = await repo.counts()
181
+ conn = get_service("database").connection()
182
+ node_cursor = await conn.execute(
183
+ "SELECT node_type, COUNT(*) FROM graph_nodes GROUP BY node_type"
184
+ )
185
+ edge_cursor = await conn.execute(
186
+ "SELECT relation_type, COUNT(*) FROM graph_edges GROUP BY relation_type"
187
+ )
188
+ return {
189
+ "nodes": nodes, "edges": edges, "supports": supports,
190
+ "dirty": await repo.source_is_dirty(),
191
+ "average_total_degree": round(2 * edges / nodes, 3) if nodes else None,
192
+ "node_types": {row[0]: row[1] for row in await node_cursor.fetchall()},
193
+ "edge_types": {row[0]: row[1] for row in await edge_cursor.fetchall()},
194
+ }
195
+
196
+
197
+ async def _graph_paths(query: str, seed_id: UUID | None = None) -> dict[str, Any]:
198
+ expansion = await get_service("graph").expand(
199
+ query_text=query, seed_memory_ids=[seed_id] if seed_id else [],
200
+ max_hops=3, max_nodes=50, max_edges=100,
201
+ )
202
+ candidates = sorted(expansion.candidate_scores,
203
+ key=lambda item: (-expansion.candidate_scores[item], str(item)))[:10]
204
+ return {
205
+ "candidate_count": len(expansion.candidate_scores),
206
+ "candidates": [{
207
+ "memory_id": str(memory_id),
208
+ "graph_score": expansion.candidate_scores[memory_id],
209
+ "paths": [
210
+ path.model_dump(mode="json")
211
+ for path in expansion.candidate_paths.get(memory_id, [])[:5]
212
+ ],
213
+ } for memory_id in candidates],
214
+ "truncated": len(expansion.candidate_scores) > 10,
215
+ }
216
+
217
+
218
+ @router.get("/graph/search")
219
+ async def graph_search(entity: str = Query(min_length=1, max_length=128)) -> dict[str, Any]:
220
+ return await _graph_paths(entity)
221
+
222
+
223
+ @router.get("/graph/show/{memory_id}")
224
+ async def graph_show(memory_id: UUID) -> dict[str, Any]:
225
+ return await _graph_paths("", memory_id)
226
+
227
+
228
+ @router.get("/temporal/current")
229
+ async def temporal_current(limit: int = Query(default=25, ge=1, le=50)) -> dict[str, Any]:
230
+ return await _temporal_rows("active", limit)
231
+
232
+
233
+ @router.get("/temporal/conflicts")
234
+ async def temporal_conflicts(limit: int = Query(default=25, ge=1, le=50)) -> dict[str, Any]:
235
+ return await _temporal_rows("contradicted", limit)
236
+
237
+
238
+ async def _temporal_rows(status: str, limit: int) -> dict[str, Any]:
239
+ conn = get_service("database").connection()
240
+ cursor = await conn.execute(
241
+ "SELECT id, status, temporal_status, observed_at, valid_from, superseded_by "
242
+ "FROM memories WHERE status = ? ORDER BY observed_at DESC, id DESC LIMIT ?",
243
+ (status, limit),
244
+ )
245
+ rows = await cursor.fetchall()
246
+ return {"status": status, "memories": [
247
+ {"memory_id": row[0], "lifecycle": row[1], "temporal_status": row[2],
248
+ "observed_at": row[3], "effective_at": row[4], "superseded_by": row[5]}
249
+ for row in rows
250
+ ]}
251
+
252
+
253
+ @router.get("/temporal/history/{memory_id}")
254
+ async def temporal_history(memory_id: UUID, include_content: bool = False) -> dict[str, Any]:
255
+ memory = await get_service("memory_repo").get(memory_id)
256
+ if memory is None:
257
+ raise HTTPException(status_code=404, detail="Memory not found")
258
+ if memory.slot is None:
259
+ return {"memory_id": str(memory_id), "history": [], "relations": []}
260
+ conn = get_service("database").connection()
261
+ cursor = await conn.execute(
262
+ "SELECT id, status, temporal_status, observed_at, valid_from, superseded_by, content "
263
+ "FROM memories WHERE slot_key = ? "
264
+ "ORDER BY COALESCE(valid_from, observed_at, created_at) DESC, id DESC LIMIT 50",
265
+ (memory.slot.key,),
266
+ )
267
+ from contextos.services.explainability import safe_text
268
+ history = []
269
+ for row in await cursor.fetchall():
270
+ item = {"memory_id": row[0], "lifecycle": row[1], "temporal_status": row[2],
271
+ "observed_at": row[3], "effective_at": row[4], "superseded_by": row[5]}
272
+ if include_content:
273
+ item["content"] = safe_text(row[6], 1000)
274
+ history.append(item)
275
+ evidence = await get_service("explainability").temporal_resolver.resolve_memory_evidence(memory)
276
+ return {"memory_id": str(memory_id), "history": history,
277
+ "relations": evidence["relations"][:50]}
278
+
279
+
280
+ @router.get("/connectors")
281
+ async def list_connectors() -> list[dict[str, Any]]:
282
+ manager = get_service("connectors")
283
+ states = {item.connector_id: item for item in await get_service("connector_repo").list_states()}
284
+ return [{"id": _public_label(cid), "registered": True,
285
+ "status": states[cid].status if cid in states else "idle",
286
+ "enabled": states[cid].enabled if cid in states else True,
287
+ "error_code": states[cid].error_code if cid in states else None,
288
+ "last_success_at": states[cid].last_success_at if cid in states else None,
289
+ "last_attempt_at": states[cid].last_attempt_at if cid in states else None}
290
+ for cid in manager.list_connectors()]
291
+
292
+
293
+ @router.post("/connectors/{connector_id}/sync")
294
+ async def sync_connector(connector_id: str) -> dict[str, Any]:
295
+ manager = get_service("connectors")
296
+ if connector_id not in manager.list_connectors():
297
+ raise HTTPException(status_code=404, detail="Connector is not registered")
298
+ result = await manager.sync(connector_id)
299
+ return dict(result.model_dump(mode="json"))
300
+
301
+
302
+ @router.post("/remember")
303
+ async def remember(request: RememberRequest) -> dict[str, Any]:
304
+ result = await get_service("ingestion").ingest(
305
+ IngestRequest(content=request.text, source_type="cli", source_role=SourceRole.USER)
306
+ )
307
+ accepted: list[str] = []
308
+ for candidate in result.candidates:
309
+ try:
310
+ resolved = await get_service("temporal").accept(
311
+ candidate, provenance_event_id=result.event_id
312
+ )
313
+ except asyncio.CancelledError:
314
+ raise
315
+ except Exception:
316
+ raise HTTPException(status_code=500, detail={
317
+ "error": "PARTIAL_WRITE" if accepted else "MEMORY_WRITE_FAILED",
318
+ "accepted_ids": accepted,
319
+ }) from None
320
+ accepted.append(str(resolved.memory.id))
321
+ return {"accepted_ids": accepted, "count": len(accepted),
322
+ "secrets_detected": result.secrets_detected}
@@ -0,0 +1,17 @@
1
+ """Ingestion API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter
6
+
7
+ from contextos.api.server import get_service
8
+ from contextos.core.models import IngestRequest, IngestResult
9
+
10
+ router = APIRouter(tags=["ingestion"])
11
+
12
+
13
+ @router.post("/ingest", response_model=IngestResult)
14
+ async def ingest(request: IngestRequest) -> IngestResult:
15
+ """Ingest content into ContextOS."""
16
+ ingestion_service = get_service("ingestion")
17
+ return await ingestion_service.ingest(request)
@@ -0,0 +1,84 @@
1
+ """Memory management API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from uuid import UUID
6
+
7
+ from fastapi import APIRouter, Query
8
+
9
+ from contextos.api.server import get_service
10
+ from contextos.core.enums import MemoryStatus, MemoryType, PrivacyLevel
11
+ from contextos.core.models import Memory, MemoryFilters, MemoryUpdate
12
+
13
+ router = APIRouter(tags=["memories"])
14
+
15
+
16
+ @router.get("/memories", response_model=list[Memory])
17
+ async def list_memories(
18
+ status: MemoryStatus | None = None,
19
+ type: MemoryType | None = None,
20
+ privacy_level: PrivacyLevel | None = None,
21
+ source_type: str | None = None,
22
+ min_confidence: float | None = None,
23
+ min_importance: float | None = None,
24
+ limit: int = Query(default=50, ge=1, le=500),
25
+ offset: int = Query(default=0, ge=0),
26
+ ) -> list[Memory]:
27
+ """List memories with optional filters."""
28
+ memory_service = get_service("memory")
29
+ filters = MemoryFilters(
30
+ status=status,
31
+ type=type,
32
+ privacy_level=privacy_level,
33
+ source_type=source_type,
34
+ min_confidence=min_confidence,
35
+ min_importance=min_importance,
36
+ limit=limit,
37
+ offset=offset,
38
+ )
39
+ return await memory_service.list(filters)
40
+
41
+
42
+ @router.get("/memories/{memory_id}", response_model=Memory)
43
+ async def get_memory(memory_id: UUID) -> Memory:
44
+ """Get a specific memory by ID."""
45
+ memory_service = get_service("memory")
46
+ memory = await memory_service.get(memory_id)
47
+ if memory is None:
48
+ from contextos.core.exceptions import MemoryNotFoundError
49
+ raise MemoryNotFoundError(str(memory_id))
50
+ return memory
51
+
52
+
53
+ @router.patch("/memories/{memory_id}", response_model=Memory)
54
+ async def update_memory(memory_id: UUID, update: MemoryUpdate) -> Memory:
55
+ """Update a memory's content or metadata."""
56
+ memory_service = get_service("memory")
57
+ return await memory_service.update(memory_id, update)
58
+
59
+
60
+ @router.post("/memories/{memory_id}/transition", response_model=Memory)
61
+ async def transition_memory(
62
+ memory_id: UUID,
63
+ new_status: MemoryStatus,
64
+ reason: str = "",
65
+ ) -> Memory:
66
+ """Transition a memory to a new lifecycle state."""
67
+ memory_service = get_service("memory")
68
+ return await memory_service.transition(memory_id, new_status, reason)
69
+
70
+
71
+ @router.delete("/memories/{memory_id}")
72
+ async def delete_memory(memory_id: UUID) -> dict:
73
+ """Soft-delete a memory."""
74
+ memory_service = get_service("memory")
75
+ await memory_service.delete(memory_id)
76
+ return {"status": "deleted", "memory_id": str(memory_id)}
77
+
78
+
79
+ @router.delete("/memories/{memory_id}/purge")
80
+ async def purge_memory(memory_id: UUID) -> dict:
81
+ """Hard-delete a memory (irreversible)."""
82
+ memory_service = get_service("memory")
83
+ await memory_service.purge(memory_id)
84
+ return {"status": "purged", "memory_id": str(memory_id)}
@@ -0,0 +1,81 @@
1
+ """API routes for model inference, capabilities, and telemetry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Query
8
+ from pydantic import BaseModel, Field
9
+
10
+ from contextos.api.server import get_service
11
+ from contextos.core.enums import RoutingPolicy
12
+ from contextos.core.models import (
13
+ AskResult,
14
+ CompilationConfig,
15
+ ModelCapabilities,
16
+ RetrievalConfig,
17
+ TelemetrySummary,
18
+ )
19
+
20
+ router = APIRouter(tags=["models"])
21
+
22
+
23
+ class AskApiRequest(BaseModel):
24
+ """Request payload for the high-level ContextOS ask endpoint."""
25
+
26
+ query: str = Field(min_length=1)
27
+ system_prompt: str | None = None
28
+ provider: str | None = None
29
+ model: str | None = None
30
+ routing_policy: RoutingPolicy | None = None
31
+ allow_fallback: bool = False
32
+ temperature: float = Field(default=0.7, ge=0.0, le=2.0)
33
+ max_output_tokens: int = Field(default=1024, ge=1)
34
+ timeout_seconds: float = Field(default=30.0, ge=0.5, le=600.0)
35
+ retrieval_config: RetrievalConfig | None = None
36
+ compilation_config: CompilationConfig | None = None
37
+ session_id: str | None = None
38
+
39
+
40
+ @router.post("/ask", response_model=AskResult)
41
+ async def ask_model(request: AskApiRequest) -> AskResult:
42
+ """Prepare context, route to chosen model, execute generation, and record telemetry."""
43
+ model_service = get_service("model_service")
44
+ return await model_service.ask(
45
+ query=request.query,
46
+ system_prompt=request.system_prompt,
47
+ retrieval_config=request.retrieval_config,
48
+ compilation_config=request.compilation_config,
49
+ routing_policy=request.routing_policy,
50
+ target_provider=request.provider,
51
+ target_model=request.model,
52
+ allow_fallback=request.allow_fallback,
53
+ temperature=request.temperature,
54
+ max_output_tokens=request.max_output_tokens,
55
+ timeout_seconds=request.timeout_seconds,
56
+ session_id=request.session_id,
57
+ )
58
+
59
+
60
+ @router.get("/models", response_model=list[ModelCapabilities])
61
+ async def list_models() -> list[ModelCapabilities]:
62
+ """List all models offered across registered providers."""
63
+ providers: dict[str, Any] = get_service("providers")
64
+ all_models: list[ModelCapabilities] = []
65
+ for prov in providers.values():
66
+ try:
67
+ m_list = await prov.list_models()
68
+ all_models.extend(m_list)
69
+ except Exception:
70
+ continue
71
+ return all_models
72
+
73
+
74
+ @router.get("/telemetry/summary", response_model=TelemetrySummary)
75
+ async def get_telemetry_summary(
76
+ provider_id: str | None = Query(default=None),
77
+ model_id: str | None = Query(default=None),
78
+ ) -> TelemetrySummary:
79
+ """Get aggregated invocation metrics."""
80
+ telemetry_query = get_service("telemetry_query")
81
+ return await telemetry_query.summary_range(provider_id=provider_id, model_id=model_id)
@@ -0,0 +1,89 @@
1
+ """Retrieval and compilation API routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+ from contextos.api.server import get_service
9
+ from contextos.core.models import (
10
+ CompiledContext,
11
+ CompilationConfig,
12
+ ContextBudget,
13
+ RetrievalConfig,
14
+ RetrievalQuery,
15
+ RetrievalResult,
16
+ )
17
+ from contextos.services.explainability import ExplanationRequest
18
+ from contextos.services.inspection import InspectionRequest
19
+
20
+ router = APIRouter(tags=["retrieval"])
21
+
22
+
23
+ class ExplainRequest(ExplanationRequest):
24
+ """Bounded request for deterministic pipeline explanation."""
25
+
26
+
27
+ @router.post("/explain")
28
+ async def explain_context(request: ExplainRequest) -> dict:
29
+ return (await get_service("explainability").explain(request)).model_dump(mode="json")
30
+
31
+
32
+ @router.post("/inspect")
33
+ async def inspect_context(request: InspectionRequest) -> dict:
34
+ return (await get_service("inspector").inspect(request)).model_dump(mode="json")
35
+
36
+
37
+ class RetrieveRequest(BaseModel):
38
+ """Request body for retrieval."""
39
+
40
+ query: str | RetrievalQuery
41
+ config: RetrievalConfig | None = None
42
+
43
+ @field_validator("query")
44
+ @classmethod
45
+ def nonblank_string_query(cls, value: str | RetrievalQuery) -> str | RetrievalQuery:
46
+ if isinstance(value, str) and not value.strip():
47
+ raise ValueError("Query cannot be blank")
48
+ return value
49
+
50
+
51
+ class CompileRequest(BaseModel):
52
+ """Request body for compilation."""
53
+ query: str = Field(min_length=1)
54
+ config: CompilationConfig | None = None
55
+ retrieval_config: RetrievalConfig | None = None
56
+
57
+
58
+ @router.post("/retrieve", response_model=RetrievalResult)
59
+ async def retrieve(request: RetrieveRequest) -> RetrievalResult:
60
+ """Retrieve relevant memories for a query."""
61
+ retrieval_service = get_service("retrieval")
62
+ return await retrieval_service.retrieve(request.query, request.config)
63
+
64
+
65
+ @router.post("/compile", response_model=CompiledContext)
66
+ async def compile_context(request: CompileRequest) -> CompiledContext:
67
+ """Retrieve and compile context for a query."""
68
+ retrieval_service = get_service("retrieval")
69
+ compilation_service = get_service("compilation")
70
+ optimizer = get_service("optimizer")
71
+
72
+ # First retrieve
73
+ retrieval_result = await retrieval_service.retrieve(
74
+ request.query, request.retrieval_config
75
+ )
76
+
77
+ compilation_config = request.config or CompilationConfig()
78
+ selection = optimizer.optimize(
79
+ request.query,
80
+ retrieval_result.memories,
81
+ ContextBudget(max_tokens=compilation_config.budget),
82
+ )
83
+
84
+ # Carry selected memories and eligible oversized rescue candidates explicitly.
85
+ return await compilation_service.compile(
86
+ query=request.query,
87
+ memories=selection,
88
+ config=compilation_config,
89
+ )