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.
Files changed (71) hide show
  1. kyber/__init__.py +6 -0
  2. kyber/__main__.py +8 -0
  3. kyber/agent/__init__.py +8 -0
  4. kyber/agent/context.py +224 -0
  5. kyber/agent/loop.py +687 -0
  6. kyber/agent/memory.py +109 -0
  7. kyber/agent/skills.py +244 -0
  8. kyber/agent/subagent.py +379 -0
  9. kyber/agent/tools/__init__.py +6 -0
  10. kyber/agent/tools/base.py +102 -0
  11. kyber/agent/tools/filesystem.py +191 -0
  12. kyber/agent/tools/message.py +86 -0
  13. kyber/agent/tools/registry.py +73 -0
  14. kyber/agent/tools/shell.py +141 -0
  15. kyber/agent/tools/spawn.py +65 -0
  16. kyber/agent/tools/task_status.py +53 -0
  17. kyber/agent/tools/web.py +163 -0
  18. kyber/bridge/package.json +26 -0
  19. kyber/bridge/src/index.ts +50 -0
  20. kyber/bridge/src/server.ts +104 -0
  21. kyber/bridge/src/types.d.ts +3 -0
  22. kyber/bridge/src/whatsapp.ts +185 -0
  23. kyber/bridge/tsconfig.json +16 -0
  24. kyber/bus/__init__.py +6 -0
  25. kyber/bus/events.py +37 -0
  26. kyber/bus/queue.py +81 -0
  27. kyber/channels/__init__.py +6 -0
  28. kyber/channels/base.py +121 -0
  29. kyber/channels/discord.py +304 -0
  30. kyber/channels/feishu.py +263 -0
  31. kyber/channels/manager.py +161 -0
  32. kyber/channels/telegram.py +302 -0
  33. kyber/channels/whatsapp.py +141 -0
  34. kyber/cli/__init__.py +1 -0
  35. kyber/cli/commands.py +736 -0
  36. kyber/config/__init__.py +6 -0
  37. kyber/config/loader.py +95 -0
  38. kyber/config/schema.py +205 -0
  39. kyber/cron/__init__.py +6 -0
  40. kyber/cron/service.py +346 -0
  41. kyber/cron/types.py +59 -0
  42. kyber/dashboard/__init__.py +5 -0
  43. kyber/dashboard/server.py +122 -0
  44. kyber/dashboard/static/app.js +458 -0
  45. kyber/dashboard/static/favicon.png +0 -0
  46. kyber/dashboard/static/index.html +107 -0
  47. kyber/dashboard/static/kyber_logo.png +0 -0
  48. kyber/dashboard/static/styles.css +608 -0
  49. kyber/heartbeat/__init__.py +5 -0
  50. kyber/heartbeat/service.py +130 -0
  51. kyber/providers/__init__.py +6 -0
  52. kyber/providers/base.py +69 -0
  53. kyber/providers/litellm_provider.py +227 -0
  54. kyber/providers/transcription.py +65 -0
  55. kyber/session/__init__.py +5 -0
  56. kyber/session/manager.py +202 -0
  57. kyber/skills/README.md +47 -0
  58. kyber/skills/github/SKILL.md +48 -0
  59. kyber/skills/skill-creator/SKILL.md +371 -0
  60. kyber/skills/summarize/SKILL.md +67 -0
  61. kyber/skills/tmux/SKILL.md +121 -0
  62. kyber/skills/tmux/scripts/find-sessions.sh +112 -0
  63. kyber/skills/tmux/scripts/wait-for-text.sh +83 -0
  64. kyber/skills/weather/SKILL.md +49 -0
  65. kyber/utils/__init__.py +5 -0
  66. kyber/utils/helpers.py +91 -0
  67. kyber_chat-1.0.0.dist-info/METADATA +35 -0
  68. kyber_chat-1.0.0.dist-info/RECORD +71 -0
  69. kyber_chat-1.0.0.dist-info/WHEEL +4 -0
  70. kyber_chat-1.0.0.dist-info/entry_points.txt +2 -0
  71. 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()