@agentunion/kite 1.0.7 → 1.3.0

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +48 -0
  3. package/cli.js +1 -1
  4. package/extensions/agents/__init__.py +1 -0
  5. package/extensions/agents/assistant/__init__.py +1 -0
  6. package/extensions/agents/assistant/entry.py +329 -0
  7. package/extensions/agents/assistant/module.md +22 -0
  8. package/extensions/agents/assistant/server.py +197 -0
  9. package/extensions/channels/__init__.py +1 -0
  10. package/extensions/channels/acp_channel/__init__.py +1 -0
  11. package/extensions/channels/acp_channel/entry.py +329 -0
  12. package/extensions/channels/acp_channel/module.md +22 -0
  13. package/extensions/channels/acp_channel/server.py +197 -0
  14. package/extensions/event_hub_bench/entry.py +624 -379
  15. package/extensions/event_hub_bench/module.md +2 -1
  16. package/extensions/services/backup/__init__.py +1 -0
  17. package/extensions/services/backup/entry.py +508 -0
  18. package/extensions/services/backup/module.md +22 -0
  19. package/extensions/services/model_service/__init__.py +1 -0
  20. package/extensions/services/model_service/entry.py +508 -0
  21. package/extensions/services/model_service/module.md +22 -0
  22. package/extensions/services/watchdog/entry.py +468 -102
  23. package/extensions/services/watchdog/module.md +3 -0
  24. package/extensions/services/watchdog/monitor.py +170 -69
  25. package/extensions/services/web/__init__.py +1 -0
  26. package/extensions/services/web/config.yaml +149 -0
  27. package/extensions/services/web/entry.py +390 -0
  28. package/extensions/services/web/module.md +24 -0
  29. package/extensions/services/web/routes/__init__.py +1 -0
  30. package/extensions/services/web/routes/routes_call.py +189 -0
  31. package/extensions/services/web/routes/routes_config.py +512 -0
  32. package/extensions/services/web/routes/routes_contacts.py +98 -0
  33. package/extensions/services/web/routes/routes_devlog.py +99 -0
  34. package/extensions/services/web/routes/routes_phone.py +81 -0
  35. package/extensions/services/web/routes/routes_sms.py +48 -0
  36. package/extensions/services/web/routes/routes_stats.py +17 -0
  37. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  38. package/extensions/services/web/routes/schemas.py +216 -0
  39. package/extensions/services/web/server.py +375 -0
  40. package/extensions/services/web/static/css/style.css +1064 -0
  41. package/extensions/services/web/static/index.html +1445 -0
  42. package/extensions/services/web/static/js/app.js +4671 -0
  43. package/extensions/services/web/vendor/__init__.py +1 -0
  44. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  45. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  46. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  47. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  48. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  49. package/extensions/services/web/vendor/config.py +139 -0
  50. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  51. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  52. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  53. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  54. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  55. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  56. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  57. package/extensions/services/web/vendor/storage/identity.py +312 -0
  58. package/extensions/services/web/vendor/storage/store.py +507 -0
  59. package/extensions/services/web/vendor/task/manager.py +864 -0
  60. package/extensions/services/web/vendor/task/models.py +45 -0
  61. package/extensions/services/web/vendor/task/webhook.py +263 -0
  62. package/extensions/services/web/vendor/tools/registry.py +321 -0
  63. package/kernel/__init__.py +0 -0
  64. package/kernel/entry.py +407 -0
  65. package/{core/event_hub/hub.py → kernel/event_hub.py} +62 -74
  66. package/kernel/module.md +33 -0
  67. package/{core/registry/store.py → kernel/registry_store.py} +23 -8
  68. package/kernel/rpc_router.py +388 -0
  69. package/kernel/server.py +267 -0
  70. package/launcher/__init__.py +10 -0
  71. package/launcher/__main__.py +6 -0
  72. package/launcher/count_lines.py +258 -0
  73. package/launcher/entry.py +1778 -0
  74. package/launcher/logging_setup.py +289 -0
  75. package/{core/launcher → launcher}/module_scanner.py +11 -6
  76. package/launcher/process_manager.py +880 -0
  77. package/main.py +11 -210
  78. package/package.json +6 -9
  79. package/__init__.py +0 -1
  80. package/__main__.py +0 -15
  81. package/core/event_hub/BENCHMARK.md +0 -94
  82. package/core/event_hub/bench.py +0 -459
  83. package/core/event_hub/bench_extreme.py +0 -308
  84. package/core/event_hub/bench_perf.py +0 -350
  85. package/core/event_hub/entry.py +0 -157
  86. package/core/event_hub/module.md +0 -20
  87. package/core/event_hub/server.py +0 -206
  88. package/core/launcher/entry.py +0 -1158
  89. package/core/launcher/process_manager.py +0 -470
  90. package/core/registry/entry.py +0 -110
  91. package/core/registry/module.md +0 -30
  92. package/core/registry/server.py +0 -289
  93. package/extensions/services/watchdog/server.py +0 -167
  94. /package/{core → extensions/services/web/vendor/bluetooth}/__init__.py +0 -0
  95. /package/{core/event_hub → extensions/services/web/vendor/conversation}/__init__.py +0 -0
  96. /package/{core/launcher → extensions/services/web/vendor/task}/__init__.py +0 -0
  97. /package/{core/registry → extensions/services/web/vendor/tools}/__init__.py +0 -0
  98. /package/{core/event_hub → kernel}/dedup.py +0 -0
  99. /package/{core/event_hub → kernel}/router.py +0 -0
  100. /package/{core/launcher → launcher}/module.md +0 -0
@@ -0,0 +1,390 @@
1
+ """
2
+ Web Management entry point.
3
+ Reads boot_info from stdin, registers to Registry, starts web service.
4
+ Serves the full AI Phone Agent web UI and all API endpoints.
5
+ """
6
+
7
+ import builtins
8
+ import json
9
+ import os
10
+ import re
11
+ import signal
12
+ import socket
13
+ import sys
14
+ import threading
15
+ import time
16
+ import traceback
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+
20
+ import asyncio
21
+ import websockets
22
+ import uvicorn
23
+
24
+
25
+ # ── Safe stdout/stderr: ignore BrokenPipeError after Launcher closes stdio ──
26
+
27
+
28
+ # ── Module configuration ──
29
+ MODULE_NAME = "web"
30
+
31
+
32
+ class _SafeWriter:
33
+ """Wraps a stream to silently swallow BrokenPipeError on write/flush."""
34
+ def __init__(self, stream):
35
+ self._stream = stream
36
+
37
+ def write(self, s):
38
+ try:
39
+ self._stream.write(s)
40
+ except (BrokenPipeError, OSError):
41
+ pass
42
+
43
+ def flush(self):
44
+ try:
45
+ self._stream.flush()
46
+ except (BrokenPipeError, OSError):
47
+ pass
48
+
49
+ def __getattr__(self, name):
50
+ return getattr(self._stream, name)
51
+
52
+ sys.stdout = _SafeWriter(sys.stdout)
53
+ sys.stderr = _SafeWriter(sys.stderr)
54
+
55
+
56
+ # ── Timestamped print + log file writer ──
57
+ # Independent implementation per module (no shared code dependency)
58
+
59
+ _builtin_print = builtins.print
60
+ _start_ts = time.monotonic()
61
+ _last_ts = time.monotonic()
62
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
63
+ _log_lock = threading.Lock()
64
+ _log_latest_path = None
65
+ _log_daily_path = None
66
+ _log_daily_date = ""
67
+ _log_dir = None
68
+ _crash_log_path = None
69
+
70
+ def _strip_ansi(s: str) -> str:
71
+ return _ANSI_RE.sub("", s)
72
+
73
+ def _resolve_daily_log_path():
74
+ """Resolve daily log path based on current date."""
75
+ global _log_daily_path, _log_daily_date
76
+ if not _log_dir:
77
+ return
78
+ today = datetime.now().strftime("%Y-%m-%d")
79
+ if today == _log_daily_date and _log_daily_path:
80
+ return
81
+ month_dir = os.path.join(_log_dir, today[:7])
82
+ os.makedirs(month_dir, exist_ok=True)
83
+ _log_daily_path = os.path.join(month_dir, f"{today}.log")
84
+ _log_daily_date = today
85
+
86
+ def _write_log(plain_line: str):
87
+ """Write a plain-text line to both latest.log and daily log."""
88
+ with _log_lock:
89
+ if _log_latest_path:
90
+ try:
91
+ with open(_log_latest_path, "a", encoding="utf-8") as f:
92
+ f.write(plain_line)
93
+ except Exception:
94
+ pass
95
+ _resolve_daily_log_path()
96
+ if _log_daily_path:
97
+ try:
98
+ with open(_log_daily_path, "a", encoding="utf-8") as f:
99
+ f.write(plain_line)
100
+ except Exception:
101
+ pass
102
+
103
+ def _write_crash(exc_type, exc_value, exc_tb, thread_name=None, severity="critical", handled=False):
104
+ """Write crash record to crashes.jsonl + daily crash archive."""
105
+ record = {
106
+ "timestamp": datetime.now(timezone.utc).isoformat(),
107
+ "module": MODULE_NAME,
108
+ "thread": thread_name or threading.current_thread().name,
109
+ "exception_type": exc_type.__name__ if exc_type else "Unknown",
110
+ "exception_message": str(exc_value),
111
+ "traceback": "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
112
+ "severity": severity,
113
+ "handled": handled,
114
+ "process_id": os.getpid(),
115
+ "platform": sys.platform,
116
+ "runtime_version": f"Python {sys.version.split()[0]}",
117
+ }
118
+
119
+ if exc_tb:
120
+ tb_entries = traceback.extract_tb(exc_tb)
121
+ if tb_entries:
122
+ last = tb_entries[-1]
123
+ record["context"] = {
124
+ "function": last.name,
125
+ "file": os.path.basename(last.filename),
126
+ "line": last.lineno,
127
+ }
128
+
129
+ line = json.dumps(record, ensure_ascii=False) + "\n"
130
+
131
+ if _crash_log_path:
132
+ try:
133
+ with open(_crash_log_path, "a", encoding="utf-8") as f:
134
+ f.write(line)
135
+ except Exception:
136
+ pass
137
+
138
+ if _log_dir:
139
+ try:
140
+ today = datetime.now().strftime("%Y-%m-%d")
141
+ archive_dir = os.path.join(_log_dir, "crashes", today[:7])
142
+ os.makedirs(archive_dir, exist_ok=True)
143
+ archive_path = os.path.join(archive_dir, f"{today}.jsonl")
144
+ with open(archive_path, "a", encoding="utf-8") as f:
145
+ f.write(line)
146
+ except Exception:
147
+ pass
148
+
149
+ def _print_crash_summary(exc_type, exc_tb, thread_name=None):
150
+ """Print crash summary to console (red highlight)."""
151
+ RED = "\033[91m"
152
+ RESET = "\033[0m"
153
+
154
+ if exc_tb:
155
+ tb_entries = traceback.extract_tb(exc_tb)
156
+ if tb_entries:
157
+ last = tb_entries[-1]
158
+ location = f"{os.path.basename(last.filename)}:{last.lineno}"
159
+ else:
160
+ location = "unknown"
161
+ else:
162
+ location = "unknown"
163
+
164
+ prefix = f"[{MODULE_NAME}]"
165
+ if thread_name:
166
+ _builtin_print(f"{prefix} {RED}线程 {thread_name} 崩溃: "
167
+ f"{exc_type.__name__} in {location}{RESET}")
168
+ else:
169
+ _builtin_print(f"{prefix} {RED}崩溃: {exc_type.__name__} in {location}{RESET}")
170
+ if _crash_log_path:
171
+ _builtin_print(f"{prefix} 崩溃日志: {_crash_log_path}")
172
+
173
+ def _setup_exception_hooks():
174
+ """Set up global exception hooks."""
175
+ _orig_excepthook = sys.excepthook
176
+
177
+ def _excepthook(exc_type, exc_value, exc_tb):
178
+ _write_crash(exc_type, exc_value, exc_tb, severity="critical", handled=False)
179
+ _print_crash_summary(exc_type, exc_tb)
180
+ _orig_excepthook(exc_type, exc_value, exc_tb)
181
+
182
+ sys.excepthook = _excepthook
183
+
184
+ if hasattr(threading, "excepthook"):
185
+ def _thread_excepthook(args):
186
+ _write_crash(args.exc_type, args.exc_value, args.exc_traceback,
187
+ thread_name=args.thread.name if args.thread else "unknown",
188
+ severity="error", handled=False)
189
+ _print_crash_summary(args.exc_type, args.exc_traceback,
190
+ thread_name=args.thread.name if args.thread else None)
191
+
192
+ threading.excepthook = _thread_excepthook
193
+
194
+ def _tprint(*args, **kwargs):
195
+ """Timestamped print that adds [timestamp] HH:MM:SS.mmm +delta prefix."""
196
+ global _last_ts
197
+ now = time.monotonic()
198
+ elapsed = now - _start_ts
199
+ delta = now - _last_ts
200
+ _last_ts = now
201
+
202
+ if elapsed < 1:
203
+ elapsed_str = f"{elapsed * 1000:.0f}ms"
204
+ elif elapsed < 100:
205
+ elapsed_str = f"{elapsed:.1f}s"
206
+ else:
207
+ elapsed_str = f"{elapsed:.0f}s"
208
+
209
+ if delta < 0.001:
210
+ delta_str = ""
211
+ elif delta < 1:
212
+ delta_str = f"+{delta * 1000:.0f}ms"
213
+ elif delta < 100:
214
+ delta_str = f"+{delta:.1f}s"
215
+ else:
216
+ delta_str = f"+{delta:.0f}s"
217
+
218
+ ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
219
+
220
+ _builtin_print(*args, **kwargs)
221
+
222
+ if _log_latest_path or _log_daily_path:
223
+ sep = kwargs.get("sep", " ")
224
+ end = kwargs.get("end", "\n")
225
+ text = sep.join(str(a) for a in args)
226
+ prefix = f"[{elapsed_str:>6}] {ts} {delta_str:>8} "
227
+ _write_log(prefix + _strip_ansi(text) + end)
228
+
229
+ builtins.print = _tprint
230
+
231
+ # Ensure project root (Kite/) is on sys.path
232
+ _this_dir = os.path.dirname(os.path.abspath(__file__))
233
+ _project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirname(os.path.dirname(_this_dir)))
234
+ if _project_root not in sys.path:
235
+ sys.path.insert(0, _project_root)
236
+
237
+ # Also add ai-phone-agent root so we can import config, storage, conversation, etc.
238
+ _agent_root = os.path.dirname(_project_root)
239
+ if _agent_root not in sys.path:
240
+ sys.path.insert(0, _agent_root)
241
+
242
+ from extensions.services.web.server import WebServer
243
+
244
+
245
+ def _fmt_elapsed(t0: float) -> str:
246
+ d = time.monotonic() - t0
247
+ if d < 1:
248
+ return f"{d * 1000:.0f}ms"
249
+ if d < 10:
250
+ return f"{d:.1f}s"
251
+ return f"{d:.0f}s"
252
+
253
+
254
+ def _read_module_md() -> dict:
255
+ """Read preferred_port and advertise_ip from own module.md."""
256
+ md_path = os.path.join(_this_dir, "module.md")
257
+ result = {"preferred_port": 0, "advertise_ip": "0.0.0.0"}
258
+ try:
259
+ with open(md_path, "r", encoding="utf-8") as f:
260
+ text = f.read()
261
+ m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
262
+ if m:
263
+ try:
264
+ import yaml
265
+ fm = yaml.safe_load(m.group(1)) or {}
266
+ except ImportError:
267
+ fm = {}
268
+ result["preferred_port"] = int(fm.get("preferred_port", 0))
269
+ result["advertise_ip"] = fm.get("advertise_ip", "0.0.0.0")
270
+ except Exception:
271
+ pass
272
+ return result
273
+
274
+
275
+ def _bind_port(preferred: int, host: str, max_attempts: int = 10) -> int | None:
276
+ """
277
+ Try to bind to preferred port, then port+1, port+2, ... up to max_attempts.
278
+ Returns bound port on success, None on failure.
279
+ """
280
+ if not preferred:
281
+ # No preferred port, use OS-assigned
282
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
283
+ s.bind((host, 0))
284
+ return s.getsockname()[1]
285
+
286
+ for attempt in range(max_attempts):
287
+ port = preferred + attempt
288
+ try:
289
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
290
+ s.bind((host, port))
291
+ if attempt > 0:
292
+ print(f"[web] Bound to port {port} (preferred {preferred} was occupied)")
293
+ return port
294
+ except OSError:
295
+ if attempt < max_attempts - 1:
296
+ continue
297
+ else:
298
+ print(f"[web] ERROR: Failed to bind port after {max_attempts} attempts ({preferred}-{port})")
299
+ return None
300
+
301
+ return None
302
+
303
+
304
+
305
+
306
+ def main():
307
+ # Initialize log file paths
308
+ global _log_dir, _log_latest_path, _crash_log_path
309
+ module_data = os.environ.get("KITE_MODULE_DATA")
310
+ if module_data:
311
+ _log_dir = os.path.join(module_data, "log")
312
+ os.makedirs(_log_dir, exist_ok=True)
313
+ suffix = os.environ.get("KITE_INSTANCE_SUFFIX", "")
314
+
315
+ _log_latest_path = os.path.join(_log_dir, f"latest{suffix}.log")
316
+ try:
317
+ with open(_log_latest_path, "w", encoding="utf-8") as f:
318
+ pass
319
+ except Exception:
320
+ _log_latest_path = None
321
+
322
+ _crash_log_path = os.path.join(_log_dir, f"crashes{suffix}.jsonl")
323
+ try:
324
+ with open(_crash_log_path, "w", encoding="utf-8") as f:
325
+ pass
326
+ except Exception:
327
+ _crash_log_path = None
328
+
329
+ _resolve_daily_log_path()
330
+
331
+ _setup_exception_hooks()
332
+
333
+ _t0 = time.monotonic()
334
+
335
+ # Read boot_info from stdin (only token)
336
+ token = ""
337
+ try:
338
+ line = sys.stdin.readline().strip()
339
+ if line:
340
+ boot_info = json.loads(line)
341
+ token = boot_info.get("token", "")
342
+ except Exception:
343
+ pass
344
+
345
+ # Read kernel_port from environment variable
346
+ kernel_port = int(os.environ.get("KITE_KERNEL_PORT", "0"))
347
+
348
+ if not token or not kernel_port:
349
+ print("[web] ERROR: Missing token or KITE_KERNEL_PORT")
350
+ sys.exit(1)
351
+
352
+ print(f"[web] Token received ({len(token)} chars), kernel port: {kernel_port} ({_fmt_elapsed(_t0)})")
353
+
354
+ # Read preferred_port from module.md
355
+ md_cfg = _read_module_md()
356
+ host = md_cfg["advertise_ip"]
357
+ port = _bind_port(md_cfg["preferred_port"], host)
358
+
359
+ # If port binding failed after 10 attempts, exit gracefully
360
+ if port is None:
361
+ print("[web] ERROR: Cannot bind to any port, exiting")
362
+ sys.exit(1)
363
+
364
+ server = WebServer(
365
+ token=token,
366
+ kernel_port=kernel_port,
367
+ host=host,
368
+ port=port,
369
+ boot_t0=_t0,
370
+ )
371
+
372
+ # Display access URL in green
373
+ display_host = "localhost" if host == "0.0.0.0" else host
374
+ url = f"http://{display_host}:{port}"
375
+ print(f"[web] Starting on {host}:{port} ({_fmt_elapsed(_t0)})")
376
+ print(f"[web] \033[32m✓ Web UI ready: {url}\033[0m")
377
+
378
+ try:
379
+ config = uvicorn.Config(server.app, host=host, port=port, log_level="warning")
380
+ uvi_server = uvicorn.Server(config)
381
+ server._uvicorn_server = uvi_server
382
+ uvi_server.run()
383
+ except Exception as e:
384
+ _write_crash(type(e), e, e.__traceback__, severity="critical", handled=True)
385
+ _print_crash_summary(type(e), e.__traceback__)
386
+ sys.exit(1)
387
+
388
+
389
+ if __name__ == "__main__":
390
+ main()
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: web
3
+ display_name: Web Management
4
+ version: "1.0"
5
+ type: service
6
+ state: enabled
7
+ runtime: python
8
+ entry: entry.py
9
+ preferred_port: 18766
10
+ advertise_ip: 0.0.0.0
11
+ events:
12
+ - web.test
13
+ subscriptions:
14
+ - module.started
15
+ - module.stopped
16
+ - module.shutdown
17
+ ---
18
+
19
+ # Web Management(Web 管理界面)
20
+
21
+ Web 管理界面模块,提供系统管理和监控的 Web UI。
22
+
23
+ - 管理界面 — 提供系统配置和状态监控的 Web UI
24
+ - 事件通知 — 通过 Event Hub 发布管理操作事件
@@ -0,0 +1,189 @@
1
+ """Routes for outgoing / incoming call management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from fastapi import APIRouter, HTTPException, Query, Request
9
+ from fastapi.responses import FileResponse
10
+
11
+ from routes.schemas import (
12
+ CallConfirmRequest,
13
+ CallMessageRequest,
14
+ CallRequest,
15
+ CallResponse,
16
+ CallStatus,
17
+ HangupResponse,
18
+ PaginatedResponse,
19
+ )
20
+ from vendor.storage import store
21
+ from vendor.storage import identity
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ router = APIRouter(tags=["calls"])
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Single-call endpoints (prefix: /call)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ @router.post("/call", response_model=CallResponse)
33
+ async def create_call(request_body: CallRequest, request: Request):
34
+ """Initiate a new outgoing call or register an incoming-call task."""
35
+ task_manager = request.app.state.task_manager
36
+ try:
37
+ task = await task_manager.create_call_task(request_body)
38
+ return CallResponse(task_id=task["task_id"], status=task.get("status", "created"))
39
+ except Exception as exc:
40
+ logger.exception("Failed to create call task")
41
+ raise HTTPException(status_code=500, detail=str(exc))
42
+
43
+
44
+ @router.get("/call/{task_id}", response_model=CallStatus)
45
+ async def get_call_status(task_id: str):
46
+ """Return the current status of a call task."""
47
+ record = await store.get_task(task_id)
48
+ if record is None:
49
+ raise HTTPException(status_code=404, detail="Task not found")
50
+
51
+ has_recording = False
52
+ call_dir = record.get("call_dir")
53
+ if call_dir:
54
+ rec_path = identity.get_recording_path(Path(call_dir))
55
+ has_recording = rec_path is not None
56
+
57
+ return CallStatus(
58
+ task_id=record.get("task_id", task_id),
59
+ status=record.get("status", "unknown"),
60
+ phone_number=record.get("phone_number"),
61
+ contact_name=record.get("contact_name"),
62
+ direction=record.get("direction"),
63
+ duration_seconds=record.get("duration_seconds"),
64
+ result=record.get("result"),
65
+ summary=record.get("summary"),
66
+ started_at=record.get("started_at"),
67
+ ended_at=record.get("ended_at"),
68
+ has_recording=has_recording,
69
+ )
70
+
71
+
72
+ @router.post("/call/{task_id}/hangup", response_model=HangupResponse)
73
+ async def hangup_call(task_id: str, request: Request):
74
+ """Hang up an active call."""
75
+ task_manager = request.app.state.task_manager
76
+ try:
77
+ result = await task_manager.hangup_task(task_id)
78
+ return HangupResponse(
79
+ task_id=task_id,
80
+ status=result.get("status", "hangup_requested") if isinstance(result, dict) else "hangup_requested",
81
+ )
82
+ except Exception as exc:
83
+ logger.exception("Failed to hangup task %s", task_id)
84
+ raise HTTPException(status_code=500, detail=str(exc))
85
+
86
+
87
+ @router.post("/call/{task_id}/confirm", response_model=CallResponse)
88
+ async def confirm_call(task_id: str, request_body: CallConfirmRequest, request: Request):
89
+ """Confirm or reject an incoming call that requires confirmation."""
90
+ task_manager = request.app.state.task_manager
91
+ try:
92
+ result = await task_manager.confirm_task(task_id, request_body)
93
+ return CallResponse(
94
+ task_id=task_id,
95
+ status=result.get("status", "confirmed") if isinstance(result, dict) else "confirmed",
96
+ )
97
+ except Exception as exc:
98
+ logger.exception("Failed to confirm task %s", task_id)
99
+ raise HTTPException(status_code=500, detail=str(exc))
100
+
101
+
102
+ @router.post("/call/{task_id}/message")
103
+ async def send_call_message(task_id: str, request_body: CallMessageRequest, request: Request):
104
+ """Inject a text message into an active call (will be spoken via TTS)."""
105
+ task_manager = request.app.state.task_manager
106
+ try:
107
+ await task_manager.send_message(task_id, request_body.message)
108
+ return {"task_id": task_id, "status": "message_sent"}
109
+ except Exception as exc:
110
+ logger.exception("Failed to send message to task %s", task_id)
111
+ raise HTTPException(status_code=500, detail=str(exc))
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Multi-call / history endpoints (prefix: /calls)
116
+ # ---------------------------------------------------------------------------
117
+
118
+ @router.get("/calls", response_model=PaginatedResponse)
119
+ async def list_calls(
120
+ page: int = Query(1, ge=1),
121
+ page_size: int = Query(20, ge=1, le=100),
122
+ direction: str | None = Query(None),
123
+ result: str | None = Query(None),
124
+ ):
125
+ """List call history with optional filters and pagination."""
126
+ filters = {}
127
+ if direction is not None:
128
+ filters["direction"] = direction
129
+ if result is not None:
130
+ filters["result"] = result
131
+
132
+ items, total = await store.list_tasks(page=page, page_size=page_size, **filters)
133
+ return PaginatedResponse(items=items, total=total, page=page, page_size=page_size)
134
+
135
+
136
+ @router.get("/calls/{task_id}/recording")
137
+ async def get_recording(task_id: str):
138
+ """Download the WAV recording for a finished call."""
139
+ record = await store.get_task(task_id)
140
+ if record is None:
141
+ raise HTTPException(status_code=404, detail="Task not found")
142
+
143
+ call_dir = record.get("call_dir")
144
+ if not call_dir:
145
+ raise HTTPException(status_code=404, detail="Recording not found")
146
+
147
+ path = identity.get_recording_path(Path(call_dir))
148
+ if path is None:
149
+ raise HTTPException(status_code=404, detail="Recording not found")
150
+
151
+ return FileResponse(
152
+ path=str(path),
153
+ media_type="audio/wav",
154
+ filename=f"{task_id}.wav",
155
+ )
156
+
157
+
158
+ @router.get("/calls/{task_id}/transcript")
159
+ async def get_transcript(task_id: str):
160
+ """Return the full transcript (list of utterance events) for a call."""
161
+ record = await store.get_task(task_id)
162
+ if record is None:
163
+ raise HTTPException(status_code=404, detail="Task not found")
164
+
165
+ call_dir = record.get("call_dir")
166
+ if not call_dir:
167
+ raise HTTPException(status_code=404, detail="Transcript not found")
168
+
169
+ transcript = await identity.load_call_messages(Path(call_dir))
170
+ if not transcript:
171
+ raise HTTPException(status_code=404, detail="Transcript not found")
172
+ return {"task_id": task_id, "transcript": transcript}
173
+
174
+
175
+ @router.get("/calls/{task_id}/summary")
176
+ async def get_summary(task_id: str):
177
+ """Return the AI-generated summary for a call."""
178
+ record = await store.get_task(task_id)
179
+ if record is None:
180
+ raise HTTPException(status_code=404, detail="Task not found")
181
+
182
+ call_dir = record.get("call_dir")
183
+ if not call_dir:
184
+ raise HTTPException(status_code=404, detail="Summary not found")
185
+
186
+ summary = await identity.load_call_summary(Path(call_dir))
187
+ if summary is None:
188
+ raise HTTPException(status_code=404, detail="Summary not found")
189
+ return {"task_id": task_id, "summary": summary}