deyta-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ """`deyta memory …` — Khora's primitives: remember / recall / forget / ingest.
2
+
3
+ The core logic lives in module-level ``run_*`` functions so the top-level shorthands
4
+ (`deyta ingest`, `deyta query`) can reuse them verbatim.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from rich.progress import BarColumn, Progress, TaskProgressColumn, TextColumn
14
+
15
+ from .. import ingest_walk, render
16
+ from ..client import DeytaClientError
17
+ from ._common import get_client, require_project, resolve_namespace, resolve_ontology
18
+
19
+ app = typer.Typer(help="Memory operations (Khora).", no_args_is_help=True)
20
+
21
+
22
+ # --------------------------------------------------------------------------- #
23
+ # Core logic (reused by the top-level aliases)
24
+ # --------------------------------------------------------------------------- #
25
+ def run_remember(
26
+ ctx: typer.Context,
27
+ text: str,
28
+ namespace: str | None,
29
+ title: str,
30
+ source: str,
31
+ entity_types: str | None,
32
+ relationship_types: str | None,
33
+ ) -> None:
34
+ cfg = require_project(ctx)
35
+ nsid = resolve_namespace(cfg, namespace)
36
+ ents, rels = resolve_ontology(cfg, entity_types, relationship_types)
37
+ client = get_client(ctx)
38
+ try:
39
+ result = client.remember(
40
+ {
41
+ "content": text,
42
+ "namespace": nsid,
43
+ "title": title,
44
+ "source": source,
45
+ "entity_types": ents,
46
+ "relationship_types": rels,
47
+ }
48
+ )
49
+ except DeytaClientError as exc:
50
+ render.error(str(exc))
51
+ raise typer.Exit(1) from exc
52
+ render.success(
53
+ f"Remembered → document {result.get('document_id')} "
54
+ f"({result.get('chunks_created', 0)} chunks, "
55
+ f"{result.get('entities_extracted', 0)} entities)"
56
+ )
57
+
58
+
59
+ def run_recall(
60
+ ctx: typer.Context,
61
+ query: str,
62
+ namespace: str | None,
63
+ mode: str,
64
+ limit: int,
65
+ min_similarity: float,
66
+ since: str | None,
67
+ until: str | None,
68
+ as_json: bool,
69
+ as_context: bool,
70
+ ) -> None:
71
+ cfg = require_project(ctx)
72
+ nsid = resolve_namespace(cfg, namespace)
73
+ client = get_client(ctx)
74
+ try:
75
+ result = client.recall(
76
+ {
77
+ "query": query,
78
+ "namespace": nsid,
79
+ "mode": mode,
80
+ "limit": limit,
81
+ "min_similarity": min_similarity,
82
+ "start_time": since,
83
+ "end_time": until,
84
+ "context": as_context,
85
+ }
86
+ )
87
+ except DeytaClientError as exc:
88
+ render.error(str(exc))
89
+ raise typer.Exit(1) from exc
90
+ if as_json:
91
+ render.console.print_json(json.dumps(result))
92
+ elif as_context:
93
+ render.console.print(result.get("context_text", ""))
94
+ else:
95
+ render.recall_result(result)
96
+
97
+
98
+ def run_ingest(
99
+ ctx: typer.Context,
100
+ path: Path,
101
+ namespace: str | None,
102
+ recursive: bool,
103
+ include: str | None,
104
+ entity_types: str | None,
105
+ relationship_types: str | None,
106
+ dry_run: bool,
107
+ ) -> None:
108
+ cfg = require_project(ctx)
109
+ nsid = resolve_namespace(cfg, namespace)
110
+ ents, rels = resolve_ontology(cfg, entity_types, relationship_types)
111
+ try:
112
+ files = ingest_walk.discover(path, recursive=recursive, include=include)
113
+ except FileNotFoundError as exc:
114
+ render.error(str(exc))
115
+ raise typer.Exit(1) from exc
116
+ root = path if path.is_dir() else path.parent
117
+ documents = ingest_walk.build_documents(files, root=root)
118
+ if not documents:
119
+ render.error(f"No ingestible text files found under {path}.")
120
+ raise typer.Exit(1)
121
+
122
+ if dry_run:
123
+ render.info(f"[bold]{len(documents)}[/] document(s) would be ingested into {nsid}:")
124
+ for d in documents:
125
+ render.info(f" • {d['source']}")
126
+ return
127
+
128
+ client = get_client(ctx)
129
+ payload = {
130
+ "documents": documents,
131
+ "namespace": nsid,
132
+ "entity_types": ents,
133
+ "relationship_types": rels,
134
+ }
135
+ with Progress(
136
+ TextColumn("[bold blue]Ingesting"),
137
+ BarColumn(),
138
+ TaskProgressColumn(),
139
+ TextColumn("{task.completed}/{task.total} docs"),
140
+ console=render.console,
141
+ ) as progress:
142
+ task = progress.add_task("ingest", total=len(documents))
143
+ try:
144
+ for event in client.ingest(payload):
145
+ kind = event.get("type")
146
+ if kind == "progress":
147
+ progress.update(task, completed=event.get("processed", 0), total=event.get("total"))
148
+ elif kind == "error":
149
+ progress.stop()
150
+ render.error(event.get("detail", "ingest failed"))
151
+ raise typer.Exit(1)
152
+ elif kind == "result":
153
+ progress.update(task, completed=event.get("total", len(documents)))
154
+ render.batch_summary(event)
155
+ except DeytaClientError as exc:
156
+ progress.stop()
157
+ render.error(str(exc))
158
+ raise typer.Exit(1) from exc
159
+
160
+
161
+ # --------------------------------------------------------------------------- #
162
+ # `deyta memory …` commands
163
+ # --------------------------------------------------------------------------- #
164
+ _NS = typer.Option(None, "--namespace", "-n", help="Namespace name or id (default: active).")
165
+ _ENT = typer.Option(None, "--entity-types", help="Comma-separated; overrides config default.")
166
+ _REL = typer.Option(None, "--relationship-types", help="Comma-separated; overrides config default.")
167
+
168
+
169
+ @app.command("remember")
170
+ def memory_remember(
171
+ ctx: typer.Context,
172
+ text: str = typer.Argument(..., help="Text to remember."),
173
+ namespace: str = _NS,
174
+ title: str = typer.Option("", "--title"),
175
+ source: str = typer.Option("", "--source"),
176
+ entity_types: str = _ENT,
177
+ relationship_types: str = _REL,
178
+ ) -> None:
179
+ """Remember a single piece of text."""
180
+ run_remember(ctx, text, namespace, title, source, entity_types, relationship_types)
181
+
182
+
183
+ @app.command("recall")
184
+ def memory_recall(
185
+ ctx: typer.Context,
186
+ query: str = typer.Argument(..., help="Query text."),
187
+ namespace: str = _NS,
188
+ mode: str = typer.Option("hybrid", "--mode", help="hybrid|vector|graph|keyword|all"),
189
+ limit: int = typer.Option(10, "--limit", "-k"),
190
+ min_similarity: float = typer.Option(0.0, "--min-similarity"),
191
+ since: str = typer.Option(None, "--since", help="ISO timestamp lower bound."),
192
+ until: str = typer.Option(None, "--until", help="ISO timestamp upper bound."),
193
+ as_json: bool = typer.Option(False, "--json", help="Emit raw JSON."),
194
+ as_context: bool = typer.Option(False, "--context", help="Emit LLM-ready context text."),
195
+ ) -> None:
196
+ """Recall from memory (semantic / graph / keyword / hybrid)."""
197
+ run_recall(ctx, query, namespace, mode, limit, min_similarity, since, until, as_json, as_context)
198
+
199
+
200
+ @app.command("forget")
201
+ def memory_forget(
202
+ ctx: typer.Context,
203
+ document_id: str = typer.Argument(..., help="Document id to forget."),
204
+ namespace: str = _NS,
205
+ ) -> None:
206
+ """Forget a document (cascades through its provenance)."""
207
+ cfg = require_project(ctx)
208
+ nsid = resolve_namespace(cfg, namespace)
209
+ client = get_client(ctx)
210
+ try:
211
+ result = client.forget({"document_id": document_id, "namespace": nsid})
212
+ except DeytaClientError as exc:
213
+ render.error(str(exc))
214
+ raise typer.Exit(1) from exc
215
+ if result.get("forgotten"):
216
+ render.success(f"Forgot document {document_id}.")
217
+ else:
218
+ render.info(f"Document {document_id} not found.")
219
+
220
+
221
+ @app.command("ingest")
222
+ def memory_ingest(
223
+ ctx: typer.Context,
224
+ path: Path = typer.Argument(..., help="File or directory to ingest."),
225
+ namespace: str = _NS,
226
+ recursive: bool = typer.Option(True, "--recursive/--no-recursive"),
227
+ include: str = typer.Option(None, "--include", help="Glob (e.g. '**/*.md')."),
228
+ entity_types: str = _ENT,
229
+ relationship_types: str = _REL,
230
+ dry_run: bool = typer.Option(False, "--dry-run"),
231
+ ) -> None:
232
+ """Ingest files from a path (bulk remember)."""
233
+ run_ingest(ctx, path, namespace, recursive, include, entity_types, relationship_types, dry_run)
@@ -0,0 +1,117 @@
1
+ """`deyta namespace …` (alias `ns`) — manage namespaces.
2
+
3
+ Khora keys namespaces by UUID; friendly names are a CLI concern, stored as a
4
+ name->UUID map in deyta.toml (and echoed into Khora's config_overrides server-side).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from .. import render
12
+ from ..client import DeytaClientError
13
+ from ._common import get_client, require_project
14
+
15
+ app = typer.Typer(help="Manage namespaces.", no_args_is_help=True)
16
+
17
+
18
+ @app.command("create")
19
+ def ns_create(ctx: typer.Context, name: str = typer.Argument(..., help="Friendly name.")) -> None:
20
+ """Create a namespace and remember its name locally."""
21
+ cfg = require_project(ctx)
22
+ if name in cfg.namespaces:
23
+ render.error(f"Namespace {name!r} already exists locally.")
24
+ raise typer.Exit(1)
25
+ client = get_client(ctx)
26
+ try:
27
+ result = client.create_namespace(name)
28
+ except DeytaClientError as exc:
29
+ render.error(str(exc))
30
+ raise typer.Exit(1) from exc
31
+ nsid = result["namespace_id"]
32
+ cfg.add_namespace(name, nsid)
33
+ if cfg.active is None:
34
+ cfg.active = name
35
+ cfg.save()
36
+ render.success(f"Created namespace {name!r} [dim]({nsid})[/]")
37
+
38
+
39
+ @app.command("list")
40
+ def ns_list(ctx: typer.Context) -> None:
41
+ """List namespaces known to the server."""
42
+ cfg = require_project(ctx)
43
+ client = get_client(ctx)
44
+ try:
45
+ namespaces = client.list_namespaces()
46
+ except DeytaClientError as exc:
47
+ render.error(str(exc))
48
+ raise typer.Exit(1) from exc
49
+ # Backfill names from the local alias map for any the server didn't store.
50
+ id_to_name = {v: k for k, v in cfg.namespaces.items()}
51
+ for ns in namespaces:
52
+ ns["name"] = ns.get("name") or id_to_name.get(ns.get("namespace_id"))
53
+ render.namespaces_table(namespaces, cfg.active)
54
+
55
+
56
+ @app.command("get")
57
+ def ns_get(ctx: typer.Context, name_or_id: str = typer.Argument(...)) -> None:
58
+ """Show one namespace."""
59
+ cfg = require_project(ctx)
60
+ client = get_client(ctx)
61
+ nsid = cfg.resolve_namespace(name_or_id)
62
+ try:
63
+ ns = client.get_namespace(nsid)
64
+ except DeytaClientError as exc:
65
+ render.error(str(exc))
66
+ raise typer.Exit(1) from exc
67
+ render.console.print(ns)
68
+
69
+
70
+ @app.command("delete")
71
+ def ns_delete(
72
+ ctx: typer.Context,
73
+ name_or_id: str = typer.Argument(...),
74
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
75
+ ) -> None:
76
+ """Deactivate a namespace (Khora soft-delete) and drop its local alias."""
77
+ cfg = require_project(ctx)
78
+ if not yes:
79
+ import questionary
80
+
81
+ if not questionary.confirm(f"Delete namespace {name_or_id!r}?", default=False).ask():
82
+ raise typer.Exit(0)
83
+ client = get_client(ctx)
84
+ nsid = cfg.resolve_namespace(name_or_id)
85
+ try:
86
+ client.delete_namespace(nsid)
87
+ except DeytaClientError as exc:
88
+ render.error(str(exc))
89
+ raise typer.Exit(1) from exc
90
+ cfg.remove_namespace(name_or_id)
91
+ cfg.save()
92
+ render.success(f"Deleted namespace {name_or_id!r}.")
93
+
94
+
95
+ @app.command("use")
96
+ def ns_use(ctx: typer.Context, name: str = typer.Argument(...)) -> None:
97
+ """Set the active namespace (so commands don't need --namespace)."""
98
+ cfg = require_project(ctx)
99
+ if name not in cfg.namespaces:
100
+ # Not in the local alias map — the server may still know it (e.g. the
101
+ # namespace was created against a different deyta.toml). Look it up and
102
+ # backfill the alias so future resolution works offline.
103
+ client = get_client(ctx)
104
+ try:
105
+ namespaces = client.list_namespaces()
106
+ except DeytaClientError as exc:
107
+ render.error(str(exc))
108
+ raise typer.Exit(1) from exc
109
+ match = next((ns for ns in namespaces if ns.get("name") == name), None)
110
+ if match is None:
111
+ known = ", ".join(sorted(n for ns in namespaces if (n := ns.get("name")))) or "(none)"
112
+ render.error(f"Unknown namespace {name!r}. Known on server: {known}")
113
+ raise typer.Exit(1)
114
+ cfg.add_namespace(name, match["namespace_id"])
115
+ cfg.active = name
116
+ cfg.save()
117
+ render.success(f"Active namespace is now {name!r}.")
@@ -0,0 +1,102 @@
1
+ """Daemon + whole-stack lifecycle: serve / status / stop / up / down."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import typer
8
+
9
+ from .. import config, docker, render
10
+ from ..client import DeytaClient, DeytaClientError
11
+ from ..server import runner
12
+ from ._common import bring_up_datastores, get_host, require_project
13
+
14
+
15
+ def serve(
16
+ ctx: typer.Context,
17
+ port: int = typer.Option(None, "--port", "-p", help="Port to bind (default from deyta.toml)."),
18
+ detach: bool = typer.Option(False, "--detach", "-d", help="Run in the background."),
19
+ ) -> None:
20
+ """Start the Deyta daemon (FastAPI wrapping Khora)."""
21
+ cfg = require_project(ctx)
22
+ bind_port = port or cfg.port
23
+ if detach:
24
+ pid = runner.serve_detached(cfg, bind_port)
25
+ if _wait_until_up(bind_port):
26
+ render.success(
27
+ f"Deyta server started (pid {pid}) on http://localhost:{bind_port}"
28
+ )
29
+ else:
30
+ render.error("Server did not become healthy; check .deyta/server.log")
31
+ raise typer.Exit(1)
32
+ else:
33
+ render.info(f"Deyta server on http://localhost:{bind_port} (Ctrl-C to stop)")
34
+ runner.serve_foreground(cfg, bind_port)
35
+
36
+
37
+ def status(ctx: typer.Context) -> None:
38
+ """Show whether the daemon is running and how it's configured."""
39
+ client = DeytaClient(get_host(ctx))
40
+ try:
41
+ health = client.health()
42
+ except DeytaClientError as exc:
43
+ render.error(str(exc))
44
+ raise typer.Exit(1) from exc
45
+ render.health_panel(health, client.host)
46
+
47
+
48
+ def stop(ctx: typer.Context) -> None:
49
+ """Stop a detached daemon started with `deyta serve --detach`."""
50
+ cfg = require_project(ctx)
51
+ try:
52
+ stopped = runner.stop_detached(cfg)
53
+ except RuntimeError as exc:
54
+ render.error(str(exc))
55
+ raise typer.Exit(1) from exc
56
+ if stopped:
57
+ render.success("Deyta server stopped.")
58
+ else:
59
+ render.info("No detached Deyta server recorded for this project.")
60
+
61
+
62
+ def up(
63
+ ctx: typer.Context,
64
+ detach: bool = typer.Option(True, "--detach/--no-detach", "-d", help="Background the server."),
65
+ ) -> None:
66
+ """Bring up the whole local stack (datastores if postgres, then the server)."""
67
+ cfg = require_project(ctx)
68
+ if cfg.backend == config.BACKEND_POSTGRES:
69
+ bring_up_datastores()
70
+ render.info("Starting server…")
71
+ pid = runner.serve_detached(cfg, cfg.port)
72
+ if _wait_until_up(cfg.port):
73
+ render.success(f"Stack up (server pid {pid}) on http://localhost:{cfg.port}")
74
+ else:
75
+ render.error("Server did not become healthy; check .deyta/server.log")
76
+ raise typer.Exit(1)
77
+
78
+
79
+ def down(ctx: typer.Context) -> None:
80
+ """Tear down the whole local stack."""
81
+ cfg = require_project(ctx)
82
+ try:
83
+ stopped = runner.stop_detached(cfg)
84
+ except RuntimeError as exc:
85
+ render.error(str(exc))
86
+ raise typer.Exit(1) from exc
87
+ render.info("Server stopped." if stopped else "No detached server running.")
88
+ if cfg.backend == config.BACKEND_POSTGRES:
89
+ try:
90
+ docker.down()
91
+ render.info("Datastores stopped.")
92
+ except docker.DockerError as exc:
93
+ render.error(str(exc))
94
+
95
+
96
+ def _wait_until_up(port: int, attempts: int = 60) -> bool:
97
+ client = DeytaClient(f"http://localhost:{port}")
98
+ for _ in range(attempts):
99
+ if client.is_up():
100
+ return True
101
+ time.sleep(0.5)
102
+ return False
@@ -0,0 +1,66 @@
1
+ """`deyta update` — upgrade whichever of the CLI / Khora has a newer release."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+
7
+ import typer
8
+
9
+ from .. import render, versions
10
+
11
+
12
+ def update(
13
+ ctx: typer.Context,
14
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
15
+ dry_run: bool = typer.Option(
16
+ False, "--dry-run", help="Show what would be upgraded without running it."
17
+ ),
18
+ ) -> None:
19
+ """Upgrade the Deyta CLI and/or Khora to the latest published versions."""
20
+ with render.console.status("[bold]Checking PyPI for newer releases…[/]", spinner="dots"):
21
+ statuses = versions.gather(check_remote=True)
22
+ render.versions_report(statuses)
23
+
24
+ outdated = [s for s in statuses if s.outdated]
25
+ if not outdated:
26
+ render.success("Everything is already up to date.")
27
+ return
28
+
29
+ installer = versions.detect_installer()
30
+ if installer is versions.Installer.EDITABLE:
31
+ render.info(
32
+ "\nThis is an editable/dev install. Update it from the source checkout:\n"
33
+ " [bold]git pull && uv sync[/]"
34
+ )
35
+ return
36
+
37
+ commands = versions.upgrade_commands(installer, [s.name for s in outdated])
38
+ if not commands:
39
+ render.error(
40
+ "Couldn't determine how to upgrade this installation. "
41
+ "Upgrade `deyta-cli` (and `khora`) with your package manager manually."
42
+ )
43
+ raise typer.Exit(1)
44
+
45
+ names = " and ".join(s.label for s in outdated)
46
+ render.info(f"\nWill upgrade {names} via [bold]{installer.value}[/]:")
47
+ for cmd in commands:
48
+ render.info(f" [dim]$[/] {' '.join(cmd)}")
49
+
50
+ if dry_run:
51
+ return
52
+
53
+ if not yes:
54
+ import questionary
55
+
56
+ if not questionary.confirm("Proceed with the upgrade?", default=True).ask():
57
+ render.info("Aborted.")
58
+ return
59
+
60
+ for cmd in commands:
61
+ result = subprocess.run(cmd)
62
+ if result.returncode != 0:
63
+ render.error(f"Upgrade command failed: {' '.join(cmd)}")
64
+ raise typer.Exit(result.returncode)
65
+
66
+ render.success(f"Upgraded {names}. Run `deyta version` to confirm.")
@@ -0,0 +1,36 @@
1
+ """`deyta version` — show installed CLI/Khora versions and flag updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .. import render, versions
8
+
9
+
10
+ def version(
11
+ ctx: typer.Context,
12
+ check: bool = typer.Option(
13
+ True,
14
+ "--check/--no-check",
15
+ help="Check PyPI for newer releases (network). Use --no-check to stay offline.",
16
+ ),
17
+ ) -> None:
18
+ """Show installed Deyta CLI and Khora versions, and whether updates exist."""
19
+ if check:
20
+ with render.console.status("[bold]Checking PyPI for newer releases…[/]", spinner="dots"):
21
+ statuses = versions.gather(check_remote=True)
22
+ else:
23
+ statuses = versions.gather(check_remote=False)
24
+
25
+ render.banner()
26
+ render.versions_report(statuses)
27
+
28
+ outdated = [s for s in statuses if s.outdated]
29
+ if outdated:
30
+ names = " and ".join(s.label for s in outdated)
31
+ render.info(
32
+ f"\n[yellow]Update available[/] for {names}. "
33
+ f"Run [bold]deyta update[/] to upgrade."
34
+ )
35
+ elif check:
36
+ render.success("Everything is up to date.")