@agentunion/kite 1.0.6 → 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.
Files changed (112) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +384 -61
  3. package/core/event_hub/hub.py +8 -0
  4. package/core/event_hub/module.md +0 -1
  5. package/core/event_hub/server.py +169 -38
  6. package/core/kite_log.py +241 -0
  7. package/core/launcher/entry.py +1306 -425
  8. package/core/launcher/module_scanner.py +10 -9
  9. package/core/launcher/process_manager.py +555 -121
  10. package/core/registry/entry.py +335 -30
  11. package/core/registry/server.py +339 -256
  12. package/core/registry/store.py +13 -2
  13. package/extensions/agents/__init__.py +1 -0
  14. package/extensions/agents/assistant/__init__.py +1 -0
  15. package/extensions/agents/assistant/entry.py +380 -0
  16. package/extensions/agents/assistant/module.md +22 -0
  17. package/extensions/agents/assistant/server.py +236 -0
  18. package/extensions/channels/__init__.py +1 -0
  19. package/extensions/channels/acp_channel/__init__.py +1 -0
  20. package/extensions/channels/acp_channel/entry.py +380 -0
  21. package/extensions/channels/acp_channel/module.md +22 -0
  22. package/extensions/channels/acp_channel/server.py +236 -0
  23. package/{core → extensions}/event_hub_bench/entry.py +664 -371
  24. package/{core → extensions}/event_hub_bench/module.md +4 -2
  25. package/extensions/services/backup/__init__.py +1 -0
  26. package/extensions/services/backup/entry.py +380 -0
  27. package/extensions/services/backup/module.md +22 -0
  28. package/extensions/services/backup/server.py +244 -0
  29. package/extensions/services/model_service/__init__.py +1 -0
  30. package/extensions/services/model_service/entry.py +380 -0
  31. package/extensions/services/model_service/module.md +22 -0
  32. package/extensions/services/model_service/server.py +236 -0
  33. package/extensions/services/watchdog/entry.py +460 -143
  34. package/extensions/services/watchdog/module.md +3 -0
  35. package/extensions/services/watchdog/monitor.py +128 -13
  36. package/extensions/services/watchdog/server.py +75 -13
  37. package/extensions/services/web/__init__.py +1 -0
  38. package/extensions/services/web/config.yaml +149 -0
  39. package/extensions/services/web/entry.py +487 -0
  40. package/extensions/services/web/module.md +24 -0
  41. package/extensions/services/web/routes/__init__.py +1 -0
  42. package/extensions/services/web/routes/routes_call.py +189 -0
  43. package/extensions/services/web/routes/routes_config.py +512 -0
  44. package/extensions/services/web/routes/routes_contacts.py +98 -0
  45. package/extensions/services/web/routes/routes_devlog.py +99 -0
  46. package/extensions/services/web/routes/routes_phone.py +81 -0
  47. package/extensions/services/web/routes/routes_sms.py +48 -0
  48. package/extensions/services/web/routes/routes_stats.py +17 -0
  49. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  50. package/extensions/services/web/routes/schemas.py +216 -0
  51. package/extensions/services/web/server.py +332 -0
  52. package/extensions/services/web/static/css/style.css +1064 -0
  53. package/extensions/services/web/static/index.html +1445 -0
  54. package/extensions/services/web/static/js/app.js +4671 -0
  55. package/extensions/services/web/vendor/__init__.py +1 -0
  56. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  57. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  58. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  59. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  60. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  61. package/extensions/services/web/vendor/config.py +139 -0
  62. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  63. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  64. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  65. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  66. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  67. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  68. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  69. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  70. package/extensions/services/web/vendor/storage/identity.py +312 -0
  71. package/extensions/services/web/vendor/storage/store.py +507 -0
  72. package/extensions/services/web/vendor/task/__init__.py +0 -0
  73. package/extensions/services/web/vendor/task/manager.py +864 -0
  74. package/extensions/services/web/vendor/task/models.py +45 -0
  75. package/extensions/services/web/vendor/task/webhook.py +263 -0
  76. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  77. package/extensions/services/web/vendor/tools/registry.py +321 -0
  78. package/main.py +344 -4
  79. package/package.json +11 -2
  80. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  82. package/core/data_dir.py +0 -62
  83. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  84. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  85. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  86. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  87. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  88. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  89. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  90. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  91. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  92. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  93. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  94. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  96. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  97. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  98. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  99. package/core/launcher/data/token.txt +0 -1
  100. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  102. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  103. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  104. package/core/registry/data/port.txt +0 -1
  105. package/core/registry/data/port_484.txt +0 -1
  106. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  108. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  110. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  111. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
  112. /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
@@ -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