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,254 @@
1
+ """cortexdb interactive REPL — chat with your memory.
2
+
3
+ Wraps the same v1 endpoints the discrete commands hit, so anything you
4
+ can do via subcommand you can do here, plus natural-language input routed
5
+ through /v1/recall.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ from cortexdb_cli.client import get_client
15
+ from cortexdb_cli.output import console, print_recall, print_search, print_error
16
+
17
+
18
+ BANNER = """
19
+ [bold magenta] ____ _ ____ ____ [/bold magenta]
20
+ [bold magenta] / ___|___ _ __| |_ _____ _| _ \\| __ ) [/bold magenta]
21
+ [bold magenta]| | / _ \\| '__| __/ _ \\ \\/ / | | | _ \\ [/bold magenta]
22
+ [bold magenta]| |__| (_) | | | || __/> <| |_| | |_) |[/bold magenta]
23
+ [bold magenta] \\____\\___/|_| \\__\\___/_/\\_\\____/|____/ [/bold magenta]
24
+
25
+ [dim]Type a question to recall from memory.[/dim]
26
+ [dim]Commands: /remember /recall /search /forget /health /usage /help /quit[/dim]
27
+ """
28
+
29
+ HELP_TEXT = """
30
+ [bold]Commands:[/bold]
31
+ [cyan]/remember TEXT[/cyan] Store a new memory
32
+ [cyan]/recall QUERY[/cyan] Recall a stratified context pack
33
+ [cyan]/search QUERY[/cyan] Granular search — individual event hits
34
+ [cyan]/forget QUERY[/cyan] Forget by query (prompts for reason)
35
+ [cyan]/health[/cyan] Identity + reachability via /v1/auth/whoami
36
+ [cyan]/usage[/cyan] Tier, caps, rate-limit headers
37
+ [cyan]/help[/cyan] This help
38
+ [cyan]/quit[/cyan] Exit
39
+
40
+ [bold]Natural Language:[/bold]
41
+ Just type a question to recall from memory.
42
+ e.g., "what happened with the deploy?"
43
+ """
44
+
45
+
46
+ def _now_iso() -> str:
47
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
48
+
49
+
50
+ def _idem(content: str, scope: str) -> str:
51
+ return "cli-repl:" + hashlib.sha256(
52
+ f"{scope}|{content}".encode("utf-8")
53
+ ).hexdigest()[:16]
54
+
55
+
56
+ def _experience_body(scope: str, text: str) -> dict[str, Any]:
57
+ return {
58
+ "scope": scope,
59
+ "modality": "conversation",
60
+ "content": {"kind": "message", "role": "user", "text": text},
61
+ "context": {"observed_at": _now_iso()},
62
+ "idempotency_key": _idem(text, scope),
63
+ }
64
+
65
+
66
+ def repl(ctx: Any) -> None:
67
+ """Run the interactive REPL."""
68
+ try:
69
+ from prompt_toolkit import PromptSession
70
+ from prompt_toolkit.history import FileHistory
71
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
72
+ from prompt_toolkit.formatted_text import HTML
73
+ except ImportError:
74
+ console.print("[error]prompt-toolkit is required for interactive mode.[/error]")
75
+ console.print("Install it: [bold]pip install prompt-toolkit[/bold]")
76
+ return
77
+
78
+ from cortexdb_cli.config import history_path
79
+ from cortexdb_cli.output import (
80
+ print_health,
81
+ print_usage,
82
+ print_remember,
83
+ print_forget,
84
+ spinner,
85
+ )
86
+
87
+ cfg = ctx.obj
88
+ scope = cfg.get("scope") or cfg.get("actor")
89
+ if not scope:
90
+ console.print(
91
+ "[warning]No scope configured. Run [bold]cortexdb init[/bold] first.[/warning]"
92
+ )
93
+ return
94
+
95
+ console.print(BANNER)
96
+
97
+ session: PromptSession[str] = PromptSession(
98
+ history=FileHistory(str(history_path())),
99
+ auto_suggest=AutoSuggestFromHistory(),
100
+ )
101
+
102
+ with get_client(cfg) as client:
103
+ while True:
104
+ try:
105
+ text = session.prompt(
106
+ HTML("<ansicyan><b>cortexdb</b></ansicyan><b>&gt;</b> "),
107
+ ).strip()
108
+ except (EOFError, KeyboardInterrupt):
109
+ console.print("\n[dim]Goodbye.[/dim]")
110
+ break
111
+
112
+ if not text:
113
+ continue
114
+
115
+ if text.lower() in ("/quit", "/exit", "/q"):
116
+ console.print("[dim]Goodbye.[/dim]")
117
+ break
118
+
119
+ elif text.lower() == "/help":
120
+ console.print(HELP_TEXT)
121
+
122
+ elif text.lower() == "/health":
123
+ try:
124
+ with spinner("Checking..."):
125
+ resp = client.get("/v1/auth/whoami")
126
+ resp.raise_for_status()
127
+ body = resp.json()
128
+ body.setdefault("status", "ok")
129
+ print_health(body, cfg)
130
+ except Exception as e:
131
+ print_error(str(e))
132
+
133
+ elif text.lower() == "/usage":
134
+ try:
135
+ with spinner("Loading usage..."):
136
+ resp = client.get("/v1/auth/whoami")
137
+ resp.raise_for_status()
138
+ body = resp.json()
139
+ for k in (
140
+ "X-RateLimit-Limit",
141
+ "X-RateLimit-Reset",
142
+ "X-Cortex-Token-Expires-In",
143
+ "X-Cortex-Token-Expires-At",
144
+ ):
145
+ if k in resp.headers:
146
+ body[k] = resp.headers[k]
147
+ print_usage(body, cfg)
148
+ except Exception as e:
149
+ print_error(str(e))
150
+
151
+ elif text.lower().startswith("/remember "):
152
+ content = text[10:].strip()
153
+ if not content:
154
+ console.print("[warning]Usage: /remember TEXT[/warning]")
155
+ continue
156
+ try:
157
+ with spinner("Storing..."):
158
+ resp = client.post("/v1/experience", json=_experience_body(scope, content))
159
+ resp.raise_for_status()
160
+ print_remember(resp.json(), cfg)
161
+ except Exception as e:
162
+ print_error(str(e))
163
+
164
+ elif text.lower().startswith("/recall "):
165
+ query = text[8:].strip()
166
+ if not query:
167
+ console.print("[warning]Usage: /recall QUERY[/warning]")
168
+ continue
169
+ try:
170
+ with spinner("Recalling..."):
171
+ resp = client.post(
172
+ "/v1/recall",
173
+ json={
174
+ "scope": scope,
175
+ "view": "holistic",
176
+ "query": query,
177
+ "diagnostics": "none",
178
+ },
179
+ )
180
+ resp.raise_for_status()
181
+ print_recall(resp.json(), cfg)
182
+ except Exception as e:
183
+ print_error(str(e))
184
+
185
+ elif text.lower().startswith("/search "):
186
+ query = text[8:].strip()
187
+ if not query:
188
+ console.print("[warning]Usage: /search QUERY[/warning]")
189
+ continue
190
+ try:
191
+ with spinner("Searching..."):
192
+ resp = client.post(
193
+ "/v1/recall",
194
+ json={
195
+ "scope": scope,
196
+ "view": "granular",
197
+ "query": query,
198
+ "diagnostics": "none",
199
+ "budgets": {"max_events": 10},
200
+ },
201
+ )
202
+ resp.raise_for_status()
203
+ print_search(resp.json(), cfg)
204
+ except Exception as e:
205
+ print_error(str(e))
206
+
207
+ elif text.lower().startswith("/forget "):
208
+ query = text[8:].strip()
209
+ if not query:
210
+ console.print("[warning]Usage: /forget QUERY[/warning]")
211
+ continue
212
+ from prompt_toolkit import prompt as pt_prompt
213
+
214
+ reason = pt_prompt("Reason: ")
215
+ if not reason.strip():
216
+ console.print("[warning]A reason is required.[/warning]")
217
+ continue
218
+ try:
219
+ with spinner("Forgetting..."):
220
+ resp = client.post(
221
+ "/v1/forget",
222
+ json={
223
+ "scope": scope,
224
+ "selector": {"query": query},
225
+ "cascade": "derived_only",
226
+ "audit_note": reason.strip(),
227
+ },
228
+ )
229
+ resp.raise_for_status()
230
+ print_forget(resp.json(), cfg)
231
+ except Exception as e:
232
+ print_error(str(e))
233
+
234
+ elif text.startswith("/"):
235
+ console.print(f"[warning]Unknown command: {text.split()[0]}[/warning]")
236
+ console.print("[dim]Type /help for available commands.[/dim]")
237
+
238
+ else:
239
+ # Natural language -> /v1/recall holistic
240
+ try:
241
+ with spinner("Thinking..."):
242
+ resp = client.post(
243
+ "/v1/recall",
244
+ json={
245
+ "scope": scope,
246
+ "view": "holistic",
247
+ "query": text,
248
+ "diagnostics": "none",
249
+ },
250
+ )
251
+ resp.raise_for_status()
252
+ print_recall(resp.json(), cfg)
253
+ except Exception as e:
254
+ print_error(str(e))
@@ -0,0 +1,99 @@
1
+ """cortexdb policy — /v1/policy/effective + /v1/policy/deployment."""
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("policy")
19
+ def policy_group() -> None:
20
+ """Policy introspection — what is this actor allowed to do, and where?"""
21
+
22
+
23
+ @policy_group.command("effective")
24
+ @click.option("--actor", "actor_arg", help="Actor to query (defaults to your actor).")
25
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope).")
26
+ @click.pass_context
27
+ @handle_errors
28
+ def policy_effective(
29
+ ctx: click.Context, actor_arg: str | None, scope: str | None
30
+ ) -> None:
31
+ """GET /v1/policy/effective — full allow/deny matrix.
32
+
33
+ Shows every capability and which policy tier (deployment / tenant /
34
+ scope / actor) decided each one. The authoritative answer to "what
35
+ can <actor> do at <scope> right now."
36
+
37
+ cortexdb policy effective --actor user:alice --scope org:acme/user:alice
38
+ """
39
+ cfg = ctx.obj
40
+ actor = actor_arg or cfg.get("actor")
41
+ target_scope = scope or cfg.get("scope") or actor
42
+ if not actor or not target_scope:
43
+ raise click.UsageError(
44
+ "Need both --actor and --scope (or run `cortexdb init` so defaults are set)."
45
+ )
46
+ params = {"actor": actor, "scope": target_scope}
47
+ with get_client(cfg) as client:
48
+ if not is_json_mode(cfg):
49
+ with spinner("Loading policy..."):
50
+ resp = client.get("/v1/policy/effective", params=params)
51
+ else:
52
+ resp = client.get("/v1/policy/effective", 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
+ caps = data.get("capabilities") or data.get("effective") or {}
61
+ if isinstance(caps, list):
62
+ # Server returned a flat allow list.
63
+ from rich.table import Table
64
+
65
+ table = Table(title="[header]Effective capabilities[/header]", border_style="cyan")
66
+ table.add_column("Capability", style="bold")
67
+ for c in caps:
68
+ table.add_row(escape(str(c)))
69
+ console.print(table)
70
+ return
71
+
72
+ from rich.table import Table
73
+
74
+ table = Table(title="[header]Effective policy[/header]", border_style="cyan")
75
+ table.add_column("Capability", style="bold")
76
+ table.add_column("Decision")
77
+ table.add_column("Decided by", style="dim")
78
+ for cap, detail in caps.items():
79
+ decision = detail.get("decision") if isinstance(detail, dict) else str(detail)
80
+ tier = detail.get("decided_by_tier", "") if isinstance(detail, dict) else ""
81
+ style = "green" if decision == "allow" else "red"
82
+ table.add_row(
83
+ escape(str(cap)),
84
+ f"[{style}]{escape(str(decision))}[/{style}]",
85
+ escape(str(tier)),
86
+ )
87
+ console.print(table)
88
+
89
+
90
+ @policy_group.command("deployment")
91
+ @click.pass_context
92
+ @handle_errors
93
+ def policy_deployment(ctx: click.Context) -> None:
94
+ """GET /v1/policy/deployment — show this deployment's preset + floor."""
95
+ cfg = ctx.obj
96
+ with get_client(cfg) as client:
97
+ resp = client.get("/v1/policy/deployment")
98
+ resp.raise_for_status()
99
+ print_json(resp.json())
@@ -0,0 +1,93 @@
1
+ """cortexdb recall — POST /v1/recall."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from cortexdb_cli.client import get_client
10
+ from cortexdb_cli.errors import handle_errors
11
+ from cortexdb_cli.output import is_json_mode, print_recall, spinner
12
+
13
+
14
+ @click.command("recall")
15
+ @click.argument("query")
16
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
17
+ @click.option(
18
+ "--view",
19
+ type=click.Choice(["raw", "granular", "structured", "holistic", "descend"]),
20
+ default="holistic",
21
+ show_default=True,
22
+ help="Pack shape — 'holistic' merges layers; 'granular' returns single-scope events.",
23
+ )
24
+ @click.option(
25
+ "--include",
26
+ multiple=True,
27
+ type=click.Choice(["events", "episodes", "facts", "beliefs", "understanding"]),
28
+ help="Layers to fill (repeatable). Default: facts + beliefs + episodes.",
29
+ )
30
+ @click.option(
31
+ "--temporal",
32
+ default=None,
33
+ help='Natural-language time window, e.g. "last 30 days".',
34
+ )
35
+ @click.option(
36
+ "--max-tokens",
37
+ type=int,
38
+ default=None,
39
+ help="Budget for the returned context_block (string).",
40
+ )
41
+ @click.option(
42
+ "--diagnostics",
43
+ type=click.Choice(["none", "summary", "full"]),
44
+ default="none",
45
+ show_default=True,
46
+ help='"none" works on every tier. "summary"/"full" need the diagnostics.read capability.',
47
+ )
48
+ @click.pass_context
49
+ @handle_errors
50
+ def recall_cmd(
51
+ ctx: click.Context,
52
+ query: str,
53
+ scope: str | None,
54
+ view: str,
55
+ include: tuple[str, ...],
56
+ temporal: str | None,
57
+ max_tokens: int | None,
58
+ diagnostics: str,
59
+ ) -> None:
60
+ """Recall context from memory.
61
+
62
+ \b
63
+ cortexdb recall "what did we decide about Acme?"
64
+ cortexdb recall "renewal status" --temporal "last 30 days"
65
+ """
66
+ cfg = ctx.obj
67
+ target_scope = scope or cfg.get("scope") or cfg.get("actor")
68
+ if not target_scope:
69
+ raise click.UsageError(
70
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
71
+ )
72
+
73
+ body: dict[str, Any] = {
74
+ "scope": target_scope,
75
+ "view": view,
76
+ "query": query,
77
+ "diagnostics": diagnostics,
78
+ }
79
+ if include:
80
+ body["include"] = list(include)
81
+ if temporal:
82
+ body["temporal"] = {"natural": temporal}
83
+ if max_tokens is not None:
84
+ body["budgets"] = {"max_tokens": max_tokens}
85
+
86
+ with get_client(cfg) as client:
87
+ if not is_json_mode(cfg):
88
+ with spinner("Recalling..."):
89
+ resp = client.post("/v1/recall", json=body)
90
+ else:
91
+ resp = client.post("/v1/recall", json=body)
92
+ resp.raise_for_status()
93
+ print_recall(resp.json(), cfg)
@@ -0,0 +1,98 @@
1
+ """cortexdb scopes — /v1/scopes* (list, register, delete, members)."""
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("scopes")
19
+ def scopes_group() -> None:
20
+ """Hierarchical scopes — list, register, members."""
21
+
22
+
23
+ @scopes_group.command("list")
24
+ @click.option("--prefix", help="Only show scopes under this prefix")
25
+ @click.option("--limit", "-l", default=100, show_default=True)
26
+ @click.pass_context
27
+ @handle_errors
28
+ def scopes_list(ctx: click.Context, prefix: str | None, limit: int) -> None:
29
+ """List scopes visible to the caller."""
30
+ cfg = ctx.obj
31
+ params: dict[str, str | int] = {"limit": limit}
32
+ if prefix:
33
+ params["prefix"] = prefix
34
+ with get_client(cfg) as client:
35
+ if not is_json_mode(cfg):
36
+ with spinner("Loading scopes..."):
37
+ resp = client.get("/v1/scopes/list", params=params)
38
+ else:
39
+ resp = client.get("/v1/scopes/list", params=params)
40
+ resp.raise_for_status()
41
+
42
+ data = resp.json()
43
+ if is_json_mode(cfg):
44
+ print_json(data)
45
+ return
46
+
47
+ items = data.get("items", data) if isinstance(data, dict) else data
48
+ if not items:
49
+ console.print("[dim]No scopes visible.[/dim]")
50
+ return
51
+
52
+ from rich.table import Table
53
+
54
+ table = Table(title="[header]Scopes[/header]", border_style="blue")
55
+ table.add_column("Path", style="bold")
56
+ table.add_column("Owner", style="cyan")
57
+ table.add_column("Members", justify="right", width=8)
58
+ for s in items:
59
+ members = s.get("member_count") or len(s.get("members") or [])
60
+ table.add_row(
61
+ escape(str(s.get("path") or s.get("scope", ""))),
62
+ escape(str(s.get("owner", ""))),
63
+ str(members),
64
+ )
65
+ console.print(table)
66
+
67
+
68
+ @scopes_group.command("register")
69
+ @click.argument("path")
70
+ @click.option("--owner", help="Owner actor — defaults to your actor.")
71
+ @click.pass_context
72
+ @handle_errors
73
+ def scopes_register(ctx: click.Context, path: str, owner: str | None) -> None:
74
+ """POST /v1/scopes — register a new scope path."""
75
+ cfg = ctx.obj
76
+ body: dict[str, str] = {"path": path}
77
+ if owner or cfg.get("actor"):
78
+ body["owner"] = owner or cfg["actor"]
79
+ with get_client(cfg) as client:
80
+ resp = client.post("/v1/scopes", json=body)
81
+ resp.raise_for_status()
82
+ if is_json_mode(cfg):
83
+ print_json(resp.json())
84
+ else:
85
+ console.print(f"[success]Registered[/success] {path}")
86
+
87
+
88
+ @scopes_group.command("members")
89
+ @click.argument("path")
90
+ @click.pass_context
91
+ @handle_errors
92
+ def scopes_members(ctx: click.Context, path: str) -> None:
93
+ """GET /v1/scopes — list members of a scope."""
94
+ cfg = ctx.obj
95
+ with get_client(cfg) as client:
96
+ resp = client.get("/v1/scopes", params={"path": path})
97
+ resp.raise_for_status()
98
+ print_json(resp.json())
@@ -0,0 +1,64 @@
1
+ """cortexdb search — POST /v1/recall (granular view) with optional filters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from cortexdb_cli.client import get_client
10
+ from cortexdb_cli.errors import handle_errors
11
+ from cortexdb_cli.output import is_json_mode, print_search, spinner
12
+
13
+
14
+ @click.command("search")
15
+ @click.argument("query")
16
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
17
+ @click.option("--limit", "-l", default=10, show_default=True, help="Max results")
18
+ @click.option("--temporal", "-t", help='Time window, e.g. "last 7 days"')
19
+ @click.option(
20
+ "--diagnostics",
21
+ type=click.Choice(["none", "summary", "full"]),
22
+ default="none",
23
+ show_default=True,
24
+ )
25
+ @click.pass_context
26
+ @handle_errors
27
+ def search_cmd(
28
+ ctx: click.Context,
29
+ query: str,
30
+ scope: str | None,
31
+ limit: int,
32
+ temporal: str | None,
33
+ diagnostics: str,
34
+ ) -> None:
35
+ """Search memories — granular view, individual event hits.
36
+
37
+ Equivalent to `cortexdb recall --view granular`, optimised for
38
+ "show me the matching events" rather than "give me a context block."
39
+ """
40
+ cfg = ctx.obj
41
+ target_scope = scope or cfg.get("scope") or cfg.get("actor")
42
+ if not target_scope:
43
+ raise click.UsageError(
44
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
45
+ )
46
+
47
+ body: dict[str, Any] = {
48
+ "scope": target_scope,
49
+ "view": "granular",
50
+ "query": query,
51
+ "diagnostics": diagnostics,
52
+ "budgets": {"max_events": limit},
53
+ }
54
+ if temporal:
55
+ body["temporal"] = {"natural": temporal}
56
+
57
+ with get_client(cfg) as client:
58
+ if not is_json_mode(cfg):
59
+ with spinner("Searching..."):
60
+ resp = client.post("/v1/recall", json=body)
61
+ else:
62
+ resp = client.post("/v1/recall", json=body)
63
+ resp.raise_for_status()
64
+ print_search(resp.json(), cfg)