arkaos 2.25.0 → 2.31.0

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 (42) hide show
  1. package/VERSION +1 -1
  2. package/arka/skills/bootstrap-agent/SKILL.md +76 -0
  3. package/arka/skills/design-ops/SKILL.md +68 -0
  4. package/arka/skills/design-ops/scripts/extract-colors.py +86 -0
  5. package/arka/skills/design-ops/scripts/shadcn-tokens.py +59 -0
  6. package/arka/skills/design-ops/scripts/wcag-contrast.py +92 -0
  7. package/arka/skills/dreams/SKILL.md +78 -0
  8. package/arka/skills/research/SKILL.md +117 -0
  9. package/config/cognition/schedules.yaml +6 -0
  10. package/config/constitution.yaml +4 -0
  11. package/config/hooks/post-tool-use.sh +17 -0
  12. package/config/hooks/user-prompt-submit.sh +16 -0
  13. package/core/agents/__pycache__/loader.cpython-313.pyc +0 -0
  14. package/core/agents/__pycache__/schema.cpython-313.pyc +0 -0
  15. package/core/agents/loader.py +4 -1
  16. package/core/agents/schema.py +29 -0
  17. package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
  18. package/core/cognition/__pycache__/dreams_reader.cpython-313.pyc +0 -0
  19. package/core/cognition/__pycache__/retrieval.cpython-313.pyc +0 -0
  20. package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
  21. package/core/cognition/dreaming.py +368 -0
  22. package/core/cognition/dreams_reader.py +141 -0
  23. package/core/cognition/retrieval.py +383 -0
  24. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  25. package/core/cognition/scheduler/daemon.py +23 -3
  26. package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
  27. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  28. package/core/runtime/__pycache__/ollama_provider.cpython-313.pyc +0 -0
  29. package/core/runtime/__pycache__/path_resolver.cpython-313.pyc +0 -0
  30. package/core/runtime/llm_provider.py +8 -1
  31. package/core/runtime/ollama_provider.py +144 -0
  32. package/departments/brand/agents/design-ops/design-ops-lead.yaml +78 -0
  33. package/departments/brand/agents/design-ops/extraction-script-writer.yaml +74 -0
  34. package/departments/brand/agents/design-ops/shadcn-padronizer.yaml +76 -0
  35. package/departments/brand/agents/design-ops/wcag-auditor.yaml +76 -0
  36. package/departments/dev/skills/mcp/SKILL.md +16 -0
  37. package/installer/cli.js +8 -1
  38. package/installer/doctor.js +13 -1
  39. package/installer/index.js +6 -3
  40. package/installer/system-tools.js +79 -2
  41. package/package.json +1 -1
  42. package/pyproject.toml +1 -1
@@ -0,0 +1,383 @@
1
+ """Hooks-as-retrieval prototype for the ArkaOS Cognitive Layer.
2
+
3
+ After each tool call, the PostToolUse hook calls into this module to
4
+ extract likely entities from the tool output, find relevant notes in the
5
+ Obsidian vault, and write a small JSON cache that the UserPromptSubmit
6
+ hook injects into the next turn as an ``[arka:context]`` advisory.
7
+
8
+ This is the prototype unblocked by Claude Code 2.1.122 (late-binding
9
+ ``ToolSearch`` + hook-block isolation, see KB note
10
+ 2026-04-29-claude-code-2-1-122-and-2-1-123). It uses filesystem grep
11
+ (ripgrep when available, Python fallback otherwise) instead of running
12
+ a separate MCP server with embeddings — a deliberate "smallest thing
13
+ that could possibly work" choice per the 2026-05-13 Conclave ADR.
14
+
15
+ Performance budget: ≤ 800 ms p95 per ``capture_context`` call. The
16
+ PostToolUse hook enforces this with a hard timeout; the function itself
17
+ caps entities (20), vault hits (5), and ripgrep wall time (1 s).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import subprocess
26
+ from dataclasses import asdict, dataclass, field
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+
30
+ from core.runtime.user_paths import user_data_root
31
+
32
+ _CACHE_DIRNAME = "context-cache"
33
+ _DEFAULT_TTL_SECONDS = 600
34
+ _REAPER_MAX_AGE_SECONDS = 24 * 60 * 60 # cache files older than 24h get pruned
35
+ _MAX_ENTITIES = 20
36
+ _MAX_VAULT_HITS = 5
37
+ _RIPGREP_TIMEOUT_S = 1.0
38
+ _PY_FALLBACK_MAX_FILES = 500
39
+
40
+ # Common-word stoplist — kept tiny on purpose; we want to capture short
41
+ # domain terms like "auth" or "DSL" but cut out the highest-frequency
42
+ # English filler tokens that dominate any tool output.
43
+ _STOPLIST = frozenset(
44
+ {
45
+ "The", "And", "But", "For", "Not", "With", "From", "This", "That",
46
+ "When", "Where", "Then", "Else", "True", "False", "None", "Null",
47
+ "Error", "Warning", "Info", "Debug", "Trace", "Yes", "No", "Run",
48
+ "Read", "Write", "Edit", "Delete", "Show", "Find", "List",
49
+ }
50
+ )
51
+
52
+ _FILE_PATH = re.compile(r"(?:^|\s)((?:[\w.-]+/)+[\w.-]+\.[a-zA-Z]{1,6})")
53
+ _CAMEL_OR_PASCAL = re.compile(r"\b([A-Z][a-z]+(?:[A-Z][a-z]+)+)\b")
54
+ _AT_MENTION = re.compile(r"@([\w./_-]+)")
55
+ _PROPER_NOUN = re.compile(r"\b([A-Z][a-zA-Z]{2,})\b")
56
+
57
+
58
+ @dataclass
59
+ class ContextHit:
60
+ """One vault note that matches one or more extracted entities."""
61
+
62
+ entity: str
63
+ vault_path: str
64
+ snippet: str
65
+ score: float = 1.0
66
+
67
+ def to_dict(self) -> dict:
68
+ return asdict(self)
69
+
70
+
71
+ @dataclass
72
+ class ContextCache:
73
+ """The JSON payload persisted per session."""
74
+
75
+ session_id: str
76
+ captured_at: str
77
+ ttl_seconds: int
78
+ hits: list[ContextHit] = field(default_factory=list)
79
+
80
+ def to_dict(self) -> dict:
81
+ return {
82
+ "session_id": self.session_id,
83
+ "captured_at": self.captured_at,
84
+ "ttl_seconds": self.ttl_seconds,
85
+ "hits": [h.to_dict() for h in self.hits],
86
+ }
87
+
88
+
89
+ def extract_entities(text: str) -> list[str]:
90
+ """Pull file paths, identifiers, and proper nouns out of *text*.
91
+
92
+ Returns a deduplicated, capped list ready for vault search. Cheap
93
+ enough to call on every PostToolUse invocation.
94
+ """
95
+ if not text:
96
+ return []
97
+ seen: set[str] = set()
98
+ out: list[str] = []
99
+
100
+ def push(tok: str) -> None:
101
+ tok = tok.strip(" .,;:!?\"'()[]{}")
102
+ if not tok or tok in _STOPLIST or tok in seen:
103
+ return
104
+ if tok.isdigit():
105
+ return
106
+ seen.add(tok)
107
+ out.append(tok)
108
+ return
109
+
110
+ for m in _FILE_PATH.finditer(text):
111
+ push(m.group(1))
112
+ for m in _AT_MENTION.finditer(text):
113
+ push(m.group(1))
114
+ for m in _CAMEL_OR_PASCAL.finditer(text):
115
+ push(m.group(1))
116
+ for m in _PROPER_NOUN.finditer(text):
117
+ if len(out) >= _MAX_ENTITIES:
118
+ break
119
+ push(m.group(1))
120
+ return out[:_MAX_ENTITIES]
121
+
122
+
123
+ def search_vault(entities: list[str], vault_path: str) -> list[ContextHit]:
124
+ """Find Obsidian notes that mention any of the extracted entities.
125
+
126
+ Uses ripgrep when available (sub-second across thousands of notes),
127
+ falls back to a pure-Python scan capped at ``_PY_FALLBACK_MAX_FILES``
128
+ files so the hook timeout stays safe on Windows or minimal Linux
129
+ installs without ripgrep.
130
+ """
131
+ vault = Path(vault_path).expanduser()
132
+ if not entities or not vault.is_dir():
133
+ return []
134
+ if _ripgrep_available():
135
+ hits = _search_ripgrep(entities, vault)
136
+ else:
137
+ hits = _search_python(entities, vault)
138
+ return hits[:_MAX_VAULT_HITS]
139
+
140
+
141
+ def capture_context(
142
+ session_id: str,
143
+ tool_output: str,
144
+ vault_path: str,
145
+ cache_dir: Path | None = None,
146
+ ) -> ContextCache:
147
+ """Run extract + search and persist the cache. Returns the cache.
148
+
149
+ Idempotent across overlapping calls in the same second: a second
150
+ invocation overwrites the first. Empty hits still produce a file
151
+ so the UserPromptSubmit reader sees a fresh ``captured_at`` and
152
+ doesn't mistake a quiet turn for stale data.
153
+ """
154
+ entities = extract_entities(tool_output)
155
+ hits = search_vault(entities, vault_path) if entities else []
156
+ cache = ContextCache(
157
+ session_id=session_id,
158
+ captured_at=datetime.now(timezone.utc).isoformat(),
159
+ ttl_seconds=_DEFAULT_TTL_SECONDS,
160
+ hits=hits,
161
+ )
162
+ _write_cache(cache, cache_dir or _default_cache_dir())
163
+ return cache
164
+
165
+
166
+ def read_context(
167
+ session_id: str,
168
+ ttl_seconds: int = _DEFAULT_TTL_SECONDS,
169
+ cache_dir: Path | None = None,
170
+ ) -> list[ContextHit]:
171
+ """Read cached hits for *session_id* if still within TTL."""
172
+ path = (cache_dir or _default_cache_dir()) / f"{session_id}.json"
173
+ if not path.exists():
174
+ return []
175
+ try:
176
+ data = json.loads(path.read_text(encoding="utf-8"))
177
+ except (json.JSONDecodeError, OSError):
178
+ return []
179
+ captured = data.get("captured_at", "")
180
+ if not _within_ttl(captured, ttl_seconds):
181
+ return []
182
+ return [ContextHit(**h) for h in data.get("hits", [])]
183
+
184
+
185
+ def format_advisory(hits: list[ContextHit], max_chars: int = 1200) -> str:
186
+ """Render hits into a compact `[arka:context]` advisory string."""
187
+ if not hits:
188
+ return ""
189
+ lines = ["[arka:context] KB matches from last turn:"]
190
+ for h in hits:
191
+ line = f"- {h.entity} → {h.vault_path}: {h.snippet[:160]}"
192
+ if sum(len(l) for l in lines) + len(line) > max_chars:
193
+ break
194
+ lines.append(line)
195
+ return "\n".join(lines)
196
+
197
+
198
+ def _default_cache_dir() -> Path:
199
+ return user_data_root() / _CACHE_DIRNAME
200
+
201
+
202
+ def _write_cache(cache: ContextCache, cache_dir: Path) -> None:
203
+ cache_dir.mkdir(parents=True, exist_ok=True)
204
+ path = cache_dir / f"{cache.session_id}.json"
205
+ tmp = cache_dir / f"{cache.session_id}.json.tmp"
206
+ tmp.write_text(json.dumps(cache.to_dict(), indent=2), encoding="utf-8")
207
+ os.replace(tmp, path)
208
+ _prune_stale_cache(cache_dir)
209
+
210
+
211
+ def _prune_stale_cache(cache_dir: Path) -> int:
212
+ """Delete cache files older than 24h to bound disk growth.
213
+
214
+ Called from ``_write_cache`` so cleanup amortises across normal use
215
+ without requiring a separate cron job. Per Marta's PR4 follow-up
216
+ observation: the read-side TTL hides stale data but on-disk files
217
+ accumulate one-per-session forever. Returns the count of pruned files.
218
+ """
219
+ if not cache_dir.is_dir():
220
+ return 0
221
+ cutoff = datetime.now(timezone.utc).timestamp() - _REAPER_MAX_AGE_SECONDS
222
+ pruned = 0
223
+ for child in cache_dir.glob("*.json"):
224
+ try:
225
+ if child.stat().st_mtime < cutoff:
226
+ child.unlink()
227
+ pruned += 1
228
+ except OSError:
229
+ continue
230
+ return pruned
231
+
232
+
233
+ def _within_ttl(captured_iso: str, ttl_seconds: int) -> bool:
234
+ try:
235
+ captured = datetime.fromisoformat(captured_iso)
236
+ except ValueError:
237
+ return False
238
+ now = datetime.now(timezone.utc)
239
+ if captured.tzinfo is None:
240
+ captured = captured.replace(tzinfo=timezone.utc)
241
+ return (now - captured).total_seconds() <= ttl_seconds
242
+
243
+
244
+ def _ripgrep_available() -> bool:
245
+ try:
246
+ subprocess.run(
247
+ ["rg", "--version"], check=True, capture_output=True, timeout=1.0
248
+ )
249
+ return True
250
+ except (FileNotFoundError, subprocess.SubprocessError):
251
+ return False
252
+
253
+
254
+ def _search_ripgrep(entities: list[str], vault: Path) -> list[ContextHit]:
255
+ pattern = "|".join(re.escape(e) for e in entities)
256
+ cmd = [
257
+ "rg", "--json", "--max-count", "1", "--smart-case",
258
+ "-tmd", "-e", pattern, str(vault),
259
+ ]
260
+ try:
261
+ result = subprocess.run(
262
+ cmd, capture_output=True, text=True, timeout=_RIPGREP_TIMEOUT_S
263
+ )
264
+ except subprocess.SubprocessError:
265
+ return []
266
+ return _parse_ripgrep_json(result.stdout, vault, entities)
267
+
268
+
269
+ def _parse_ripgrep_json(stdout: str, vault: Path, entities: list[str]) -> list[ContextHit]:
270
+ hits: list[ContextHit] = []
271
+ seen: set[str] = set()
272
+ for line in stdout.splitlines():
273
+ try:
274
+ event = json.loads(line)
275
+ except json.JSONDecodeError:
276
+ continue
277
+ if event.get("type") != "match":
278
+ continue
279
+ data = event.get("data", {})
280
+ path = data.get("path", {}).get("text", "")
281
+ if not path or path in seen:
282
+ continue
283
+ seen.add(path)
284
+ text = data.get("lines", {}).get("text", "").strip()
285
+ matched_entity = _pick_matched_entity(text, entities)
286
+ rel = _relative_to_vault(path, vault)
287
+ hits.append(ContextHit(entity=matched_entity, vault_path=rel, snippet=text))
288
+ return hits
289
+
290
+
291
+ def _search_python(entities: list[str], vault: Path) -> list[ContextHit]:
292
+ pattern = re.compile("|".join(re.escape(e) for e in entities), re.IGNORECASE)
293
+ files = list(vault.rglob("*.md"))[:_PY_FALLBACK_MAX_FILES]
294
+ hits: list[ContextHit] = []
295
+ for f in files:
296
+ try:
297
+ text = f.read_text(encoding="utf-8", errors="ignore")
298
+ except OSError:
299
+ continue
300
+ match = pattern.search(text)
301
+ if not match:
302
+ continue
303
+ snippet_start = max(0, match.start() - 40)
304
+ snippet_end = min(len(text), match.end() + 80)
305
+ snippet = text[snippet_start:snippet_end].replace("\n", " ").strip()
306
+ hits.append(
307
+ ContextHit(
308
+ entity=match.group(0),
309
+ vault_path=str(f.relative_to(vault)),
310
+ snippet=snippet,
311
+ )
312
+ )
313
+ if len(hits) >= _MAX_VAULT_HITS:
314
+ break
315
+ return hits
316
+
317
+
318
+ def _pick_matched_entity(text: str, entities: list[str]) -> str:
319
+ lower = text.lower()
320
+ for e in entities:
321
+ if e.lower() in lower:
322
+ return e
323
+ return entities[0] if entities else ""
324
+
325
+
326
+ def _relative_to_vault(absolute: str, vault: Path) -> str:
327
+ try:
328
+ return str(Path(absolute).relative_to(vault))
329
+ except ValueError:
330
+ return absolute
331
+
332
+
333
+ def _vault_path_or_none() -> str | None:
334
+ """Resolve the configured vault path or return None when missing.
335
+
336
+ Hooks call this; they must never crash because profile.json is absent.
337
+ """
338
+ try:
339
+ from core.runtime.path_resolver import load_profile
340
+ return load_profile().vault_path
341
+ except Exception:
342
+ return None
343
+
344
+
345
+ def _cli_capture(argv: list[str]) -> int:
346
+ """Stdin → capture_context. Used by the PostToolUse hook."""
347
+ import sys
348
+ if len(argv) < 2:
349
+ return 2
350
+ session_id = argv[1]
351
+ vault = _vault_path_or_none()
352
+ if not vault:
353
+ return 0 # silent skip when no profile
354
+ tool_output = sys.stdin.read()
355
+ capture_context(session_id, tool_output, vault)
356
+ return 0
357
+
358
+
359
+ def _cli_inject(argv: list[str]) -> int:
360
+ """Print advisory for *session_id*. Used by the UserPromptSubmit hook."""
361
+ if len(argv) < 2:
362
+ return 2
363
+ session_id = argv[1]
364
+ advisory = format_advisory(read_context(session_id))
365
+ if advisory:
366
+ print(advisory)
367
+ return 0
368
+
369
+
370
+ def main(argv: list[str]) -> int:
371
+ if len(argv) < 2:
372
+ return 2
373
+ cmd = argv[1]
374
+ if cmd == "capture":
375
+ return _cli_capture(argv[1:])
376
+ if cmd == "inject":
377
+ return _cli_inject(argv[1:])
378
+ return 2
379
+
380
+
381
+ if __name__ == "__main__":
382
+ import sys
383
+ sys.exit(main(sys.argv))
@@ -18,7 +18,16 @@ import yaml
18
18
 
19
19
  @dataclass
20
20
  class ScheduleConfig:
21
- """Configuration for a single scheduled cognitive task."""
21
+ """Configuration for a single scheduled cognitive task.
22
+
23
+ Two execution modes (mutually exclusive):
24
+ - prompt_file (default): shell out to the active Claude CLI with the
25
+ rendered prompt as the user input. Backward-compat for legacy
26
+ dreaming.md / research.md schedules.
27
+ - python_module: invoke ``python -m <module> [args...]`` directly.
28
+ Used by Dreaming v2 (PR8) which is a Python engine, not a
29
+ prompt-only task.
30
+ """
22
31
 
23
32
  command: str
24
33
  prompt_file: str
@@ -27,6 +36,8 @@ class ScheduleConfig:
27
36
  retry_on_fail: bool = True
28
37
  max_retries: int = 2
29
38
  timeout_minutes: int = 60
39
+ python_module: str | None = None
40
+ module_args: list[str] = field(default_factory=list)
30
41
 
31
42
  @classmethod
32
43
  def load(cls, config_path: str) -> "list[ScheduleConfig]":
@@ -43,12 +54,14 @@ class ScheduleConfig:
43
54
  schedules.append(
44
55
  cls(
45
56
  command=cfg["command"],
46
- prompt_file=cfg["prompt_file"],
57
+ prompt_file=cfg.get("prompt_file", ""),
47
58
  run_time=time(hour, minute),
48
59
  enabled=cfg.get("enabled", True),
49
60
  retry_on_fail=cfg.get("retry_on_fail", True),
50
61
  max_retries=cfg.get("max_retries", 2),
51
62
  timeout_minutes=cfg.get("timeout_minutes", 60),
63
+ python_module=cfg.get("python_module"),
64
+ module_args=list(cfg.get("module_args") or []),
52
65
  )
53
66
  )
54
67
  return schedules
@@ -140,7 +153,14 @@ class ArkaScheduler:
140
153
  )
141
154
 
142
155
  def _build_command(self, schedule: ScheduleConfig) -> list[str]:
143
- """Build the Claude CLI invocation for a schedule."""
156
+ """Build the subprocess invocation for a schedule.
157
+
158
+ Dispatches on python_module first (PR8 Dreaming v2 path), falls
159
+ back to the legacy Claude-CLI-with-prompt path for unchanged
160
+ schedules.
161
+ """
162
+ if schedule.python_module:
163
+ return [sys.executable, "-m", schedule.python_module, *schedule.module_args]
144
164
  claude_bin = self._resolve_claude_binary()
145
165
  prompt_path = os.path.expanduser(schedule.prompt_file)
146
166
  prompt_content = Path(prompt_path).read_text(encoding="utf-8")
@@ -31,7 +31,7 @@ from core.runtime.pricing import estimate_cost_usd
31
31
 
32
32
 
33
33
  _DEFAULT_CONFIG_PATH = Path.home() / ".arkaos" / "config.json"
34
- _FALLBACK_ORDER: tuple[str, ...] = ("subagent", "anthropic-direct", "stub")
34
+ _FALLBACK_ORDER: tuple[str, ...] = ("subagent", "ollama", "anthropic-direct", "stub")
35
35
 
36
36
 
37
37
  # ─── Public dataclass ─────────────────────────────────────────────────
@@ -305,8 +305,15 @@ class StubProvider:
305
305
  # ─── Factory + fallback chain ─────────────────────────────────────────
306
306
 
307
307
 
308
+ def _import_ollama_provider() -> type:
309
+ """Lazy import to keep the Ollama provider optional at runtime."""
310
+ from core.runtime.ollama_provider import OllamaProvider
311
+ return OllamaProvider
312
+
313
+
308
314
  _PROVIDERS: dict[str, type] = {
309
315
  "subagent": SubagentProvider,
316
+ "ollama": _import_ollama_provider(),
310
317
  "anthropic-direct": AnthropicDirectProvider,
311
318
  "stub": StubProvider,
312
319
  }
@@ -0,0 +1,144 @@
1
+ """Ollama provider — local LLM inference via the localhost API.
2
+
3
+ One of several backends behind the `LLMProvider` Protocol in
4
+ ``core/runtime/llm_provider.py``. The cognitive layer never imports
5
+ this class directly; it goes through ``get_llm_provider()`` which
6
+ selects from a configurable chain (subagent → ollama → anthropic-direct
7
+ → stub by default; user-configurable in ``~/.arkaos/config.json``).
8
+
9
+ Communicates with the local Ollama service at the standard
10
+ ``http://localhost:11434`` endpoint via stdlib ``urllib`` — no extra
11
+ dependencies. Model picked from ``OLLAMA_MODEL`` env var, then
12
+ ``profile.json:cognitiveModel``, then a sensible default. Whichever
13
+ the user has chosen sticks to a single chat completion call per
14
+ ``complete()`` invocation.
15
+
16
+ See ``docs/adr/2026-05-13-cognitive-layer-pivot-to-hooks.md`` and the
17
+ PR8 v2.30.0 commit for the architectural rationale.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import urllib.error
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ from core.runtime.llm_provider import LLMResponse, LLMUnavailable
29
+
30
+
31
+ _OLLAMA_HOST = "http://localhost:11434"
32
+ _DEFAULT_MODEL = "gemma3:27b" # fallback only — most users override via env / profile
33
+ _GENERATE_TIMEOUT_S = 120 # nightly Dreaming run — large prompts allowed
34
+
35
+
36
+ class OllamaProvider:
37
+ """Provider that hits the local Ollama daemon for chat completion."""
38
+
39
+ ENV_HOST = "OLLAMA_HOST"
40
+ ENV_MODEL = "OLLAMA_MODEL"
41
+
42
+ def __init__(self, model: str | None = None, host: str | None = None) -> None:
43
+ self._model_override = model
44
+ self._host = host or os.environ.get(self.ENV_HOST, _OLLAMA_HOST)
45
+
46
+ def name(self) -> str:
47
+ return "ollama"
48
+
49
+ def is_available(self) -> bool:
50
+ """Cheap reachability check against ``/api/tags``."""
51
+ url = f"{self._host.rstrip('/')}/api/tags"
52
+ try:
53
+ with urllib.request.urlopen(url, timeout=1.5) as response:
54
+ return response.status == 200
55
+ except (urllib.error.URLError, TimeoutError, OSError):
56
+ return False
57
+
58
+ def complete(
59
+ self,
60
+ prompt: str,
61
+ *,
62
+ max_tokens: int = 2000,
63
+ system: str = "",
64
+ ) -> LLMResponse:
65
+ model = self._resolve_model()
66
+ if not model:
67
+ raise LLMUnavailable("Ollama model not configured (set OLLAMA_MODEL or profile.cognitiveModel)")
68
+
69
+ payload = self._build_payload(model, prompt, system, max_tokens)
70
+ data = self._post_generate(payload)
71
+ return self._to_response(data, model)
72
+
73
+ def _resolve_model(self) -> str | None:
74
+ if self._model_override:
75
+ return self._model_override
76
+ env_value = os.environ.get(self.ENV_MODEL, "").strip()
77
+ if env_value:
78
+ return env_value
79
+ profile_model = _read_profile_model()
80
+ if profile_model:
81
+ return profile_model
82
+ return _DEFAULT_MODEL
83
+
84
+ def _build_payload(self, model: str, prompt: str, system: str, max_tokens: int) -> dict:
85
+ """Build /api/chat payload.
86
+
87
+ Using the chat endpoint (not /api/generate) means Ollama applies
88
+ the model's official chat template, which is critical for
89
+ instruction-tuned models like Qwen, Gemma, Mistral — without the
90
+ template most models burn through their token budget before
91
+ emitting visible content. Smoke-tested 2026-05-13 against
92
+ qwen3-coder:30b which returned the expected "hi" in 2 tokens.
93
+ """
94
+ messages: list[dict] = []
95
+ if system:
96
+ messages.append({"role": "system", "content": system})
97
+ messages.append({"role": "user", "content": prompt})
98
+ return {
99
+ "model": model,
100
+ "messages": messages,
101
+ "stream": False,
102
+ "options": {"num_predict": max_tokens},
103
+ }
104
+
105
+ def _post_generate(self, payload: dict) -> dict:
106
+ url = f"{self._host.rstrip('/')}/api/chat"
107
+ body = json.dumps(payload).encode("utf-8")
108
+ request = urllib.request.Request(
109
+ url, data=body, headers={"Content-Type": "application/json"}
110
+ )
111
+ try:
112
+ with urllib.request.urlopen(request, timeout=_GENERATE_TIMEOUT_S) as response:
113
+ raw = response.read().decode("utf-8")
114
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
115
+ raise LLMUnavailable(f"Ollama request failed: {exc}") from exc
116
+ try:
117
+ return json.loads(raw)
118
+ except json.JSONDecodeError as exc:
119
+ raise LLMUnavailable(f"Ollama returned invalid JSON: {exc}") from exc
120
+
121
+ def _to_response(self, data: dict, model: str) -> LLMResponse:
122
+ message = data.get("message", {}) or {}
123
+ text = (message.get("content") or data.get("response") or "").strip()
124
+ tokens_in = int(data.get("prompt_eval_count", 0) or 0)
125
+ tokens_out = int(data.get("eval_count", 0) or 0)
126
+ return LLMResponse(
127
+ text=text,
128
+ tokens_in=tokens_in,
129
+ tokens_out=tokens_out,
130
+ cached_tokens=0, # Ollama does not surface a cache signal
131
+ model=model,
132
+ )
133
+
134
+
135
+ def _read_profile_model() -> str | None:
136
+ path = Path.home() / ".arkaos" / "profile.json"
137
+ try:
138
+ data = json.loads(path.read_text(encoding="utf-8"))
139
+ except (OSError, json.JSONDecodeError):
140
+ return None
141
+ value = data.get("cognitiveModel")
142
+ if isinstance(value, str) and value.strip():
143
+ return value.strip()
144
+ return None