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,171 @@
1
+ """cortexdb facts — GET /v1/facts + GET /v1/facts/timeline."""
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 (
12
+ console,
13
+ escape,
14
+ is_json_mode,
15
+ print_json,
16
+ spinner,
17
+ )
18
+
19
+
20
+ def _render_subject(s: Any) -> str:
21
+ """Subjects are `{type, id}` or `{id}` or a bare string."""
22
+ if isinstance(s, dict):
23
+ return s.get("id") or s.get("value") or str(s)
24
+ return str(s) if s is not None else ""
25
+
26
+
27
+ def _render_object(o: Any) -> str:
28
+ """Objects are `{type, datatype, value}` or `{type, id}` or a bare string."""
29
+ if isinstance(o, dict):
30
+ return str(o.get("value") or o.get("id") or o)
31
+ return str(o) if o is not None else ""
32
+
33
+
34
+ @click.group("facts")
35
+ def facts_group() -> None:
36
+ """Facts — typed subject/predicate/object triples derived from events."""
37
+
38
+
39
+ def _scope_or_die(cfg: dict, scope: str | None) -> str:
40
+ s = scope or cfg.get("scope") or cfg.get("actor")
41
+ if not s:
42
+ raise click.UsageError(
43
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
44
+ )
45
+ return s
46
+
47
+
48
+ @facts_group.command("list")
49
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
50
+ @click.option("--subject", help="Filter by subject")
51
+ @click.option("--predicate", help="Filter by predicate")
52
+ @click.option("--limit", "-l", default=50, show_default=True)
53
+ @click.pass_context
54
+ @handle_errors
55
+ def facts_list(
56
+ ctx: click.Context,
57
+ scope: str | None,
58
+ subject: str | None,
59
+ predicate: str | None,
60
+ limit: int,
61
+ ) -> None:
62
+ """List facts in a scope."""
63
+ cfg = ctx.obj
64
+ s = _scope_or_die(cfg, scope)
65
+ params: dict[str, str | int] = {"scope": s, "limit": limit}
66
+ if subject:
67
+ params["subject"] = subject
68
+ if predicate:
69
+ params["predicate"] = predicate
70
+
71
+ with get_client(cfg) as client:
72
+ if not is_json_mode(cfg):
73
+ with spinner("Loading facts..."):
74
+ resp = client.get("/v1/facts", params=params)
75
+ else:
76
+ resp = client.get("/v1/facts", params=params)
77
+ resp.raise_for_status()
78
+
79
+ data = resp.json()
80
+ if is_json_mode(cfg):
81
+ print_json(data)
82
+ return
83
+
84
+ items = data.get("items", data) if isinstance(data, dict) else data
85
+ if not items:
86
+ console.print("[dim]No facts in this scope yet.[/dim]")
87
+ return
88
+
89
+ from rich.table import Table
90
+
91
+ table = Table(title="[header]Facts[/header]", border_style="blue")
92
+ table.add_column("Subject", style="bold", max_width=24)
93
+ table.add_column("Predicate", style="magenta", max_width=20)
94
+ table.add_column("Object", max_width=40)
95
+ table.add_column("Conf", justify="right", width=6)
96
+ table.add_column("Valid from", style="dim", width=20)
97
+ for f in items:
98
+ table.add_row(
99
+ escape(_render_subject(f.get("subject"))),
100
+ escape(str(f.get("predicate", ""))),
101
+ escape(_render_object(f.get("object"))),
102
+ f"{(f.get('confidence') or 0):.0%}",
103
+ escape(str(f.get("valid_from") or f.get("observed_at", "")))[:20],
104
+ )
105
+ console.print(table)
106
+
107
+
108
+ @facts_group.command("timeline")
109
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
110
+ @click.option("--subject", required=True, help="Subject to trace")
111
+ @click.option("--predicate", help="Limit timeline to one predicate")
112
+ @click.option("--as-of", help="Bi-temporal: only show facts known as of this timestamp")
113
+ @click.pass_context
114
+ @handle_errors
115
+ def facts_timeline(
116
+ ctx: click.Context,
117
+ scope: str | None,
118
+ subject: str,
119
+ predicate: str | None,
120
+ as_of: str | None,
121
+ ) -> None:
122
+ """Show the full bi-temporal lineage of a fact.
123
+
124
+ cortexdb facts timeline --subject "opp_xyz" --predicate stage
125
+ """
126
+ cfg = ctx.obj
127
+ s = _scope_or_die(cfg, scope)
128
+ params: dict[str, str] = {"scope": s, "subject": subject}
129
+ if predicate:
130
+ params["predicate"] = predicate
131
+ if as_of:
132
+ params["as_of"] = as_of
133
+
134
+ with get_client(cfg) as client:
135
+ if not is_json_mode(cfg):
136
+ with spinner("Loading timeline..."):
137
+ resp = client.get("/v1/facts/timeline", params=params)
138
+ else:
139
+ resp = client.get("/v1/facts/timeline", params=params)
140
+ resp.raise_for_status()
141
+
142
+ data = resp.json()
143
+ if is_json_mode(cfg):
144
+ print_json(data)
145
+ return
146
+
147
+ items = data.get("items", data) if isinstance(data, dict) else data
148
+ if not items:
149
+ console.print("[dim]No fact history for this subject.[/dim]")
150
+ return
151
+
152
+ from rich.table import Table
153
+
154
+ table = Table(
155
+ title=f"[header]Timeline[/header] [dim]{escape(subject)}[/dim]",
156
+ border_style="cyan",
157
+ )
158
+ table.add_column("Predicate", style="magenta")
159
+ table.add_column("Object")
160
+ table.add_column("Valid from", style="dim")
161
+ table.add_column("Valid to", style="dim")
162
+ table.add_column("Recorded at", style="dim")
163
+ for f in items:
164
+ table.add_row(
165
+ escape(str(f.get("predicate", ""))),
166
+ escape(_render_object(f.get("object"))),
167
+ escape(str(f.get("valid_from", ""))[:20]),
168
+ escape(str(f.get("valid_to") or "")[:20]),
169
+ escape(str(f.get("recorded_at", ""))[:20]),
170
+ )
171
+ console.print(table)
@@ -0,0 +1,86 @@
1
+ """cortexdb forget — POST /v1/forget."""
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_forget, spinner
12
+
13
+
14
+ @click.command("forget")
15
+ @click.option("--memory-id", "-i", multiple=True, help="Event id(s) to forget. Repeatable.")
16
+ @click.option("--query", "-q", help='Free-text predicate, e.g. "test data" or "deal_stage:lost"')
17
+ @click.option("--subject", help="Forget everything where about_subject matches this value.")
18
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
19
+ @click.option("--reason", "-r", required=True, help="Audit trail note — recorded against the forget.")
20
+ @click.option(
21
+ "--cascade",
22
+ type=click.Choice(["none", "derived_only", "redact_events"]),
23
+ default="derived_only",
24
+ show_default=True,
25
+ help="How aggressively to propagate the forget across derived layers.",
26
+ )
27
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
28
+ @click.pass_context
29
+ @handle_errors
30
+ def forget_cmd(
31
+ ctx: click.Context,
32
+ memory_id: tuple[str, ...],
33
+ query: str | None,
34
+ subject: str | None,
35
+ scope: str | None,
36
+ reason: str,
37
+ cascade: str,
38
+ yes: bool,
39
+ ) -> None:
40
+ """Delete memories with an audit trail.
41
+
42
+ \b
43
+ cortexdb forget --memory-id evt_01HX... --reason "user requested deletion"
44
+ cortexdb forget --query "test data" --reason "cleaning up test fixtures"
45
+ cortexdb forget --subject "user:alice" --reason "GDPR erasure" --cascade redact_events
46
+ """
47
+ cfg = ctx.obj
48
+ target_scope = scope or cfg.get("scope") or cfg.get("actor")
49
+ if not target_scope:
50
+ raise click.UsageError(
51
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
52
+ )
53
+
54
+ selector: dict[str, Any] = {}
55
+ if memory_id:
56
+ selector["memory_ids"] = list(memory_id)
57
+ if query:
58
+ selector["query"] = query
59
+ if subject:
60
+ selector["about_subject"] = subject
61
+ if not selector:
62
+ raise click.UsageError("Provide at least one of --memory-id, --query, or --subject.")
63
+
64
+ if not yes and not is_json_mode(cfg):
65
+ target = (
66
+ f"{len(memory_id)} memory id(s)"
67
+ if memory_id
68
+ else (f'query "{query}"' if query else f"subject {subject}")
69
+ )
70
+ click.confirm(f"Forget {target} in scope {target_scope}?", abort=True)
71
+
72
+ body = {
73
+ "scope": target_scope,
74
+ "selector": selector,
75
+ "cascade": cascade,
76
+ "audit_note": reason,
77
+ }
78
+
79
+ with get_client(cfg) as client:
80
+ if not is_json_mode(cfg):
81
+ with spinner("Forgetting..."):
82
+ resp = client.post("/v1/forget", json=body)
83
+ else:
84
+ resp = client.post("/v1/forget", json=body)
85
+ resp.raise_for_status()
86
+ print_forget(resp.json(), cfg)
@@ -0,0 +1,198 @@
1
+ """cortexdb import — bulk-load memories via POST /v1/experience."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import hashlib
7
+ import io
8
+ import json
9
+ import sys
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import click
15
+
16
+ from cortexdb_cli.client import get_client
17
+ from cortexdb_cli.errors import handle_errors
18
+ from cortexdb_cli.output import console, is_json_mode, print_json
19
+
20
+
21
+ def _now_iso() -> str:
22
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
23
+
24
+
25
+ def _idem(content: str, scope: str, i: int) -> str:
26
+ h = hashlib.sha256(f"{scope}|{i}|{content}".encode("utf-8")).hexdigest()[:16]
27
+ return f"cli-import:{h}"
28
+
29
+
30
+ def _envelope(content: str, scope: str, idem: str, extra_meta: dict[str, Any]) -> dict[str, Any]:
31
+ labels = []
32
+ role = extra_meta.get("role", "user")
33
+ if "source" in extra_meta:
34
+ labels.append(f"source:{extra_meta['source']}")
35
+ if "tags" in extra_meta:
36
+ for t in str(extra_meta["tags"]).split(","):
37
+ t = t.strip()
38
+ if t:
39
+ labels.append(t)
40
+ body: dict[str, Any] = {
41
+ "scope": scope,
42
+ "modality": "conversation",
43
+ "content": {"kind": "message", "role": role, "text": content},
44
+ "context": {"observed_at": extra_meta.get("observed_at", _now_iso())},
45
+ "idempotency_key": idem,
46
+ }
47
+ if labels:
48
+ body["context"]["labels"] = labels
49
+ return body
50
+
51
+
52
+ @click.command("import")
53
+ @click.argument("file", type=click.Path(exists=True), required=False)
54
+ @click.option(
55
+ "--format",
56
+ "fmt",
57
+ type=click.Choice(["json", "jsonl", "csv", "text"]),
58
+ help="File format. Auto-detected from extension if omitted.",
59
+ )
60
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
61
+ @click.option(
62
+ "--single",
63
+ is_flag=True,
64
+ help="Treat a .txt input as one memory rather than one-per-line.",
65
+ )
66
+ @click.pass_context
67
+ @handle_errors
68
+ def import_cmd(
69
+ ctx: click.Context,
70
+ file: str | None,
71
+ fmt: str | None,
72
+ scope: str | None,
73
+ single: bool,
74
+ ) -> None:
75
+ """Import memories from a file.
76
+
77
+ \b
78
+ cortexdb import data.json
79
+ cortexdb import data.jsonl
80
+ cortexdb import notes.csv
81
+ cortexdb import notes.txt --single
82
+ cat data.json | cortexdb import -
83
+ """
84
+ cfg = ctx.obj
85
+ target_scope = scope or cfg.get("scope") or cfg.get("actor")
86
+ if not target_scope:
87
+ raise click.UsageError(
88
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
89
+ )
90
+
91
+ # Read input
92
+ if file == "-" or (file is None and not sys.stdin.isatty()):
93
+ raw = sys.stdin.read()
94
+ if not fmt:
95
+ fmt = "json"
96
+ elif file:
97
+ path = Path(file)
98
+ raw = path.read_text(encoding="utf-8")
99
+ if not fmt:
100
+ ext = path.suffix.lower()
101
+ fmt = {
102
+ ".json": "json",
103
+ ".jsonl": "jsonl",
104
+ ".ndjson": "jsonl",
105
+ ".csv": "csv",
106
+ ".txt": "text",
107
+ ".md": "text",
108
+ }.get(ext, "json")
109
+ else:
110
+ raise click.UsageError("Provide a file path or pipe from stdin.")
111
+
112
+ # Parse
113
+ memories: list[dict[str, Any]] = []
114
+ if fmt == "json":
115
+ data = json.loads(raw)
116
+ if isinstance(data, list):
117
+ memories = data
118
+ elif isinstance(data, dict) and "memories" in data:
119
+ memories = data["memories"]
120
+ else:
121
+ memories = [data]
122
+ elif fmt == "jsonl":
123
+ for line in raw.splitlines():
124
+ line = line.strip()
125
+ if not line:
126
+ continue
127
+ memories.append(json.loads(line))
128
+ elif fmt == "csv":
129
+ reader = csv.DictReader(io.StringIO(raw))
130
+ for row in reader:
131
+ if "content" in row:
132
+ memories.append(dict(row))
133
+ elif fmt == "text":
134
+ if single:
135
+ memories = [{"content": raw.strip()}]
136
+ else:
137
+ for line in raw.strip().splitlines():
138
+ line = line.strip()
139
+ if line:
140
+ memories.append({"content": line})
141
+
142
+ if not memories:
143
+ raise click.UsageError("No memories found in input.")
144
+
145
+ # POST each one
146
+ stored = 0
147
+ errors = 0
148
+
149
+ with get_client(cfg) as client:
150
+ total = len(memories)
151
+ if not is_json_mode(cfg):
152
+ from rich.progress import (
153
+ Progress,
154
+ SpinnerColumn,
155
+ BarColumn,
156
+ TextColumn,
157
+ )
158
+
159
+ with Progress(
160
+ SpinnerColumn(),
161
+ TextColumn("[progress.description]{task.description}"),
162
+ BarColumn(),
163
+ TextColumn("{task.completed}/{task.total}"),
164
+ console=console,
165
+ ) as progress:
166
+ task = progress.add_task("Importing...", total=total)
167
+ for i, mem in enumerate(memories):
168
+ content = mem.get("content") or mem.get("text") or ""
169
+ if not content:
170
+ progress.advance(task)
171
+ continue
172
+ extra = {k: v for k, v in mem.items() if k not in ("content", "text")}
173
+ body = _envelope(content, target_scope, _idem(content, target_scope, i), extra)
174
+ resp = client.post("/v1/experience", json=body)
175
+ if resp.status_code < 400:
176
+ stored += 1
177
+ else:
178
+ errors += 1
179
+ progress.advance(task)
180
+
181
+ console.print(f"\n[success]Imported {stored} memories[/success]", end="")
182
+ if errors:
183
+ console.print(f" [warning]({errors} errors)[/warning]")
184
+ else:
185
+ console.print()
186
+ else:
187
+ for i, mem in enumerate(memories):
188
+ content = mem.get("content") or mem.get("text") or ""
189
+ if not content:
190
+ continue
191
+ extra = {k: v for k, v in mem.items() if k not in ("content", "text")}
192
+ body = _envelope(content, target_scope, _idem(content, target_scope, i), extra)
193
+ resp = client.post("/v1/experience", json=body)
194
+ if resp.status_code < 400:
195
+ stored += 1
196
+ else:
197
+ errors += 1
198
+ print_json({"imported": stored, "errors": errors})
@@ -0,0 +1,95 @@
1
+ """cortexdb init — anonymous signup + config persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import httpx
7
+
8
+ from cortexdb_cli.client import DEFAULT_ENDPOINT, signup
9
+ from cortexdb_cli.config import config_path, save_config
10
+ from cortexdb_cli.output import console
11
+ from cortexdb_cli.state import save_state, state_path
12
+
13
+
14
+ @click.command("init")
15
+ @click.option(
16
+ "--endpoint",
17
+ default=DEFAULT_ENDPOINT,
18
+ show_default=True,
19
+ help="CortexDB v1 API base URL.",
20
+ )
21
+ @click.option(
22
+ "--api-key",
23
+ default=None,
24
+ help="Existing PASETO bearer token. If set, skips anonymous signup.",
25
+ )
26
+ @click.option(
27
+ "--actor",
28
+ default=None,
29
+ help="X-Cortex-Actor value. Required when --api-key is set.",
30
+ )
31
+ @click.option(
32
+ "--scope",
33
+ default=None,
34
+ help="Default scope path. Defaults to the actor.",
35
+ )
36
+ def init_cmd(
37
+ endpoint: str,
38
+ api_key: str | None,
39
+ actor: str | None,
40
+ scope: str | None,
41
+ ) -> None:
42
+ """Set up CortexDB CLI.
43
+
44
+ \b
45
+ Without flags: anonymous signup (free tier, 7-day token TTL).
46
+ With --api-key: register an existing token + actor (e.g. from your IdP).
47
+ """
48
+ console.print("\n[header]CortexDB CLI setup[/header]\n")
49
+
50
+ if api_key:
51
+ if not actor:
52
+ raise click.UsageError("--actor is required when --api-key is supplied.")
53
+ # Pure config write — no signup call.
54
+ save_config("default", {"endpoint": endpoint})
55
+ save_state(
56
+ {
57
+ "token": api_key,
58
+ "user_id": actor,
59
+ "scope": scope or actor,
60
+ "source": "explicit",
61
+ }
62
+ )
63
+ console.print(f"[success]Saved token + actor to {state_path()}[/success]")
64
+ console.print(f"[dim]Endpoint stored in {config_path()}[/dim]\n")
65
+ return
66
+
67
+ # Anonymous signup path.
68
+ console.print(f" Endpoint: [bold]{endpoint}[/bold]")
69
+ console.print(" No email, no card — free tier, 7-day token TTL.\n")
70
+
71
+ try:
72
+ resp = signup(endpoint)
73
+ except httpx.HTTPError as exc:
74
+ console.print(f"[error]Signup failed: {exc}[/error]")
75
+ raise SystemExit(2) from exc
76
+
77
+ state = {
78
+ "token": resp["token"],
79
+ "user_id": resp["user_id"],
80
+ "scope": resp.get("scope") or resp["user_id"],
81
+ "expires_at": resp.get("expires_at"),
82
+ "tier": resp.get("tier", "free"),
83
+ "source": "signup",
84
+ }
85
+ save_state(state)
86
+ save_config("default", {"endpoint": endpoint})
87
+
88
+ console.print(f" [success]Token minted[/success] (expires {resp.get('expires_at', '?')})")
89
+ console.print(f" [success]Actor:[/success] {resp['user_id']}")
90
+ console.print(f" [success]Default scope:[/success] {state['scope']}")
91
+ console.print(f"\n[dim]State written to {state_path()}[/dim]")
92
+ console.print(f"[dim]Endpoint stored in {config_path()}[/dim]\n")
93
+ console.print("Try it:\n")
94
+ console.print(' [bold]cortexdb remember "Q3 revenue: $2.4M"[/bold]')
95
+ console.print(' [bold]cortexdb recall "Q3 revenue?"[/bold]\n')