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.
- contextos/__init__.py +3 -0
- contextos/__main__.py +6 -0
- contextos/api/__init__.py +1 -0
- contextos/api/routes/__init__.py +1 -0
- contextos/api/routes/desktop.py +322 -0
- contextos/api/routes/ingest.py +17 -0
- contextos/api/routes/memories.py +84 -0
- contextos/api/routes/models.py +81 -0
- contextos/api/routes/retrieval.py +89 -0
- contextos/api/routes/system.py +216 -0
- contextos/api/server.py +195 -0
- contextos/benchmarks/__init__.py +1 -0
- contextos/benchmarks/compilation.py +245 -0
- contextos/benchmarks/connectors.py +423 -0
- contextos/benchmarks/explainability.py +103 -0
- contextos/benchmarks/final.py +406 -0
- contextos/benchmarks/graph.py +310 -0
- contextos/benchmarks/graph_adversarial.py +525 -0
- contextos/benchmarks/mcp.py +324 -0
- contextos/benchmarks/model_routing.py +203 -0
- contextos/benchmarks/optimization.py +305 -0
- contextos/benchmarks/rescue_integration.py +127 -0
- contextos/benchmarks/retrieval.py +266 -0
- contextos/benchmarks/temporal.py +377 -0
- contextos/benchmarks/temporal_hotpath.py +76 -0
- contextos/benchmarks/terminal.py +62 -0
- contextos/cli/__init__.py +1 -0
- contextos/cli/app.py +932 -0
- contextos/cli/dashboard.py +174 -0
- contextos/cli/formatters.py +299 -0
- contextos/config/__init__.py +1 -0
- contextos/config/settings.py +160 -0
- contextos/connectors/__init__.py +6 -0
- contextos/connectors/fake.py +11 -0
- contextos/connectors/json_import.py +125 -0
- contextos/connectors/local_files.py +102 -0
- contextos/connectors/manager.py +293 -0
- contextos/connectors/models.py +62 -0
- contextos/connectors/protocols.py +11 -0
- contextos/core/__init__.py +103 -0
- contextos/core/enums.py +489 -0
- contextos/core/exceptions.py +293 -0
- contextos/core/models.py +1147 -0
- contextos/core/protocols.py +549 -0
- contextos/daemon/__init__.py +1 -0
- contextos/daemon/manager.py +510 -0
- contextos/daemon/state.py +127 -0
- contextos/daemon/wiring.py +296 -0
- contextos/demo.py +217 -0
- contextos/embedding/__init__.py +1 -0
- contextos/embedding/deterministic.py +76 -0
- contextos/embedding/sentence_transformers.py +80 -0
- contextos/mcp/__init__.py +5 -0
- contextos/mcp/server.py +269 -0
- contextos/providers/__init__.py +13 -0
- contextos/providers/fake.py +217 -0
- contextos/providers/ollama.py +297 -0
- contextos/providers/openai_compatible.py +337 -0
- contextos/services/__init__.py +1 -0
- contextos/services/compilation.py +535 -0
- contextos/services/explainability.py +553 -0
- contextos/services/extraction.py +311 -0
- contextos/services/graph.py +524 -0
- contextos/services/graph_retrieval.py +143 -0
- contextos/services/ingestion.py +143 -0
- contextos/services/inspection.py +174 -0
- contextos/services/memory.py +291 -0
- contextos/services/model_service.py +409 -0
- contextos/services/optimization.py +426 -0
- contextos/services/privacy.py +331 -0
- contextos/services/retrieval.py +302 -0
- contextos/services/retrieval_index.py +88 -0
- contextos/services/router.py +302 -0
- contextos/services/secret_scanner.py +207 -0
- contextos/services/telemetry_query.py +102 -0
- contextos/services/temporal.py +500 -0
- contextos/services/token_counter.py +222 -0
- contextos/storage/__init__.py +1 -0
- contextos/storage/connector_repo.py +67 -0
- contextos/storage/database.py +497 -0
- contextos/storage/event_repo.py +137 -0
- contextos/storage/graph_repo.py +228 -0
- contextos/storage/lexical/__init__.py +1 -0
- contextos/storage/lexical/bm25.py +134 -0
- contextos/storage/memory_repo.py +589 -0
- contextos/storage/relation_repo.py +80 -0
- contextos/storage/telemetry_repo.py +481 -0
- contextos/storage/vector/__init__.py +1 -0
- contextos/storage/vector/in_memory.py +162 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
- 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()
|