kivi-cli 2.0.6__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.
- kivi_cli-2.0.6.dist-info/METADATA +17 -0
- kivi_cli-2.0.6.dist-info/RECORD +47 -0
- kivi_cli-2.0.6.dist-info/WHEEL +5 -0
- kivi_cli-2.0.6.dist-info/entry_points.txt +2 -0
- kivi_cli-2.0.6.dist-info/top_level.txt +1 -0
- kiviai/__init__.py +0 -0
- kiviai/backend.py +1372 -0
- kiviai/index.html +6172 -0
- kiviai/services/__init__.py +0 -0
- kiviai/services/git_service.py +219 -0
- kiviai/services/monitor.py +97 -0
- kiviai/services/providers/__init__.py +0 -0
- kiviai/services/providers/base.py +131 -0
- kiviai/services/providers/claude.py +125 -0
- kiviai/services/providers/copilot.py +251 -0
- kiviai/services/providers/inhouse.py +130 -0
- kiviai/services/providers/kivi.py +108 -0
- kiviai/services/providers/model_costs.py +137 -0
- kiviai/services/providers/openai_agent.py +113 -0
- kiviai/services/providers/opencode.py +157 -0
- kiviai/services/providers/orchestrator_provider.py +163 -0
- kiviai/services/providers/registry.py +89 -0
- kiviai/services/providers/vllm.py +319 -0
- kiviai/services/scheduler.py +244 -0
- kiviai/services/tools/__init__.py +1 -0
- kiviai/services/tools/copilot_tools/__init__.py +92 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
- kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
- kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
- kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
- kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
- kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
- kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
- kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
- kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
- kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
- kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
- kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
- kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
- kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
- kiviai/services/tools/copilot_tools/web/_store.py +122 -0
- kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
- kiviai/services/tools/copilot_tools/web/search.py +116 -0
- kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
- kiviai/services/tools/tool_manager.py +239 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""vLLM provider — OpenAI-compatible API with agentic tool-calling loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import AsyncIterator
|
|
9
|
+
|
|
10
|
+
from .base import BaseProvider, ProviderConfig, ProviderEvent, EventType
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DEFAULT_VLLM_URL = "http://192.168.170.49:8000"
|
|
15
|
+
DEFAULT_VLLM_MODEL = "" # empty = vLLM auto-picks the loaded model
|
|
16
|
+
MAX_TOOL_ITERATIONS = 1248 # safety limit for agentic loop
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VLLMProvider(BaseProvider):
|
|
20
|
+
name = "vllm"
|
|
21
|
+
display_name = "vLLM (Local)"
|
|
22
|
+
description = "Self-hosted models via vLLM's OpenAI-compatible API. Run any HuggingFace model locally with GPU acceleration. Configure IP, port, and model in Settings."
|
|
23
|
+
supports_streaming = True
|
|
24
|
+
supports_tools = True
|
|
25
|
+
supports_sessions = False
|
|
26
|
+
available_models = ["auto"]
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self._stop = False
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
# Helpers
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
async def _discover_model(self, base_url: str) -> str | None:
|
|
36
|
+
"""Auto-discover model from server /v1/models endpoint."""
|
|
37
|
+
try:
|
|
38
|
+
import httpx
|
|
39
|
+
async with httpx.AsyncClient(timeout=5) as hc:
|
|
40
|
+
r = await hc.get(f"{base_url}/models")
|
|
41
|
+
data = r.json()
|
|
42
|
+
if data.get("data"):
|
|
43
|
+
return data["data"][0]["id"]
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _extract_thinking(content: str) -> tuple[str, str]:
|
|
50
|
+
"""Separate thinking from visible content for Qwen3 and similar models.
|
|
51
|
+
|
|
52
|
+
Handles multiple formats:
|
|
53
|
+
- <think>X</think>Y → thinking=X, content=Y
|
|
54
|
+
- X</think>Y → thinking=X, content=Y (Qwen3 tool-call format)
|
|
55
|
+
- <think>X (unclosed) → thinking=X, content=""
|
|
56
|
+
|
|
57
|
+
Returns (thinking_text, clean_content).
|
|
58
|
+
"""
|
|
59
|
+
if not content:
|
|
60
|
+
return "", ""
|
|
61
|
+
|
|
62
|
+
# Case 1: Full <think>...</think> pairs
|
|
63
|
+
if '<think>' in content:
|
|
64
|
+
thinking_parts = re.findall(r'<think>(.*?)</think>', content, re.DOTALL)
|
|
65
|
+
clean = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL)
|
|
66
|
+
# Handle unclosed <think> at end
|
|
67
|
+
unclosed = re.search(r'<think>(.*?)$', clean, re.DOTALL)
|
|
68
|
+
if unclosed:
|
|
69
|
+
thinking_parts.append(unclosed.group(1))
|
|
70
|
+
clean = re.sub(r'<think>.*?$', '', clean, flags=re.DOTALL)
|
|
71
|
+
clean = clean.strip()
|
|
72
|
+
return "\n".join(thinking_parts).strip(), clean
|
|
73
|
+
|
|
74
|
+
# Case 2: Bare </think> (Qwen3 pattern — thinking before tag, content after)
|
|
75
|
+
if '</think>' in content:
|
|
76
|
+
parts = content.split('</think>', 1)
|
|
77
|
+
thinking = parts[0].strip()
|
|
78
|
+
clean = parts[1].strip() if len(parts) > 1 else ""
|
|
79
|
+
return thinking, clean
|
|
80
|
+
|
|
81
|
+
# Case 3: No thinking markers
|
|
82
|
+
return "", content.strip()
|
|
83
|
+
|
|
84
|
+
def _build_messages(self, prompt: str, history: list | None, cwd: str, images: list | None = None) -> list[dict]:
|
|
85
|
+
"""Build the messages list with system prompt, history, and user prompt.
|
|
86
|
+
|
|
87
|
+
If images is provided (list of base64 data-URLs), the user message uses
|
|
88
|
+
the OpenAI multimodal content format with image_url entries.
|
|
89
|
+
"""
|
|
90
|
+
from kiviai.services.tools.tool_manager import get_system_prompt
|
|
91
|
+
|
|
92
|
+
messages = [{"role": "system", "content": get_system_prompt(cwd)}]
|
|
93
|
+
if history:
|
|
94
|
+
for h in history:
|
|
95
|
+
if h.get("role") in ("user", "assistant") and not h.get("metadata"):
|
|
96
|
+
messages.append({"role": h["role"], "content": h["content"]})
|
|
97
|
+
|
|
98
|
+
# Build user message — multimodal if images present
|
|
99
|
+
if images:
|
|
100
|
+
content_parts = []
|
|
101
|
+
if prompt:
|
|
102
|
+
content_parts.append({"type": "text", "text": prompt})
|
|
103
|
+
for img_data in images:
|
|
104
|
+
content_parts.append({
|
|
105
|
+
"type": "image_url",
|
|
106
|
+
"image_url": {"url": img_data}
|
|
107
|
+
})
|
|
108
|
+
messages.append({"role": "user", "content": content_parts})
|
|
109
|
+
else:
|
|
110
|
+
messages.append({"role": "user", "content": prompt})
|
|
111
|
+
return messages
|
|
112
|
+
|
|
113
|
+
async def _execute_tool(self, name: str, arguments: dict, cwd: str) -> tuple[str, bool]:
|
|
114
|
+
"""Run a tool in a thread pool to avoid blocking the event loop."""
|
|
115
|
+
from kiviai.services.tools.tool_manager import execute_tool
|
|
116
|
+
return await asyncio.get_event_loop().run_in_executor(
|
|
117
|
+
None, execute_tool, name, arguments, cwd
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Main run loop
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async def run(self, prompt: str, config: ProviderConfig, history=None) -> AsyncIterator[ProviderEvent]:
|
|
125
|
+
"""Agentic loop: stream text, detect tool calls, execute, repeat."""
|
|
126
|
+
self._stop = False
|
|
127
|
+
base_url = config.base_url or config.extra.get("vllm_url") or DEFAULT_VLLM_URL
|
|
128
|
+
api_key = config.api_key or config.extra.get("vllm_key") or "dummy"
|
|
129
|
+
model = config.model if config.model else (
|
|
130
|
+
config.extra.get("vllm_model") if config.extra.get("vllm_model") else DEFAULT_VLLM_MODEL
|
|
131
|
+
)
|
|
132
|
+
cwd = config.cwd or config.extra.get("cwd", "") or config.extra.get("working_dir", "")
|
|
133
|
+
|
|
134
|
+
if not base_url.endswith("/v1"):
|
|
135
|
+
base_url = base_url.rstrip("/") + "/v1"
|
|
136
|
+
|
|
137
|
+
if not model:
|
|
138
|
+
model = await self._discover_model(base_url)
|
|
139
|
+
if not model:
|
|
140
|
+
yield ProviderEvent(type=EventType.ERROR,
|
|
141
|
+
content="No model specified and could not auto-detect from vLLM server")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
from openai import AsyncOpenAI
|
|
146
|
+
except ImportError:
|
|
147
|
+
yield ProviderEvent(type=EventType.ERROR,
|
|
148
|
+
content="openai package required: pip install openai")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
from kiviai.services.tools.tool_manager import TOOL_DEFINITIONS
|
|
152
|
+
|
|
153
|
+
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
|
|
154
|
+
images = config.extra.get("images") if config.extra else None
|
|
155
|
+
messages = self._build_messages(prompt, history, cwd, images=images)
|
|
156
|
+
|
|
157
|
+
for iteration in range(MAX_TOOL_ITERATIONS):
|
|
158
|
+
if self._stop:
|
|
159
|
+
yield ProviderEvent(type=EventType.TEXT, content="⏹ *stopped*")
|
|
160
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
# Non-streaming call to detect tool_calls in the response
|
|
165
|
+
response = await client.chat.completions.create(
|
|
166
|
+
model=model,
|
|
167
|
+
messages=messages,
|
|
168
|
+
tools=TOOL_DEFINITIONS,
|
|
169
|
+
tool_choice="auto",
|
|
170
|
+
max_tokens=4096,
|
|
171
|
+
)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
err_str = str(e)
|
|
174
|
+
# If the server doesn't support tools, fall back to plain streaming
|
|
175
|
+
if "tool" in err_str.lower() or "function" in err_str.lower() or "400" in err_str:
|
|
176
|
+
log.warning("vLLM server doesn't support tools, falling back to plain text: %s", err_str)
|
|
177
|
+
async for ev in self._plain_stream(client, model, messages):
|
|
178
|
+
yield ev
|
|
179
|
+
return
|
|
180
|
+
yield ProviderEvent(type=EventType.ERROR, content=err_str)
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
choice = response.choices[0] if response.choices else None
|
|
184
|
+
if not choice:
|
|
185
|
+
yield ProviderEvent(type=EventType.ERROR, content="No response from model")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
msg = choice.message
|
|
189
|
+
|
|
190
|
+
# Separate thinking from visible content
|
|
191
|
+
if msg.content:
|
|
192
|
+
thinking, clean = self._extract_thinking(msg.content)
|
|
193
|
+
if thinking:
|
|
194
|
+
yield ProviderEvent(type=EventType.THINKING, content=thinking)
|
|
195
|
+
if clean:
|
|
196
|
+
yield ProviderEvent(type=EventType.TEXT, content=clean)
|
|
197
|
+
|
|
198
|
+
# Check for tool calls
|
|
199
|
+
if not msg.tool_calls:
|
|
200
|
+
# No more tools — model is done
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
# Append the assistant message (with tool_calls) to messages
|
|
204
|
+
messages.append(msg.model_dump())
|
|
205
|
+
|
|
206
|
+
# Execute each tool call
|
|
207
|
+
for tc in msg.tool_calls:
|
|
208
|
+
if self._stop:
|
|
209
|
+
yield ProviderEvent(type=EventType.TEXT, content="\n⏹ *stopped*")
|
|
210
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
fn_name = tc.function.name
|
|
214
|
+
try:
|
|
215
|
+
fn_args = json.loads(tc.function.arguments) if tc.function.arguments else {}
|
|
216
|
+
except json.JSONDecodeError:
|
|
217
|
+
fn_args = {}
|
|
218
|
+
|
|
219
|
+
# Emit TOOL_START event for frontend
|
|
220
|
+
yield ProviderEvent(
|
|
221
|
+
type=EventType.TOOL_START,
|
|
222
|
+
metadata={
|
|
223
|
+
"title": f"⚙ {fn_name}",
|
|
224
|
+
"args": json.dumps(fn_args, indent=2),
|
|
225
|
+
},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Execute the tool
|
|
229
|
+
result_text, is_error = await self._execute_tool(fn_name, fn_args, cwd)
|
|
230
|
+
|
|
231
|
+
# Emit TOOL_RESULT event for frontend
|
|
232
|
+
yield ProviderEvent(
|
|
233
|
+
type=EventType.TOOL_RESULT,
|
|
234
|
+
content=result_text,
|
|
235
|
+
metadata={"is_error": is_error},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Append tool result to messages for the next iteration
|
|
239
|
+
messages.append({
|
|
240
|
+
"role": "tool",
|
|
241
|
+
"tool_call_id": tc.id,
|
|
242
|
+
"content": result_text[:8000], # truncate for context limits
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
# Usage info (self-hosted = $0 cost)
|
|
246
|
+
if response.usage:
|
|
247
|
+
yield ProviderEvent(
|
|
248
|
+
type=EventType.COST,
|
|
249
|
+
metadata={
|
|
250
|
+
"total_cost_usd": 0.0,
|
|
251
|
+
"usage": {
|
|
252
|
+
"input_tokens": response.usage.prompt_tokens or 0,
|
|
253
|
+
"output_tokens": response.usage.completion_tokens or 0,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
yield ProviderEvent(
|
|
259
|
+
type=EventType.TEXT,
|
|
260
|
+
content=f"\n\n⚠ Reached max tool iterations ({MAX_TOOL_ITERATIONS}). Stopping.",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
# Fallback: plain streaming (no tools)
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
async def _plain_stream(self, client, model: str, messages: list[dict]) -> AsyncIterator[ProviderEvent]:
|
|
270
|
+
"""Simple streaming text completion — fallback when tools are unsupported."""
|
|
271
|
+
try:
|
|
272
|
+
stream = await client.chat.completions.create(
|
|
273
|
+
model=model,
|
|
274
|
+
messages=messages,
|
|
275
|
+
stream=True,
|
|
276
|
+
max_tokens=4096,
|
|
277
|
+
)
|
|
278
|
+
last_chunk = None
|
|
279
|
+
async for chunk in stream:
|
|
280
|
+
if self._stop:
|
|
281
|
+
yield ProviderEvent(type=EventType.TEXT, content="⏹ *stopped*")
|
|
282
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
283
|
+
return
|
|
284
|
+
last_chunk = chunk
|
|
285
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
286
|
+
if delta and delta.content:
|
|
287
|
+
yield ProviderEvent(type=EventType.TEXT, content=delta.content)
|
|
288
|
+
if chunk.choices and chunk.choices[0].finish_reason:
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
if last_chunk and hasattr(last_chunk, 'usage') and last_chunk.usage:
|
|
292
|
+
yield ProviderEvent(
|
|
293
|
+
type=EventType.COST,
|
|
294
|
+
metadata={
|
|
295
|
+
"total_cost_usd": 0.0,
|
|
296
|
+
"usage": {
|
|
297
|
+
"input_tokens": last_chunk.usage.prompt_tokens or 0,
|
|
298
|
+
"output_tokens": last_chunk.usage.completion_tokens or 0,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
yield ProviderEvent(type=EventType.ERROR, content=str(e))
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
yield ProviderEvent(type=EventType.DONE)
|
|
307
|
+
|
|
308
|
+
async def stop(self):
|
|
309
|
+
self._stop = True
|
|
310
|
+
|
|
311
|
+
async def health_check(self):
|
|
312
|
+
base_url = DEFAULT_VLLM_URL
|
|
313
|
+
try:
|
|
314
|
+
import httpx
|
|
315
|
+
async with httpx.AsyncClient(timeout=3) as client:
|
|
316
|
+
r = await client.get(f"{base_url}/health")
|
|
317
|
+
return {"status": "ok" if r.status_code == 200 else "error", "provider": self.name}
|
|
318
|
+
except Exception:
|
|
319
|
+
return {"status": "unavailable", "provider": self.name}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scheduler service — cron jobs, delayed tasks, recurring jobs.
|
|
3
|
+
|
|
4
|
+
Supports:
|
|
5
|
+
- One-time delayed tasks (run prompt after N seconds)
|
|
6
|
+
- Recurring cron-style tasks (e.g., "every day at 9am")
|
|
7
|
+
- Task queuing with priority
|
|
8
|
+
- Persistent scheduling (survives restarts via SQLite)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sqlite3
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
from typing import Callable, Any
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("cc-ui.scheduler")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScheduledJob:
|
|
25
|
+
def __init__(self, id: str, name: str, prompt: str, model: str = "claude",
|
|
26
|
+
mode: str = "bypassPermissions", cwd: str = "",
|
|
27
|
+
schedule: str = "", next_run: str = "",
|
|
28
|
+
interval_seconds: int = 0, one_shot: bool = False,
|
|
29
|
+
enabled: bool = True, last_run: str = "",
|
|
30
|
+
last_status: str = "", created_at: str = ""):
|
|
31
|
+
self.id = id
|
|
32
|
+
self.name = name
|
|
33
|
+
self.prompt = prompt
|
|
34
|
+
self.model = model
|
|
35
|
+
self.mode = mode
|
|
36
|
+
self.cwd = cwd
|
|
37
|
+
self.schedule = schedule # cron expression or human-readable
|
|
38
|
+
self.next_run = next_run
|
|
39
|
+
self.interval_seconds = interval_seconds
|
|
40
|
+
self.one_shot = one_shot
|
|
41
|
+
self.enabled = enabled
|
|
42
|
+
self.last_run = last_run
|
|
43
|
+
self.last_status = last_status
|
|
44
|
+
self.created_at = created_at or datetime.now().isoformat()
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
"id": self.id, "name": self.name, "prompt": self.prompt,
|
|
49
|
+
"model": self.model, "mode": self.mode, "cwd": self.cwd,
|
|
50
|
+
"schedule": self.schedule, "next_run": self.next_run,
|
|
51
|
+
"interval_seconds": self.interval_seconds, "one_shot": self.one_shot,
|
|
52
|
+
"enabled": self.enabled, "last_run": self.last_run,
|
|
53
|
+
"last_status": self.last_status, "created_at": self.created_at,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Scheduler:
|
|
58
|
+
def __init__(self, db_path: str, task_callback: Callable = None):
|
|
59
|
+
self.db_path = db_path
|
|
60
|
+
self.task_callback = task_callback # async fn(prompt, model, mode, cwd) -> task_id
|
|
61
|
+
self._jobs: dict[str, ScheduledJob] = {}
|
|
62
|
+
self._running = False
|
|
63
|
+
self._task: asyncio.Task | None = None
|
|
64
|
+
self._init_db()
|
|
65
|
+
self._load_jobs()
|
|
66
|
+
|
|
67
|
+
def _db(self):
|
|
68
|
+
return sqlite3.connect(self.db_path)
|
|
69
|
+
|
|
70
|
+
def _init_db(self):
|
|
71
|
+
with self._db() as con:
|
|
72
|
+
con.execute("""CREATE TABLE IF NOT EXISTS scheduled_jobs (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
name TEXT, prompt TEXT, model TEXT, mode TEXT, cwd TEXT,
|
|
75
|
+
schedule TEXT, next_run TEXT, interval_seconds INTEGER,
|
|
76
|
+
one_shot INTEGER, enabled INTEGER,
|
|
77
|
+
last_run TEXT, last_status TEXT, created_at TEXT
|
|
78
|
+
)""")
|
|
79
|
+
|
|
80
|
+
def _load_jobs(self):
|
|
81
|
+
with self._db() as con:
|
|
82
|
+
rows = con.execute("SELECT * FROM scheduled_jobs").fetchall()
|
|
83
|
+
for r in rows:
|
|
84
|
+
job = ScheduledJob(
|
|
85
|
+
id=r[0], name=r[1], prompt=r[2], model=r[3], mode=r[4],
|
|
86
|
+
cwd=r[5], schedule=r[6], next_run=r[7],
|
|
87
|
+
interval_seconds=r[8], one_shot=bool(r[9]),
|
|
88
|
+
enabled=bool(r[10]), last_run=r[11], last_status=r[12],
|
|
89
|
+
created_at=r[13],
|
|
90
|
+
)
|
|
91
|
+
self._jobs[job.id] = job
|
|
92
|
+
|
|
93
|
+
def _save_job(self, job: ScheduledJob):
|
|
94
|
+
with self._db() as con:
|
|
95
|
+
con.execute("""INSERT OR REPLACE INTO scheduled_jobs
|
|
96
|
+
(id, name, prompt, model, mode, cwd, schedule, next_run,
|
|
97
|
+
interval_seconds, one_shot, enabled, last_run, last_status, created_at)
|
|
98
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
99
|
+
(job.id, job.name, job.prompt, job.model, job.mode, job.cwd,
|
|
100
|
+
job.schedule, job.next_run, job.interval_seconds,
|
|
101
|
+
int(job.one_shot), int(job.enabled),
|
|
102
|
+
job.last_run, job.last_status, job.created_at))
|
|
103
|
+
|
|
104
|
+
def _delete_job(self, job_id: str):
|
|
105
|
+
with self._db() as con:
|
|
106
|
+
con.execute("DELETE FROM scheduled_jobs WHERE id=?", (job_id,))
|
|
107
|
+
self._jobs.pop(job_id, None)
|
|
108
|
+
|
|
109
|
+
def add_job(self, name: str, prompt: str, model: str = "claude",
|
|
110
|
+
mode: str = "bypassPermissions", cwd: str = "",
|
|
111
|
+
schedule: str = "", interval_seconds: int = 0,
|
|
112
|
+
delay_seconds: int = 0, one_shot: bool = False) -> ScheduledJob:
|
|
113
|
+
"""Add a new scheduled job."""
|
|
114
|
+
job_id = str(uuid.uuid4())[:8]
|
|
115
|
+
|
|
116
|
+
if delay_seconds > 0:
|
|
117
|
+
next_run = (datetime.now() + timedelta(seconds=delay_seconds)).isoformat()
|
|
118
|
+
one_shot = True
|
|
119
|
+
elif interval_seconds > 0:
|
|
120
|
+
next_run = (datetime.now() + timedelta(seconds=interval_seconds)).isoformat()
|
|
121
|
+
else:
|
|
122
|
+
next_run = self._parse_schedule(schedule)
|
|
123
|
+
|
|
124
|
+
job = ScheduledJob(
|
|
125
|
+
id=job_id, name=name, prompt=prompt, model=model, mode=mode,
|
|
126
|
+
cwd=cwd, schedule=schedule, next_run=next_run,
|
|
127
|
+
interval_seconds=interval_seconds, one_shot=one_shot,
|
|
128
|
+
)
|
|
129
|
+
self._jobs[job_id] = job
|
|
130
|
+
self._save_job(job)
|
|
131
|
+
log.info("Scheduled job '%s' (id=%s) next_run=%s", name, job_id, next_run)
|
|
132
|
+
return job
|
|
133
|
+
|
|
134
|
+
def remove_job(self, job_id: str) -> bool:
|
|
135
|
+
if job_id in self._jobs:
|
|
136
|
+
self._delete_job(job_id)
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def list_jobs(self) -> list[dict]:
|
|
141
|
+
return [j.to_dict() for j in self._jobs.values()]
|
|
142
|
+
|
|
143
|
+
def get_job(self, job_id: str) -> dict | None:
|
|
144
|
+
job = self._jobs.get(job_id)
|
|
145
|
+
return job.to_dict() if job else None
|
|
146
|
+
|
|
147
|
+
def toggle_job(self, job_id: str) -> bool:
|
|
148
|
+
job = self._jobs.get(job_id)
|
|
149
|
+
if job:
|
|
150
|
+
job.enabled = not job.enabled
|
|
151
|
+
self._save_job(job)
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def _parse_schedule(self, schedule: str) -> str:
|
|
156
|
+
"""Parse a simple schedule string into next_run ISO timestamp."""
|
|
157
|
+
lower = schedule.lower().strip()
|
|
158
|
+
now = datetime.now()
|
|
159
|
+
|
|
160
|
+
if "every" in lower:
|
|
161
|
+
if "minute" in lower:
|
|
162
|
+
return (now + timedelta(minutes=1)).isoformat()
|
|
163
|
+
elif "hour" in lower:
|
|
164
|
+
return (now + timedelta(hours=1)).isoformat()
|
|
165
|
+
elif "day" in lower:
|
|
166
|
+
return (now + timedelta(days=1)).isoformat()
|
|
167
|
+
elif "week" in lower:
|
|
168
|
+
return (now + timedelta(weeks=1)).isoformat()
|
|
169
|
+
|
|
170
|
+
# Default: 1 hour from now
|
|
171
|
+
return (now + timedelta(hours=1)).isoformat()
|
|
172
|
+
|
|
173
|
+
def _compute_next_run(self, job: ScheduledJob) -> str:
|
|
174
|
+
"""Compute next run time after execution."""
|
|
175
|
+
now = datetime.now()
|
|
176
|
+
if job.interval_seconds > 0:
|
|
177
|
+
return (now + timedelta(seconds=job.interval_seconds)).isoformat()
|
|
178
|
+
return self._parse_schedule(job.schedule)
|
|
179
|
+
|
|
180
|
+
async def start(self):
|
|
181
|
+
"""Start the scheduler loop."""
|
|
182
|
+
if self._running:
|
|
183
|
+
return
|
|
184
|
+
self._running = True
|
|
185
|
+
self._task = asyncio.create_task(self._loop())
|
|
186
|
+
log.info("Scheduler started with %d jobs", len(self._jobs))
|
|
187
|
+
|
|
188
|
+
async def stop(self):
|
|
189
|
+
"""Stop the scheduler loop."""
|
|
190
|
+
self._running = False
|
|
191
|
+
if self._task:
|
|
192
|
+
self._task.cancel()
|
|
193
|
+
try:
|
|
194
|
+
await self._task
|
|
195
|
+
except asyncio.CancelledError:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
async def _loop(self):
|
|
199
|
+
"""Main scheduler loop — checks every 10 seconds."""
|
|
200
|
+
while self._running:
|
|
201
|
+
try:
|
|
202
|
+
now = datetime.now()
|
|
203
|
+
for job in list(self._jobs.values()):
|
|
204
|
+
if not job.enabled or not job.next_run:
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
next_run = datetime.fromisoformat(job.next_run)
|
|
208
|
+
except ValueError:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
if now >= next_run:
|
|
212
|
+
log.info("Executing scheduled job '%s' (id=%s)", job.name, job.id)
|
|
213
|
+
await self._execute_job(job)
|
|
214
|
+
|
|
215
|
+
except asyncio.CancelledError:
|
|
216
|
+
break
|
|
217
|
+
except Exception as e:
|
|
218
|
+
log.error("Scheduler loop error: %s", e)
|
|
219
|
+
|
|
220
|
+
await asyncio.sleep(10)
|
|
221
|
+
|
|
222
|
+
async def _execute_job(self, job: ScheduledJob):
|
|
223
|
+
"""Execute a scheduled job."""
|
|
224
|
+
try:
|
|
225
|
+
if self.task_callback:
|
|
226
|
+
await self.task_callback(job.prompt, job.model, job.mode, job.cwd)
|
|
227
|
+
job.last_status = "success"
|
|
228
|
+
else:
|
|
229
|
+
job.last_status = "no_callback"
|
|
230
|
+
|
|
231
|
+
job.last_run = datetime.now().isoformat()
|
|
232
|
+
|
|
233
|
+
if job.one_shot:
|
|
234
|
+
job.enabled = False
|
|
235
|
+
else:
|
|
236
|
+
job.next_run = self._compute_next_run(job)
|
|
237
|
+
|
|
238
|
+
self._save_job(job)
|
|
239
|
+
|
|
240
|
+
except Exception as e:
|
|
241
|
+
job.last_status = f"error: {e}"
|
|
242
|
+
job.last_run = datetime.now().isoformat()
|
|
243
|
+
self._save_job(job)
|
|
244
|
+
log.error("Job '%s' failed: %s", job.name, e)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tools package — tool schemas + executor for local LLM providers."""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
copilot_tools — Python implementations of GitHub Copilot CLI tools.
|
|
3
|
+
|
|
4
|
+
Modules:
|
|
5
|
+
bash_execution — Shell session management (bash, write_bash, read_bash, stop_bash, list_bash)
|
|
6
|
+
file_operations — File I/O primitives (view, create, edit)
|
|
7
|
+
code_search — Code & file discovery (grep, glob)
|
|
8
|
+
session_workflow — Session state & user interaction (report_intent, ask_user, sql, fetch_docs)
|
|
9
|
+
agent_orchestration — Multi-agent orchestration (task, read_agent, list_agents, skill)
|
|
10
|
+
markdown — Structured extraction from markdown documents
|
|
11
|
+
web — Web search, fetch, and ChromaDB-backed content store
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from copilot_tools.bash_execution import (
|
|
15
|
+
bash,
|
|
16
|
+
write_bash,
|
|
17
|
+
read_bash,
|
|
18
|
+
stop_bash,
|
|
19
|
+
list_bash,
|
|
20
|
+
)
|
|
21
|
+
from copilot_tools.file_operations import view, create, edit
|
|
22
|
+
from copilot_tools.code_search import grep, glob_search
|
|
23
|
+
from copilot_tools.session_workflow import (
|
|
24
|
+
report_intent,
|
|
25
|
+
ask_user,
|
|
26
|
+
sql,
|
|
27
|
+
fetch_copilot_cli_documentation,
|
|
28
|
+
)
|
|
29
|
+
from copilot_tools.agent_orchestration import (
|
|
30
|
+
launch_task,
|
|
31
|
+
read_agent,
|
|
32
|
+
list_agents,
|
|
33
|
+
execute_skill,
|
|
34
|
+
)
|
|
35
|
+
from copilot_tools.markdown import (
|
|
36
|
+
get_overview as markdown_get_overview,
|
|
37
|
+
get_headers as markdown_get_headers,
|
|
38
|
+
get_section as markdown_get_section,
|
|
39
|
+
get_intro as markdown_get_intro,
|
|
40
|
+
get_links as markdown_get_links,
|
|
41
|
+
get_tables_metadata as markdown_get_tables_metadata,
|
|
42
|
+
get_table as markdown_get_table,
|
|
43
|
+
)
|
|
44
|
+
from copilot_tools.web import (
|
|
45
|
+
web_fetch,
|
|
46
|
+
web_search,
|
|
47
|
+
web_store_get,
|
|
48
|
+
web_store_get_text,
|
|
49
|
+
web_store_search,
|
|
50
|
+
web_store_list,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
# bash_execution
|
|
55
|
+
"bash",
|
|
56
|
+
"write_bash",
|
|
57
|
+
"read_bash",
|
|
58
|
+
"stop_bash",
|
|
59
|
+
"list_bash",
|
|
60
|
+
# file_operations
|
|
61
|
+
"view",
|
|
62
|
+
"create",
|
|
63
|
+
"edit",
|
|
64
|
+
# code_search
|
|
65
|
+
"grep",
|
|
66
|
+
"glob_search",
|
|
67
|
+
# session_workflow
|
|
68
|
+
"report_intent",
|
|
69
|
+
"ask_user",
|
|
70
|
+
"sql",
|
|
71
|
+
"fetch_copilot_cli_documentation",
|
|
72
|
+
# agent_orchestration
|
|
73
|
+
"launch_task",
|
|
74
|
+
"read_agent",
|
|
75
|
+
"list_agents",
|
|
76
|
+
"execute_skill",
|
|
77
|
+
# markdown
|
|
78
|
+
"markdown_get_overview",
|
|
79
|
+
"markdown_get_headers",
|
|
80
|
+
"markdown_get_section",
|
|
81
|
+
"markdown_get_intro",
|
|
82
|
+
"markdown_get_links",
|
|
83
|
+
"markdown_get_tables_metadata",
|
|
84
|
+
"markdown_get_table",
|
|
85
|
+
# web
|
|
86
|
+
"web_search",
|
|
87
|
+
"web_fetch",
|
|
88
|
+
"web_store_get",
|
|
89
|
+
"web_store_get_text",
|
|
90
|
+
"web_store_search",
|
|
91
|
+
"web_store_list",
|
|
92
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Orchestration Tools — multi-agent management.
|
|
3
|
+
|
|
4
|
+
launch_task — Spawn a specialised sub-agent (explore, task, general-purpose, code-review)
|
|
5
|
+
read_agent — Retrieve status & results from a background agent
|
|
6
|
+
list_agents — List all active / completed agents
|
|
7
|
+
execute_skill — Execute a named skill in the main conversation context
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from copilot_tools.agent_orchestration.tools import (
|
|
11
|
+
launch_task,
|
|
12
|
+
read_agent,
|
|
13
|
+
list_agents,
|
|
14
|
+
execute_skill,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = ["launch_task", "read_agent", "list_agents", "execute_skill"]
|