kelam 0.1.0__tar.gz
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.
- kelam-0.1.0/PKG-INFO +47 -0
- kelam-0.1.0/README.md +37 -0
- kelam-0.1.0/kelam.egg-info/PKG-INFO +47 -0
- kelam-0.1.0/kelam.egg-info/SOURCES.txt +10 -0
- kelam-0.1.0/kelam.egg-info/dependency_links.txt +1 -0
- kelam-0.1.0/kelam.egg-info/entry_points.txt +2 -0
- kelam-0.1.0/kelam.egg-info/requires.txt +3 -0
- kelam-0.1.0/kelam.egg-info/top_level.txt +1 -0
- kelam-0.1.0/kelam_cli/__init__.py +6 -0
- kelam-0.1.0/kelam_cli/main.py +557 -0
- kelam-0.1.0/pyproject.toml +22 -0
- kelam-0.1.0/setup.cfg +4 -0
kelam-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kelam
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build and run voice AI agents from the terminal — the Kelam CLI.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: kelam-core>=0.1.0
|
|
8
|
+
Requires-Dist: httpx<1,>=0.27
|
|
9
|
+
Requires-Dist: typer<1,>=0.12
|
|
10
|
+
|
|
11
|
+
# kelam
|
|
12
|
+
|
|
13
|
+
Build and run voice AI agents from the terminal — the **Kelam CLI**.
|
|
14
|
+
|
|
15
|
+
A thin, dependency-light client (httpx + typer + the shared `kelam-core` contract) for the
|
|
16
|
+
Kelam voice-agent control plane. Create agents, deploy them, place real phone calls, talk to
|
|
17
|
+
them in the browser, send texts, and export call data — all from `kelam`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
curl -fsSL https://kelam.sh | sh # one-command installer
|
|
23
|
+
# or:
|
|
24
|
+
uv tool install kelam
|
|
25
|
+
pipx install kelam
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configure
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
export KELAM_API_URL=https://<your-kelam-host>
|
|
32
|
+
export KELAM_PASSWORD=<password> # only if the server uses shared-password auth
|
|
33
|
+
kelam list
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## The loop
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
kelam create my-bot # scaffold + provision a phone number
|
|
40
|
+
kelam deploy <agent_id> # assemble + cache the runtime
|
|
41
|
+
kelam call <agent_id> +1206... # place a real outbound call
|
|
42
|
+
kelam web <agent_id> # or talk in the browser, no phone number
|
|
43
|
+
kelam export --since 7d # call logs + derived metrics
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The platform (API + voice worker) lives in the separate `kelam-backend` package; this package
|
|
47
|
+
is just the operator CLI.
|
kelam-0.1.0/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# kelam
|
|
2
|
+
|
|
3
|
+
Build and run voice AI agents from the terminal — the **Kelam CLI**.
|
|
4
|
+
|
|
5
|
+
A thin, dependency-light client (httpx + typer + the shared `kelam-core` contract) for the
|
|
6
|
+
Kelam voice-agent control plane. Create agents, deploy them, place real phone calls, talk to
|
|
7
|
+
them in the browser, send texts, and export call data — all from `kelam`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
curl -fsSL https://kelam.sh | sh # one-command installer
|
|
13
|
+
# or:
|
|
14
|
+
uv tool install kelam
|
|
15
|
+
pipx install kelam
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configure
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
export KELAM_API_URL=https://<your-kelam-host>
|
|
22
|
+
export KELAM_PASSWORD=<password> # only if the server uses shared-password auth
|
|
23
|
+
kelam list
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## The loop
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
kelam create my-bot # scaffold + provision a phone number
|
|
30
|
+
kelam deploy <agent_id> # assemble + cache the runtime
|
|
31
|
+
kelam call <agent_id> +1206... # place a real outbound call
|
|
32
|
+
kelam web <agent_id> # or talk in the browser, no phone number
|
|
33
|
+
kelam export --since 7d # call logs + derived metrics
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The platform (API + voice worker) lives in the separate `kelam-backend` package; this package
|
|
37
|
+
is just the operator CLI.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kelam
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build and run voice AI agents from the terminal — the Kelam CLI.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: kelam-core>=0.1.0
|
|
8
|
+
Requires-Dist: httpx<1,>=0.27
|
|
9
|
+
Requires-Dist: typer<1,>=0.12
|
|
10
|
+
|
|
11
|
+
# kelam
|
|
12
|
+
|
|
13
|
+
Build and run voice AI agents from the terminal — the **Kelam CLI**.
|
|
14
|
+
|
|
15
|
+
A thin, dependency-light client (httpx + typer + the shared `kelam-core` contract) for the
|
|
16
|
+
Kelam voice-agent control plane. Create agents, deploy them, place real phone calls, talk to
|
|
17
|
+
them in the browser, send texts, and export call data — all from `kelam`.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
curl -fsSL https://kelam.sh | sh # one-command installer
|
|
23
|
+
# or:
|
|
24
|
+
uv tool install kelam
|
|
25
|
+
pipx install kelam
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configure
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
export KELAM_API_URL=https://<your-kelam-host>
|
|
32
|
+
export KELAM_PASSWORD=<password> # only if the server uses shared-password auth
|
|
33
|
+
kelam list
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## The loop
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
kelam create my-bot # scaffold + provision a phone number
|
|
40
|
+
kelam deploy <agent_id> # assemble + cache the runtime
|
|
41
|
+
kelam call <agent_id> +1206... # place a real outbound call
|
|
42
|
+
kelam web <agent_id> # or talk in the browser, no phone number
|
|
43
|
+
kelam export --since 7d # call logs + derived metrics
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The platform (API + voice worker) lives in the separate `kelam-backend` package; this package
|
|
47
|
+
is just the operator CLI.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kelam_cli
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""kelam — the command-line client for the Kelam voice-agent platform.
|
|
2
|
+
|
|
3
|
+
A thin HTTP client over the control plane (depends only on kelam-core + httpx + typer), so
|
|
4
|
+
`uv tool install kelam` / `pipx install kelam` stays small and fast. The platform itself
|
|
5
|
+
(API + worker) lives in the separate kelam-backend package.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""The `kelam` CLI — a thin HTTP client over the control plane, operated by the
|
|
2
|
+
engineer (or by Claude). Core logic is plain functions taking an httpx.Client so
|
|
3
|
+
the whole CLI<->API<->AWS path is integration-testable; Typer commands wrap them.
|
|
4
|
+
|
|
5
|
+
Config via env: KELAM_API_URL (default http://localhost:8000), KELAM_WORKSPACE (default "default").
|
|
6
|
+
The git-style loop: create -> pull -> (edit/Claude) -> verify -> push -> deploy -> call.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import webbrowser
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import urlencode
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import typer
|
|
22
|
+
|
|
23
|
+
from kelam_core.metrics import CSV_FIELDS, flatten_call, summarize
|
|
24
|
+
from kelam_core.translation import folder_to_records, records_to_folder
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def api_base() -> str:
|
|
28
|
+
"""The control-plane base URL (KELAM_API_URL, default localhost), no trailing slash.
|
|
29
|
+
The single place the default lives — make_client and the URL builders all read it."""
|
|
30
|
+
return os.environ.get("KELAM_API_URL", "http://localhost:8000").rstrip("/")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_client() -> httpx.Client:
|
|
34
|
+
ws = os.environ.get("KELAM_WORKSPACE", "default")
|
|
35
|
+
headers = {"X-Workspace": ws}
|
|
36
|
+
password = os.environ.get("KELAM_PASSWORD")
|
|
37
|
+
if password:
|
|
38
|
+
headers["X-Kelam-Password"] = password
|
|
39
|
+
return httpx.Client(base_url=api_base(), headers=headers, timeout=120.0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---- core operations (testable; take an httpx.Client) ----
|
|
43
|
+
|
|
44
|
+
def cli_create(client: httpx.Client, name: str, description: str = "",
|
|
45
|
+
inbound: bool = True, outbound: bool = True) -> dict:
|
|
46
|
+
r = client.post("/agents", json={"name": name, "description": description,
|
|
47
|
+
"inbound": inbound, "outbound": outbound})
|
|
48
|
+
r.raise_for_status()
|
|
49
|
+
return r.json()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cli_pull(client: httpx.Client, agent_id: str, dest) -> dict:
|
|
53
|
+
r = client.get(f"/agents/{agent_id}")
|
|
54
|
+
r.raise_for_status()
|
|
55
|
+
data = r.json()
|
|
56
|
+
dest = Path(dest)
|
|
57
|
+
records_to_folder(data["records"], dest)
|
|
58
|
+
(dest / ".agentmeta.json").write_text(
|
|
59
|
+
json.dumps({"agent_id": agent_id, **data["meta"]}, indent=2), encoding="utf-8"
|
|
60
|
+
)
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cli_push(client: httpx.Client, agent_id: str, src) -> dict:
|
|
65
|
+
r = client.post(f"/agents/{agent_id}/push", json={"records": folder_to_records(src)})
|
|
66
|
+
r.raise_for_status()
|
|
67
|
+
return r.json()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cli_verify(client: httpx.Client, agent_id: str, src) -> list[str]:
|
|
71
|
+
r = client.post(f"/agents/{agent_id}/verify", json={"records": folder_to_records(src)})
|
|
72
|
+
r.raise_for_status()
|
|
73
|
+
return r.json()["issues"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cli_deploy(client: httpx.Client, agent_id: str) -> dict:
|
|
77
|
+
r = client.post(f"/agents/{agent_id}/deploy")
|
|
78
|
+
r.raise_for_status()
|
|
79
|
+
return r.json()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cli_list(client: httpx.Client) -> list[dict]:
|
|
83
|
+
r = client.get("/agents")
|
|
84
|
+
r.raise_for_status()
|
|
85
|
+
return r.json()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cli_secret_set(client: httpx.Client, name: str, value: str) -> dict:
|
|
89
|
+
r = client.post("/secrets", json={"name": name, "value": value})
|
|
90
|
+
r.raise_for_status()
|
|
91
|
+
return r.json()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cli_delete(client: httpx.Client, agent_id: str) -> dict:
|
|
95
|
+
r = client.delete(f"/agents/{agent_id}")
|
|
96
|
+
r.raise_for_status()
|
|
97
|
+
return r.json()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cli_call(client: httpx.Client, agent_id: str, to_number: str,
|
|
101
|
+
prompt: str | None = None) -> dict:
|
|
102
|
+
body = {"agent_id": agent_id, "to_number": to_number}
|
|
103
|
+
if prompt:
|
|
104
|
+
body["prompt"] = prompt
|
|
105
|
+
r = client.post("/calls", json=body)
|
|
106
|
+
r.raise_for_status()
|
|
107
|
+
return r.json()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def web_call_url(agent_id: str, prompt: str | None = None) -> str:
|
|
111
|
+
"""The browser test-page URL for an agent: <KELAM_API_URL>/web-call?agent_id=...
|
|
112
|
+
The page (served by the API) dispatches the agent and connects over WebRTC on Start.
|
|
113
|
+
|
|
114
|
+
The per-call prompt and the shared password (KELAM_PASSWORD, if set) travel in the
|
|
115
|
+
URL FRAGMENT: the browser never sends it to the server, so a 32 KiB prompt can't
|
|
116
|
+
blow the HTTP request-line limit and the password never lands in access logs."""
|
|
117
|
+
ws = os.environ.get("KELAM_WORKSPACE", "default")
|
|
118
|
+
url = f"{api_base()}/web-call?{urlencode({'agent_id': agent_id, 'workspace': ws})}"
|
|
119
|
+
frag = {}
|
|
120
|
+
if prompt:
|
|
121
|
+
frag["prompt"] = prompt
|
|
122
|
+
password = os.environ.get("KELAM_PASSWORD")
|
|
123
|
+
if password:
|
|
124
|
+
frag["key"] = password
|
|
125
|
+
if frag:
|
|
126
|
+
url += "#" + urlencode(frag)
|
|
127
|
+
return url
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_instructions(prompt: str | None, file: str | None) -> str | None:
|
|
131
|
+
"""Combine --prompt text and the contents of --file into the per-call instructions.
|
|
132
|
+
The file is read here (on the operator's machine); only the resulting text crosses the API."""
|
|
133
|
+
parts = []
|
|
134
|
+
if prompt:
|
|
135
|
+
parts.append(prompt)
|
|
136
|
+
if file:
|
|
137
|
+
parts.append(Path(file).read_text(encoding="utf-8"))
|
|
138
|
+
combined = "\n\n".join(p.strip() for p in parts if p and p.strip())
|
|
139
|
+
return combined or None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cli_get_call(client: httpx.Client, call_id: str) -> dict:
|
|
143
|
+
r = client.get(f"/calls/{call_id}")
|
|
144
|
+
r.raise_for_status()
|
|
145
|
+
return r.json()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cli_list_calls(client: httpx.Client, agent_id: str,
|
|
149
|
+
limit: int = 50, cursor: str | None = None) -> dict:
|
|
150
|
+
"""One page: {"calls": [...], "next_cursor": str|null}."""
|
|
151
|
+
params: dict = {"agent_id": agent_id, "limit": limit}
|
|
152
|
+
if cursor:
|
|
153
|
+
params["cursor"] = cursor
|
|
154
|
+
r = client.get("/calls", params=params)
|
|
155
|
+
r.raise_for_status()
|
|
156
|
+
return r.json()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_since(text: str) -> str:
|
|
160
|
+
""" "30m" / "24h" / "7d" / "2w" shorthand -> ISO-8601 UTC; ISO dates pass through.
|
|
161
|
+
Raises BadParameter on anything else so the error surfaces before the HTTP call."""
|
|
162
|
+
m = re.fullmatch(r"(\d+)([mhdw])", text.strip())
|
|
163
|
+
if m:
|
|
164
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
165
|
+
delta = {"m": timedelta(minutes=n), "h": timedelta(hours=n),
|
|
166
|
+
"d": timedelta(days=n), "w": timedelta(weeks=n)}[unit]
|
|
167
|
+
return (datetime.now(timezone.utc) - delta).isoformat()
|
|
168
|
+
try:
|
|
169
|
+
datetime.fromisoformat(text)
|
|
170
|
+
except ValueError:
|
|
171
|
+
raise typer.BadParameter(
|
|
172
|
+
f"--since must be like 30m/24h/7d/2w or ISO-8601, got {text!r}")
|
|
173
|
+
return text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cli_export(client: httpx.Client, agent_id: str | None = None, since: str | None = None,
|
|
177
|
+
status: str | None = None, limit: int | None = None,
|
|
178
|
+
page_size: int = 200) -> list[dict]:
|
|
179
|
+
"""Walk the paginated export until exhausted (or `limit` calls collected). Each
|
|
180
|
+
page is one bounded server-side query; the loop key is next_cursor, never item
|
|
181
|
+
count — filtered pages can run short and still have more behind them."""
|
|
182
|
+
calls: list[dict] = []
|
|
183
|
+
cursor: str | None = None
|
|
184
|
+
while True:
|
|
185
|
+
params = {k: v for k, v in {
|
|
186
|
+
"agent_id": agent_id, "since": since, "status": status,
|
|
187
|
+
"limit": min(page_size, limit - len(calls)) if limit else page_size,
|
|
188
|
+
"cursor": cursor,
|
|
189
|
+
}.items() if v is not None}
|
|
190
|
+
r = client.get("/calls/export", params=params)
|
|
191
|
+
r.raise_for_status()
|
|
192
|
+
body = r.json()
|
|
193
|
+
calls.extend(body["calls"])
|
|
194
|
+
cursor = body.get("next_cursor")
|
|
195
|
+
if not cursor or (limit and len(calls) >= limit):
|
|
196
|
+
return calls[:limit] if limit else calls
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def render_export(calls: list[dict], fmt: str) -> str:
|
|
200
|
+
"""Enriched calls -> jsonl (one call per line), json (array), or csv (flat metric
|
|
201
|
+
rows, no transcript text). jsonl/csv are what the kelam-viz skill consumes."""
|
|
202
|
+
if fmt == "jsonl":
|
|
203
|
+
return "\n".join(json.dumps(c) for c in calls) + ("\n" if calls else "")
|
|
204
|
+
if fmt == "json":
|
|
205
|
+
return json.dumps(calls, indent=2) + "\n"
|
|
206
|
+
if fmt == "csv":
|
|
207
|
+
buf = io.StringIO()
|
|
208
|
+
writer = csv.DictWriter(buf, fieldnames=CSV_FIELDS)
|
|
209
|
+
writer.writeheader()
|
|
210
|
+
for call in calls:
|
|
211
|
+
writer.writerow(flatten_call(call))
|
|
212
|
+
return buf.getvalue()
|
|
213
|
+
raise typer.BadParameter(f"--format must be jsonl, json, or csv, got {fmt!r}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cli_text(client: httpx.Client, agent_id: str, to_number: str, body: str = "",
|
|
217
|
+
media_urls: list[str] | None = None, channel: str = "sms") -> dict:
|
|
218
|
+
r = client.post("/messages", json={"agent_id": agent_id, "to_number": to_number,
|
|
219
|
+
"body": body, "media_urls": media_urls or [],
|
|
220
|
+
"channel": channel})
|
|
221
|
+
r.raise_for_status()
|
|
222
|
+
return r.json()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cli_list_texts(client: httpx.Client, agent_id: str | None = None, peer: str | None = None,
|
|
226
|
+
since: str | None = None, limit: int = 50,
|
|
227
|
+
cursor: str | None = None) -> dict:
|
|
228
|
+
"""One page: {"messages": [...], "next_cursor": str|null}."""
|
|
229
|
+
params = {k: v for k, v in {"agent_id": agent_id, "peer": peer, "since": since,
|
|
230
|
+
"limit": limit, "cursor": cursor}.items() if v is not None}
|
|
231
|
+
r = client.get("/messages", params=params)
|
|
232
|
+
r.raise_for_status()
|
|
233
|
+
return r.json()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def cli_context(client: httpx.Client, agent_id: str, peer: str) -> dict:
|
|
237
|
+
"""The compose-a-reply bundle: message thread + call transcripts with the peer."""
|
|
238
|
+
r = client.get("/messages/context", params={"agent_id": agent_id, "peer": peer})
|
|
239
|
+
r.raise_for_status()
|
|
240
|
+
return r.json()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cli_peers(client: httpx.Client, agent_id: str, limit: int = 20) -> dict:
|
|
244
|
+
"""Recently active numbers across calls + texts: {"peers": [...], "truncated": bool}."""
|
|
245
|
+
r = client.get(f"/agents/{agent_id}/peers", params={"limit": limit})
|
|
246
|
+
r.raise_for_status()
|
|
247
|
+
return r.json()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def stream_call_events(client: httpx.Client, call_id: str):
|
|
251
|
+
"""Yield the call's live events from the SSE endpoint until the stream closes.
|
|
252
|
+
Long-lived read: no read timeout (the server sends keepalives and closes on `ended`)."""
|
|
253
|
+
timeout = httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)
|
|
254
|
+
with client.stream("GET", f"/calls/{call_id}/stream", timeout=timeout) as r:
|
|
255
|
+
r.raise_for_status()
|
|
256
|
+
for line in r.iter_lines():
|
|
257
|
+
if line.startswith("data: "):
|
|
258
|
+
yield json.loads(line[len("data: "):])
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def follow_call(client: httpx.Client, call_id: str, echo=print) -> dict | None:
|
|
262
|
+
"""Print events as JSONL until the call ends; return the final CallLog (None on timeout)."""
|
|
263
|
+
for event in stream_call_events(client, call_id):
|
|
264
|
+
echo(json.dumps(event))
|
|
265
|
+
if event.get("type") == "ended":
|
|
266
|
+
return event.get("call")
|
|
267
|
+
if event.get("type") == "timeout":
|
|
268
|
+
return None
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---- Typer commands ----
|
|
273
|
+
|
|
274
|
+
app = typer.Typer(help="Kelam — build and run voice AI agents from the terminal.")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.command()
|
|
278
|
+
def create(
|
|
279
|
+
name: str,
|
|
280
|
+
description: str = "",
|
|
281
|
+
no_number: bool = typer.Option(
|
|
282
|
+
False, "--no-number",
|
|
283
|
+
help="web-only agent: skip provisioning a phone number (talk to it with `kelam web`)",
|
|
284
|
+
),
|
|
285
|
+
):
|
|
286
|
+
"""Create an agent and pull it into ./<name>/.
|
|
287
|
+
|
|
288
|
+
By default auto-provisions a phone number. With --no-number the agent has no inbound or
|
|
289
|
+
outbound telephony and costs nothing — reach it over the browser with `kelam web <id>`."""
|
|
290
|
+
with make_client() as c:
|
|
291
|
+
d = cli_create(c, name, description,
|
|
292
|
+
inbound=not no_number, outbound=not no_number)
|
|
293
|
+
cli_pull(c, d["agent_id"], Path(name))
|
|
294
|
+
typer.echo(json.dumps(d))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command()
|
|
298
|
+
def pull(agent_id: str, dest: str = ""):
|
|
299
|
+
"""Pull an agent's files into a local folder."""
|
|
300
|
+
with make_client() as c:
|
|
301
|
+
cli_pull(c, agent_id, Path(dest or agent_id))
|
|
302
|
+
typer.echo(json.dumps({"pulled": agent_id, "dest": dest or agent_id}))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command()
|
|
306
|
+
def verify(agent_id: str, src: str = "."):
|
|
307
|
+
"""Lint a local agent folder (config, tools, secret references)."""
|
|
308
|
+
with make_client() as c:
|
|
309
|
+
issues = cli_verify(c, agent_id, src)
|
|
310
|
+
typer.echo(json.dumps({"issues": issues}))
|
|
311
|
+
if issues:
|
|
312
|
+
raise typer.Exit(1)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.command()
|
|
316
|
+
def push(agent_id: str, src: str = "."):
|
|
317
|
+
"""Push a local agent folder (creates a new version)."""
|
|
318
|
+
with make_client() as c:
|
|
319
|
+
typer.echo(json.dumps(cli_push(c, agent_id, src)))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@app.command()
|
|
323
|
+
def deploy(agent_id: str):
|
|
324
|
+
"""Deploy: assemble + cache the runtime, ready for calls."""
|
|
325
|
+
with make_client() as c:
|
|
326
|
+
typer.echo(json.dumps(cli_deploy(c, agent_id)))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@app.command("list")
|
|
330
|
+
def list_cmd():
|
|
331
|
+
"""List agents in the workspace."""
|
|
332
|
+
with make_client() as c:
|
|
333
|
+
typer.echo(json.dumps(cli_list(c)))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@app.command("secret-set")
|
|
337
|
+
def secret_set(
|
|
338
|
+
name: str,
|
|
339
|
+
env_var: str = typer.Option(
|
|
340
|
+
None, "--env-var", help="read the value from this env var instead of a hidden prompt"
|
|
341
|
+
),
|
|
342
|
+
):
|
|
343
|
+
"""Set a workspace secret (stored encrypted in SSM). The value is read from --env-var or a
|
|
344
|
+
hidden prompt — never passed on the command line (keeps it out of shell history/transcripts)."""
|
|
345
|
+
if env_var:
|
|
346
|
+
if env_var not in os.environ:
|
|
347
|
+
raise typer.BadParameter(f"env var {env_var!r} is not set")
|
|
348
|
+
value = os.environ[env_var]
|
|
349
|
+
else:
|
|
350
|
+
value = typer.prompt("Secret value", hide_input=True)
|
|
351
|
+
with make_client() as c:
|
|
352
|
+
cli_secret_set(c, name, value)
|
|
353
|
+
typer.echo(json.dumps({"ok": True}))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@app.command()
|
|
357
|
+
def call(
|
|
358
|
+
agent_id: str,
|
|
359
|
+
to_number: str,
|
|
360
|
+
prompt: str = typer.Option(
|
|
361
|
+
None, "--prompt", "-p", help="per-call instructions, seeded as the agent's first message"
|
|
362
|
+
),
|
|
363
|
+
file: str = typer.Option(
|
|
364
|
+
None, "--file", "-f", help="read per-call instructions from this file (combined with --prompt)"
|
|
365
|
+
),
|
|
366
|
+
wait: bool = typer.Option(
|
|
367
|
+
False, "--wait", "-w", help="stream the call live and exit with the final transcript"
|
|
368
|
+
),
|
|
369
|
+
):
|
|
370
|
+
"""Place an outbound call from the agent to a phone number (fire-and-forget).
|
|
371
|
+
|
|
372
|
+
Optional --prompt/--file tailor THIS call without redeploying — e.g.
|
|
373
|
+
`kelam call <id> +1206... -p "extract their email, budget, and timeline"`.
|
|
374
|
+
With --wait, stream the call's events live (JSONL) and exit when it ends,
|
|
375
|
+
printing the final CallLog — the place->follow->transcript loop in one command."""
|
|
376
|
+
if file and not Path(file).is_file():
|
|
377
|
+
raise typer.BadParameter(f"file not found: {file}")
|
|
378
|
+
instructions = resolve_instructions(prompt, file)
|
|
379
|
+
with make_client() as c:
|
|
380
|
+
placed = cli_call(c, agent_id, to_number, prompt=instructions)
|
|
381
|
+
typer.echo(json.dumps(placed))
|
|
382
|
+
if not wait:
|
|
383
|
+
return
|
|
384
|
+
final = follow_call(c, placed["call_id"], echo=typer.echo)
|
|
385
|
+
if final is None:
|
|
386
|
+
raise typer.Exit(1) # stream timed out without a terminal status
|
|
387
|
+
typer.echo(json.dumps(final))
|
|
388
|
+
if final.get("status") != "completed":
|
|
389
|
+
raise typer.Exit(1)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@app.command()
|
|
393
|
+
def web(
|
|
394
|
+
agent_id: str,
|
|
395
|
+
prompt: str = typer.Option(
|
|
396
|
+
None, "--prompt", "-p", help="per-call instructions, seeded as the agent's first message"
|
|
397
|
+
),
|
|
398
|
+
file: str = typer.Option(
|
|
399
|
+
None, "--file", "-f", help="read per-call instructions from this file (combined with --prompt)"
|
|
400
|
+
),
|
|
401
|
+
no_open: bool = typer.Option(
|
|
402
|
+
False, "--no-open", help="just print the URL; don't open a browser"
|
|
403
|
+
),
|
|
404
|
+
):
|
|
405
|
+
"""Talk to a deployed agent in your browser over WebRTC — no phone number required.
|
|
406
|
+
|
|
407
|
+
Opens the test page; click Start to dispatch the agent, connect your mic, and watch
|
|
408
|
+
the live transcript. The call is logged like any other (`kelam transcript <call_id>`).
|
|
409
|
+
Optional --prompt/--file tailor THIS call without redeploying. The page is served by
|
|
410
|
+
the API, so point KELAM_API_URL at the box you want (defaults to localhost:8000)."""
|
|
411
|
+
if file and not Path(file).is_file():
|
|
412
|
+
raise typer.BadParameter(f"file not found: {file}")
|
|
413
|
+
url = web_call_url(agent_id, resolve_instructions(prompt, file))
|
|
414
|
+
typer.echo(json.dumps({"agent_id": agent_id, "url": url}))
|
|
415
|
+
if not no_open:
|
|
416
|
+
webbrowser.open(url)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@app.command()
|
|
420
|
+
def transcript(
|
|
421
|
+
target: str = typer.Argument(..., metavar="CALL_ID|AGENT_ID",
|
|
422
|
+
help="a call_id (one call) — or an agent_id, with a phone number"),
|
|
423
|
+
peer: str = typer.Argument(
|
|
424
|
+
None, metavar="[PEER]",
|
|
425
|
+
help="a peer from `kelam peers` (usually a phone number): return the full "
|
|
426
|
+
"voice+text history with it instead"),
|
|
427
|
+
):
|
|
428
|
+
"""Fetch a transcript.
|
|
429
|
+
|
|
430
|
+
One arg (a call_id) -> that call's log: status + transcript + recording_url,
|
|
431
|
+
plus a short-lived presigned recording_playback_url (click-to-listen, no manual
|
|
432
|
+
aws s3 presign). Two args (an agent_id and a phone number) -> the whole
|
|
433
|
+
conversation with that number: the text thread plus call logs/transcripts, as
|
|
434
|
+
{messages, calls}. Read it, then reply with `kelam text`."""
|
|
435
|
+
with make_client() as c:
|
|
436
|
+
if peer is None:
|
|
437
|
+
typer.echo(json.dumps(cli_get_call(c, target)))
|
|
438
|
+
return
|
|
439
|
+
# Full-length numbers are normalized to E.164 (a bare 12065550123 gains its +);
|
|
440
|
+
# everything else `kelam peers` can yield — short codes, alphanumeric sender ids,
|
|
441
|
+
# anonymous caller ids — passes through exactly as stored, never rejected.
|
|
442
|
+
peer = peer.strip()
|
|
443
|
+
if re.fullmatch(r"\+?[1-9]\d{7,14}", peer):
|
|
444
|
+
peer = "+" + peer.lstrip("+")
|
|
445
|
+
typer.echo(json.dumps(cli_context(c, target, peer)))
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@app.command()
|
|
449
|
+
def calls(
|
|
450
|
+
agent_id: str,
|
|
451
|
+
limit: int = typer.Option(50, "--limit", "-n", help="page size"),
|
|
452
|
+
cursor: str = typer.Option(None, "--cursor", help="resume from a previous page's next_cursor"),
|
|
453
|
+
):
|
|
454
|
+
"""List an agent's recent calls (newest first), one page at a time.
|
|
455
|
+
Output is {"calls": [...], "next_cursor": ...}; pass --cursor to continue."""
|
|
456
|
+
with make_client() as c:
|
|
457
|
+
typer.echo(json.dumps(cli_list_calls(c, agent_id, limit=limit, cursor=cursor)))
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@app.command()
|
|
461
|
+
def export(
|
|
462
|
+
agent_id: str = typer.Option(None, "--agent", "-a", help="only this agent's calls (default: whole workspace)"),
|
|
463
|
+
since: str = typer.Option(None, "--since", help='only calls started after this: "30m", "24h", "7d", "2w", or ISO-8601'),
|
|
464
|
+
status: str = typer.Option(None, "--status", help="filter by status: completed | failed | busy | no_answer | in_progress"),
|
|
465
|
+
limit: int = typer.Option(None, "--limit", "-n", help="newest N calls after filtering"),
|
|
466
|
+
fmt: str = typer.Option("jsonl", "--format", help="jsonl (default) | json | csv (flat metrics, no transcript)"),
|
|
467
|
+
out: str = typer.Option(None, "--out", "-o", help="write to this file instead of stdout"),
|
|
468
|
+
):
|
|
469
|
+
"""Export call logs — transcripts plus derived metrics (turns, words, talk ratio,
|
|
470
|
+
durations) — for analysis or visualization. Newest first."""
|
|
471
|
+
with make_client() as c:
|
|
472
|
+
calls = cli_export(c, agent_id=agent_id, since=parse_since(since) if since else None,
|
|
473
|
+
status=status, limit=limit)
|
|
474
|
+
rendered = render_export(calls, fmt)
|
|
475
|
+
if out:
|
|
476
|
+
Path(out).write_text(rendered, encoding="utf-8")
|
|
477
|
+
typer.echo(json.dumps({"exported": len(calls), "format": fmt, "out": out}))
|
|
478
|
+
else:
|
|
479
|
+
typer.echo(rendered, nl=False)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@app.command()
|
|
483
|
+
def stats(
|
|
484
|
+
agent_id: str = typer.Option(None, "--agent", "-a", help="only this agent's calls (default: whole workspace)"),
|
|
485
|
+
since: str = typer.Option(None, "--since", help='only calls started after this: "30m", "24h", "7d", "2w", or ISO-8601'),
|
|
486
|
+
):
|
|
487
|
+
"""Aggregate call stats: counts by status/direction/agent, duration percentiles, turn totals."""
|
|
488
|
+
with make_client() as c:
|
|
489
|
+
calls = cli_export(c, agent_id=agent_id, since=parse_since(since) if since else None)
|
|
490
|
+
typer.echo(json.dumps(summarize(calls), indent=2))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@app.command()
|
|
494
|
+
def text(
|
|
495
|
+
agent_id: str,
|
|
496
|
+
to_number: str,
|
|
497
|
+
body: str = typer.Argument("", help="the message text (optional when --media is given)"),
|
|
498
|
+
media: list[str] = typer.Option(
|
|
499
|
+
None, "--media", "-m", help="media URL to attach (repeatable) — images etc."
|
|
500
|
+
),
|
|
501
|
+
channel: str = typer.Option(
|
|
502
|
+
"sms", "--channel", "-c", help="delivery channel: sms (default) or whatsapp"
|
|
503
|
+
),
|
|
504
|
+
):
|
|
505
|
+
"""Send a message (SMS, MMS with --media, or --channel whatsapp) from the agent's number.
|
|
506
|
+
|
|
507
|
+
e.g. `kelam text <id> +12065550123 "running 5 minutes late"`. The message is
|
|
508
|
+
stored on the (agent, number) thread; replies land there too (the number's SMS
|
|
509
|
+
webhook is wired to this server automatically at provision when KELAM_PUBLIC_URL
|
|
510
|
+
is set)."""
|
|
511
|
+
if not body and not media:
|
|
512
|
+
raise typer.BadParameter("provide a message body and/or --media")
|
|
513
|
+
with make_client() as c:
|
|
514
|
+
typer.echo(json.dumps(cli_text(c, agent_id, to_number, body=body,
|
|
515
|
+
media_urls=list(media) if media else None,
|
|
516
|
+
channel=channel)))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.command()
|
|
520
|
+
def texts(
|
|
521
|
+
agent_id: str,
|
|
522
|
+
peer: str = typer.Option(None, "--peer", help="only the thread with this number"),
|
|
523
|
+
since: str = typer.Option(None, "--since", help='only messages after this: "30m", "24h", "7d", "2w", or ISO-8601'),
|
|
524
|
+
limit: int = typer.Option(50, "--limit", "-n", help="page size"),
|
|
525
|
+
cursor: str = typer.Option(None, "--cursor", help="resume from a previous page's next_cursor"),
|
|
526
|
+
):
|
|
527
|
+
"""List an agent's messages (newest first), one page at a time.
|
|
528
|
+
Output is {"messages": [...], "next_cursor": ...}; pass --cursor to continue."""
|
|
529
|
+
with make_client() as c:
|
|
530
|
+
typer.echo(json.dumps(cli_list_texts(
|
|
531
|
+
c, agent_id, peer=peer, since=parse_since(since) if since else None,
|
|
532
|
+
limit=limit, cursor=cursor)))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@app.command()
|
|
536
|
+
def peers(
|
|
537
|
+
agent_id: str,
|
|
538
|
+
limit: int = typer.Option(20, "--limit", "-n", help="how many distinct numbers to return"),
|
|
539
|
+
):
|
|
540
|
+
"""Recently active numbers across this agent's calls AND texts, newest contact first.
|
|
541
|
+
|
|
542
|
+
The starting point for following up: pick a number, then `kelam transcript <id> <number>`
|
|
543
|
+
for the full voice+text history. Each entry has last_contact, last_direction, and the
|
|
544
|
+
channels (call/text) it used. Output: {"peers": [...], "truncated": bool}."""
|
|
545
|
+
with make_client() as c:
|
|
546
|
+
typer.echo(json.dumps(cli_peers(c, agent_id, limit=limit)))
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@app.command()
|
|
550
|
+
def delete(agent_id: str):
|
|
551
|
+
"""Delete an agent."""
|
|
552
|
+
with make_client() as c:
|
|
553
|
+
typer.echo(json.dumps(cli_delete(c, agent_id)))
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
if __name__ == "__main__":
|
|
557
|
+
app()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kelam"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Build and run voice AI agents from the terminal — the Kelam CLI."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
# Capped at the next major per the repo dep policy (issue #40); 0.x libs capped at <1.
|
|
8
|
+
dependencies = [
|
|
9
|
+
"kelam-core>=0.1.0",
|
|
10
|
+
"httpx>=0.27,<1",
|
|
11
|
+
"typer>=0.12,<1",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
kelam = "kelam_cli.main:app"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["setuptools>=68"]
|
|
19
|
+
build-backend = "setuptools.build_meta"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
include = ["kelam_cli*"]
|
kelam-0.1.0/setup.cfg
ADDED