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,174 @@
1
+ """cortexdb entities — entity-level views over /v1/facts.
2
+
3
+ The v1 surface doesn't expose a dedicated /v1/entities route. Entities are
4
+ the distinct subjects of facts, so `entities list` and `entities get` are
5
+ both implemented on top of /v1/facts:
6
+
7
+ entities list → GET /v1/facts (group by subject)
8
+ entities get ID → GET /v1/facts?subject= (one entity's facts)
9
+ entities link → POST /v1/experience (sentence the LLM extractor
10
+ promotes to a fact triple)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ from collections import defaultdict
17
+ from datetime import datetime, timezone
18
+ from typing import Any
19
+
20
+ import click
21
+
22
+ from cortexdb_cli.client import get_client
23
+ from cortexdb_cli.errors import handle_errors
24
+ from cortexdb_cli.output import (
25
+ is_json_mode,
26
+ print_entities,
27
+ print_entity_detail,
28
+ print_json,
29
+ print_success,
30
+ spinner,
31
+ )
32
+
33
+
34
+ @click.group("entities")
35
+ def entities_group() -> None:
36
+ """Entities — distinct subjects across the Facts layer."""
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
+ @entities_group.command("list")
49
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
50
+ @click.option("--limit", "-l", default=200, show_default=True, help="Max facts to fold")
51
+ @click.pass_context
52
+ @handle_errors
53
+ def entities_list(ctx: click.Context, scope: str | None, limit: int) -> None:
54
+ """List entities (distinct fact subjects)."""
55
+ cfg = ctx.obj
56
+ s = _scope_or_die(cfg, scope)
57
+ with get_client(cfg) as client:
58
+ if not is_json_mode(cfg):
59
+ with spinner("Loading entities..."):
60
+ resp = client.get("/v1/facts", params={"scope": s, "limit": limit})
61
+ else:
62
+ resp = client.get("/v1/facts", params={"scope": s, "limit": limit})
63
+ resp.raise_for_status()
64
+
65
+ data = resp.json()
66
+ facts = data.get("items", data) if isinstance(data, dict) else data
67
+
68
+ grouped: dict[str, dict[str, Any]] = defaultdict(
69
+ lambda: {"subject": "", "predicates": set(), "fact_count": 0}
70
+ )
71
+ for f in facts:
72
+ raw = f.get("subject") or f.get("entity_id") or ""
73
+ # v1 returns subject as {type, id}; legacy paths return a string.
74
+ subj = raw.get("id") if isinstance(raw, dict) else raw
75
+ if not subj:
76
+ continue
77
+ bucket = grouped[subj]
78
+ bucket["subject"] = subj
79
+ bucket["predicates"].add(f.get("predicate", ""))
80
+ bucket["fact_count"] += 1
81
+
82
+ entities = [
83
+ {
84
+ "id": subj,
85
+ "subject": v["subject"],
86
+ "predicates": sorted(p for p in v["predicates"] if p),
87
+ "fact_count": v["fact_count"],
88
+ }
89
+ for subj, v in grouped.items()
90
+ ]
91
+ entities.sort(key=lambda e: e["fact_count"], reverse=True)
92
+ print_entities(entities, cfg)
93
+
94
+
95
+ @entities_group.command("get")
96
+ @click.argument("entity_id")
97
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
98
+ @click.pass_context
99
+ @handle_errors
100
+ def entities_get(ctx: click.Context, entity_id: str, scope: str | None) -> None:
101
+ """Show every fact about an entity."""
102
+ cfg = ctx.obj
103
+ s = _scope_or_die(cfg, scope)
104
+ with get_client(cfg) as client:
105
+ if not is_json_mode(cfg):
106
+ with spinner("Loading entity..."):
107
+ resp = client.get(
108
+ "/v1/facts", params={"scope": s, "subject": entity_id, "limit": 500}
109
+ )
110
+ else:
111
+ resp = client.get(
112
+ "/v1/facts", params={"scope": s, "subject": entity_id, "limit": 500}
113
+ )
114
+ resp.raise_for_status()
115
+ data = resp.json()
116
+ facts = data.get("items", data) if isinstance(data, dict) else data
117
+ print_entity_detail({"id": entity_id, "facts": facts}, cfg)
118
+
119
+
120
+ @entities_group.command("link")
121
+ @click.option("--source", "-s", required=True, help="Source entity id")
122
+ @click.option("--target", "-t", required=True, help="Target entity id")
123
+ @click.option("--relationship", "-r", required=True, help="Relationship label (predicate)")
124
+ @click.option("--fact", "-f", default="", help="Plain-English sentence the LLM will extract from")
125
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
126
+ @click.pass_context
127
+ @handle_errors
128
+ def entities_link(
129
+ ctx: click.Context,
130
+ source: str,
131
+ target: str,
132
+ relationship: str,
133
+ fact: str,
134
+ scope: str | None,
135
+ ) -> None:
136
+ """Link two entities by recording a sentence for the LLM extractor.
137
+
138
+ The v1 entity graph is derived — you can't insert a triple directly.
139
+ Instead we write an experience whose text the extractor promotes to a
140
+ fact triple on the next pipeline tick.
141
+
142
+ cortexdb entities link -s acme -t cortexdb -r uses
143
+ """
144
+ cfg = ctx.obj
145
+ s = _scope_or_die(cfg, scope)
146
+ sentence = fact or f"{source} {relationship} {target}."
147
+
148
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
149
+ idem = "cli-link:" + hashlib.sha256(
150
+ f"{s}|{source}|{relationship}|{target}".encode("utf-8")
151
+ ).hexdigest()[:16]
152
+
153
+ body = {
154
+ "scope": s,
155
+ "modality": "observation",
156
+ "content": {"kind": "message", "role": "system", "text": sentence},
157
+ "context": {
158
+ "observed_at": now,
159
+ "labels": [f"link:{relationship}", f"subject:{source}", f"object:{target}"],
160
+ },
161
+ "idempotency_key": idem,
162
+ }
163
+
164
+ with get_client(cfg) as client:
165
+ resp = client.post("/v1/experience", json=body)
166
+ resp.raise_for_status()
167
+ if is_json_mode(cfg):
168
+ print_json(resp.json())
169
+ else:
170
+ print_success(
171
+ f"Recorded: {source} —[{relationship}]→ {target}. "
172
+ "The fact extractor will promote this to a triple on the next tick.",
173
+ cfg,
174
+ )
@@ -0,0 +1,131 @@
1
+ """cortexdb episodes — GET /v1/episodes + GET /v1/events/{id} + delete via /v1/forget."""
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
+ is_json_mode,
12
+ print_episodes,
13
+ print_json,
14
+ print_success,
15
+ spinner,
16
+ )
17
+
18
+
19
+ @click.group("episodes")
20
+ def episodes_group() -> None:
21
+ """Episodes — segmented narrative summaries built from raw events."""
22
+
23
+
24
+ def _scope_or_die(cfg: dict, scope: str | None) -> str:
25
+ s = scope or cfg.get("scope") or cfg.get("actor")
26
+ if not s:
27
+ raise click.UsageError(
28
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
29
+ )
30
+ return s
31
+
32
+
33
+ @episodes_group.command("list")
34
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
35
+ @click.option("--limit", "-l", default=20, show_default=True, help="Max results")
36
+ @click.option(
37
+ "--view",
38
+ type=click.Choice(["local", "holistic", "descend"]),
39
+ default="local",
40
+ show_default=True,
41
+ help="local = this scope only; holistic = ancestors; descend = descendants.",
42
+ )
43
+ @click.pass_context
44
+ @handle_errors
45
+ def episodes_list(
46
+ ctx: click.Context, scope: str | None, limit: int, view: str
47
+ ) -> None:
48
+ """List episodes."""
49
+ cfg = ctx.obj
50
+ s = _scope_or_die(cfg, scope)
51
+ params = {"scope": s, "view": view, "limit": limit}
52
+
53
+ with get_client(cfg) as client:
54
+ if not is_json_mode(cfg):
55
+ with spinner("Loading episodes..."):
56
+ resp = client.get("/v1/episodes", params=params)
57
+ else:
58
+ resp = client.get("/v1/episodes", params=params)
59
+ resp.raise_for_status()
60
+ print_episodes(resp.json(), cfg)
61
+
62
+
63
+ @episodes_group.command("get")
64
+ @click.argument("event_id")
65
+ @click.pass_context
66
+ @handle_errors
67
+ def episodes_get(ctx: click.Context, event_id: str) -> None:
68
+ """Get a specific event by id (GET /v1/events/{id})."""
69
+ cfg = ctx.obj
70
+ with get_client(cfg) as client:
71
+ resp = client.get(f"/v1/events/{event_id}")
72
+ resp.raise_for_status()
73
+ data = resp.json()
74
+ if is_json_mode(cfg):
75
+ print_json(data)
76
+ else:
77
+ from rich.panel import Panel
78
+ from cortexdb_cli.output import escape
79
+
80
+ content = data.get("content", {})
81
+ text = ""
82
+ if isinstance(content, dict):
83
+ text = content.get("text") or content.get("summary") or str(content)
84
+ modality = data.get("modality", "")
85
+ actor = data.get("observed_actor") or data.get("actor", "")
86
+ console.print(
87
+ Panel(
88
+ f"{escape(str(text))}\n\n"
89
+ f"[dim]Modality: {escape(modality)} Actor: {escape(actor)}[/dim]",
90
+ title=f"[header]Event {escape(event_id[:16])}[/header]",
91
+ border_style="cyan",
92
+ padding=(1, 2),
93
+ )
94
+ )
95
+
96
+
97
+ @episodes_group.command("delete")
98
+ @click.argument("event_id")
99
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
100
+ @click.option("--reason", "-r", required=True, help="Audit trail note.")
101
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
102
+ @click.pass_context
103
+ @handle_errors
104
+ def episodes_delete(
105
+ ctx: click.Context,
106
+ event_id: str,
107
+ scope: str | None,
108
+ reason: str,
109
+ yes: bool,
110
+ ) -> None:
111
+ """Delete an event via /v1/forget (selector.memory_ids)."""
112
+ cfg = ctx.obj
113
+ s = _scope_or_die(cfg, scope)
114
+ if not yes and not is_json_mode(cfg):
115
+ click.confirm(f"Forget event {event_id} in scope {s}?", abort=True)
116
+
117
+ body = {
118
+ "scope": s,
119
+ "selector": {"memory_ids": [event_id]},
120
+ "cascade": "derived_only",
121
+ "audit_note": reason,
122
+ }
123
+
124
+ with get_client(cfg) as client:
125
+ resp = client.post("/v1/forget", json=body)
126
+ resp.raise_for_status()
127
+ data = resp.json()
128
+ if is_json_mode(cfg):
129
+ print_json(data)
130
+ else:
131
+ print_success(f"Forgot event {event_id}", cfg)
@@ -0,0 +1,238 @@
1
+ """cortexdb experience — POST /v1/experience.
2
+
3
+ `remember` is kept as a hidden alias so existing scripts and muscle memory
4
+ keep working, but `experience` is the canonical command — it matches the
5
+ v1 endpoint name and the SDK method.
6
+
7
+ The command is a Click group so we can hang `bulk` under it for
8
+ `POST /v1/experience/bulk`. When you call `cortexdb experience "text"`
9
+ the group's default invocation stores a single envelope; with no args
10
+ it shows help.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import sys
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import click
23
+
24
+ from cortexdb_cli.client import get_client
25
+ from cortexdb_cli.errors import handle_errors
26
+ from cortexdb_cli.output import console, is_json_mode, print_json, print_remember, spinner
27
+
28
+
29
+ def _now_iso() -> str:
30
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
31
+
32
+
33
+ def _idem(content: str, scope: str) -> str:
34
+ h = hashlib.sha256(f"{scope}|{content}".encode("utf-8")).hexdigest()[:16]
35
+ return f"cli:{h}"
36
+
37
+
38
+ def _store(
39
+ cfg: dict[str, Any],
40
+ content: str | None,
41
+ scope: str | None,
42
+ source: str | None,
43
+ tags: str | None,
44
+ modality: str,
45
+ role: str,
46
+ wait: str | None,
47
+ ) -> None:
48
+ """Shared implementation of `experience [text]` and the `remember` alias."""
49
+ if content == "-" or (content is None and not sys.stdin.isatty()):
50
+ content = sys.stdin.read().strip()
51
+ if not content:
52
+ raise click.UsageError(
53
+ "No content provided. Pass text as an argument or pipe from stdin."
54
+ )
55
+
56
+ target_scope = scope or cfg.get("scope") or cfg.get("actor")
57
+ if not target_scope:
58
+ raise click.UsageError(
59
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
60
+ )
61
+
62
+ labels: list[str] = []
63
+ if tags:
64
+ labels = [t.strip() for t in tags.split(",") if t.strip()]
65
+ if source:
66
+ labels.append(f"source:{source}")
67
+
68
+ body: dict[str, Any] = {
69
+ "scope": target_scope,
70
+ "modality": modality,
71
+ "content": {"kind": "message", "role": role, "text": content},
72
+ "context": {"observed_at": _now_iso()},
73
+ "idempotency_key": _idem(content, target_scope),
74
+ }
75
+ if labels:
76
+ body["context"]["labels"] = labels
77
+
78
+ params = {"wait": wait} if wait else None
79
+
80
+ with get_client(cfg) as client:
81
+ if not is_json_mode(cfg):
82
+ with spinner("Storing memory..."):
83
+ resp = client.post("/v1/experience", json=body, params=params)
84
+ else:
85
+ resp = client.post("/v1/experience", json=body, params=params)
86
+ resp.raise_for_status()
87
+ print_remember(resp.json(), cfg)
88
+
89
+
90
+ @click.group("experience", invoke_without_command=True)
91
+ @click.argument("content", required=False)
92
+ @click.option("--scope", "-S", help="Scope path (defaults to your default scope)")
93
+ @click.option("--source", "-s", help="Source label, e.g. slack, github")
94
+ @click.option("--tags", "-t", help="Comma-separated labels")
95
+ @click.option(
96
+ "--modality",
97
+ type=click.Choice(["conversation", "document", "observation", "feedback"]),
98
+ default="conversation",
99
+ show_default=True,
100
+ )
101
+ @click.option(
102
+ "--role",
103
+ type=click.Choice(["user", "assistant", "system", "tool"]),
104
+ default="user",
105
+ show_default=True,
106
+ )
107
+ @click.option(
108
+ "--wait",
109
+ type=click.Choice(["captured", "indexed", "consolidated"]),
110
+ default=None,
111
+ help="Block until the requested lifecycle stage.",
112
+ )
113
+ @click.pass_context
114
+ @handle_errors
115
+ def experience_cmd(
116
+ ctx: click.Context,
117
+ content: str | None,
118
+ scope: str | None,
119
+ source: str | None,
120
+ tags: str | None,
121
+ modality: str,
122
+ role: str,
123
+ wait: str | None,
124
+ ) -> None:
125
+ """Store a memory.
126
+
127
+ \b
128
+ cortexdb experience "Deploy succeeded at 14:32 UTC"
129
+ echo "Important fact" | cortexdb experience -
130
+ cat envelopes.jsonl | cortexdb experience bulk -
131
+ """
132
+ if ctx.invoked_subcommand is None:
133
+ # `cortexdb experience "text"` — the common single-envelope path.
134
+ if content is None and sys.stdin.isatty():
135
+ click.echo(ctx.get_help())
136
+ return
137
+ _store(ctx.obj, content, scope, source, tags, modality, role, wait)
138
+
139
+
140
+ @experience_cmd.command("bulk")
141
+ @click.argument("file", type=click.Path(exists=True), required=False)
142
+ @click.option("--scope", "-S", help="Scope path applied when an envelope omits it.")
143
+ @click.option(
144
+ "--ordering",
145
+ type=click.Choice(["independent", "strict_temporal"]),
146
+ default="independent",
147
+ show_default=True,
148
+ help="strict_temporal — fail the batch if observed_at is non-monotonic per actor.",
149
+ )
150
+ @click.pass_context
151
+ @handle_errors
152
+ def experience_bulk(
153
+ ctx: click.Context, file: str | None, scope: str | None, ordering: str
154
+ ) -> None:
155
+ """POST /v1/experience/bulk — submit a JSONL file of pre-built envelopes.
156
+
157
+ Each line must be a fully-formed experience envelope. Use
158
+ `cortexdb import` for plain-text or CSV ingestion where the CLI
159
+ wraps each row in an envelope for you.
160
+
161
+ cortexdb experience bulk events.jsonl
162
+ """
163
+ cfg = ctx.obj
164
+ target_scope = scope or cfg.get("scope") or cfg.get("actor")
165
+ if not target_scope:
166
+ raise click.UsageError(
167
+ "No scope configured. Run `cortexdb init` first, or pass --scope explicitly."
168
+ )
169
+
170
+ if file == "-" or (file is None and not sys.stdin.isatty()):
171
+ raw = sys.stdin.read()
172
+ elif file:
173
+ raw = Path(file).read_text(encoding="utf-8")
174
+ else:
175
+ raise click.UsageError("Provide a JSONL file or pipe from stdin.")
176
+
177
+ items: list[dict[str, Any]] = []
178
+ for line in raw.splitlines():
179
+ line = line.strip()
180
+ if not line:
181
+ continue
182
+ env = json.loads(line)
183
+ env.setdefault("scope", target_scope)
184
+ items.append(env)
185
+ if not items:
186
+ raise click.UsageError("No envelopes in input.")
187
+
188
+ body = {"items": items, "ordering": ordering}
189
+ with get_client(cfg) as client:
190
+ if not is_json_mode(cfg):
191
+ with spinner(f"Submitting {len(items)} envelopes..."):
192
+ resp = client.post("/v1/experience/bulk", json=body)
193
+ else:
194
+ resp = client.post("/v1/experience/bulk", json=body)
195
+ resp.raise_for_status()
196
+ data = resp.json()
197
+ if is_json_mode(cfg):
198
+ print_json(data)
199
+ else:
200
+ accepted = data.get("accepted") or data.get("count") or len(items)
201
+ console.print(f"[success]Accepted {accepted} envelopes[/success]")
202
+
203
+
204
+ # Hidden alias — keep `cortexdb remember "..."` working for muscle memory.
205
+ @click.command("remember", hidden=True)
206
+ @click.argument("content", required=False)
207
+ @click.option("--scope", "-S")
208
+ @click.option("--source", "-s")
209
+ @click.option("--tags", "-t")
210
+ @click.option(
211
+ "--modality",
212
+ type=click.Choice(["conversation", "document", "observation", "feedback"]),
213
+ default="conversation",
214
+ )
215
+ @click.option(
216
+ "--role",
217
+ type=click.Choice(["user", "assistant", "system", "tool"]),
218
+ default="user",
219
+ )
220
+ @click.option(
221
+ "--wait",
222
+ type=click.Choice(["captured", "indexed", "consolidated"]),
223
+ default=None,
224
+ )
225
+ @click.pass_context
226
+ @handle_errors
227
+ def remember_alias(
228
+ ctx: click.Context,
229
+ content: str | None,
230
+ scope: str | None,
231
+ source: str | None,
232
+ tags: str | None,
233
+ modality: str,
234
+ role: str,
235
+ wait: str | None,
236
+ ) -> None:
237
+ """Alias for `experience`. Hidden — prefer `cortexdb experience`."""
238
+ _store(ctx.obj, content, scope, source, tags, modality, role, wait)
@@ -0,0 +1,65 @@
1
+ """cortexdb export — POST /v1/export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+
11
+ from cortexdb_cli.client import get_client
12
+ from cortexdb_cli.errors import handle_errors
13
+ from cortexdb_cli.output import console, is_json_mode, print_json, spinner
14
+
15
+
16
+ @click.command("export")
17
+ @click.option("--output", "-o", type=click.Path(), help="Write JSON to this path")
18
+ @click.option("--scope", "-S", help="Scope path to export (defaults to your default scope)")
19
+ @click.option(
20
+ "--include",
21
+ multiple=True,
22
+ type=click.Choice(["events", "episodes", "facts", "beliefs", "understanding"]),
23
+ help="Layer(s) to include (repeatable). Defaults to events + facts.",
24
+ )
25
+ @click.pass_context
26
+ @handle_errors
27
+ def export_cmd(
28
+ ctx: click.Context,
29
+ output: str | None,
30
+ scope: str | None,
31
+ include: tuple[str, ...],
32
+ ) -> None:
33
+ """Export memories to JSON.
34
+
35
+ \b
36
+ cortexdb export # stdout
37
+ cortexdb export -o backup.json
38
+ cortexdb export --include facts --include beliefs
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] = {"scope": target_scope}
48
+ if include:
49
+ body["include"] = list(include)
50
+
51
+ with get_client(cfg) as client:
52
+ if not is_json_mode(cfg):
53
+ with spinner("Exporting..."):
54
+ resp = client.post("/v1/export", json=body)
55
+ else:
56
+ resp = client.post("/v1/export", json=body)
57
+ resp.raise_for_status()
58
+ data = resp.json()
59
+
60
+ if output:
61
+ Path(output).write_text(json.dumps(data, indent=2), encoding="utf-8")
62
+ if not is_json_mode(cfg):
63
+ console.print(f"[success]Exported to {output}[/success]")
64
+ else:
65
+ print_json(data)