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.
- cortexdb_cli/__init__.py +3 -0
- cortexdb_cli/client.py +92 -0
- cortexdb_cli/commands/__init__.py +1 -0
- cortexdb_cli/commands/admin.py +83 -0
- cortexdb_cli/commands/answer.py +98 -0
- cortexdb_cli/commands/auth.py +128 -0
- cortexdb_cli/commands/beliefs.py +121 -0
- cortexdb_cli/commands/config_cmd.py +50 -0
- cortexdb_cli/commands/entities.py +174 -0
- cortexdb_cli/commands/episodes.py +131 -0
- cortexdb_cli/commands/experience.py +238 -0
- cortexdb_cli/commands/export_cmd.py +65 -0
- cortexdb_cli/commands/facts.py +171 -0
- cortexdb_cli/commands/forget.py +86 -0
- cortexdb_cli/commands/import_cmd.py +198 -0
- cortexdb_cli/commands/init.py +95 -0
- cortexdb_cli/commands/interactive.py +254 -0
- cortexdb_cli/commands/policy.py +99 -0
- cortexdb_cli/commands/recall.py +93 -0
- cortexdb_cli/commands/scopes.py +98 -0
- cortexdb_cli/commands/search.py +64 -0
- cortexdb_cli/commands/understanding.py +141 -0
- cortexdb_cli/config.py +113 -0
- cortexdb_cli/errors.py +96 -0
- cortexdb_cli/main.py +140 -0
- cortexdb_cli/output.py +399 -0
- cortexdb_cli/state.py +53 -0
- cortexdb_cli-0.2.0.dist-info/METADATA +122 -0
- cortexdb_cli-0.2.0.dist-info/RECORD +31 -0
- cortexdb_cli-0.2.0.dist-info/WHEEL +4 -0
- cortexdb_cli-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -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)
|