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.
Files changed (47) hide show
  1. kivi_cli-2.0.6.dist-info/METADATA +17 -0
  2. kivi_cli-2.0.6.dist-info/RECORD +47 -0
  3. kivi_cli-2.0.6.dist-info/WHEEL +5 -0
  4. kivi_cli-2.0.6.dist-info/entry_points.txt +2 -0
  5. kivi_cli-2.0.6.dist-info/top_level.txt +1 -0
  6. kiviai/__init__.py +0 -0
  7. kiviai/backend.py +1372 -0
  8. kiviai/index.html +6172 -0
  9. kiviai/services/__init__.py +0 -0
  10. kiviai/services/git_service.py +219 -0
  11. kiviai/services/monitor.py +97 -0
  12. kiviai/services/providers/__init__.py +0 -0
  13. kiviai/services/providers/base.py +131 -0
  14. kiviai/services/providers/claude.py +125 -0
  15. kiviai/services/providers/copilot.py +251 -0
  16. kiviai/services/providers/inhouse.py +130 -0
  17. kiviai/services/providers/kivi.py +108 -0
  18. kiviai/services/providers/model_costs.py +137 -0
  19. kiviai/services/providers/openai_agent.py +113 -0
  20. kiviai/services/providers/opencode.py +157 -0
  21. kiviai/services/providers/orchestrator_provider.py +163 -0
  22. kiviai/services/providers/registry.py +89 -0
  23. kiviai/services/providers/vllm.py +319 -0
  24. kiviai/services/scheduler.py +244 -0
  25. kiviai/services/tools/__init__.py +1 -0
  26. kiviai/services/tools/copilot_tools/__init__.py +92 -0
  27. kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
  28. kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
  29. kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
  30. kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
  31. kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
  32. kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
  33. kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
  34. kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
  35. kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
  36. kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
  37. kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
  38. kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
  39. kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
  40. kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
  41. kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
  42. kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
  43. kiviai/services/tools/copilot_tools/web/_store.py +122 -0
  44. kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
  45. kiviai/services/tools/copilot_tools/web/search.py +116 -0
  46. kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
  47. 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"]