kyber-chat 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kyber/__init__.py +6 -0
- kyber/__main__.py +8 -0
- kyber/agent/__init__.py +8 -0
- kyber/agent/context.py +224 -0
- kyber/agent/loop.py +687 -0
- kyber/agent/memory.py +109 -0
- kyber/agent/skills.py +244 -0
- kyber/agent/subagent.py +379 -0
- kyber/agent/tools/__init__.py +6 -0
- kyber/agent/tools/base.py +102 -0
- kyber/agent/tools/filesystem.py +191 -0
- kyber/agent/tools/message.py +86 -0
- kyber/agent/tools/registry.py +73 -0
- kyber/agent/tools/shell.py +141 -0
- kyber/agent/tools/spawn.py +65 -0
- kyber/agent/tools/task_status.py +53 -0
- kyber/agent/tools/web.py +163 -0
- kyber/bridge/package.json +26 -0
- kyber/bridge/src/index.ts +50 -0
- kyber/bridge/src/server.ts +104 -0
- kyber/bridge/src/types.d.ts +3 -0
- kyber/bridge/src/whatsapp.ts +185 -0
- kyber/bridge/tsconfig.json +16 -0
- kyber/bus/__init__.py +6 -0
- kyber/bus/events.py +37 -0
- kyber/bus/queue.py +81 -0
- kyber/channels/__init__.py +6 -0
- kyber/channels/base.py +121 -0
- kyber/channels/discord.py +304 -0
- kyber/channels/feishu.py +263 -0
- kyber/channels/manager.py +161 -0
- kyber/channels/telegram.py +302 -0
- kyber/channels/whatsapp.py +141 -0
- kyber/cli/__init__.py +1 -0
- kyber/cli/commands.py +736 -0
- kyber/config/__init__.py +6 -0
- kyber/config/loader.py +95 -0
- kyber/config/schema.py +205 -0
- kyber/cron/__init__.py +6 -0
- kyber/cron/service.py +346 -0
- kyber/cron/types.py +59 -0
- kyber/dashboard/__init__.py +5 -0
- kyber/dashboard/server.py +122 -0
- kyber/dashboard/static/app.js +458 -0
- kyber/dashboard/static/favicon.png +0 -0
- kyber/dashboard/static/index.html +107 -0
- kyber/dashboard/static/kyber_logo.png +0 -0
- kyber/dashboard/static/styles.css +608 -0
- kyber/heartbeat/__init__.py +5 -0
- kyber/heartbeat/service.py +130 -0
- kyber/providers/__init__.py +6 -0
- kyber/providers/base.py +69 -0
- kyber/providers/litellm_provider.py +227 -0
- kyber/providers/transcription.py +65 -0
- kyber/session/__init__.py +5 -0
- kyber/session/manager.py +202 -0
- kyber/skills/README.md +47 -0
- kyber/skills/github/SKILL.md +48 -0
- kyber/skills/skill-creator/SKILL.md +371 -0
- kyber/skills/summarize/SKILL.md +67 -0
- kyber/skills/tmux/SKILL.md +121 -0
- kyber/skills/tmux/scripts/find-sessions.sh +112 -0
- kyber/skills/tmux/scripts/wait-for-text.sh +83 -0
- kyber/skills/weather/SKILL.md +49 -0
- kyber/utils/__init__.py +5 -0
- kyber/utils/helpers.py +91 -0
- kyber_chat-1.0.0.dist-info/METADATA +35 -0
- kyber_chat-1.0.0.dist-info/RECORD +71 -0
- kyber_chat-1.0.0.dist-info/WHEEL +4 -0
- kyber_chat-1.0.0.dist-info/entry_points.txt +2 -0
- kyber_chat-1.0.0.dist-info/licenses/LICENSE +21 -0
kyber/cli/commands.py
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"""CLI commands for kyber."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from kyber import __version__, __logo__
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="kyber",
|
|
14
|
+
help=f"{__logo__} kyber - Personal AI Assistant",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def version_callback(value: bool):
|
|
22
|
+
if value:
|
|
23
|
+
console.print(f"{__logo__} kyber v{__version__}")
|
|
24
|
+
raise typer.Exit()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback()
|
|
28
|
+
def main(
|
|
29
|
+
version: bool = typer.Option(
|
|
30
|
+
None, "--version", "-v", callback=version_callback, is_eager=True
|
|
31
|
+
),
|
|
32
|
+
):
|
|
33
|
+
"""kyber - Personal AI Assistant."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ============================================================================
|
|
38
|
+
# Onboard / Setup
|
|
39
|
+
# ============================================================================
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def onboard():
|
|
44
|
+
"""Initialize kyber configuration and workspace."""
|
|
45
|
+
from kyber.config.loader import get_config_path, save_config
|
|
46
|
+
from kyber.config.schema import Config
|
|
47
|
+
from kyber.utils.helpers import get_workspace_path
|
|
48
|
+
|
|
49
|
+
config_path = get_config_path()
|
|
50
|
+
|
|
51
|
+
if config_path.exists():
|
|
52
|
+
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
|
|
53
|
+
if not typer.confirm("Overwrite?"):
|
|
54
|
+
raise typer.Exit()
|
|
55
|
+
|
|
56
|
+
# Create default config
|
|
57
|
+
config = Config()
|
|
58
|
+
save_config(config)
|
|
59
|
+
console.print(f"[green]✓[/green] Created config at {config_path}")
|
|
60
|
+
|
|
61
|
+
# Create workspace
|
|
62
|
+
workspace = get_workspace_path()
|
|
63
|
+
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
|
64
|
+
|
|
65
|
+
# Create default bootstrap files
|
|
66
|
+
_create_workspace_templates(workspace)
|
|
67
|
+
|
|
68
|
+
console.print(f"\n{__logo__} kyber is ready!")
|
|
69
|
+
console.print("\nNext steps:")
|
|
70
|
+
console.print(" 1. Add your API key to [cyan]~/.kyber/config.json[/cyan]")
|
|
71
|
+
console.print(" Get one at: https://openrouter.ai/keys")
|
|
72
|
+
console.print(" 2. Chat: [cyan]kyber agent -m \"Hello!\"[/cyan]")
|
|
73
|
+
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/kyber#-chat-apps[/dim]")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _create_workspace_templates(workspace: Path):
|
|
79
|
+
"""Create default workspace template files."""
|
|
80
|
+
templates = {
|
|
81
|
+
"AGENTS.md": """# Agent Instructions
|
|
82
|
+
|
|
83
|
+
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
|
84
|
+
|
|
85
|
+
## Guidelines
|
|
86
|
+
|
|
87
|
+
- Always explain what you're doing before taking actions
|
|
88
|
+
- Ask for clarification when the request is ambiguous
|
|
89
|
+
- Use tools to help accomplish tasks
|
|
90
|
+
- Remember important information in your memory files
|
|
91
|
+
""",
|
|
92
|
+
"SOUL.md": """# Soul
|
|
93
|
+
|
|
94
|
+
I am kyber, a lightweight AI assistant.
|
|
95
|
+
|
|
96
|
+
## Personality
|
|
97
|
+
|
|
98
|
+
- Helpful and friendly
|
|
99
|
+
- Concise and to the point
|
|
100
|
+
- Curious and eager to learn
|
|
101
|
+
|
|
102
|
+
## Values
|
|
103
|
+
|
|
104
|
+
- Accuracy over speed
|
|
105
|
+
- User privacy and safety
|
|
106
|
+
- Transparency in actions
|
|
107
|
+
""",
|
|
108
|
+
"USER.md": """# User
|
|
109
|
+
|
|
110
|
+
Information about the user goes here.
|
|
111
|
+
|
|
112
|
+
## Preferences
|
|
113
|
+
|
|
114
|
+
- Communication style: (casual/formal)
|
|
115
|
+
- Timezone: (your timezone)
|
|
116
|
+
- Language: (your preferred language)
|
|
117
|
+
""",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for filename, content in templates.items():
|
|
121
|
+
file_path = workspace / filename
|
|
122
|
+
if not file_path.exists():
|
|
123
|
+
file_path.write_text(content)
|
|
124
|
+
console.print(f" [dim]Created {filename}[/dim]")
|
|
125
|
+
|
|
126
|
+
# Create memory directory and MEMORY.md
|
|
127
|
+
memory_dir = workspace / "memory"
|
|
128
|
+
memory_dir.mkdir(exist_ok=True)
|
|
129
|
+
memory_file = memory_dir / "MEMORY.md"
|
|
130
|
+
if not memory_file.exists():
|
|
131
|
+
memory_file.write_text("""# Long-term Memory
|
|
132
|
+
|
|
133
|
+
This file stores important information that should persist across sessions.
|
|
134
|
+
|
|
135
|
+
## User Information
|
|
136
|
+
|
|
137
|
+
(Important facts about the user)
|
|
138
|
+
|
|
139
|
+
## Preferences
|
|
140
|
+
|
|
141
|
+
(User preferences learned over time)
|
|
142
|
+
|
|
143
|
+
## Important Notes
|
|
144
|
+
|
|
145
|
+
(Things to remember)
|
|
146
|
+
""")
|
|
147
|
+
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ============================================================================
|
|
151
|
+
# Gateway / Server
|
|
152
|
+
# ============================================================================
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command()
|
|
156
|
+
def gateway(
|
|
157
|
+
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
|
158
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
159
|
+
):
|
|
160
|
+
"""Start the kyber gateway."""
|
|
161
|
+
from kyber.config.loader import load_config, get_data_dir
|
|
162
|
+
from kyber.bus.queue import MessageBus
|
|
163
|
+
from kyber.providers.litellm_provider import LiteLLMProvider
|
|
164
|
+
from kyber.agent.loop import AgentLoop
|
|
165
|
+
from kyber.channels.manager import ChannelManager
|
|
166
|
+
from kyber.cron.service import CronService
|
|
167
|
+
from kyber.cron.types import CronJob
|
|
168
|
+
from kyber.heartbeat.service import HeartbeatService
|
|
169
|
+
|
|
170
|
+
if verbose:
|
|
171
|
+
import logging
|
|
172
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
173
|
+
|
|
174
|
+
console.print(f"{__logo__} Starting kyber gateway on port {port}...")
|
|
175
|
+
|
|
176
|
+
config = load_config()
|
|
177
|
+
|
|
178
|
+
# Create components
|
|
179
|
+
bus = MessageBus()
|
|
180
|
+
|
|
181
|
+
# Create provider (supports OpenRouter, Anthropic, OpenAI, Bedrock)
|
|
182
|
+
api_key = config.get_api_key()
|
|
183
|
+
api_base = config.get_api_base()
|
|
184
|
+
provider_name = (config.agents.defaults.provider or "").strip().lower() or config.get_provider_name()
|
|
185
|
+
model = config.agents.defaults.model
|
|
186
|
+
is_bedrock = model.startswith("bedrock/")
|
|
187
|
+
|
|
188
|
+
if provider_name and not api_key and not is_bedrock:
|
|
189
|
+
console.print(f"[red]Error: No API key configured for provider {provider_name}.[/red]")
|
|
190
|
+
console.print(f"Set one in ~/.kyber/config.json under providers.{provider_name}.apiKey")
|
|
191
|
+
raise typer.Exit(1)
|
|
192
|
+
|
|
193
|
+
if not api_key and not is_bedrock:
|
|
194
|
+
console.print("[red]Error: No API key configured.[/red]")
|
|
195
|
+
console.print("Set one in ~/.kyber/config.json under providers.openrouter.apiKey or providers.kimi.apiKey")
|
|
196
|
+
raise typer.Exit(1)
|
|
197
|
+
|
|
198
|
+
provider = LiteLLMProvider(
|
|
199
|
+
api_key=api_key,
|
|
200
|
+
api_base=api_base,
|
|
201
|
+
default_model=config.agents.defaults.model,
|
|
202
|
+
provider_name=provider_name,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Create agent
|
|
206
|
+
agent = AgentLoop(
|
|
207
|
+
bus=bus,
|
|
208
|
+
provider=provider,
|
|
209
|
+
workspace=config.workspace_path,
|
|
210
|
+
model=config.agents.defaults.model,
|
|
211
|
+
max_iterations=config.agents.defaults.max_tool_iterations,
|
|
212
|
+
brave_api_key=config.tools.web.search.api_key or None,
|
|
213
|
+
search_max_results=config.tools.web.search.max_results,
|
|
214
|
+
exec_config=config.tools.exec,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Create cron service
|
|
218
|
+
async def on_cron_job(job: CronJob) -> str | None:
|
|
219
|
+
"""Execute a cron job through the agent."""
|
|
220
|
+
response = await agent.process_direct(
|
|
221
|
+
job.payload.message,
|
|
222
|
+
session_key=f"cron:{job.id}"
|
|
223
|
+
)
|
|
224
|
+
# Optionally deliver to channel
|
|
225
|
+
if job.payload.deliver and job.payload.to:
|
|
226
|
+
from kyber.bus.events import OutboundMessage
|
|
227
|
+
await bus.publish_outbound(OutboundMessage(
|
|
228
|
+
channel=job.payload.channel or "whatsapp",
|
|
229
|
+
chat_id=job.payload.to,
|
|
230
|
+
content=response or ""
|
|
231
|
+
))
|
|
232
|
+
return response
|
|
233
|
+
|
|
234
|
+
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
|
235
|
+
cron = CronService(cron_store_path, on_job=on_cron_job)
|
|
236
|
+
|
|
237
|
+
# Create heartbeat service
|
|
238
|
+
async def on_heartbeat(prompt: str) -> str:
|
|
239
|
+
"""Execute heartbeat through the agent."""
|
|
240
|
+
return await agent.process_direct(prompt, session_key="heartbeat")
|
|
241
|
+
|
|
242
|
+
heartbeat = HeartbeatService(
|
|
243
|
+
workspace=config.workspace_path,
|
|
244
|
+
on_heartbeat=on_heartbeat,
|
|
245
|
+
interval_s=30 * 60, # 30 minutes
|
|
246
|
+
enabled=True
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Create channel manager
|
|
250
|
+
channels = ChannelManager(config, bus)
|
|
251
|
+
|
|
252
|
+
if channels.enabled_channels:
|
|
253
|
+
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
|
254
|
+
else:
|
|
255
|
+
console.print("[yellow]Warning: No channels enabled[/yellow]")
|
|
256
|
+
|
|
257
|
+
cron_status = cron.status()
|
|
258
|
+
if cron_status["jobs"] > 0:
|
|
259
|
+
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
|
|
260
|
+
|
|
261
|
+
console.print(f"[green]✓[/green] Heartbeat: every 30m")
|
|
262
|
+
|
|
263
|
+
async def run():
|
|
264
|
+
try:
|
|
265
|
+
await cron.start()
|
|
266
|
+
await heartbeat.start()
|
|
267
|
+
await asyncio.gather(
|
|
268
|
+
agent.run(),
|
|
269
|
+
channels.start_all(),
|
|
270
|
+
)
|
|
271
|
+
except KeyboardInterrupt:
|
|
272
|
+
console.print("\nShutting down...")
|
|
273
|
+
heartbeat.stop()
|
|
274
|
+
cron.stop()
|
|
275
|
+
agent.stop()
|
|
276
|
+
await channels.stop_all()
|
|
277
|
+
|
|
278
|
+
asyncio.run(run())
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ============================================================================
|
|
284
|
+
# Agent Commands
|
|
285
|
+
# ============================================================================
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command()
|
|
289
|
+
def agent(
|
|
290
|
+
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
|
|
291
|
+
session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
|
|
292
|
+
):
|
|
293
|
+
"""Interact with the agent directly."""
|
|
294
|
+
from kyber.config.loader import load_config
|
|
295
|
+
from kyber.bus.queue import MessageBus
|
|
296
|
+
from kyber.providers.litellm_provider import LiteLLMProvider
|
|
297
|
+
from kyber.agent.loop import AgentLoop
|
|
298
|
+
|
|
299
|
+
config = load_config()
|
|
300
|
+
|
|
301
|
+
api_key = config.get_api_key()
|
|
302
|
+
api_base = config.get_api_base()
|
|
303
|
+
provider_name = (config.agents.defaults.provider or "").strip().lower() or config.get_provider_name()
|
|
304
|
+
model = config.agents.defaults.model
|
|
305
|
+
is_bedrock = model.startswith("bedrock/")
|
|
306
|
+
|
|
307
|
+
if provider_name and not api_key and not is_bedrock:
|
|
308
|
+
console.print(f"[red]Error: No API key configured for provider {provider_name}.[/red]")
|
|
309
|
+
console.print(f"Set one in ~/.kyber/config.json under providers.{provider_name}.apiKey")
|
|
310
|
+
raise typer.Exit(1)
|
|
311
|
+
|
|
312
|
+
if not api_key and not is_bedrock:
|
|
313
|
+
console.print("[red]Error: No API key configured.[/red]")
|
|
314
|
+
raise typer.Exit(1)
|
|
315
|
+
|
|
316
|
+
bus = MessageBus()
|
|
317
|
+
provider = LiteLLMProvider(
|
|
318
|
+
api_key=api_key,
|
|
319
|
+
api_base=api_base,
|
|
320
|
+
default_model=config.agents.defaults.model,
|
|
321
|
+
provider_name=provider_name,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
agent_loop = AgentLoop(
|
|
325
|
+
bus=bus,
|
|
326
|
+
provider=provider,
|
|
327
|
+
workspace=config.workspace_path,
|
|
328
|
+
brave_api_key=config.tools.web.search.api_key or None,
|
|
329
|
+
exec_config=config.tools.exec,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if message:
|
|
333
|
+
# Single message mode
|
|
334
|
+
async def run_once():
|
|
335
|
+
response = await agent_loop.process_direct(message, session_id)
|
|
336
|
+
console.print(f"\n{__logo__} {response}")
|
|
337
|
+
|
|
338
|
+
asyncio.run(run_once())
|
|
339
|
+
else:
|
|
340
|
+
# Interactive mode
|
|
341
|
+
console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n")
|
|
342
|
+
|
|
343
|
+
async def run_interactive():
|
|
344
|
+
while True:
|
|
345
|
+
try:
|
|
346
|
+
user_input = console.input("[bold blue]You:[/bold blue] ")
|
|
347
|
+
if not user_input.strip():
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
response = await agent_loop.process_direct(user_input, session_id)
|
|
351
|
+
console.print(f"\n{__logo__} {response}\n")
|
|
352
|
+
except KeyboardInterrupt:
|
|
353
|
+
console.print("\nGoodbye!")
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
asyncio.run(run_interactive())
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@app.command()
|
|
362
|
+
def dashboard(
|
|
363
|
+
host: str | None = typer.Option(None, "--host", help="Dashboard host"),
|
|
364
|
+
port: int | None = typer.Option(None, "--port", help="Dashboard port"),
|
|
365
|
+
show_token: bool = typer.Option(False, "--show-token", help="Print dashboard token"),
|
|
366
|
+
):
|
|
367
|
+
"""Start the kyber web dashboard."""
|
|
368
|
+
import secrets
|
|
369
|
+
import uvicorn
|
|
370
|
+
|
|
371
|
+
from kyber.config.loader import load_config, save_config
|
|
372
|
+
from kyber.dashboard.server import create_dashboard_app
|
|
373
|
+
|
|
374
|
+
config = load_config()
|
|
375
|
+
dash = config.dashboard
|
|
376
|
+
|
|
377
|
+
if host:
|
|
378
|
+
dash.host = host
|
|
379
|
+
if port:
|
|
380
|
+
dash.port = port
|
|
381
|
+
|
|
382
|
+
if not dash.auth_token.strip():
|
|
383
|
+
dash.auth_token = secrets.token_urlsafe(32)
|
|
384
|
+
save_config(config)
|
|
385
|
+
console.print("[green]✓[/green] Generated new dashboard token and saved to config")
|
|
386
|
+
|
|
387
|
+
if dash.host not in {"127.0.0.1", "localhost", "::1"} and not dash.allowed_hosts:
|
|
388
|
+
console.print("[red]Refusing to bind dashboard to a non-local host without allowedHosts configured.[/red]")
|
|
389
|
+
console.print("Set dashboard.allowedHosts in ~/.kyber/config.json to the hostnames you expect.")
|
|
390
|
+
raise typer.Exit(1)
|
|
391
|
+
|
|
392
|
+
app = create_dashboard_app(config)
|
|
393
|
+
url = f"http://{dash.host}:{dash.port}"
|
|
394
|
+
console.print(f"{__logo__} Kyber dashboard running at {url}")
|
|
395
|
+
if show_token:
|
|
396
|
+
console.print(f" Token: [bold]{dash.auth_token}[/bold]")
|
|
397
|
+
else:
|
|
398
|
+
masked = dash.auth_token[:6] + "…" + dash.auth_token[-4:]
|
|
399
|
+
console.print(f" Token: [dim]{masked}[/dim] (run with --show-token to reveal)")
|
|
400
|
+
console.print(f" Open: {url}")
|
|
401
|
+
uvicorn.run(app, host=dash.host, port=dash.port, log_level="info")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ============================================================================
|
|
405
|
+
# Channel Commands
|
|
406
|
+
# ============================================================================
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
channels_app = typer.Typer(help="Manage channels")
|
|
410
|
+
app.add_typer(channels_app, name="channels")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@channels_app.command("status")
|
|
414
|
+
def channels_status():
|
|
415
|
+
"""Show channel status."""
|
|
416
|
+
from kyber.config.loader import load_config
|
|
417
|
+
|
|
418
|
+
config = load_config()
|
|
419
|
+
|
|
420
|
+
table = Table(title="Channel Status")
|
|
421
|
+
table.add_column("Channel", style="cyan")
|
|
422
|
+
table.add_column("Enabled", style="green")
|
|
423
|
+
table.add_column("Configuration", style="yellow")
|
|
424
|
+
|
|
425
|
+
# WhatsApp
|
|
426
|
+
wa = config.channels.whatsapp
|
|
427
|
+
table.add_row(
|
|
428
|
+
"WhatsApp",
|
|
429
|
+
"✓" if wa.enabled else "✗",
|
|
430
|
+
wa.bridge_url
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Telegram
|
|
434
|
+
tg = config.channels.telegram
|
|
435
|
+
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
|
436
|
+
table.add_row(
|
|
437
|
+
"Telegram",
|
|
438
|
+
"✓" if tg.enabled else "✗",
|
|
439
|
+
tg_config
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# Discord
|
|
443
|
+
dc = config.channels.discord
|
|
444
|
+
dc_config = f"token: {dc.token[:10]}..." if dc.token else "[dim]not configured[/dim]"
|
|
445
|
+
table.add_row(
|
|
446
|
+
"Discord",
|
|
447
|
+
"✓" if dc.enabled else "✗",
|
|
448
|
+
dc_config
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Feishu
|
|
452
|
+
fs = config.channels.feishu
|
|
453
|
+
fs_config = f"app_id: {fs.app_id}" if fs.app_id else "[dim]not configured[/dim]"
|
|
454
|
+
table.add_row(
|
|
455
|
+
"Feishu",
|
|
456
|
+
"✓" if fs.enabled else "✗",
|
|
457
|
+
fs_config
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
console.print(table)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _get_bridge_dir() -> Path:
|
|
464
|
+
"""Get the bridge directory, setting it up if needed."""
|
|
465
|
+
import shutil
|
|
466
|
+
import subprocess
|
|
467
|
+
|
|
468
|
+
# User's bridge location
|
|
469
|
+
user_bridge = Path.home() / ".kyber" / "bridge"
|
|
470
|
+
|
|
471
|
+
# Check if already built
|
|
472
|
+
if (user_bridge / "dist" / "index.js").exists():
|
|
473
|
+
return user_bridge
|
|
474
|
+
|
|
475
|
+
# Check for npm
|
|
476
|
+
if not shutil.which("npm"):
|
|
477
|
+
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
|
|
478
|
+
raise typer.Exit(1)
|
|
479
|
+
|
|
480
|
+
# Find source bridge: first check package data, then source dir
|
|
481
|
+
pkg_bridge = Path(__file__).parent.parent / "bridge" # kyber/bridge (installed)
|
|
482
|
+
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
|
|
483
|
+
|
|
484
|
+
source = None
|
|
485
|
+
if (pkg_bridge / "package.json").exists():
|
|
486
|
+
source = pkg_bridge
|
|
487
|
+
elif (src_bridge / "package.json").exists():
|
|
488
|
+
source = src_bridge
|
|
489
|
+
|
|
490
|
+
if not source:
|
|
491
|
+
console.print("[red]Bridge source not found.[/red]")
|
|
492
|
+
console.print("Try reinstalling: pip install --force-reinstall kyber")
|
|
493
|
+
raise typer.Exit(1)
|
|
494
|
+
|
|
495
|
+
console.print(f"{__logo__} Setting up bridge...")
|
|
496
|
+
|
|
497
|
+
# Copy to user directory
|
|
498
|
+
user_bridge.parent.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
if user_bridge.exists():
|
|
500
|
+
shutil.rmtree(user_bridge)
|
|
501
|
+
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
|
|
502
|
+
|
|
503
|
+
# Install and build
|
|
504
|
+
try:
|
|
505
|
+
console.print(" Installing dependencies...")
|
|
506
|
+
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
|
|
507
|
+
|
|
508
|
+
console.print(" Building...")
|
|
509
|
+
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
|
|
510
|
+
|
|
511
|
+
console.print("[green]✓[/green] Bridge ready\n")
|
|
512
|
+
except subprocess.CalledProcessError as e:
|
|
513
|
+
console.print(f"[red]Build failed: {e}[/red]")
|
|
514
|
+
if e.stderr:
|
|
515
|
+
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
|
|
516
|
+
raise typer.Exit(1)
|
|
517
|
+
|
|
518
|
+
return user_bridge
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@channels_app.command("login")
|
|
522
|
+
def channels_login():
|
|
523
|
+
"""Link device via QR code."""
|
|
524
|
+
import subprocess
|
|
525
|
+
|
|
526
|
+
bridge_dir = _get_bridge_dir()
|
|
527
|
+
|
|
528
|
+
console.print(f"{__logo__} Starting bridge...")
|
|
529
|
+
console.print("Scan the QR code to connect.\n")
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True)
|
|
533
|
+
except subprocess.CalledProcessError as e:
|
|
534
|
+
console.print(f"[red]Bridge failed: {e}[/red]")
|
|
535
|
+
except FileNotFoundError:
|
|
536
|
+
console.print("[red]npm not found. Please install Node.js.[/red]")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ============================================================================
|
|
540
|
+
# Cron Commands
|
|
541
|
+
# ============================================================================
|
|
542
|
+
|
|
543
|
+
cron_app = typer.Typer(help="Manage scheduled tasks")
|
|
544
|
+
app.add_typer(cron_app, name="cron")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@cron_app.command("list")
|
|
548
|
+
def cron_list(
|
|
549
|
+
all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"),
|
|
550
|
+
):
|
|
551
|
+
"""List scheduled jobs."""
|
|
552
|
+
from kyber.config.loader import get_data_dir
|
|
553
|
+
from kyber.cron.service import CronService
|
|
554
|
+
|
|
555
|
+
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
556
|
+
service = CronService(store_path)
|
|
557
|
+
|
|
558
|
+
jobs = service.list_jobs(include_disabled=all)
|
|
559
|
+
|
|
560
|
+
if not jobs:
|
|
561
|
+
console.print("No scheduled jobs.")
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
table = Table(title="Scheduled Jobs")
|
|
565
|
+
table.add_column("ID", style="cyan")
|
|
566
|
+
table.add_column("Name")
|
|
567
|
+
table.add_column("Schedule")
|
|
568
|
+
table.add_column("Status")
|
|
569
|
+
table.add_column("Next Run")
|
|
570
|
+
|
|
571
|
+
import time
|
|
572
|
+
for job in jobs:
|
|
573
|
+
# Format schedule
|
|
574
|
+
if job.schedule.kind == "every":
|
|
575
|
+
sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
|
|
576
|
+
elif job.schedule.kind == "cron":
|
|
577
|
+
sched = job.schedule.expr or ""
|
|
578
|
+
else:
|
|
579
|
+
sched = "one-time"
|
|
580
|
+
|
|
581
|
+
# Format next run
|
|
582
|
+
next_run = ""
|
|
583
|
+
if job.state.next_run_at_ms:
|
|
584
|
+
next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
|
|
585
|
+
next_run = next_time
|
|
586
|
+
|
|
587
|
+
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
|
588
|
+
|
|
589
|
+
table.add_row(job.id, job.name, sched, status, next_run)
|
|
590
|
+
|
|
591
|
+
console.print(table)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@cron_app.command("add")
|
|
595
|
+
def cron_add(
|
|
596
|
+
name: str = typer.Option(..., "--name", "-n", help="Job name"),
|
|
597
|
+
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
|
|
598
|
+
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
|
|
599
|
+
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
|
|
600
|
+
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
|
|
601
|
+
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
|
|
602
|
+
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
|
|
603
|
+
channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"),
|
|
604
|
+
):
|
|
605
|
+
"""Add a scheduled job."""
|
|
606
|
+
from kyber.config.loader import get_data_dir
|
|
607
|
+
from kyber.cron.service import CronService
|
|
608
|
+
from kyber.cron.types import CronSchedule
|
|
609
|
+
|
|
610
|
+
# Determine schedule type
|
|
611
|
+
if every:
|
|
612
|
+
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
|
613
|
+
elif cron_expr:
|
|
614
|
+
schedule = CronSchedule(kind="cron", expr=cron_expr)
|
|
615
|
+
elif at:
|
|
616
|
+
import datetime
|
|
617
|
+
dt = datetime.datetime.fromisoformat(at)
|
|
618
|
+
schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
|
|
619
|
+
else:
|
|
620
|
+
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
|
621
|
+
raise typer.Exit(1)
|
|
622
|
+
|
|
623
|
+
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
624
|
+
service = CronService(store_path)
|
|
625
|
+
|
|
626
|
+
job = service.add_job(
|
|
627
|
+
name=name,
|
|
628
|
+
schedule=schedule,
|
|
629
|
+
message=message,
|
|
630
|
+
deliver=deliver,
|
|
631
|
+
to=to,
|
|
632
|
+
channel=channel,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@cron_app.command("remove")
|
|
639
|
+
def cron_remove(
|
|
640
|
+
job_id: str = typer.Argument(..., help="Job ID to remove"),
|
|
641
|
+
):
|
|
642
|
+
"""Remove a scheduled job."""
|
|
643
|
+
from kyber.config.loader import get_data_dir
|
|
644
|
+
from kyber.cron.service import CronService
|
|
645
|
+
|
|
646
|
+
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
647
|
+
service = CronService(store_path)
|
|
648
|
+
|
|
649
|
+
if service.remove_job(job_id):
|
|
650
|
+
console.print(f"[green]✓[/green] Removed job {job_id}")
|
|
651
|
+
else:
|
|
652
|
+
console.print(f"[red]Job {job_id} not found[/red]")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@cron_app.command("enable")
|
|
656
|
+
def cron_enable(
|
|
657
|
+
job_id: str = typer.Argument(..., help="Job ID"),
|
|
658
|
+
disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"),
|
|
659
|
+
):
|
|
660
|
+
"""Enable or disable a job."""
|
|
661
|
+
from kyber.config.loader import get_data_dir
|
|
662
|
+
from kyber.cron.service import CronService
|
|
663
|
+
|
|
664
|
+
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
665
|
+
service = CronService(store_path)
|
|
666
|
+
|
|
667
|
+
job = service.enable_job(job_id, enabled=not disable)
|
|
668
|
+
if job:
|
|
669
|
+
status = "disabled" if disable else "enabled"
|
|
670
|
+
console.print(f"[green]✓[/green] Job '{job.name}' {status}")
|
|
671
|
+
else:
|
|
672
|
+
console.print(f"[red]Job {job_id} not found[/red]")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@cron_app.command("run")
|
|
676
|
+
def cron_run(
|
|
677
|
+
job_id: str = typer.Argument(..., help="Job ID to run"),
|
|
678
|
+
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
|
|
679
|
+
):
|
|
680
|
+
"""Manually run a job."""
|
|
681
|
+
from kyber.config.loader import get_data_dir
|
|
682
|
+
from kyber.cron.service import CronService
|
|
683
|
+
|
|
684
|
+
store_path = get_data_dir() / "cron" / "jobs.json"
|
|
685
|
+
service = CronService(store_path)
|
|
686
|
+
|
|
687
|
+
async def run():
|
|
688
|
+
return await service.run_job(job_id, force=force)
|
|
689
|
+
|
|
690
|
+
if asyncio.run(run()):
|
|
691
|
+
console.print(f"[green]✓[/green] Job executed")
|
|
692
|
+
else:
|
|
693
|
+
console.print(f"[red]Failed to run job {job_id}[/red]")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# ============================================================================
|
|
697
|
+
# Status Commands
|
|
698
|
+
# ============================================================================
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@app.command()
|
|
702
|
+
def status():
|
|
703
|
+
"""Show kyber status."""
|
|
704
|
+
from kyber.config.loader import load_config, get_config_path
|
|
705
|
+
|
|
706
|
+
config_path = get_config_path()
|
|
707
|
+
config = load_config()
|
|
708
|
+
workspace = config.workspace_path
|
|
709
|
+
|
|
710
|
+
console.print(f"{__logo__} kyber Status\n")
|
|
711
|
+
|
|
712
|
+
console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
|
|
713
|
+
console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
|
|
714
|
+
|
|
715
|
+
if config_path.exists():
|
|
716
|
+
console.print(f"Model: {config.agents.defaults.model}")
|
|
717
|
+
|
|
718
|
+
# Check API keys
|
|
719
|
+
has_openrouter = bool(config.providers.openrouter.api_key)
|
|
720
|
+
has_kimi = bool(config.providers.kimi.api_key)
|
|
721
|
+
has_anthropic = bool(config.providers.anthropic.api_key)
|
|
722
|
+
has_openai = bool(config.providers.openai.api_key)
|
|
723
|
+
has_gemini = bool(config.providers.gemini.api_key)
|
|
724
|
+
has_vllm = bool(config.providers.vllm.api_base)
|
|
725
|
+
|
|
726
|
+
console.print(f"OpenRouter API: {'[green]✓[/green]' if has_openrouter else '[dim]not set[/dim]'}")
|
|
727
|
+
console.print(f"Kimi API: {'[green]✓[/green]' if has_kimi else '[dim]not set[/dim]'}")
|
|
728
|
+
console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}")
|
|
729
|
+
console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}")
|
|
730
|
+
console.print(f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}")
|
|
731
|
+
vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
|
|
732
|
+
console.print(f"vLLM/Local: {vllm_status}")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
if __name__ == "__main__":
|
|
736
|
+
app()
|