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/cli/app.py ADDED
@@ -0,0 +1,932 @@
1
+ """ContextOS CLI — main application and top-level commands.
2
+
3
+ The CLI is a thin client. All business logic runs in the daemon.
4
+ Commands communicate with the daemon via HTTP (localhost).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ import time
12
+ from typing import Any
13
+
14
+ import httpx
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.live import Live
18
+ from rich.text import Text
19
+
20
+ from contextos import __version__
21
+
22
+ app = typer.Typer(
23
+ name="contextos",
24
+ help="ContextOS — Local-first personal AI memory runtime",
25
+ no_args_is_help=True,
26
+ pretty_exceptions_enable=False,
27
+ )
28
+
29
+ # Sub-command groups
30
+ memory_app = typer.Typer(help="Manage memories", no_args_is_help=True)
31
+ config_app = typer.Typer(help="Manage configuration", no_args_is_help=True)
32
+ memories_app = typer.Typer(help="Manage memories", no_args_is_help=True)
33
+ connectors_app = typer.Typer(help="Inspect and sync registered connectors", no_args_is_help=True)
34
+ models_app = typer.Typer(help="Inspect registered models", no_args_is_help=True)
35
+ graph_app = typer.Typer(help="Inspect the bounded memory graph", no_args_is_help=True)
36
+
37
+ app.add_typer(memory_app, name="memory")
38
+ app.add_typer(config_app, name="config")
39
+ app.add_typer(memories_app, name="memories")
40
+ app.add_typer(connectors_app, name="connectors")
41
+ app.add_typer(models_app, name="models")
42
+ app.add_typer(graph_app, name="graph")
43
+
44
+ console = Console()
45
+ error_console = Console(stderr=True)
46
+
47
+
48
+ def _base_url() -> str:
49
+ """Get the daemon base URL."""
50
+ from contextos.config.settings import load_settings
51
+ settings = load_settings()
52
+ host = "127.0.0.1" if settings.daemon.host == "localhost" else settings.daemon.host
53
+ host = f"[{host}]" if ":" in host else host
54
+ return f"http://{host}:{settings.daemon.port}"
55
+
56
+
57
+ def _api(method: str, path: str, **kwargs) -> httpx.Response:
58
+ """Make an API request to the daemon."""
59
+ url = f"{_base_url()}/api/v1{path}"
60
+ try:
61
+ with httpx.Client(timeout=30.0, trust_env=False) as client:
62
+ response = client.request(method, url, **kwargs)
63
+ if response.status_code >= 400:
64
+ try:
65
+ from contextos.cli.dashboard import safe
66
+ error = response.json()
67
+ error_msg = safe(error.get("error", error.get("detail", "Request failed")), limit=500)
68
+ error_console.print("Error: ", Text(error_msg))
69
+ except Exception:
70
+ error_console.print("Error: Request failed")
71
+ raise typer.Exit(1)
72
+ return response
73
+ except (httpx.ConnectError, httpx.TimeoutException):
74
+ error_console.print("[red]Error:[/red] Cannot connect to ContextOS daemon.")
75
+ error_console.print("Start it with: [bold]contextos start[/bold]")
76
+ raise typer.Exit(1)
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Top-level Commands
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ @app.command()
85
+ def start(
86
+ foreground: bool = typer.Option(False, "--foreground", "-f", help="Run in foreground"),
87
+ ) -> None:
88
+ """Start the ContextOS daemon."""
89
+ from contextos.config.settings import load_settings
90
+ from contextos.core.exceptions import DaemonAlreadyRunningError, DaemonLockTimeoutError
91
+ from contextos.daemon.manager import start_daemon
92
+
93
+ settings = load_settings()
94
+ if foreground:
95
+ import os
96
+ from contextos.config.settings import Settings
97
+ child_settings = os.environ.get("CONTEXTOS_DAEMON_SETTINGS")
98
+ if child_settings:
99
+ settings = Settings.model_validate_json(child_settings)
100
+ console.print("[bold]Starting ContextOS daemon (foreground)...[/bold]")
101
+ start_daemon(settings, foreground=True)
102
+ return
103
+
104
+ console.print("[bold]Starting ContextOS daemon...[/bold]")
105
+ try:
106
+ start_daemon(settings, foreground=False)
107
+ except DaemonAlreadyRunningError as exc:
108
+ console.print(f"[yellow]ContextOS daemon is already running (PID: {exc.pid})[/yellow]")
109
+ raise typer.Exit(0)
110
+ except DaemonLockTimeoutError as exc:
111
+ error_console.print(f"Error: {exc}")
112
+ raise typer.Exit(1) from exc
113
+ except (RuntimeError, OSError) as exc:
114
+ error_console.print(f"Error: {exc}")
115
+ raise typer.Exit(1) from exc
116
+ console.print(f"[green][OK][/green] Daemon started on {settings.daemon.host}:{settings.daemon.port}")
117
+
118
+
119
+ @app.command()
120
+ def stop() -> None:
121
+ """Stop the ContextOS daemon."""
122
+ from contextos.config.settings import load_settings
123
+ from contextos.daemon.manager import stop_daemon
124
+
125
+ try:
126
+ stop_daemon(load_settings())
127
+ console.print("[green][OK][/green] Daemon stopped")
128
+ except Exception as e:
129
+ error_console.print(f"[red]Error:[/red] {e}")
130
+ raise typer.Exit(1)
131
+
132
+
133
+ @app.command()
134
+ def status(
135
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
136
+ ) -> None:
137
+ """Show system status."""
138
+ resp = _api("GET", "/status")
139
+ data = resp.json()
140
+
141
+ if json_output:
142
+ console.print_json(json.dumps(data))
143
+ else:
144
+ from contextos.cli.formatters import format_status
145
+ from contextos.core.models import SystemStatus
146
+ format_status(SystemStatus(**data))
147
+
148
+
149
+ @app.command()
150
+ def stats(
151
+ model: str | None = typer.Option(None, "--model", help="Filter by model ID"),
152
+ provider: str | None = typer.Option(None, "--provider", help="Filter by provider ID"),
153
+ compare: bool = typer.Option(False, "--compare", help="Show provider/model breakdown"),
154
+ today: bool = typer.Option(False, "--today", help="Use the current UTC day"),
155
+ week: bool = typer.Option(False, "--week", help="Use the last seven days"),
156
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
157
+ ) -> None:
158
+ """Show real activity and model-specific token statistics."""
159
+ if today and week:
160
+ raise typer.BadParameter("Choose --today or --week")
161
+ resp = _api("GET", "/dashboard", params={
162
+ "model": model, "provider": provider,
163
+ "period": "today" if today else "week" if week else "all",
164
+ })
165
+ data = resp.json()
166
+
167
+ if json_output:
168
+ console.print_json(json.dumps(data))
169
+ else:
170
+ from contextos.cli.dashboard import render_dashboard
171
+ console.print(render_dashboard(data, model, compare=compare))
172
+
173
+
174
+ @app.command()
175
+ def monitor(
176
+ model: str | None = typer.Option(None, "--model", help="Filter by model ID"),
177
+ provider: str | None = typer.Option(None, "--provider", help="Filter by provider ID"),
178
+ interval: float = typer.Option(2.0, "--interval", min=0.5, max=60.0),
179
+ samples: int | None = typer.Option(None, "--samples", min=1, max=1000),
180
+ ) -> None:
181
+ """Watch bounded local activity until Ctrl-C or the requested sample count."""
182
+ from contextos.cli.dashboard import render_dashboard
183
+ count = 0
184
+ try:
185
+ with Live(console=console, refresh_per_second=2, screen=False) as live:
186
+ while samples is None or count < samples:
187
+ data = _api("GET", "/dashboard", params={"model": model, "provider": provider}).json()
188
+ live.update(render_dashboard(data, model), refresh=True)
189
+ count += 1
190
+ if samples is None or count < samples:
191
+ time.sleep(interval)
192
+ except KeyboardInterrupt:
193
+ return
194
+
195
+
196
+ @app.command()
197
+ def desktop() -> None:
198
+ """Open the local monitor in its own Windows terminal window."""
199
+ if sys.platform != "win32":
200
+ monitor()
201
+ return
202
+ import subprocess
203
+ subprocess.Popen(
204
+ [sys.executable, "-m", "contextos", "monitor"],
205
+ creationflags=subprocess.CREATE_NEW_CONSOLE,
206
+ close_fds=True,
207
+ )
208
+
209
+
210
+ @app.command()
211
+ def health(json_output: bool = typer.Option(False, "--json")) -> None:
212
+ """Check daemon and database/index health."""
213
+ result = _api("POST", "/doctor").json()
214
+ if json_output:
215
+ console.print_json(json.dumps(result))
216
+ else:
217
+ from contextos.cli.formatters import format_doctor_results
218
+ format_doctor_results(result)
219
+ if not result.get("overall"):
220
+ raise typer.Exit(1)
221
+
222
+
223
+ @app.command()
224
+ def doctor(
225
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
226
+ ) -> None:
227
+ """Run diagnostic checks."""
228
+ resp = _api("POST", "/doctor")
229
+ data = resp.json()
230
+
231
+ if json_output:
232
+ console.print_json(json.dumps(data))
233
+ else:
234
+ from contextos.cli.formatters import format_doctor_results
235
+ format_doctor_results(data)
236
+
237
+
238
+ @app.command()
239
+ def ingest(
240
+ content: str = typer.Argument(..., help="Content to ingest"),
241
+ source: str = typer.Option("cli_input", "--source", "-s", help="Source type"),
242
+ source_uri: str | None = typer.Option(None, "--file", help="Source file path"),
243
+ memory_type: str | None = typer.Option(None, "--type", "-t", help="Memory type hint"),
244
+ skip_scan: bool = typer.Option(False, "--skip-secret-scan", help="Deprecated compatibility flag; privacy scanning is always enforced"),
245
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
246
+ tags: str | None = typer.Option(None, "--tags", help="Comma-separated tags"),
247
+ ) -> None:
248
+ """Ingest content into ContextOS."""
249
+ # Handle file input
250
+ if source_uri:
251
+ from pathlib import Path
252
+ file_path = Path(source_uri)
253
+ if file_path.exists():
254
+ content = file_path.read_text(encoding="utf-8")
255
+ source = "file"
256
+ else:
257
+ error_console.print(f"[red]Error:[/red] File not found: {source_uri}")
258
+ raise typer.Exit(1)
259
+
260
+ payload = {
261
+ "content": content,
262
+ "source_type": source,
263
+ "source_uri": source_uri,
264
+ "skip_secret_scan": skip_scan,
265
+ }
266
+ if memory_type:
267
+ payload["memory_type"] = memory_type
268
+ if tags:
269
+ payload["tags"] = [t.strip() for t in tags.split(",")]
270
+
271
+ resp = _api("POST", "/ingest", json=payload)
272
+ data = resp.json()
273
+
274
+ if json_output:
275
+ console.print_json(json.dumps(data))
276
+ else:
277
+ from contextos.cli.formatters import format_ingest_result
278
+ from contextos.core.models import IngestResult
279
+ format_ingest_result(IngestResult(**data))
280
+
281
+
282
+ @app.command()
283
+ def retrieve(
284
+ query: str = typer.Argument(..., help="Query to retrieve memories for"),
285
+ trace: bool = typer.Option(False, "--trace", help="Show pipeline trace"),
286
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
287
+ top_k: int = typer.Option(20, "--top-k", "-k", help="Max results per strategy"),
288
+ ) -> None:
289
+ """Retrieve relevant memories."""
290
+ payload = {
291
+ "query": query,
292
+ "config": {"vector_top_k": top_k, "bm25_top_k": top_k},
293
+ }
294
+
295
+ resp = _api("POST", "/retrieve", json=payload)
296
+ data = resp.json()
297
+
298
+ if json_output:
299
+ console.print_json(json.dumps(data))
300
+ else:
301
+ from contextos.cli.formatters import format_retrieval_result
302
+ from contextos.core.models import RetrievalResult
303
+ format_retrieval_result(RetrievalResult(**data), show_trace=trace)
304
+
305
+
306
+ @app.command()
307
+ def compile(
308
+ query: str = typer.Argument(..., help="Query/task to compile context for"),
309
+ show_context: bool = typer.Option(False, "--show-context", help="Show compiled context"),
310
+ budget: int = typer.Option(4000, "--budget", "-b", help="Token budget"),
311
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
312
+ format: str = typer.Option("text", "--format", help="Output format (text/json)"),
313
+ ) -> None:
314
+ """Compile optimized context for an LLM."""
315
+ payload = {
316
+ "query": query,
317
+ "config": {"budget": budget, "format": format},
318
+ }
319
+
320
+ resp = _api("POST", "/compile", json=payload)
321
+ data = resp.json()
322
+
323
+ if json_output:
324
+ console.print_json(json.dumps(data))
325
+ else:
326
+ from contextos.cli.formatters import format_compiled_context
327
+ from contextos.core.models import CompiledContext
328
+ format_compiled_context(CompiledContext(**data), show_context=show_context)
329
+
330
+
331
+ @app.command()
332
+ def version() -> None:
333
+ """Show version."""
334
+ console.print(f"ContextOS v{__version__}")
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Memory Sub-commands
339
+ # ---------------------------------------------------------------------------
340
+
341
+
342
+ @memory_app.command("list")
343
+ def memory_list(
344
+ status_filter: str | None = typer.Option(None, "--status", "-s", help="Filter by status"),
345
+ type_filter: str | None = typer.Option(None, "--type", "-t", help="Filter by type"),
346
+ limit: int = typer.Option(50, "--limit", "-n", help="Max results"),
347
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
348
+ ) -> None:
349
+ """List memories."""
350
+ params = {"limit": limit}
351
+ if status_filter:
352
+ params["status"] = status_filter
353
+ if type_filter:
354
+ params["type"] = type_filter
355
+
356
+ resp = _api("GET", "/memories", params=params)
357
+ data = resp.json()
358
+
359
+ if json_output:
360
+ console.print_json(json.dumps(data))
361
+ else:
362
+ from contextos.cli.formatters import format_memory_list
363
+ from contextos.core.models import Memory
364
+ memories = [Memory(**m) for m in data]
365
+ format_memory_list(memories)
366
+
367
+
368
+ @memory_app.command("search")
369
+ def memory_search(
370
+ query: str = typer.Argument(..., help="Search query"),
371
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results"),
372
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
373
+ ) -> None:
374
+ """Search memories."""
375
+ payload = {"query": query, "config": {"vector_top_k": limit, "bm25_top_k": limit}}
376
+ resp = _api("POST", "/retrieve", json=payload)
377
+ data = resp.json()
378
+
379
+ if json_output:
380
+ console.print_json(json.dumps(data))
381
+ else:
382
+ from contextos.cli.formatters import format_retrieval_result
383
+ from contextos.core.models import RetrievalResult
384
+ format_retrieval_result(RetrievalResult(**data))
385
+
386
+
387
+ @memories_app.command("list")
388
+ def memories_list(
389
+ status_filter: str | None = typer.Option(None, "--status"),
390
+ limit: int = typer.Option(25, "--limit", min=1, max=100),
391
+ json_output: bool = typer.Option(False, "--json"),
392
+ ) -> None:
393
+ """List bounded memory metadata; content requires --json or show."""
394
+ params = {"limit": limit}
395
+ if status_filter:
396
+ params["status"] = status_filter
397
+ rows = _api("GET", "/memories", params=params).json()
398
+ if json_output:
399
+ console.print_json(json.dumps(rows))
400
+ else:
401
+ from rich.table import Table
402
+
403
+ from contextos.cli.dashboard import safe
404
+ table = Table(title="Memories - metadata only")
405
+ for column in ("ID", "Type", "Status", "Privacy", "Tokens"):
406
+ table.add_column(column)
407
+ for row in rows:
408
+ table.add_row(safe(row["id"], 36), safe(row["type"]), safe(row["status"]),
409
+ safe(row["privacy_level"]), str(row["token_count"]))
410
+ console.print(table)
411
+
412
+
413
+ @memories_app.command("search")
414
+ def memories_search(
415
+ query: str = typer.Argument(...),
416
+ limit: int = typer.Option(10, "--limit", min=1, max=50),
417
+ json_output: bool = typer.Option(False, "--json"),
418
+ ) -> None:
419
+ """Search memories; show IDs and scores by default."""
420
+ result = _api("POST", "/retrieve", json={"query": query,
421
+ "config": {"vector_top_k": limit, "bm25_top_k": limit}}).json()
422
+ if json_output:
423
+ console.print_json(json.dumps(result))
424
+ else:
425
+ from rich.table import Table
426
+
427
+ from contextos.cli.dashboard import safe
428
+ table = Table(title="Memory matches - metadata only")
429
+ for column in ("ID", "Type", "Score"):
430
+ table.add_column(column)
431
+ for item in result["memories"][:limit]:
432
+ table.add_row(safe(item["memory"]["id"], 36), safe(item["memory"]["type"]),
433
+ f"{item['final_score']:.3f}")
434
+ console.print(table)
435
+
436
+
437
+ @memories_app.command("show")
438
+ def memories_show(memory_id: str = typer.Argument(...), json_output: bool = typer.Option(False, "--json")) -> None:
439
+ """Show one memory, including its private content."""
440
+ from uuid import UUID
441
+ try:
442
+ memory_id = str(UUID(memory_id))
443
+ except ValueError:
444
+ raise typer.BadParameter("Expected a memory UUID") from None
445
+ row = _api("GET", f"/memories/{memory_id}").json()
446
+ if json_output:
447
+ console.print_json(json.dumps(row))
448
+ else:
449
+ from contextos.cli.dashboard import safe
450
+ console.print(Text(safe(row["content"], 10_000, allow_newlines=True)))
451
+ console.print(f"ID: {memory_id} | {safe(row['status'])} | {safe(row['privacy_level'])}")
452
+
453
+
454
+ @memories_app.command("remember")
455
+ def memories_remember() -> None:
456
+ """Remember text from stdin or a hidden prompt; never place it in process arguments."""
457
+ import getpass
458
+ content = sys.stdin.read(10_001) if not sys.stdin.isatty() else getpass.getpass("Memory: ")
459
+ if not content.strip() or len(content) > 10_000:
460
+ raise typer.BadParameter("Memory must contain 1-10,000 characters")
461
+ result = _api("POST", "/remember", json={"text": content}).json()
462
+ console.print(f"Accepted {result['count']} memories")
463
+
464
+
465
+ @connectors_app.command("list")
466
+ def connectors_list() -> None:
467
+ """List registered connectors and their persisted state."""
468
+ rows = _api("GET", "/connectors").json()
469
+ from rich.table import Table
470
+
471
+ from contextos.cli.dashboard import safe
472
+ table = Table(title="Connectors")
473
+ for column in ("ID", "Status", "Enabled", "Error code"):
474
+ table.add_column(column)
475
+ for row in rows:
476
+ table.add_row(safe(row["id"]), safe(row["status"]), str(row["enabled"]), safe(row["error_code"] or ""))
477
+ if not rows:
478
+ table.add_row("No connectors registered", "", "", "")
479
+ console.print(table)
480
+
481
+
482
+ @connectors_app.command("status")
483
+ def connectors_status(connector_id: str = typer.Argument(...)) -> None:
484
+ """Show one connector state."""
485
+ from contextos.cli.dashboard import safe
486
+ rows = _api("GET", "/connectors").json()
487
+ row = next((item for item in rows if item["id"] == connector_id), None)
488
+ if row is None:
489
+ error_console.print("Connector is not registered")
490
+ raise typer.Exit(1)
491
+ for key in ("id", "status", "enabled", "error_code", "last_success_at"):
492
+ console.print(f"{key}: {safe(row.get(key))}")
493
+
494
+
495
+ @connectors_app.command("sync")
496
+ def connectors_sync(connector_id: str = typer.Argument(...)) -> None:
497
+ """Run a registered connector through its existing sync pipeline."""
498
+ from contextos.cli.dashboard import safe
499
+ result = _api("POST", f"/connectors/{safe(connector_id, 100)}/sync").json()
500
+ console.print(f"{safe(result['status'])}: {result['accepted']} accepted, {result['failed']} failed")
501
+ if result["status"] not in ("success", "disabled"):
502
+ raise typer.Exit(1)
503
+
504
+
505
+ @models_app.command("list")
506
+ def models_list(json_output: bool = typer.Option(False, "--json")) -> None:
507
+ """List currently discoverable models."""
508
+ rows = _api("GET", "/models").json()
509
+ if json_output:
510
+ console.print_json(json.dumps(rows))
511
+ else:
512
+ from rich.table import Table
513
+
514
+ from contextos.cli.dashboard import safe
515
+ table = Table(title="Discoverable models")
516
+ for column in ("Provider", "Model", "Local"):
517
+ table.add_column(column)
518
+ for row in rows:
519
+ table.add_row(safe(row.get("provider_id")), safe(row.get("model_id")), str(row.get("local")))
520
+ if not rows:
521
+ table.add_row("No models available", "", "")
522
+ console.print(table)
523
+
524
+
525
+ @app.command()
526
+ def preview(query: str = typer.Argument(...), budget: int = typer.Option(4000, "--budget", min=1, max=32000),
527
+ show_context: bool = typer.Option(False, "--show-context"),
528
+ explain: bool = typer.Option(False, "--explain")) -> None:
529
+ """Preview selected memories and provenance without invoking a model."""
530
+ from contextos.cli.dashboard import safe
531
+ if explain:
532
+ result = _api("POST", "/explain", json={"query": query, "budget": min(budget, 8000), "include_content": show_context}).json()
533
+ _print_explanation(result)
534
+ return
535
+ result = _api("POST", "/compile", json={"query": query, "config": {"budget": budget}}).json()
536
+ console.print(f"Compiled {result['total_tokens']} / {result['budget']} tokens")
537
+ console.print(f"Selected {result['memories_included']} of {result['memories_considered']} memories")
538
+ for fact in result["facts"][:50]:
539
+ ids = ", ".join(safe(item, 36) for item in fact["source_memory_ids"])
540
+ console.print(Text(f"{safe(fact['fact_id'])}: {ids} | {safe(fact['input_kind'])}"))
541
+ if result["excluded_facts"]:
542
+ console.print("Exclusions:")
543
+ for fact in result["excluded_facts"][:50]:
544
+ console.print(Text(f"{safe(fact['fact_id'])}: {safe(fact['reason'])}"))
545
+ if show_context:
546
+ console.print(Text(safe(result["context_text"], 50_000, allow_newlines=True)))
547
+
548
+
549
+ def _print_explanation(result: dict) -> None:
550
+ from contextos.services.explainability import safe_text
551
+ console.print(f"QUERY TRACE {safe_text(result.get('trace_id'), 36)}")
552
+ for row in result.get("candidates", [])[:100]:
553
+ retrieval = row.get("retrieval", {})
554
+ temporal = row.get("temporal", {})
555
+ optimizer = row.get("optimizer", {})
556
+ console.print(f"[{row.get('rank', '?')}] Memory {safe_text(row.get('memory_id'), 36)}")
557
+ console.print(f" retrieved: {safe_text(retrieval.get('origin'))} rank {row.get('rank')}")
558
+ console.print(f" temporal: {safe_text(temporal.get('status'))}; eligible={temporal.get('eligible')}")
559
+ graph_paths = row.get("graph", [])
560
+ graph_labels = []
561
+ for gp in graph_paths:
562
+ for pn in gp.get("path_nodes", []):
563
+ if pn.get("label"):
564
+ graph_labels.append(pn["label"])
565
+ label_str = f"; entities={','.join(graph_labels[:3])}" if graph_labels else ""
566
+ console.print(f" graph: {len(graph_paths)} path(s); tokens={row.get('token_cost')}{label_str}")
567
+ console.print(f" optimizer: {'selected' if row.get('selected') else 'excluded'}; reason={safe_text(optimizer.get('reason'))}")
568
+ if row.get("content") is not None:
569
+ console.print(Text(safe_text(row["content"], 1000)))
570
+ if result.get("content") is not None:
571
+ console.print("Compiled context:")
572
+ console.print(Text(safe_text(result["content"], 20_000)))
573
+ if result.get("requested_memory") is not None:
574
+ console.print("Requested memory: " + json.dumps(result["requested_memory"], ensure_ascii=True))
575
+ dispatch = result.get("provider_dispatch", {})
576
+ state = dispatch.get("state", "NOT_ATTEMPTED")
577
+ console.print(f"Provider dispatch: {safe_text(state, 32)}")
578
+ console.print("Final context: prepared by ContextOS")
579
+ console.print("Final context stats: " + json.dumps(result.get("final_context", {}), ensure_ascii=True))
580
+
581
+
582
+ @app.command()
583
+ def explain(query: str = typer.Argument(...), budget: int = typer.Option(1000, "--budget", min=1, max=8000),
584
+ mode: str = typer.Option("hybrid", "--mode"), graph: bool = typer.Option(True, "--graph/--no-graph"),
585
+ limit: int = typer.Option(25, "--limit", min=1, max=100),
586
+ memory_id: str | None = typer.Option(None, "--memory-id", help="Explain one requested memory when it was observed"),
587
+ temporal_scope: str = typer.Option("current", "--temporal-scope"),
588
+ json_output: bool = typer.Option(False, "--json"),
589
+ show_content: bool = typer.Option(False, "--show-content")) -> None:
590
+ """Explain retrieval, selection, and compilation using recorded pipeline signals."""
591
+ result = _api("POST", "/explain", json={"query": query, "budget": budget, "mode": mode,
592
+ "graph": graph, "limit": limit,
593
+ "target_memory_id": memory_id,
594
+ "temporal_scope": temporal_scope,
595
+ "include_content": show_content}).json()
596
+ if json_output:
597
+ console.print_json(json.dumps(result, ensure_ascii=True))
598
+ else:
599
+ _print_explanation(result)
600
+
601
+
602
+ @app.command()
603
+ def inspect(
604
+ query: str = typer.Argument(...),
605
+ mode: str = typer.Option("hybrid", "--mode"),
606
+ graph: bool = typer.Option(False, "--graph/--no-graph"),
607
+ budget: int = typer.Option(1000, "--budget", min=1, max=8000),
608
+ limit: int = typer.Option(25, "--limit", min=1, max=100),
609
+ memory: str | None = typer.Option(None, "--memory"),
610
+ target_model: str | None = typer.Option(None, "--target-model"),
611
+ compare: bool = typer.Option(False, "--compare"),
612
+ show_content: bool = typer.Option(False, "--show-content"),
613
+ json_output: bool = typer.Option(False, "--json"),
614
+ ) -> None:
615
+ """Inspect one bounded context preparation, with optional retrieval comparison."""
616
+ result = _api("POST", "/inspect", json={
617
+ "query": query, "mode": mode, "graph": graph, "budget": budget,
618
+ "limit": limit, "target_memory_id": memory, "target_model": target_model,
619
+ "compare": compare, "include_content": show_content,
620
+ }).json()
621
+ if json_output:
622
+ console.print_json(json.dumps(result, ensure_ascii=True))
623
+ return
624
+ from rich.table import Table
625
+
626
+ from contextos.services.explainability import safe_text
627
+ console.print(Text("ContextOS RAG inspection " + safe_text(result["inspection_id"], 36)))
628
+ stages = Table(title="Pipeline")
629
+ for name in ("Stage", "Input", "Output", "Removed", "Latency ms"):
630
+ stages.add_column(name)
631
+ for stage in result["stages"][:20]:
632
+ stages.add_row(
633
+ safe_text(stage["name"], 64), str(stage["input_count"]),
634
+ str(stage["output_count"]), str(stage["removed_count"]),
635
+ f"{stage['latency_ms']:.2f}",
636
+ )
637
+ console.print(stages)
638
+ diff = result["context_diff"]
639
+ context = Table(title="Context token diff")
640
+ context.add_column("Basis")
641
+ context.add_column("Tokens")
642
+ for label, key in (("Retrieved", "candidate_tokens"), ("Optimized", "optimized_tokens"),
643
+ ("Compiled", "compiled_tokens"), ("Removed", "tokens_removed")):
644
+ context.add_row(label, str(diff[key]))
645
+ console.print(context)
646
+ console.print(Text(
647
+ f"Reduction {diff['reduction_ratio']:.1%} [{safe_text(diff['token_measurement_source'])}; "
648
+ f"{safe_text(diff['tokenizer'], 80)}] | facts {diff['facts_emitted']} emitted, "
649
+ f"{diff['facts_excluded']} excluded | provider {safe_text(result['provider_dispatch']['state'])}"
650
+ ))
651
+ candidates = Table(title="Candidates (bounded)")
652
+ for name in ("Rank", "Memory", "Origin", "BM25", "Dense", "Graph", "Temporal", "Selected", "Compiler"):
653
+ candidates.add_column(name, overflow="crop")
654
+ for row in result["candidates"][:limit]:
655
+ retrieval = row["retrieval"]
656
+ candidates.add_row(
657
+ str(row["rank"]), safe_text(row["memory_id"], 36),
658
+ safe_text(retrieval["origin"], 20), str(retrieval["lexical_rank"] or "-"),
659
+ str(retrieval["dense_rank"] or "-"), str(retrieval["graph_rank"] or "-"),
660
+ safe_text(row["temporal"]["status"], 20), str(row["selected"]),
661
+ safe_text(",".join(row["compiler_transformations"]) or "not available", 50),
662
+ )
663
+ console.print(candidates)
664
+ if result.get("requested_memory"):
665
+ requested = result["requested_memory"]
666
+ console.print(Text(
667
+ f"Memory {safe_text(requested['memory_id'], 36)}: "
668
+ f"{safe_text(requested['reason_code'], 50)} ({safe_text(requested['reason'], 80)})"
669
+ ))
670
+ for row in result["candidates"][:limit]:
671
+ for path in row["graph"][:5]:
672
+ nodes = [safe_text(node.get("label") or node["node_type"], 120) for node in path["path_nodes"]]
673
+ edges = [safe_text(edge["edge_type"], 40) for edge in path["path_edges"]]
674
+ route = "".join(f" --{edge}--> {nodes[index + 1]}" for index, edge in enumerate(edges)
675
+ if index + 1 < len(nodes))
676
+ if nodes:
677
+ console.print(Text(f"Graph {safe_text(row['memory_id'], 36)}: {nodes[0]}{route}"))
678
+ if result.get("comparison"):
679
+ table = Table(title="Retrieval comparison (no ground truth)")
680
+ for name in ("Mode", "Candidates", "Overlap", "Latency ms"):
681
+ table.add_column(name)
682
+ for run in result["comparison"]["runs"]:
683
+ table.add_row(safe_text(run["mode"]), str(run["candidate_count"]),
684
+ str(run["overlap_with_inspection"]), f"{run['latency_ms']:.2f}")
685
+ console.print(table)
686
+ if result.get("content") is not None:
687
+ console.print(Text(safe_text(result["content"], 20_000)))
688
+
689
+
690
+ @app.command()
691
+ def benchmark(
692
+ extended: bool = typer.Option(False, "--extended"),
693
+ json_output: bool = typer.Option(False, "--json"),
694
+ ) -> None:
695
+ """Run the isolated local synthetic comparison benchmark."""
696
+ import asyncio
697
+
698
+ from contextos.benchmarks.final import measure
699
+ result = asyncio.run(measure(extended=extended))
700
+ if json_output:
701
+ console.print_json(json.dumps(result, ensure_ascii=True))
702
+ return
703
+ from rich.table import Table
704
+ table = Table(title=result["label"])
705
+ for column in ("Memories", "Strategy", "Recall@5", "MRR", "NDCG@10", "Reduction"):
706
+ table.add_column(column)
707
+ for size, corpus in result["corpora"].items():
708
+ for name, row in corpus["strategies"].items():
709
+ table.add_row(size, name, f"{row['retrieval']['recall@5']:.3f}",
710
+ f"{row['retrieval']['mrr']:.3f}",
711
+ f"{row['retrieval']['ndcg@10']:.3f}",
712
+ f"{row['weighted_token_reduction']:.1%}" if row["weighted_token_reduction"] is not None else "UNKNOWN")
713
+ console.print(table)
714
+ console.print("[BENCHMARKED] Synthetic queries only; answer quality NOT AVAILABLE")
715
+
716
+
717
+ @app.command()
718
+ def demo(json_output: bool = typer.Option(False, "--json")) -> None:
719
+ """Run a disposable offline walkthrough with deterministic services."""
720
+ import asyncio
721
+
722
+ from contextos.demo import run_demo
723
+ result = asyncio.run(run_demo())
724
+ if json_output:
725
+ console.print_json(json.dumps(result, ensure_ascii=True))
726
+ return
727
+ from rich.table import Table
728
+ table = Table(title=result["label"])
729
+ table.add_column("Stage")
730
+ table.add_column("Observed result")
731
+ table.add_row("Temporal", f"{result['temporal']['previous_status']} -> {result['temporal']['current_status']}")
732
+ table.add_row("Connector", f"{result['connector_first']['accepted']} accepted; {result['connector_second']['unchanged']} unchanged on repeat")
733
+ table.add_row("Graph", f"{result['graph']['nodes']} nodes, {result['graph']['edges']} edges, {result['graph']['expansion_candidates']} expansion candidates")
734
+ table.add_row("Inspector", f"{result['inspection']['candidates']} candidates, {result['inspection']['context_diff']['facts_emitted']} facts")
735
+ table.add_row("Model", f"{result['model']['provider']}/{result['model']['model']}: {result['model']['dispatch_state']}")
736
+ table.add_row("Privacy", "secret rejected" if result["privacy_secret_rejected"] else "secret was not rejected")
737
+ console.print(table)
738
+ console.print("Synthetic data was stored in a temporary directory and removed after the run.")
739
+
740
+
741
+ @graph_app.command("stats")
742
+ def graph_stats_cli(json_output: bool = typer.Option(False, "--json")) -> None:
743
+ """Show persisted graph projection counts and freshness."""
744
+ data = _api("GET", "/graph/stats").json()
745
+ if json_output:
746
+ console.print_json(json.dumps(data, ensure_ascii=True))
747
+ return
748
+ from rich.table import Table
749
+ table = Table(title="Memory graph projection")
750
+ table.add_column("Metric")
751
+ table.add_column("Measured value")
752
+ for label, key in (("Nodes", "nodes"), ("Edges", "edges"), ("Supports", "supports")):
753
+ table.add_row(label, str(data[key]))
754
+ table.add_row("Projection", "dirty" if data["dirty"] else "clean")
755
+ table.add_row("Average total degree", str(data["average_total_degree"]) if data["average_total_degree"] is not None else "UNKNOWN")
756
+ console.print(table)
757
+
758
+
759
+ def _print_graph_paths(data: dict[str, Any]) -> None:
760
+ from contextos.services.explainability import safe_text
761
+ console.print(Text(f"Graph candidates: {data['candidate_count']}"))
762
+ for candidate in data["candidates"][:10]:
763
+ console.print(Text(f"Memory {safe_text(candidate['memory_id'], 36)} score {candidate['graph_score']:.4f}"))
764
+ for path in candidate["paths"][:5]:
765
+ nodes = [safe_text(node.get("label") or node["node_type"], 120) for node in path["path_nodes"]]
766
+ edges = [safe_text(edge["edge_type"], 40) for edge in path["path_edges"]]
767
+ route = "".join(f" --{edge}--> {nodes[index+1]}" for index, edge in enumerate(edges)
768
+ if index + 1 < len(nodes))
769
+ if nodes:
770
+ console.print(Text(nodes[0] + route))
771
+
772
+
773
+ @graph_app.command("search")
774
+ def graph_search_cli(entity: str = typer.Argument(...), json_output: bool = typer.Option(False, "--json")) -> None:
775
+ """Traverse from an entity using the existing bounded graph engine."""
776
+ data = _api("GET", "/graph/search", params={"entity": entity}).json()
777
+ if json_output:
778
+ console.print_json(json.dumps(data, ensure_ascii=True))
779
+ else:
780
+ _print_graph_paths(data)
781
+
782
+
783
+ @graph_app.command("show")
784
+ def graph_show_cli(memory_id: str = typer.Argument(...), json_output: bool = typer.Option(False, "--json")) -> None:
785
+ """Traverse graph paths from one memory ID."""
786
+ from uuid import UUID
787
+ try:
788
+ safe_id = str(UUID(memory_id))
789
+ except ValueError:
790
+ raise typer.BadParameter("memory_id must be a UUID") from None
791
+ data = _api("GET", f"/graph/show/{safe_id}").json()
792
+ if json_output:
793
+ console.print_json(json.dumps(data, ensure_ascii=True))
794
+ else:
795
+ _print_graph_paths(data)
796
+
797
+
798
+ def _print_temporal_rows(data: dict[str, Any]) -> None:
799
+ from rich.table import Table
800
+
801
+ from contextos.services.explainability import safe_text
802
+ rows = data.get("history", data.get("memories", []))
803
+ table = Table(title="Temporal memory metadata")
804
+ for name in ("Memory", "Lifecycle", "Temporal", "Observed", "Effective", "Replaced by"):
805
+ table.add_column(name, overflow="crop")
806
+ content_lines = []
807
+ for row in rows[:50]:
808
+ table.add_row(
809
+ safe_text(row["memory_id"], 36), safe_text(row["lifecycle"], 20),
810
+ safe_text(row["temporal_status"], 20), safe_text(row["observed_at"], 32),
811
+ safe_text(row["effective_at"], 32), safe_text(row["superseded_by"], 36),
812
+ )
813
+ if row.get("content") is not None:
814
+ content_lines.append((safe_text(row["memory_id"], 36), safe_text(row["content"], 1000)))
815
+ console.print(table)
816
+ for memory_id, content in content_lines:
817
+ console.print(Text(f"{memory_id}: {content}"))
818
+ if data.get("relations"):
819
+ for relation in data["relations"][:50]:
820
+ console.print(Text(
821
+ f"{safe_text(relation['relation_type'], 32)}: "
822
+ f"{safe_text(relation['related_memory_id'], 36)} "
823
+ f"({safe_text(relation['related_memory_state'], 20)})"
824
+ ))
825
+
826
+
827
+ @memories_app.command("current")
828
+ def memories_current(limit: int = typer.Option(25, "--limit", min=1, max=50),
829
+ json_output: bool = typer.Option(False, "--json")) -> None:
830
+ """List current memory metadata without private content."""
831
+ data = _api("GET", "/temporal/current", params={"limit": limit}).json()
832
+ console.print_json(json.dumps(data, ensure_ascii=True)) if json_output else _print_temporal_rows(data)
833
+
834
+
835
+ @memories_app.command("conflicts")
836
+ def memories_conflicts(limit: int = typer.Option(25, "--limit", min=1, max=50),
837
+ json_output: bool = typer.Option(False, "--json")) -> None:
838
+ """List contradicted memory metadata without private content."""
839
+ data = _api("GET", "/temporal/conflicts", params={"limit": limit}).json()
840
+ console.print_json(json.dumps(data, ensure_ascii=True)) if json_output else _print_temporal_rows(data)
841
+
842
+
843
+ @memories_app.command("history")
844
+ def memories_history(memory_id: str = typer.Argument(...),
845
+ show_content: bool = typer.Option(False, "--show-content"),
846
+ json_output: bool = typer.Option(False, "--json")) -> None:
847
+ """Inspect a bounded slot timeline and persisted relation evidence."""
848
+ from uuid import UUID
849
+ try:
850
+ safe_id = str(UUID(memory_id))
851
+ except ValueError:
852
+ raise typer.BadParameter("memory_id must be a UUID") from None
853
+ data = _api("GET", f"/temporal/history/{safe_id}",
854
+ params={"include_content": show_content}).json()
855
+ console.print_json(json.dumps(data, ensure_ascii=True)) if json_output else _print_temporal_rows(data)
856
+
857
+
858
+ @memory_app.command("inspect")
859
+ def memory_inspect(
860
+ memory_id: str = typer.Argument(..., help="Memory ID"),
861
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
862
+ ) -> None:
863
+ """Inspect a memory's full details."""
864
+ resp = _api("GET", f"/memories/{memory_id}")
865
+ data = resp.json()
866
+
867
+ if json_output:
868
+ console.print_json(json.dumps(data))
869
+ else:
870
+ from contextos.cli.formatters import format_memory
871
+ from contextos.core.models import Memory
872
+ format_memory(Memory(**data), detailed=True)
873
+
874
+
875
+ @memory_app.command("delete")
876
+ def memory_delete(
877
+ memory_id: str = typer.Argument(..., help="Memory ID"),
878
+ force: bool = typer.Option(False, "--force", help="Skip confirmation"),
879
+ ) -> None:
880
+ """Soft-delete a memory."""
881
+ if not force:
882
+ confirm = typer.confirm(f"Delete memory {memory_id[:8]}...?")
883
+ if not confirm:
884
+ raise typer.Abort()
885
+
886
+ _api("DELETE", f"/memories/{memory_id}")
887
+ console.print(f"[green][OK][/green] Memory {memory_id[:8]}... deleted")
888
+
889
+
890
+ @memory_app.command("purge")
891
+ def memory_purge(
892
+ memory_id: str = typer.Argument(..., help="Memory ID"),
893
+ force: bool = typer.Option(False, "--force", help="Skip confirmation"),
894
+ ) -> None:
895
+ """Hard-delete a memory (irreversible)."""
896
+ if not force:
897
+ confirm = typer.confirm(
898
+ f"[red]PERMANENTLY[/red] purge memory {memory_id[:8]}...? This cannot be undone.",
899
+ default=False,
900
+ )
901
+ if not confirm:
902
+ raise typer.Abort()
903
+
904
+ _api("DELETE", f"/memories/{memory_id}/purge")
905
+ console.print(f"[green][OK][/green] Memory {memory_id[:8]}... purged")
906
+
907
+
908
+ # ---------------------------------------------------------------------------
909
+ # Config Sub-commands
910
+ # ---------------------------------------------------------------------------
911
+
912
+
913
+ @config_app.command("show")
914
+ def config_show() -> None:
915
+ """Show current configuration."""
916
+ from contextos.config.settings import load_settings
917
+ settings = load_settings()
918
+ console.print_json(settings.model_dump_json(indent=2))
919
+
920
+
921
+ # ---------------------------------------------------------------------------
922
+ # Entry Point
923
+ # ---------------------------------------------------------------------------
924
+
925
+
926
+ def main() -> None:
927
+ """CLI entry point."""
928
+ app()
929
+
930
+
931
+ if __name__ == "__main__":
932
+ main()