agent-devkit 0.0.4 → 0.1.5
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.
- package/README.md +42 -2
- package/package.json +1 -1
- package/runtime/README.md +50 -2
- package/runtime/agent +29 -13
- package/runtime/agents/README.md +3 -0
- package/runtime/agents/github-pr-reviewer/AGENTS.md +10 -0
- package/runtime/agents/github-pr-reviewer/README.md +7 -0
- package/runtime/agents/github-pr-reviewer/agent.yaml +33 -0
- package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/capability.yaml +20 -0
- package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/decision-rules.md +8 -0
- package/runtime/agents/github-pr-reviewer/capabilities/create-review-automation/workflow.md +9 -0
- package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/capability.yaml +20 -0
- package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/decision-rules.md +7 -0
- package/runtime/agents/github-pr-reviewer/capabilities/inspect-pr/workflow.md +8 -0
- package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/capability.yaml +20 -0
- package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/decision-rules.md +7 -0
- package/runtime/agents/github-pr-reviewer/capabilities/list-review-requests/workflow.md +8 -0
- package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/capability.yaml +20 -0
- package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/decision-rules.md +9 -0
- package/runtime/agents/github-pr-reviewer/capabilities/review-pr-diff/workflow.md +10 -0
- package/runtime/agents/github-pr-reviewer/infra/README.md +7 -0
- package/runtime/agents/github-pr-reviewer/knowledge/context.md +7 -0
- package/runtime/agents/github-pr-reviewer/knowledge/system.md +17 -0
- package/runtime/agents/github-pr-reviewer/templates/pr-automation-output.md +10 -0
- package/runtime/agents/github-pr-reviewer/templates/pr-inspection-output.md +9 -0
- package/runtime/agents/github-pr-reviewer/templates/pr-list-output.md +9 -0
- package/runtime/agents/github-pr-reviewer/templates/pr-review-output.md +17 -0
- package/runtime/cli/README.md +37 -1
- package/runtime/cli/aikit/__init__.py +1 -1
- package/runtime/cli/aikit/aliases.py +196 -0
- package/runtime/cli/aikit/app_home.py +78 -0
- package/runtime/cli/aikit/audit.py +344 -0
- package/runtime/cli/aikit/calendar.py +163 -0
- package/runtime/cli/aikit/configuration_orchestrator.py +291 -0
- package/runtime/cli/aikit/decision_store.py +158 -0
- package/runtime/cli/aikit/diagnostics.py +9 -0
- package/runtime/cli/aikit/fallback.py +48 -2
- package/runtime/cli/aikit/github_pr.py +254 -0
- package/runtime/cli/aikit/identity.py +75 -0
- package/runtime/cli/aikit/llm.py +249 -48
- package/runtime/cli/aikit/main.py +1240 -32
- package/runtime/cli/aikit/memory.py +148 -8
- package/runtime/cli/aikit/model_router.py +70 -0
- package/runtime/cli/aikit/notifications.py +9 -0
- package/runtime/cli/aikit/ollama.py +237 -0
- package/runtime/cli/aikit/permissions.py +345 -0
- package/runtime/cli/aikit/personality.py +123 -0
- package/runtime/cli/aikit/provider_wizard.py +19 -0
- package/runtime/cli/aikit/providers.py +4 -5
- package/runtime/cli/aikit/review_gate.py +40 -0
- package/runtime/cli/aikit/scheduler.py +18 -0
- package/runtime/cli/aikit/sessions.py +396 -0
- package/runtime/cli/aikit/setup_wizard.py +12 -0
- package/runtime/cli/aikit/tasks.py +351 -0
- package/runtime/cli/aikit/toolchain.py +274 -0
- package/runtime/providers/calendar.yaml +28 -0
- package/runtime/providers/github.yaml +42 -0
- package/runtime/providers/local-notification.yaml +19 -0
- package/runtime/providers/local-scheduler.yaml +24 -0
- package/runtime/vendor/skills/napkin/napkin.md +13 -3
|
@@ -8,27 +8,140 @@ from datetime import datetime, timezone
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
-
from cli.aikit.
|
|
11
|
+
from cli.aikit.app_home import (
|
|
12
|
+
app_path,
|
|
13
|
+
cache_home,
|
|
14
|
+
ensure_app_home,
|
|
15
|
+
memory_home as app_memory_home,
|
|
16
|
+
sessions_home,
|
|
17
|
+
tasks_home,
|
|
18
|
+
)
|
|
19
|
+
from cli.aikit.llm import config_path, load_config, save_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
SECRET_VALUE_PATTERN = re.compile(
|
|
23
|
+
r"(?i)\b("
|
|
24
|
+
r"sk-[a-z0-9_-]{12,}|"
|
|
25
|
+
r"npm_[a-z0-9]{12,}|"
|
|
26
|
+
r"gh[pousr]_[a-z0-9_]{12,}|"
|
|
27
|
+
r"xox[baprs]-[a-z0-9-]{12,}|"
|
|
28
|
+
r"AKIA[0-9A-Z]{12,}|"
|
|
29
|
+
r"ASIA[0-9A-Z]{12,}"
|
|
30
|
+
r")\b"
|
|
31
|
+
)
|
|
32
|
+
SECRET_ASSIGNMENT_PATTERN = re.compile(
|
|
33
|
+
r"(?i)([\"']?)([a-z0-9_]*(?:token|secret|password|passwd|pwd|senha|chave|api[_-]?key|private[_-]?key)[a-z0-9_]*)([\"']?)(\s*[:=]\s*)([\"']?)([^\s,;}\"']+)([\"']?)"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
MEMORY_FILE_TEMPLATES: dict[str, str] = {
|
|
37
|
+
"profile.md": """# Profile
|
|
38
|
+
|
|
39
|
+
Local user profile for Agent DevKit.
|
|
40
|
+
|
|
41
|
+
## User
|
|
42
|
+
|
|
43
|
+
- Name:
|
|
44
|
+
- Primary language:
|
|
45
|
+
- Timezone:
|
|
46
|
+
|
|
47
|
+
## Notes
|
|
48
|
+
|
|
49
|
+
- Add stable facts the agent should remember.
|
|
50
|
+
""",
|
|
51
|
+
"personality.md": """# Personality
|
|
52
|
+
|
|
53
|
+
Configured public identity and response style for Agent DevKit.
|
|
54
|
+
|
|
55
|
+
## Agent
|
|
56
|
+
|
|
57
|
+
- Name: Agent DevKit
|
|
58
|
+
|
|
59
|
+
## Style
|
|
60
|
+
|
|
61
|
+
- Tone: direct
|
|
62
|
+
- Detail level: concise
|
|
63
|
+
""",
|
|
64
|
+
"preferences.md": """# Preferences
|
|
65
|
+
|
|
66
|
+
Reusable user preferences.
|
|
67
|
+
|
|
68
|
+
## Defaults
|
|
69
|
+
|
|
70
|
+
- Prefer local-first execution.
|
|
71
|
+
- Ask before external writes.
|
|
72
|
+
""",
|
|
73
|
+
"projects.md": """# Projects
|
|
74
|
+
|
|
75
|
+
Frequently used projects and repositories.
|
|
76
|
+
|
|
77
|
+
## Items
|
|
78
|
+
|
|
79
|
+
- Add project names, paths, and non-secret references here.
|
|
80
|
+
""",
|
|
81
|
+
"routines.md": """# Routines
|
|
82
|
+
|
|
83
|
+
Recurring workflows, checks, and habits.
|
|
84
|
+
|
|
85
|
+
## Items
|
|
86
|
+
|
|
87
|
+
- Add routines that should be easy to reuse.
|
|
88
|
+
""",
|
|
89
|
+
"napkin.md": """# Agent DevKit Napkin
|
|
90
|
+
|
|
91
|
+
Curated local runbook entries promoted from repeated use.
|
|
92
|
+
|
|
93
|
+
## Execution & Validation
|
|
94
|
+
|
|
95
|
+
- Keep high-value reusable notes here.
|
|
96
|
+
""",
|
|
97
|
+
}
|
|
12
98
|
|
|
13
99
|
|
|
14
100
|
def memory_home() -> Path:
|
|
15
|
-
return
|
|
101
|
+
return app_memory_home()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ensure_memory() -> dict[str, Any]:
|
|
105
|
+
ensure_app_home()
|
|
106
|
+
home = memory_home()
|
|
107
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
files: list[dict[str, Any]] = []
|
|
109
|
+
created: list[str] = []
|
|
110
|
+
for name, template in MEMORY_FILE_TEMPLATES.items():
|
|
111
|
+
path = home / name
|
|
112
|
+
if not path.exists():
|
|
113
|
+
path.write_text(template, encoding="utf-8")
|
|
114
|
+
created.append(str(path))
|
|
115
|
+
files.append({"name": name, "path": str(path), "exists": path.exists()})
|
|
116
|
+
return {
|
|
117
|
+
"kind": "memory-path",
|
|
118
|
+
"status": "ok",
|
|
119
|
+
"home": str(home),
|
|
120
|
+
"created": created,
|
|
121
|
+
"files": files,
|
|
122
|
+
}
|
|
16
123
|
|
|
17
124
|
|
|
18
125
|
def normalize_prompt(prompt: str) -> str:
|
|
19
|
-
text = " ".join(prompt.lower().split())
|
|
126
|
+
text = " ".join(redact_secrets(prompt).lower().split())
|
|
20
127
|
return text[:160]
|
|
21
128
|
|
|
22
129
|
|
|
130
|
+
def redact_secrets(value: str) -> str:
|
|
131
|
+
redacted = SECRET_VALUE_PATTERN.sub("[REDACTED_SECRET]", value)
|
|
132
|
+
return SECRET_ASSIGNMENT_PATTERN.sub(r"\1\2\3\4\5[REDACTED_SECRET]\7", redacted)
|
|
133
|
+
|
|
134
|
+
|
|
23
135
|
def napkin_context(root: Path, *, agent_id: str | None = None, source_id: str | None = None) -> dict[str, Any]:
|
|
24
136
|
paths = [
|
|
25
137
|
root / "vendor" / "skills" / "napkin" / "napkin.md",
|
|
138
|
+
memory_home() / "napkin.md",
|
|
26
139
|
memory_home() / "global" / "napkin.md",
|
|
27
140
|
]
|
|
28
141
|
if agent_id:
|
|
29
|
-
paths.append(memory_home() / "agents" / agent_id / "napkin.md")
|
|
142
|
+
paths.append(memory_home() / "agents" / sanitize_segment(agent_id) / "napkin.md")
|
|
30
143
|
if source_id:
|
|
31
|
-
paths.append(memory_home() / "sources" / source_id / "napkin.md")
|
|
144
|
+
paths.append(memory_home() / "sources" / sanitize_segment(source_id) / "napkin.md")
|
|
32
145
|
return {
|
|
33
146
|
"loaded": any(path.exists() for path in paths),
|
|
34
147
|
"paths": [
|
|
@@ -61,6 +174,7 @@ def record_usage(
|
|
|
61
174
|
|
|
62
175
|
|
|
63
176
|
def show_memory(root: Path, *, agent_id: str | None = None, source_id: str | None = None) -> dict[str, Any]:
|
|
177
|
+
memory_paths = ensure_memory()
|
|
64
178
|
config = load_config()
|
|
65
179
|
memory = config.get("memory") if isinstance(config.get("memory"), dict) else {}
|
|
66
180
|
usage = memory.get("usage") if isinstance(memory.get("usage"), dict) else {}
|
|
@@ -69,6 +183,7 @@ def show_memory(root: Path, *, agent_id: str | None = None, source_id: str | Non
|
|
|
69
183
|
"status": "ok",
|
|
70
184
|
"config_path": str(config_path()),
|
|
71
185
|
"memory_home": str(memory_home()),
|
|
186
|
+
"files": memory_paths["files"],
|
|
72
187
|
"usage": {
|
|
73
188
|
"prompts": sorted_usage(usage.get("prompts") or {}),
|
|
74
189
|
"routes": sorted_usage(usage.get("routes") or {}),
|
|
@@ -78,10 +193,19 @@ def show_memory(root: Path, *, agent_id: str | None = None, source_id: str | Non
|
|
|
78
193
|
}
|
|
79
194
|
|
|
80
195
|
|
|
81
|
-
def reset_memory(
|
|
196
|
+
def reset_memory(
|
|
197
|
+
*,
|
|
198
|
+
all_memory: bool = False,
|
|
199
|
+
agent_id: str | None = None,
|
|
200
|
+
source_id: str | None = None,
|
|
201
|
+
reset_sessions: bool = False,
|
|
202
|
+
reset_tasks: bool = False,
|
|
203
|
+
reset_cache: bool = False,
|
|
204
|
+
) -> dict[str, Any]:
|
|
82
205
|
config = load_config()
|
|
83
206
|
removed_paths: list[str] = []
|
|
84
|
-
|
|
207
|
+
scoped_reset = any([agent_id, source_id, reset_sessions, reset_tasks, reset_cache])
|
|
208
|
+
if all_memory or not scoped_reset:
|
|
85
209
|
config.pop("memory", None)
|
|
86
210
|
if memory_home().exists():
|
|
87
211
|
removed_paths.append(str(memory_home()))
|
|
@@ -97,6 +221,14 @@ def reset_memory(*, all_memory: bool = False, agent_id: str | None = None, sourc
|
|
|
97
221
|
usage.get("sources", {}).pop(source_id, None)
|
|
98
222
|
path = memory_home() / "sources" / sanitize_segment(source_id)
|
|
99
223
|
remove_path(path, removed_paths)
|
|
224
|
+
if all_memory or reset_sessions:
|
|
225
|
+
remove_path(sessions_home(), removed_paths)
|
|
226
|
+
remove_path(app_path("state", "active-session.json"), removed_paths)
|
|
227
|
+
if all_memory or reset_tasks:
|
|
228
|
+
remove_path(tasks_home(), removed_paths)
|
|
229
|
+
if all_memory or reset_cache:
|
|
230
|
+
remove_path(cache_home(), removed_paths)
|
|
231
|
+
ensure_app_home()
|
|
100
232
|
written_path = save_config(config)
|
|
101
233
|
return {
|
|
102
234
|
"kind": "memory-reset",
|
|
@@ -104,9 +236,16 @@ def reset_memory(*, all_memory: bool = False, agent_id: str | None = None, sourc
|
|
|
104
236
|
"config_path": str(written_path),
|
|
105
237
|
"removed_paths": removed_paths,
|
|
106
238
|
"sources_preserved": True,
|
|
239
|
+
"sessions_reset": bool(all_memory or reset_sessions),
|
|
240
|
+
"tasks_reset": bool(all_memory or reset_tasks),
|
|
241
|
+
"cache_reset": bool(all_memory or reset_cache),
|
|
107
242
|
}
|
|
108
243
|
|
|
109
244
|
|
|
245
|
+
def memory_path_payload() -> dict[str, Any]:
|
|
246
|
+
return ensure_memory()
|
|
247
|
+
|
|
248
|
+
|
|
110
249
|
def increment_bucket(bucket: dict[str, Any], key: str, now: str) -> None:
|
|
111
250
|
item = bucket.setdefault(key, {"count": 0, "first_seen": now, "last_seen": now})
|
|
112
251
|
item["count"] = int(item.get("count") or 0) + 1
|
|
@@ -132,4 +271,5 @@ def remove_path(path: Path, removed_paths: list[str]) -> None:
|
|
|
132
271
|
|
|
133
272
|
|
|
134
273
|
def sanitize_segment(value: str) -> str:
|
|
135
|
-
|
|
274
|
+
sanitized = re.sub(r"[^a-zA-Z0-9._-]+", "-", value).strip(".-")
|
|
275
|
+
return sanitized or "item"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Task-to-model routing decisions for Agent DevKit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cli.aikit.llm import llm_preference, load_config
|
|
9
|
+
from cli.aikit.ollama import ollama_status
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
OPERATIONAL_PATTERN = re.compile(
|
|
13
|
+
r"(?i)\b(resum|sumari|classifi|extra(?:i|ir)|normaliz|compar|logs?|rascunho|agrupe|agrupar)\b"
|
|
14
|
+
)
|
|
15
|
+
HIGH_LEVEL_PATTERN = re.compile(
|
|
16
|
+
r"(?i)\b(arquitet|decid|aprovar|reprovar|especifica|requisit|implemente|codigo|c[oó]digo|documento|automac|deploy|seguran)\b"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_model_plan(prompt: str, *, route: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
21
|
+
preference = llm_preference(load_config())
|
|
22
|
+
ollama = ollama_status()
|
|
23
|
+
local_available = ollama.get("status") == "ok"
|
|
24
|
+
operational = bool(OPERATIONAL_PATTERN.search(prompt))
|
|
25
|
+
high_level = bool(HIGH_LEVEL_PATTERN.search(prompt))
|
|
26
|
+
use_local = operational and local_available
|
|
27
|
+
return {
|
|
28
|
+
"kind": "model-plan",
|
|
29
|
+
"status": "planned",
|
|
30
|
+
"intent": route.get("intent") if route else "llm",
|
|
31
|
+
"primary_coordinators": coordinator_order(preference),
|
|
32
|
+
"local_llm_role": "operational-worker",
|
|
33
|
+
"local_llm_available": local_available,
|
|
34
|
+
"local_llm_provider": "ollama",
|
|
35
|
+
"local_llm_recommended": operational,
|
|
36
|
+
"local_llm_selected": use_local,
|
|
37
|
+
"delegation": {
|
|
38
|
+
"allowed": operational,
|
|
39
|
+
"selected": use_local,
|
|
40
|
+
"reason": local_reason(operational=operational, local_available=local_available, high_level=high_level),
|
|
41
|
+
"forbidden_actions": [
|
|
42
|
+
"final-review",
|
|
43
|
+
"external-write",
|
|
44
|
+
"permission-decision",
|
|
45
|
+
"architecture-decision",
|
|
46
|
+
"pr-approval",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
"fallback_order": preference.get("order") or [],
|
|
50
|
+
"route": route,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def coordinator_order(preference: dict[str, Any]) -> list[str]:
|
|
55
|
+
order = list(preference.get("order") or [])
|
|
56
|
+
preferred = [item for item in order if item in {"claude-code", "codex-cli"}]
|
|
57
|
+
for item in ("claude-code", "codex-cli"):
|
|
58
|
+
if item not in preferred:
|
|
59
|
+
preferred.append(item)
|
|
60
|
+
return preferred
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def local_reason(*, operational: bool, local_available: bool, high_level: bool) -> str:
|
|
64
|
+
if operational and local_available and high_level:
|
|
65
|
+
return "Use Ollama for operational preprocessing; coordinator review remains mandatory."
|
|
66
|
+
if operational and local_available:
|
|
67
|
+
return "Task is operational and local Ollama is available."
|
|
68
|
+
if operational and not local_available:
|
|
69
|
+
return "Task is operational, but Ollama is not available; coordinator/API fallback should execute."
|
|
70
|
+
return "Task requires coordinator-level reasoning or review."
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Ollama local LLM discovery and model management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
OLLAMA_TIMEOUT_SECONDS = 120
|
|
15
|
+
DEFAULT_BASE_URL = "http://localhost:11434"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ollama_status(*, base_url: str = DEFAULT_BASE_URL) -> dict[str, Any]:
|
|
19
|
+
binary = shutil.which("ollama")
|
|
20
|
+
version = command_output(["ollama", "--version"]) if binary else None
|
|
21
|
+
daemon = daemon_status(base_url) if binary else {"status": "unknown", "message": "Ollama binary is not installed."}
|
|
22
|
+
models = list_local_models(binary_available=bool(binary))
|
|
23
|
+
status = "ok" if binary else "missing"
|
|
24
|
+
return {
|
|
25
|
+
"kind": "ollama-status",
|
|
26
|
+
"status": status,
|
|
27
|
+
"binary": binary,
|
|
28
|
+
"version": first_line(version),
|
|
29
|
+
"base_url": base_url,
|
|
30
|
+
"daemon": daemon,
|
|
31
|
+
"models": models,
|
|
32
|
+
"model_count": len(models),
|
|
33
|
+
"install_plan": install_plan() if not binary else None,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ollama_models() -> dict[str, Any]:
|
|
38
|
+
binary = shutil.which("ollama")
|
|
39
|
+
items = list_local_models(binary_available=bool(binary))
|
|
40
|
+
return {
|
|
41
|
+
"kind": "ollama-models",
|
|
42
|
+
"status": "ok" if binary else "missing",
|
|
43
|
+
"binary": binary,
|
|
44
|
+
"items": items,
|
|
45
|
+
"recommended": recommended_models(installed={item["name"] for item in items}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ollama_pull(model: str | None, *, yes: bool = False, dry_run: bool = False) -> dict[str, Any]:
|
|
50
|
+
if not model:
|
|
51
|
+
raise ValueError("ollama pull requires a model name")
|
|
52
|
+
binary = shutil.which("ollama")
|
|
53
|
+
command = ["ollama", "pull", model]
|
|
54
|
+
if dry_run or not yes:
|
|
55
|
+
return {
|
|
56
|
+
"kind": "ollama-pull",
|
|
57
|
+
"status": "planned" if dry_run else "needs-confirmation",
|
|
58
|
+
"ok": bool(dry_run),
|
|
59
|
+
"model": model,
|
|
60
|
+
"binary": binary,
|
|
61
|
+
"command": command,
|
|
62
|
+
"dry_run": dry_run,
|
|
63
|
+
"yes": yes,
|
|
64
|
+
"message": "Use --yes to pull the model." if not dry_run and not yes else "Dry-run only.",
|
|
65
|
+
}
|
|
66
|
+
if not binary:
|
|
67
|
+
return {
|
|
68
|
+
"kind": "ollama-pull",
|
|
69
|
+
"status": "missing",
|
|
70
|
+
"ok": False,
|
|
71
|
+
"model": model,
|
|
72
|
+
"command": command,
|
|
73
|
+
"message": "Ollama binary not found in PATH.",
|
|
74
|
+
"install_plan": install_plan(),
|
|
75
|
+
"exit_code": 2,
|
|
76
|
+
}
|
|
77
|
+
process = run_command(command, timeout=OLLAMA_TIMEOUT_SECONDS * 5)
|
|
78
|
+
return {
|
|
79
|
+
"kind": "ollama-pull",
|
|
80
|
+
"status": "ok" if process.returncode == 0 else "failed",
|
|
81
|
+
"ok": process.returncode == 0,
|
|
82
|
+
"model": model,
|
|
83
|
+
"binary": binary,
|
|
84
|
+
"command": command,
|
|
85
|
+
"exit_code": process.returncode,
|
|
86
|
+
"stdout": safe_tail(process.stdout),
|
|
87
|
+
"stderr": safe_tail(process.stderr),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def ollama_update(*, yes: bool = False, dry_run: bool = False) -> dict[str, Any]:
|
|
92
|
+
command = update_command()
|
|
93
|
+
if dry_run or not yes:
|
|
94
|
+
return {
|
|
95
|
+
"kind": "ollama-update",
|
|
96
|
+
"status": "planned" if dry_run else "needs-confirmation",
|
|
97
|
+
"ok": bool(dry_run),
|
|
98
|
+
"command": command,
|
|
99
|
+
"dry_run": dry_run,
|
|
100
|
+
"yes": yes,
|
|
101
|
+
"message": "Use --yes to run the update command." if not dry_run and not yes else "Dry-run only.",
|
|
102
|
+
}
|
|
103
|
+
process = run_command(command, timeout=OLLAMA_TIMEOUT_SECONDS * 5, shell=True)
|
|
104
|
+
return {
|
|
105
|
+
"kind": "ollama-update",
|
|
106
|
+
"status": "ok" if process.returncode == 0 else "failed",
|
|
107
|
+
"ok": process.returncode == 0,
|
|
108
|
+
"command": command,
|
|
109
|
+
"exit_code": process.returncode,
|
|
110
|
+
"stdout": safe_tail(process.stdout),
|
|
111
|
+
"stderr": safe_tail(process.stderr),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def list_local_models(*, binary_available: bool) -> list[dict[str, Any]]:
|
|
116
|
+
if not binary_available:
|
|
117
|
+
return []
|
|
118
|
+
output = command_output(["ollama", "list"])
|
|
119
|
+
if not output:
|
|
120
|
+
return []
|
|
121
|
+
return parse_ollama_list(output)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_ollama_list(output: str) -> list[dict[str, Any]]:
|
|
125
|
+
items: list[dict[str, Any]] = []
|
|
126
|
+
for line in output.splitlines():
|
|
127
|
+
stripped = line.strip()
|
|
128
|
+
if not stripped or stripped.lower().startswith("name "):
|
|
129
|
+
continue
|
|
130
|
+
parts = stripped.split()
|
|
131
|
+
if not parts:
|
|
132
|
+
continue
|
|
133
|
+
items.append(
|
|
134
|
+
{
|
|
135
|
+
"name": parts[0],
|
|
136
|
+
"id": parts[1] if len(parts) > 1 else None,
|
|
137
|
+
"size": parts[2] if len(parts) > 2 else None,
|
|
138
|
+
"modified": " ".join(parts[3:]) if len(parts) > 3 else None,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
return items
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def recommended_models(*, installed: set[str]) -> list[dict[str, Any]]:
|
|
145
|
+
catalog = [
|
|
146
|
+
("qwen2.5-coder", "coding", "operational code reading and generation"),
|
|
147
|
+
("deepseek-coder", "coding", "code analysis and mechanical refactors"),
|
|
148
|
+
("deepseek-r1", "reasoning", "local reasoning drafts with mandatory review"),
|
|
149
|
+
("llama3.2", "general", "general local summaries and classification"),
|
|
150
|
+
("mistral", "general", "lightweight operational summaries"),
|
|
151
|
+
("gemma", "classification", "small extraction and classification tasks"),
|
|
152
|
+
]
|
|
153
|
+
return [
|
|
154
|
+
{
|
|
155
|
+
"name": name,
|
|
156
|
+
"family": family,
|
|
157
|
+
"recommended_for": recommended_for,
|
|
158
|
+
"installed": any(item == name or item.startswith(f"{name}:") for item in installed),
|
|
159
|
+
}
|
|
160
|
+
for name, family, recommended_for in catalog
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def daemon_status(base_url: str) -> dict[str, Any]:
|
|
165
|
+
url = base_url.rstrip("/") + "/api/tags"
|
|
166
|
+
try:
|
|
167
|
+
with urllib.request.urlopen(url, timeout=2) as response: # noqa: S310 - local configurable daemon URL.
|
|
168
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
169
|
+
except urllib.error.URLError as exc:
|
|
170
|
+
return {"status": "unavailable", "message": str(exc.reason)}
|
|
171
|
+
try:
|
|
172
|
+
payload = json.loads(raw)
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
return {"status": "unknown", "message": "Ollama daemon returned non-JSON response."}
|
|
175
|
+
return {"status": "ok", "models": len(payload.get("models") or []) if isinstance(payload, dict) else None}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def install_plan() -> dict[str, Any]:
|
|
179
|
+
system = platform.system().lower()
|
|
180
|
+
if system == "darwin":
|
|
181
|
+
command = "brew install ollama"
|
|
182
|
+
elif system.startswith("win"):
|
|
183
|
+
command = "winget install Ollama.Ollama"
|
|
184
|
+
else:
|
|
185
|
+
command = "curl -fsSL https://ollama.com/install.sh | sh"
|
|
186
|
+
return {"platform": platform_key(), "command": command, "requires_opt_in": True}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def update_command() -> str:
|
|
190
|
+
system = platform.system().lower()
|
|
191
|
+
if system == "darwin":
|
|
192
|
+
return "brew upgrade ollama"
|
|
193
|
+
if system.startswith("win"):
|
|
194
|
+
return "winget upgrade Ollama.Ollama"
|
|
195
|
+
return "curl -fsSL https://ollama.com/install.sh | sh"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def platform_key() -> str:
|
|
199
|
+
system = platform.system().lower()
|
|
200
|
+
if system == "darwin":
|
|
201
|
+
return "darwin"
|
|
202
|
+
if system.startswith("win"):
|
|
203
|
+
return "windows"
|
|
204
|
+
return "linux"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def command_output(command: list[str]) -> str | None:
|
|
208
|
+
try:
|
|
209
|
+
process = run_command(command, timeout=10)
|
|
210
|
+
except OSError:
|
|
211
|
+
return None
|
|
212
|
+
if process.returncode != 0:
|
|
213
|
+
return None
|
|
214
|
+
return process.stdout.strip() or process.stderr.strip()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def run_command(command: list[str] | str, *, timeout: int, shell: bool = False) -> subprocess.CompletedProcess[str]:
|
|
218
|
+
return subprocess.run(
|
|
219
|
+
command,
|
|
220
|
+
shell=shell,
|
|
221
|
+
check=False,
|
|
222
|
+
text=True,
|
|
223
|
+
stdout=subprocess.PIPE,
|
|
224
|
+
stderr=subprocess.PIPE,
|
|
225
|
+
timeout=timeout,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def safe_tail(value: str | None, *, limit: int = 4000) -> str:
|
|
230
|
+
text = value or ""
|
|
231
|
+
return text[-limit:]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def first_line(value: str | None) -> str | None:
|
|
235
|
+
if not value:
|
|
236
|
+
return None
|
|
237
|
+
return value.splitlines()[0]
|