cortexdb-cli 0.2.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,141 @@
1
+ """cortexdb understanding — GET /v1/understanding* + POST /v1/understanding/synthesize."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from cortexdb_cli.client import get_client
8
+ from cortexdb_cli.errors import handle_errors
9
+ from cortexdb_cli.output import (
10
+ console,
11
+ escape,
12
+ is_json_mode,
13
+ print_json,
14
+ spinner,
15
+ )
16
+
17
+
18
+ @click.group("understanding")
19
+ def understanding_group() -> None:
20
+ """Understanding — LLM-synthesized concepts and themes across a scope."""
21
+
22
+
23
+ def _scope_or_die(cfg: dict, scope: str | None) -> str:
24
+ s = scope or cfg.get("scope") or cfg.get("actor")
25
+ if not s:
26
+ raise click.UsageError(
27
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
28
+ )
29
+ return s
30
+
31
+
32
+ @understanding_group.command("list")
33
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
34
+ @click.option("--topic", help="Filter by topic name")
35
+ @click.option("--limit", "-l", default=50, show_default=True)
36
+ @click.pass_context
37
+ @handle_errors
38
+ def understanding_list(
39
+ ctx: click.Context, scope: str | None, topic: str | None, limit: int
40
+ ) -> None:
41
+ """List synthesized concepts."""
42
+ cfg = ctx.obj
43
+ s = _scope_or_die(cfg, scope)
44
+ params: dict[str, str | int] = {"scope": s, "limit": limit}
45
+ if topic:
46
+ params["topic"] = topic
47
+ with get_client(cfg) as client:
48
+ if not is_json_mode(cfg):
49
+ with spinner("Loading concepts..."):
50
+ resp = client.get("/v1/understanding", params=params)
51
+ else:
52
+ resp = client.get("/v1/understanding", params=params)
53
+ resp.raise_for_status()
54
+
55
+ data = resp.json()
56
+ if is_json_mode(cfg):
57
+ print_json(data)
58
+ return
59
+
60
+ items = data.get("items", data) if isinstance(data, dict) else data
61
+ if not items:
62
+ console.print(
63
+ "[dim]No concepts synthesised yet. Try [bold]cortexdb understanding synthesize[/bold] "
64
+ "to trigger a pass.[/dim]"
65
+ )
66
+ return
67
+
68
+ from rich.table import Table
69
+
70
+ table = Table(title="[header]Understanding[/header]", border_style="blue")
71
+ table.add_column("Concept", style="bold", max_width=24)
72
+ table.add_column("Topic", style="magenta", max_width=20)
73
+ table.add_column("Summary", max_width=60)
74
+ table.add_column("Conf", justify="right", width=6)
75
+ for c in items:
76
+ table.add_row(
77
+ escape(str(c.get("name") or c.get("concept_id", ""))[:24]),
78
+ escape(str(c.get("topic", ""))),
79
+ escape(str(c.get("summary", ""))[:120]),
80
+ f"{(c.get('confidence') or 0):.0%}",
81
+ )
82
+ console.print(table)
83
+ if isinstance(data, dict) and data.get("_partial"):
84
+ console.print(
85
+ "[dim]_partial: true — the synthesizer is still catching up on this scope.[/dim]"
86
+ )
87
+
88
+
89
+ @understanding_group.command("coverage")
90
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
91
+ @click.pass_context
92
+ @handle_errors
93
+ def understanding_coverage(ctx: click.Context, scope: str | None) -> None:
94
+ """Per-topic synthesis coverage and confidence."""
95
+ cfg = ctx.obj
96
+ s = _scope_or_die(cfg, scope)
97
+ with get_client(cfg) as client:
98
+ if not is_json_mode(cfg):
99
+ with spinner("Loading coverage..."):
100
+ resp = client.get("/v1/understanding/coverage", params={"scope": s})
101
+ else:
102
+ resp = client.get("/v1/understanding/coverage", params={"scope": s})
103
+ resp.raise_for_status()
104
+ print_json(resp.json())
105
+
106
+
107
+ @understanding_group.command("synthesize")
108
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
109
+ @click.option("--topic", "topics", multiple=True, help="Restrict to topic(s); repeat for multiple.")
110
+ @click.option("--force", is_flag=True, help="Force re-synthesis even if already current.")
111
+ @click.pass_context
112
+ @handle_errors
113
+ def understanding_synthesize(
114
+ ctx: click.Context, scope: str | None, topics: tuple[str, ...], force: bool
115
+ ) -> None:
116
+ """Trigger an on-demand synthesis pass.
117
+
118
+ Calls an LLM (Claude Opus 4.6 by default) — counts against the
119
+ tenant's LLM quota. Capability `understanding.synthesize` required.
120
+ The free tier denies this; paid tiers allow it.
121
+
122
+ cortexdb understanding synthesize --topic sales_process
123
+ """
124
+ cfg = ctx.obj
125
+ s = _scope_or_die(cfg, scope)
126
+ body: dict[str, object] = {"scope": s, "force": force}
127
+ if topics:
128
+ body["topics"] = list(topics)
129
+ with get_client(cfg) as client:
130
+ resp = client.post("/v1/understanding/synthesize", json=body)
131
+ resp.raise_for_status()
132
+ data = resp.json()
133
+ if is_json_mode(cfg):
134
+ print_json(data)
135
+ else:
136
+ console.print(
137
+ f"[success]Synthesis scheduled[/success] — job [bold]{data.get('job_id', '?')}[/bold]"
138
+ )
139
+ stream = data.get("lifecycle_stream")
140
+ if stream:
141
+ console.print(f"[dim]Watch: {escape(stream)}[/dim]")
cortexdb_cli/config.py ADDED
@@ -0,0 +1,113 @@
1
+ """Configuration management for CortexDB CLI.
2
+
3
+ Two files live under `~/.cortexdb/`:
4
+ - `config.toml` — human-editable: endpoint, profile names.
5
+ - `state.json` — machine-managed: PASETO token, actor, scope, expiry
6
+ (written by `cortexdb init`; never edited by hand).
7
+
8
+ `load_config` merges everything together so commands see one dict:
9
+
10
+ endpoint ← env CORTEXDB_URL > config.toml > default
11
+ api_key ← env CORTEXDB_API_KEY > state.json[token] > config.toml
12
+ actor ← env CORTEXDB_ACTOR > state.json[user_id] > config.toml
13
+ scope ← env CORTEXDB_SCOPE > state.json[scope] > config.toml > actor
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ if sys.version_info >= (3, 11):
24
+ import tomllib
25
+ else:
26
+ import tomli as tomllib
27
+
28
+ import tomli_w
29
+
30
+ DEFAULT_ENDPOINT = "https://api-v1.cortexdb.ai"
31
+
32
+
33
+ def config_dir() -> Path:
34
+ """Return the CortexDB config directory, creating it if needed."""
35
+ d = Path.home() / ".cortexdb"
36
+ d.mkdir(parents=True, exist_ok=True)
37
+ return d
38
+
39
+
40
+ def config_path() -> Path:
41
+ return config_dir() / "config.toml"
42
+
43
+
44
+ def history_path() -> Path:
45
+ return config_dir() / "history"
46
+
47
+
48
+ def load_config(profile: str = "default") -> dict[str, Any]:
49
+ """Load merged config: TOML file + state.json + env vars."""
50
+ cfg: dict[str, Any] = {}
51
+ path = config_path()
52
+ if path.exists():
53
+ with open(path, "rb") as f:
54
+ data = tomllib.load(f)
55
+ if profile != "default" and profile in data.get("profiles", {}):
56
+ cfg = dict(data["profiles"][profile])
57
+ elif "default" in data:
58
+ cfg = dict(data["default"])
59
+ else:
60
+ cfg = {k: v for k, v in data.items() if k != "profiles"}
61
+
62
+ # Merge persisted signup state.
63
+ # Import here to avoid a circular import (state.py imports config_dir).
64
+ from cortexdb_cli.state import load_state
65
+
66
+ st = load_state()
67
+ if st.get("token") and not cfg.get("api_key"):
68
+ cfg["api_key"] = st["token"]
69
+ if st.get("user_id") and not cfg.get("actor"):
70
+ cfg["actor"] = st["user_id"]
71
+ if st.get("scope") and not cfg.get("scope"):
72
+ cfg["scope"] = st["scope"]
73
+ if st.get("expires_at") and not cfg.get("expires_at"):
74
+ cfg["expires_at"] = st["expires_at"]
75
+
76
+ # Env vars win.
77
+ if os.environ.get("CORTEXDB_API_KEY"):
78
+ cfg["api_key"] = os.environ["CORTEXDB_API_KEY"]
79
+ if os.environ.get("CORTEXDB_URL"):
80
+ cfg["endpoint"] = os.environ["CORTEXDB_URL"]
81
+ if os.environ.get("CORTEXDB_ENDPOINT"):
82
+ cfg["endpoint"] = os.environ["CORTEXDB_ENDPOINT"]
83
+ if os.environ.get("CORTEXDB_ACTOR"):
84
+ cfg["actor"] = os.environ["CORTEXDB_ACTOR"]
85
+ if os.environ.get("CORTEXDB_SCOPE"):
86
+ cfg["scope"] = os.environ["CORTEXDB_SCOPE"]
87
+
88
+ cfg.setdefault("endpoint", DEFAULT_ENDPOINT)
89
+ # Scope defaults to actor when not explicitly set.
90
+ if cfg.get("actor") and not cfg.get("scope"):
91
+ cfg["scope"] = cfg["actor"]
92
+ return cfg
93
+
94
+
95
+ def save_config(profile: str, data: dict[str, Any]) -> None:
96
+ """Save the human-editable TOML config for a profile."""
97
+ path = config_path()
98
+ existing: dict[str, Any] = {}
99
+ if path.exists():
100
+ with open(path, "rb") as f:
101
+ existing = tomllib.load(f)
102
+
103
+ if profile == "default":
104
+ existing["default"] = data
105
+ else:
106
+ existing.setdefault("profiles", {})[profile] = data
107
+
108
+ with open(path, "wb") as f:
109
+ tomli_w.dump(existing, f)
110
+
111
+
112
+ def get_value(cfg: dict[str, Any], key: str, default: Any = None) -> Any:
113
+ return cfg.get(key, default)
cortexdb_cli/errors.py ADDED
@@ -0,0 +1,96 @@
1
+ """Error handling for CortexDB CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import sys
7
+ from typing import Any, Callable
8
+
9
+ import click
10
+ import httpx
11
+
12
+ from cortexdb_cli.output import err_console
13
+
14
+
15
+ def escape_safe(s: Any) -> str:
16
+ """Rich-safe escape used in the error decorator (avoids circular imports)."""
17
+ from rich.markup import escape
18
+
19
+ return escape(str(s))
20
+
21
+
22
+ def handle_errors(fn: Callable[..., Any]) -> Callable[..., Any]:
23
+ """Decorator that catches common errors and prints friendly messages."""
24
+
25
+ @functools.wraps(fn)
26
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
27
+ try:
28
+ return fn(*args, **kwargs)
29
+ except httpx.HTTPStatusError as exc:
30
+ status = exc.response.status_code
31
+ body: dict[str, Any] = {}
32
+ try:
33
+ body = exc.response.json()
34
+ except Exception:
35
+ body = {}
36
+ code = body.get("error_code") or body.get("code") or ""
37
+ msg = body.get("message") or body.get("error") or exc.response.text[:200]
38
+
39
+ if status == 401:
40
+ if code in ("TOKEN_EXPIRED", "INVALID_TOKEN_SIGNATURE", "MISSING_TOKEN"):
41
+ err_console.print(
42
+ f"[error]Token rejected ({code}).[/error] "
43
+ "Run [bold]cortexdb init[/bold] to mint a fresh one."
44
+ )
45
+ elif code == "actor_mismatch":
46
+ err_console.print(
47
+ "[error]Actor mismatch.[/error] The X-Cortex-Actor header "
48
+ "doesn't match the token's subject. Re-run "
49
+ "[bold]cortexdb init[/bold] to re-bind."
50
+ )
51
+ else:
52
+ err_console.print(
53
+ f"[error]Authentication failed:[/error] {msg}\n"
54
+ "Run [bold]cortexdb init[/bold] to set up credentials."
55
+ )
56
+ elif status == 403:
57
+ # The v1 surface always cites the missing capability.
58
+ details = body.get("details") or {}
59
+ cap = details.get("capability") or ""
60
+ tier = details.get("decided_by_tier") or ""
61
+ if cap:
62
+ err_console.print(
63
+ f"[error]Denied by {escape_safe(tier)}:[/error] "
64
+ f"missing capability [bold]{escape_safe(cap)}[/bold]."
65
+ )
66
+ if cap == "diagnostics.read":
67
+ err_console.print(
68
+ "[dim]Hint: pass [bold]--diagnostics none[/bold] (free tier).[/dim]"
69
+ )
70
+ else:
71
+ err_console.print(f"[error]Forbidden:[/error] {msg}")
72
+ elif status == 429:
73
+ retry = exc.response.headers.get("Retry-After", "?")
74
+ err_console.print(f"[error]Rate limited.[/error] Retry after {retry}s.")
75
+ elif status == 402:
76
+ err_console.print(f"[warning]Feature not available on your tier.[/warning] {msg}")
77
+ else:
78
+ err_console.print(f"[error]API error (HTTP {status}, {code}):[/error] {msg}")
79
+ sys.exit(1)
80
+ except httpx.ConnectError:
81
+ err_console.print(
82
+ "[error]Cannot connect to CortexDB.[/error] "
83
+ "Check your endpoint and network."
84
+ )
85
+ sys.exit(2)
86
+ except httpx.TimeoutException:
87
+ err_console.print("[error]Request timed out.[/error]")
88
+ sys.exit(2)
89
+ except click.Abort:
90
+ err_console.print("\n[dim]Aborted.[/dim]")
91
+ sys.exit(130)
92
+ except KeyboardInterrupt:
93
+ err_console.print("\n[dim]Interrupted.[/dim]")
94
+ sys.exit(130)
95
+
96
+ return wrapper
cortexdb_cli/main.py ADDED
@@ -0,0 +1,140 @@
1
+ """CortexDB CLI — the v1 memory API at your fingertips.
2
+
3
+ Usage:
4
+ cortexdb init anonymous signup; persist token + actor
5
+ cortexdb experience "..." store a memory (POST /v1/experience)
6
+ cortexdb experience bulk file.jsonl bulk ingest (POST /v1/experience/bulk)
7
+ cortexdb recall "..." recall context pack (POST /v1/recall)
8
+ cortexdb search "..." granular event hits (POST /v1/recall, granular)
9
+ cortexdb answer "..." recall + LLM answer (POST /v1/answer)
10
+ cortexdb forget --memory-id ... delete with audit (POST /v1/forget)
11
+ cortexdb episodes {list,get,delete}
12
+ cortexdb entities {list,get,link}
13
+ cortexdb facts {list,timeline}
14
+ cortexdb beliefs {list,why,build}
15
+ cortexdb understanding {list,coverage,synthesize}
16
+ cortexdb scopes {list,register,members}
17
+ cortexdb auth {whoami,signup,tokens,revoke}
18
+ cortexdb policy {effective,deployment}
19
+ cortexdb admin {health,usage,layers}
20
+ cortexdb import data.json
21
+ cortexdb export -o backup.json
22
+ cortexdb config {show,set}
23
+ cortexdb interactive REPL
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import sys
29
+
30
+ import click
31
+
32
+ from cortexdb_cli import __version__
33
+ from cortexdb_cli.client import DEFAULT_ENDPOINT
34
+ from cortexdb_cli.config import load_config
35
+
36
+ try:
37
+ import rich_click
38
+
39
+ rich_click.rich_click.SHOW_ARGUMENTS = True
40
+ rich_click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
41
+ rich_click.rich_click.STYLE_COMMANDS_TABLE_COLUMN_WIDTH_RATIO = (1, 2)
42
+ rich_click.rich_click.USE_MARKDOWN = True
43
+ except ImportError:
44
+ pass
45
+
46
+
47
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
48
+
49
+
50
+ @click.group(invoke_without_command=True, context_settings=CONTEXT_SETTINGS)
51
+ @click.option("--api-key", envvar="CORTEXDB_API_KEY", help="PASETO bearer token (or env CORTEXDB_API_KEY)")
52
+ @click.option("--endpoint", envvar="CORTEXDB_ENDPOINT", help="API base URL (default api-v1.cortexdb.ai)")
53
+ @click.option("--actor", envvar="CORTEXDB_ACTOR", help="X-Cortex-Actor (or env CORTEXDB_ACTOR)")
54
+ @click.option("--scope", envvar="CORTEXDB_SCOPE", help="Default scope path (or env CORTEXDB_SCOPE)")
55
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON (auto-enabled when piped)")
56
+ @click.option("--profile", "-p", default="default", help="Config profile")
57
+ @click.version_option(version=__version__, prog_name="cortexdb")
58
+ @click.pass_context
59
+ def cli(
60
+ ctx: click.Context,
61
+ api_key: str | None,
62
+ endpoint: str | None,
63
+ actor: str | None,
64
+ scope: str | None,
65
+ json_output: bool,
66
+ profile: str,
67
+ ) -> None:
68
+ """CortexDB — long-term memory layer for AI systems.
69
+
70
+ Run without a command to enter the interactive REPL.
71
+ """
72
+ ctx.ensure_object(dict)
73
+
74
+ cfg = load_config(profile)
75
+ ctx.obj["api_key"] = api_key or cfg.get("api_key", "")
76
+ ctx.obj["endpoint"] = endpoint or cfg.get("endpoint") or DEFAULT_ENDPOINT
77
+ ctx.obj["actor"] = actor or cfg.get("actor", "")
78
+ ctx.obj["scope"] = scope or cfg.get("scope") or ctx.obj["actor"]
79
+ ctx.obj["expires_at"] = cfg.get("expires_at")
80
+ ctx.obj["json_output"] = json_output or not sys.stdout.isatty()
81
+ ctx.obj["profile"] = profile
82
+
83
+ # No subcommand → interactive REPL.
84
+ if ctx.invoked_subcommand is None:
85
+ if not sys.stdout.isatty():
86
+ click.echo(ctx.get_help())
87
+ return
88
+ from cortexdb_cli.commands.interactive import repl
89
+
90
+ repl(ctx)
91
+
92
+
93
+ # --- Register commands ---
94
+
95
+ from cortexdb_cli.commands.admin import admin_group
96
+ from cortexdb_cli.commands.answer import answer_cmd
97
+ from cortexdb_cli.commands.auth import auth_group
98
+ from cortexdb_cli.commands.beliefs import beliefs_group
99
+ from cortexdb_cli.commands.config_cmd import config_group
100
+ from cortexdb_cli.commands.entities import entities_group
101
+ from cortexdb_cli.commands.episodes import episodes_group
102
+ from cortexdb_cli.commands.experience import experience_cmd, remember_alias
103
+ from cortexdb_cli.commands.export_cmd import export_cmd
104
+ from cortexdb_cli.commands.facts import facts_group
105
+ from cortexdb_cli.commands.forget import forget_cmd
106
+ from cortexdb_cli.commands.import_cmd import import_cmd
107
+ from cortexdb_cli.commands.init import init_cmd
108
+ from cortexdb_cli.commands.policy import policy_group
109
+ from cortexdb_cli.commands.recall import recall_cmd
110
+ from cortexdb_cli.commands.scopes import scopes_group
111
+ from cortexdb_cli.commands.search import search_cmd
112
+ from cortexdb_cli.commands.understanding import understanding_group
113
+
114
+ cli.add_command(init_cmd)
115
+ cli.add_command(experience_cmd)
116
+ cli.add_command(remember_alias) # hidden alias for muscle memory
117
+ cli.add_command(recall_cmd)
118
+ cli.add_command(search_cmd)
119
+ cli.add_command(answer_cmd)
120
+ cli.add_command(forget_cmd)
121
+ cli.add_command(episodes_group)
122
+ cli.add_command(entities_group)
123
+ cli.add_command(facts_group)
124
+ cli.add_command(beliefs_group)
125
+ cli.add_command(understanding_group)
126
+ cli.add_command(scopes_group)
127
+ cli.add_command(auth_group)
128
+ cli.add_command(policy_group)
129
+ cli.add_command(admin_group)
130
+ cli.add_command(import_cmd)
131
+ cli.add_command(export_cmd)
132
+ cli.add_command(config_group)
133
+
134
+
135
+ def main() -> None:
136
+ cli()
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()