@agentunion/kite 1.0.7 → 1.2.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.
- package/core/event_hub/entry.py +305 -26
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/server.py +80 -17
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +978 -284
- package/core/launcher/process_manager.py +456 -46
- package/core/registry/entry.py +272 -3
- package/core/registry/server.py +339 -289
- package/core/registry/store.py +10 -4
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/extensions/event_hub_bench/entry.py +664 -379
- package/extensions/event_hub_bench/module.md +2 -1
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -147
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +230 -90
- package/package.json +1 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Identity-based directory storage for calls organized by user and contact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import aiofiles
|
|
12
|
+
|
|
13
|
+
from .. import config as cfg
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_user_phone() -> str:
|
|
17
|
+
"""Get the user's phone number from config."""
|
|
18
|
+
return cfg.get("user.phone_number", "") or "unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sanitize_phone(phone: str) -> str:
|
|
22
|
+
"""Sanitize a phone number for use as a directory name.
|
|
23
|
+
|
|
24
|
+
Removes +, spaces, dashes, parentheses. Returns digits only.
|
|
25
|
+
"""
|
|
26
|
+
cleaned = re.sub(r"[^\d]", "", phone)
|
|
27
|
+
return cleaned or "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def user_dir(user_phone: str) -> Path:
|
|
31
|
+
"""Return the user directory path: data/users/{phone}/"""
|
|
32
|
+
return cfg.data_dir() / "users" / sanitize_phone(user_phone)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def contact_dir(user_phone: str, contact_phone: str) -> Path:
|
|
36
|
+
"""Return the contact directory: data/users/{phone}/contacts/{contact_phone}/"""
|
|
37
|
+
return user_dir(user_phone) / "contacts" / sanitize_phone(contact_phone)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def call_dir(user_phone: str, contact_phone: str, started_at: str | datetime) -> Path:
|
|
41
|
+
"""Return the call directory: .../calls/{20260225-100000}/"""
|
|
42
|
+
if isinstance(started_at, str):
|
|
43
|
+
started_at = datetime.fromisoformat(started_at)
|
|
44
|
+
folder_name = started_at.strftime("%Y%m%d-%H%M%S")
|
|
45
|
+
return contact_dir(user_phone, contact_phone) / "calls" / folder_name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_call_dir(user_phone: str, contact_phone: str, started_at: str | datetime) -> Path:
|
|
49
|
+
"""Create the call directory structure (including recordings/) and return the path."""
|
|
50
|
+
path = call_dir(user_phone, contact_phone, started_at)
|
|
51
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
(path / "recordings").mkdir(exist_ok=True)
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def load_user_context(user_phone: str) -> str:
|
|
57
|
+
"""Read all .md files under the user directory, concatenated."""
|
|
58
|
+
udir = user_dir(user_phone)
|
|
59
|
+
if not udir.exists():
|
|
60
|
+
return ""
|
|
61
|
+
parts: list[str] = []
|
|
62
|
+
for md_file in sorted(udir.glob("*.md")):
|
|
63
|
+
try:
|
|
64
|
+
async with aiofiles.open(md_file, "r", encoding="utf-8") as f:
|
|
65
|
+
content = await f.read()
|
|
66
|
+
if content.strip():
|
|
67
|
+
parts.append(f"## {md_file.stem}\n\n{content.strip()}")
|
|
68
|
+
except Exception:
|
|
69
|
+
continue
|
|
70
|
+
return "\n\n".join(parts)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def load_contact_context(user_phone: str, contact_phone: str) -> str:
|
|
74
|
+
"""Read all .md files under the contact directory, concatenated."""
|
|
75
|
+
cdir = contact_dir(user_phone, contact_phone)
|
|
76
|
+
if not cdir.exists():
|
|
77
|
+
return ""
|
|
78
|
+
parts: list[str] = []
|
|
79
|
+
for md_file in sorted(cdir.glob("*.md")):
|
|
80
|
+
try:
|
|
81
|
+
async with aiofiles.open(md_file, "r", encoding="utf-8") as f:
|
|
82
|
+
content = await f.read()
|
|
83
|
+
if content.strip():
|
|
84
|
+
parts.append(f"## {md_file.stem}\n\n{content.strip()}")
|
|
85
|
+
except Exception:
|
|
86
|
+
continue
|
|
87
|
+
return "\n\n".join(parts)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def load_recent_summaries(
|
|
91
|
+
user_phone: str,
|
|
92
|
+
contact_phone: str,
|
|
93
|
+
max_count: int = 10,
|
|
94
|
+
max_days: int = 30,
|
|
95
|
+
) -> str:
|
|
96
|
+
"""Load recent call summaries for a contact, newest first.
|
|
97
|
+
|
|
98
|
+
Scans calls/ subdirectories sorted by name (timestamp-based),
|
|
99
|
+
limited by max_count and max_days.
|
|
100
|
+
"""
|
|
101
|
+
calls_root = contact_dir(user_phone, contact_phone) / "calls"
|
|
102
|
+
if not calls_root.exists():
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
# Collect call dirs sorted by name descending (newest first)
|
|
106
|
+
call_dirs = sorted(
|
|
107
|
+
[d for d in calls_root.iterdir() if d.is_dir()],
|
|
108
|
+
key=lambda d: d.name,
|
|
109
|
+
reverse=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=max_days)
|
|
113
|
+
parts: list[str] = []
|
|
114
|
+
|
|
115
|
+
for cdir in call_dirs:
|
|
116
|
+
if len(parts) >= max_count:
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
# Parse dir name to check date cutoff
|
|
120
|
+
try:
|
|
121
|
+
dir_dt = datetime.strptime(cdir.name, "%Y%m%d-%H%M%S").replace(
|
|
122
|
+
tzinfo=timezone.utc
|
|
123
|
+
)
|
|
124
|
+
if dir_dt < cutoff:
|
|
125
|
+
break # dirs are sorted newest first, so we can stop
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
summary_path = cdir / "summary.md"
|
|
130
|
+
if not summary_path.exists():
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
async with aiofiles.open(summary_path, "r", encoding="utf-8") as f:
|
|
135
|
+
content = await f.read()
|
|
136
|
+
if content.strip():
|
|
137
|
+
parts.append(f"### {cdir.name}\n\n{content.strip()}")
|
|
138
|
+
except Exception:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
return "\n\n".join(parts)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def save_call_message(call_path: Path, entry: dict[str, Any]) -> None:
|
|
145
|
+
"""Append a message entry to messages.jsonl in the call directory."""
|
|
146
|
+
filepath = call_path / "messages.jsonl"
|
|
147
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
async with aiofiles.open(filepath, "a", encoding="utf-8") as f:
|
|
149
|
+
await f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def save_call_summary(call_path: Path, summary: str) -> None:
|
|
153
|
+
"""Write summary.md in the call directory."""
|
|
154
|
+
filepath = call_path / "summary.md"
|
|
155
|
+
async with aiofiles.open(filepath, "w", encoding="utf-8") as f:
|
|
156
|
+
await f.write(summary)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def save_session_info(call_path: Path, session: dict[str, Any]) -> None:
|
|
160
|
+
"""Write session.json in the call directory."""
|
|
161
|
+
filepath = call_path / "session.json"
|
|
162
|
+
async with aiofiles.open(filepath, "w", encoding="utf-8") as f:
|
|
163
|
+
await f.write(json.dumps(session, ensure_ascii=False, indent=2))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def load_call_summary(call_path: Path) -> str | None:
|
|
167
|
+
"""Read summary.md from a call directory."""
|
|
168
|
+
filepath = call_path / "summary.md"
|
|
169
|
+
if not filepath.exists():
|
|
170
|
+
return None
|
|
171
|
+
async with aiofiles.open(filepath, "r", encoding="utf-8") as f:
|
|
172
|
+
return await f.read()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_recording_path(call_path: Path) -> Path | None:
|
|
176
|
+
"""Return the first .wav file in recordings/ under the call directory, or None."""
|
|
177
|
+
rec_dir = call_path / "recordings"
|
|
178
|
+
if not rec_dir.exists():
|
|
179
|
+
return None
|
|
180
|
+
for wav in rec_dir.glob("*.wav"):
|
|
181
|
+
return wav
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def load_call_messages(call_path: Path) -> list[dict[str, Any]]:
|
|
186
|
+
"""Read all messages from messages.jsonl in a call directory."""
|
|
187
|
+
filepath = call_path / "messages.jsonl"
|
|
188
|
+
if not filepath.exists():
|
|
189
|
+
return []
|
|
190
|
+
records: list[dict[str, Any]] = []
|
|
191
|
+
async with aiofiles.open(filepath, "r", encoding="utf-8") as f:
|
|
192
|
+
async for line in f:
|
|
193
|
+
line = line.strip()
|
|
194
|
+
if line:
|
|
195
|
+
try:
|
|
196
|
+
records.append(json.loads(line))
|
|
197
|
+
except json.JSONDecodeError:
|
|
198
|
+
continue
|
|
199
|
+
return records
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Tools & permissions helpers
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def load_tools_config(path: Path) -> dict[str, Any] | None:
|
|
207
|
+
"""Read a tools.yaml file and return its contents, or None if missing."""
|
|
208
|
+
if not path.exists():
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
import yaml
|
|
212
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
213
|
+
return yaml.safe_load(f) or {}
|
|
214
|
+
except Exception:
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def load_contact_profile(user_phone: str, contact_phone: str) -> dict[str, Any] | None:
|
|
219
|
+
"""Read the contact's profile.yaml and return its contents, or None if missing."""
|
|
220
|
+
profile_path = contact_dir(user_phone, contact_phone) / "profile.yaml"
|
|
221
|
+
if not profile_path.exists():
|
|
222
|
+
return None
|
|
223
|
+
try:
|
|
224
|
+
import yaml
|
|
225
|
+
with open(profile_path, "r", encoding="utf-8") as f:
|
|
226
|
+
return yaml.safe_load(f) or {}
|
|
227
|
+
except Exception:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _get_owners_list() -> list[dict[str, Any]]:
|
|
232
|
+
"""Return the ``user.owners`` list from config, normalised.
|
|
233
|
+
|
|
234
|
+
Supports both the new format (list of dicts) and the legacy
|
|
235
|
+
``user.owner_ids`` format (list of phone strings) for backwards
|
|
236
|
+
compatibility.
|
|
237
|
+
"""
|
|
238
|
+
owners = cfg.get("user.owners", []) or []
|
|
239
|
+
if owners:
|
|
240
|
+
# Normalise: if someone put a plain string in the list, wrap it
|
|
241
|
+
return [
|
|
242
|
+
o if isinstance(o, dict) else {"phone": str(o)}
|
|
243
|
+
for o in owners
|
|
244
|
+
]
|
|
245
|
+
# Fallback: legacy owner_ids
|
|
246
|
+
owner_ids = cfg.get("user.owner_ids", []) or []
|
|
247
|
+
return [{"phone": str(oid)} for oid in owner_ids]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def is_owner(user_phone: str, contact_phone: str) -> bool:
|
|
251
|
+
"""Check if the contact is an owner.
|
|
252
|
+
|
|
253
|
+
Single source of truth: ``config.yaml`` → ``user.owners``.
|
|
254
|
+
"""
|
|
255
|
+
clean_contact = sanitize_phone(contact_phone)
|
|
256
|
+
return any(
|
|
257
|
+
sanitize_phone(o.get("phone", "")) == clean_contact
|
|
258
|
+
for o in _get_owners_list()
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def get_owner_info(contact_phone: str) -> dict[str, Any] | None:
|
|
263
|
+
"""Return the owner config entry for *contact_phone*, or ``None``.
|
|
264
|
+
|
|
265
|
+
The returned dict may contain ``phone``, ``name``, ``note``, etc.
|
|
266
|
+
"""
|
|
267
|
+
clean = sanitize_phone(contact_phone)
|
|
268
|
+
for o in _get_owners_list():
|
|
269
|
+
if sanitize_phone(o.get("phone", "")) == clean:
|
|
270
|
+
return o
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def ensure_contact_profile(
|
|
275
|
+
user_phone: str,
|
|
276
|
+
contact_phone: str,
|
|
277
|
+
contact_name: str | None = None,
|
|
278
|
+
) -> Path:
|
|
279
|
+
"""Ensure the contact directory and profile.yaml exist.
|
|
280
|
+
|
|
281
|
+
Creates ``profile.yaml`` with basic contact info on first encounter.
|
|
282
|
+
If the contact is an owner and the owner config has a ``name``, that
|
|
283
|
+
name is used as the default (caller-supplied *contact_name* takes
|
|
284
|
+
priority).
|
|
285
|
+
|
|
286
|
+
Owner status is NOT written here — it is determined solely by
|
|
287
|
+
``config.user.owners`` at runtime.
|
|
288
|
+
|
|
289
|
+
Returns the contact directory path.
|
|
290
|
+
"""
|
|
291
|
+
import yaml
|
|
292
|
+
|
|
293
|
+
cdir = contact_dir(user_phone, contact_phone)
|
|
294
|
+
cdir.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
|
|
296
|
+
profile_path = cdir / "profile.yaml"
|
|
297
|
+
|
|
298
|
+
if not profile_path.exists():
|
|
299
|
+
# Try to pull name from owner config
|
|
300
|
+
name = contact_name
|
|
301
|
+
if not name:
|
|
302
|
+
owner = get_owner_info(contact_phone)
|
|
303
|
+
if owner:
|
|
304
|
+
name = owner.get("name", "")
|
|
305
|
+
profile: dict[str, Any] = {
|
|
306
|
+
"name": name or "",
|
|
307
|
+
"phone": contact_phone,
|
|
308
|
+
}
|
|
309
|
+
with open(profile_path, "w", encoding="utf-8") as f:
|
|
310
|
+
yaml.dump(profile, f, allow_unicode=True, default_flow_style=False)
|
|
311
|
+
|
|
312
|
+
return cdir
|