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
@@ -0,0 +1,130 @@
1
+ """Heartbeat service - periodic agent wake-up to check for tasks."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Coroutine
6
+
7
+ from loguru import logger
8
+
9
+ # Default interval: 30 minutes
10
+ DEFAULT_HEARTBEAT_INTERVAL_S = 30 * 60
11
+
12
+ # The prompt sent to agent during heartbeat
13
+ HEARTBEAT_PROMPT = """Read HEARTBEAT.md in your workspace (if it exists).
14
+ Follow any instructions or tasks listed there.
15
+ If nothing needs attention, reply with just: HEARTBEAT_OK"""
16
+
17
+ # Token that indicates "nothing to do"
18
+ HEARTBEAT_OK_TOKEN = "HEARTBEAT_OK"
19
+
20
+
21
+ def _is_heartbeat_empty(content: str | None) -> bool:
22
+ """Check if HEARTBEAT.md has no actionable content."""
23
+ if not content:
24
+ return True
25
+
26
+ # Lines to skip: empty, headers, HTML comments, empty checkboxes
27
+ skip_patterns = {"- [ ]", "* [ ]", "- [x]", "* [x]"}
28
+
29
+ for line in content.split("\n"):
30
+ line = line.strip()
31
+ if not line or line.startswith("#") or line.startswith("<!--") or line in skip_patterns:
32
+ continue
33
+ return False # Found actionable content
34
+
35
+ return True
36
+
37
+
38
+ class HeartbeatService:
39
+ """
40
+ Periodic heartbeat service that wakes the agent to check for tasks.
41
+
42
+ The agent reads HEARTBEAT.md from the workspace and executes any
43
+ tasks listed there. If nothing needs attention, it replies HEARTBEAT_OK.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ workspace: Path,
49
+ on_heartbeat: Callable[[str], Coroutine[Any, Any, str]] | None = None,
50
+ interval_s: int = DEFAULT_HEARTBEAT_INTERVAL_S,
51
+ enabled: bool = True,
52
+ ):
53
+ self.workspace = workspace
54
+ self.on_heartbeat = on_heartbeat
55
+ self.interval_s = interval_s
56
+ self.enabled = enabled
57
+ self._running = False
58
+ self._task: asyncio.Task | None = None
59
+
60
+ @property
61
+ def heartbeat_file(self) -> Path:
62
+ return self.workspace / "HEARTBEAT.md"
63
+
64
+ def _read_heartbeat_file(self) -> str | None:
65
+ """Read HEARTBEAT.md content."""
66
+ if self.heartbeat_file.exists():
67
+ try:
68
+ return self.heartbeat_file.read_text()
69
+ except Exception:
70
+ return None
71
+ return None
72
+
73
+ async def start(self) -> None:
74
+ """Start the heartbeat service."""
75
+ if not self.enabled:
76
+ logger.info("Heartbeat disabled")
77
+ return
78
+
79
+ self._running = True
80
+ self._task = asyncio.create_task(self._run_loop())
81
+ logger.info(f"Heartbeat started (every {self.interval_s}s)")
82
+
83
+ def stop(self) -> None:
84
+ """Stop the heartbeat service."""
85
+ self._running = False
86
+ if self._task:
87
+ self._task.cancel()
88
+ self._task = None
89
+
90
+ async def _run_loop(self) -> None:
91
+ """Main heartbeat loop."""
92
+ while self._running:
93
+ try:
94
+ await asyncio.sleep(self.interval_s)
95
+ if self._running:
96
+ await self._tick()
97
+ except asyncio.CancelledError:
98
+ break
99
+ except Exception as e:
100
+ logger.error(f"Heartbeat error: {e}")
101
+
102
+ async def _tick(self) -> None:
103
+ """Execute a single heartbeat tick."""
104
+ content = self._read_heartbeat_file()
105
+
106
+ # Skip if HEARTBEAT.md is empty or doesn't exist
107
+ if _is_heartbeat_empty(content):
108
+ logger.debug("Heartbeat: no tasks (HEARTBEAT.md empty)")
109
+ return
110
+
111
+ logger.info("Heartbeat: checking for tasks...")
112
+
113
+ if self.on_heartbeat:
114
+ try:
115
+ response = await self.on_heartbeat(HEARTBEAT_PROMPT)
116
+
117
+ # Check if agent said "nothing to do"
118
+ if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
119
+ logger.info("Heartbeat: OK (no action needed)")
120
+ else:
121
+ logger.info(f"Heartbeat: completed task")
122
+
123
+ except Exception as e:
124
+ logger.error(f"Heartbeat execution failed: {e}")
125
+
126
+ async def trigger_now(self) -> str | None:
127
+ """Manually trigger a heartbeat."""
128
+ if self.on_heartbeat:
129
+ return await self.on_heartbeat(HEARTBEAT_PROMPT)
130
+ return None
@@ -0,0 +1,6 @@
1
+ """LLM provider abstraction module."""
2
+
3
+ from kyber.providers.base import LLMProvider, LLMResponse
4
+ from kyber.providers.litellm_provider import LiteLLMProvider
5
+
6
+ __all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider"]
@@ -0,0 +1,69 @@
1
+ """Base LLM provider interface."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class ToolCallRequest:
10
+ """A tool call request from the LLM."""
11
+ id: str
12
+ name: str
13
+ arguments: dict[str, Any]
14
+
15
+
16
+ @dataclass
17
+ class LLMResponse:
18
+ """Response from an LLM provider."""
19
+ content: str | None
20
+ tool_calls: list[ToolCallRequest] = field(default_factory=list)
21
+ finish_reason: str = "stop"
22
+ usage: dict[str, int] = field(default_factory=dict)
23
+
24
+ @property
25
+ def has_tool_calls(self) -> bool:
26
+ """Check if response contains tool calls."""
27
+ return len(self.tool_calls) > 0
28
+
29
+
30
+ class LLMProvider(ABC):
31
+ """
32
+ Abstract base class for LLM providers.
33
+
34
+ Implementations should handle the specifics of each provider's API
35
+ while maintaining a consistent interface.
36
+ """
37
+
38
+ def __init__(self, api_key: str | None = None, api_base: str | None = None):
39
+ self.api_key = api_key
40
+ self.api_base = api_base
41
+
42
+ @abstractmethod
43
+ async def chat(
44
+ self,
45
+ messages: list[dict[str, Any]],
46
+ tools: list[dict[str, Any]] | None = None,
47
+ model: str | None = None,
48
+ max_tokens: int = 4096,
49
+ temperature: float = 0.7,
50
+ ) -> LLMResponse:
51
+ """
52
+ Send a chat completion request.
53
+
54
+ Args:
55
+ messages: List of message dicts with 'role' and 'content'.
56
+ tools: Optional list of tool definitions.
57
+ model: Model identifier (provider-specific).
58
+ max_tokens: Maximum tokens in response.
59
+ temperature: Sampling temperature.
60
+
61
+ Returns:
62
+ LLMResponse with content and/or tool calls.
63
+ """
64
+ pass
65
+
66
+ @abstractmethod
67
+ def get_default_model(self) -> str:
68
+ """Get the default model for this provider."""
69
+ pass
@@ -0,0 +1,227 @@
1
+ """LiteLLM provider implementation for multi-provider support."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import litellm
7
+ from litellm import acompletion
8
+ from loguru import logger
9
+
10
+ from kyber.providers.base import LLMProvider, LLMResponse, ToolCallRequest
11
+
12
+
13
+ class LiteLLMProvider(LLMProvider):
14
+ """
15
+ LLM provider using LiteLLM for multi-provider support.
16
+
17
+ Supports OpenRouter, Anthropic, OpenAI, Gemini, and many other providers through
18
+ a unified interface.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ api_key: str | None = None,
24
+ api_base: str | None = None,
25
+ default_model: str = "anthropic/claude-opus-4-5",
26
+ provider_name: str | None = None,
27
+ ):
28
+ super().__init__(api_key, api_base)
29
+ self.default_model = default_model
30
+ self.provider_name = provider_name.strip().lower() if provider_name else None
31
+
32
+ if self.provider_name:
33
+ self.is_openrouter = self.provider_name == "openrouter"
34
+ self.is_vllm = self.provider_name == "vllm"
35
+ else:
36
+ # Detect OpenRouter by api_key prefix or explicit api_base
37
+ self.is_openrouter = (
38
+ (api_key and api_key.startswith("sk-or-")) or
39
+ (api_base and "openrouter" in api_base)
40
+ )
41
+ # Track if using custom endpoint (vLLM, etc.)
42
+ self.is_vllm = bool(api_base) and not self.is_openrouter
43
+
44
+ # Configure LiteLLM based on provider
45
+ if api_key:
46
+ if self.is_openrouter:
47
+ # OpenRouter mode - set key
48
+ os.environ["OPENROUTER_API_KEY"] = api_key
49
+ elif self.is_vllm:
50
+ # vLLM/custom endpoint - uses OpenAI-compatible API
51
+ os.environ.setdefault("OPENAI_API_KEY", api_key)
52
+ elif self.provider_name == "deepseek" or "deepseek" in default_model:
53
+ os.environ.setdefault("DEEPSEEK_API_KEY", api_key)
54
+ elif self.provider_name == "anthropic" or "anthropic" in default_model:
55
+ os.environ.setdefault("ANTHROPIC_API_KEY", api_key)
56
+ elif self.provider_name == "openai" or "openai" in default_model or "gpt" in default_model:
57
+ os.environ.setdefault("OPENAI_API_KEY", api_key)
58
+ elif self.provider_name == "gemini" or "gemini" in default_model.lower():
59
+ os.environ.setdefault("GEMINI_API_KEY", api_key)
60
+ elif self.provider_name == "zhipu" or "glm" in default_model or "zhipu" in default_model or "zai" in default_model:
61
+ os.environ.setdefault("ZHIPUAI_API_KEY", api_key)
62
+ elif self.provider_name == "groq" or "groq" in default_model:
63
+ os.environ.setdefault("GROQ_API_KEY", api_key)
64
+
65
+ if api_base:
66
+ litellm.api_base = api_base
67
+
68
+ # Disable LiteLLM logging noise
69
+ litellm.suppress_debug_info = True
70
+
71
+ async def chat(
72
+ self,
73
+ messages: list[dict[str, Any]],
74
+ tools: list[dict[str, Any]] | None = None,
75
+ model: str | None = None,
76
+ max_tokens: int = 4096,
77
+ temperature: float = 0.7,
78
+ ) -> LLMResponse:
79
+ """
80
+ Send a chat completion request via LiteLLM.
81
+
82
+ Args:
83
+ messages: List of message dicts with 'role' and 'content'.
84
+ tools: Optional list of tool definitions in OpenAI format.
85
+ model: Model identifier (e.g., 'anthropic/claude-sonnet-4-5').
86
+ max_tokens: Maximum tokens in response.
87
+ temperature: Sampling temperature.
88
+
89
+ Returns:
90
+ LLMResponse with content and/or tool calls.
91
+ """
92
+ model = model or self.default_model
93
+
94
+ # For OpenRouter, prefix model name if not already prefixed
95
+ if self.is_openrouter and not model.startswith("openrouter/"):
96
+ model = f"openrouter/{model}"
97
+
98
+ # For Zhipu/Z.ai, ensure prefix is present
99
+ # Handle cases like "glm-4.7-flash" -> "zai/glm-4.7-flash"
100
+ if ("glm" in model.lower() or "zhipu" in model.lower()) and not (
101
+ model.startswith("zhipu/") or
102
+ model.startswith("zai/") or
103
+ model.startswith("openrouter/")
104
+ ):
105
+ model = f"zai/{model}"
106
+
107
+ # For vLLM, use hosted_vllm/ prefix per LiteLLM docs
108
+ if self.is_vllm:
109
+ model = f"hosted_vllm/{model}"
110
+
111
+ # For Gemini, ensure gemini/ prefix if not already present.
112
+ # Skip if routing via OpenRouter (openrouter/...) since that provider handles it.
113
+ if (
114
+ "gemini" in model.lower()
115
+ and not model.startswith("gemini/")
116
+ and not model.startswith("openrouter/")
117
+ ):
118
+ model = f"gemini/{model}"
119
+
120
+ kwargs: dict[str, Any] = {
121
+ "model": model,
122
+ "messages": messages,
123
+ "max_tokens": max_tokens,
124
+ "temperature": temperature,
125
+ }
126
+
127
+ # Pass api_base directly for custom endpoints (vLLM, etc.)
128
+ if self.api_base:
129
+ kwargs["api_base"] = self.api_base
130
+
131
+ if tools:
132
+ kwargs["tools"] = tools
133
+ kwargs["tool_choice"] = "auto"
134
+
135
+ last_error: Exception | None = None
136
+ for attempt in range(3):
137
+ try:
138
+ response = await acompletion(**kwargs)
139
+ return self._parse_response(response)
140
+ except Exception as e:
141
+ last_error = e
142
+ error_str = str(e)
143
+ # Retry on transient / parse errors from the provider
144
+ is_transient = any(tok in error_str.lower() for tok in [
145
+ "unable to get json response",
146
+ "expecting value",
147
+ "jsondecodeerror",
148
+ "timeout",
149
+ "rate limit",
150
+ "429",
151
+ "500",
152
+ "502",
153
+ "503",
154
+ "504",
155
+ "overloaded",
156
+ "connection",
157
+ ])
158
+ if is_transient and attempt < 2:
159
+ wait = 2 ** attempt # 1s, 2s
160
+ logger.warning(
161
+ f"LLM call failed (attempt {attempt + 1}/3), "
162
+ f"retrying in {wait}s: {error_str}"
163
+ )
164
+ import asyncio
165
+ await asyncio.sleep(wait)
166
+ continue
167
+ # Non-transient or final attempt — return error as content
168
+ return LLMResponse(
169
+ content=f"Error calling LLM: {error_str}",
170
+ finish_reason="error",
171
+ )
172
+ # Should not reach here, but just in case
173
+ return LLMResponse(
174
+ content=f"Error calling LLM: {str(last_error)}",
175
+ finish_reason="error",
176
+ )
177
+
178
+ def _parse_response(self, response: Any) -> LLMResponse:
179
+ """Parse LiteLLM response into our standard format."""
180
+ try:
181
+ choice = response.choices[0]
182
+ except (IndexError, AttributeError):
183
+ logger.warning("LLM response has no choices")
184
+ return LLMResponse(content=None, finish_reason="error")
185
+
186
+ message = choice.message
187
+
188
+ tool_calls = []
189
+ if hasattr(message, "tool_calls") and message.tool_calls:
190
+ for tc in message.tool_calls:
191
+ try:
192
+ # Parse arguments from JSON string if needed
193
+ args = tc.function.arguments
194
+ if isinstance(args, str):
195
+ import json
196
+ try:
197
+ args = json.loads(args)
198
+ except json.JSONDecodeError:
199
+ args = {"raw": args}
200
+
201
+ tool_calls.append(ToolCallRequest(
202
+ id=tc.id or f"call_{id(tc)}",
203
+ name=tc.function.name,
204
+ arguments=args,
205
+ ))
206
+ except (AttributeError, TypeError) as e:
207
+ logger.warning(f"Skipping malformed tool call: {e}")
208
+ continue
209
+
210
+ usage = {}
211
+ if hasattr(response, "usage") and response.usage:
212
+ usage = {
213
+ "prompt_tokens": response.usage.prompt_tokens,
214
+ "completion_tokens": response.usage.completion_tokens,
215
+ "total_tokens": response.usage.total_tokens,
216
+ }
217
+
218
+ return LLMResponse(
219
+ content=message.content,
220
+ tool_calls=tool_calls,
221
+ finish_reason=choice.finish_reason or "stop",
222
+ usage=usage,
223
+ )
224
+
225
+ def get_default_model(self) -> str:
226
+ """Get the default model."""
227
+ return self.default_model
@@ -0,0 +1,65 @@
1
+ """Voice transcription provider using Groq."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from loguru import logger
9
+
10
+
11
+ class GroqTranscriptionProvider:
12
+ """
13
+ Voice transcription provider using Groq's Whisper API.
14
+
15
+ Groq offers extremely fast transcription with a generous free tier.
16
+ """
17
+
18
+ def __init__(self, api_key: str | None = None):
19
+ self.api_key = api_key or os.environ.get("GROQ_API_KEY")
20
+ self.api_url = "https://api.groq.com/openai/v1/audio/transcriptions"
21
+
22
+ async def transcribe(self, file_path: str | Path) -> str:
23
+ """
24
+ Transcribe an audio file using Groq.
25
+
26
+ Args:
27
+ file_path: Path to the audio file.
28
+
29
+ Returns:
30
+ Transcribed text.
31
+ """
32
+ if not self.api_key:
33
+ logger.warning("Groq API key not configured for transcription")
34
+ return ""
35
+
36
+ path = Path(file_path)
37
+ if not path.exists():
38
+ logger.error(f"Audio file not found: {file_path}")
39
+ return ""
40
+
41
+ try:
42
+ async with httpx.AsyncClient() as client:
43
+ with open(path, "rb") as f:
44
+ files = {
45
+ "file": (path.name, f),
46
+ "model": (None, "whisper-large-v3"),
47
+ }
48
+ headers = {
49
+ "Authorization": f"Bearer {self.api_key}",
50
+ }
51
+
52
+ response = await client.post(
53
+ self.api_url,
54
+ headers=headers,
55
+ files=files,
56
+ timeout=60.0
57
+ )
58
+
59
+ response.raise_for_status()
60
+ data = response.json()
61
+ return data.get("text", "")
62
+
63
+ except Exception as e:
64
+ logger.error(f"Groq transcription error: {e}")
65
+ return ""
@@ -0,0 +1,5 @@
1
+ """Session management module."""
2
+
3
+ from kyber.session.manager import SessionManager, Session
4
+
5
+ __all__ = ["SessionManager", "Session"]