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.
- package/VERSION +1 -1
- package/arka/skills/bootstrap-agent/SKILL.md +76 -0
- package/arka/skills/design-ops/SKILL.md +68 -0
- package/arka/skills/design-ops/scripts/extract-colors.py +86 -0
- package/arka/skills/design-ops/scripts/shadcn-tokens.py +59 -0
- package/arka/skills/design-ops/scripts/wcag-contrast.py +92 -0
- package/arka/skills/dreams/SKILL.md +78 -0
- package/arka/skills/research/SKILL.md +117 -0
- package/config/cognition/schedules.yaml +6 -0
- package/config/constitution.yaml +4 -0
- package/config/hooks/post-tool-use.sh +17 -0
- package/config/hooks/user-prompt-submit.sh +16 -0
- package/core/agents/__pycache__/loader.cpython-313.pyc +0 -0
- package/core/agents/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/agents/loader.py +4 -1
- package/core/agents/schema.py +29 -0
- package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
- package/core/cognition/__pycache__/dreams_reader.cpython-313.pyc +0 -0
- package/core/cognition/__pycache__/retrieval.cpython-313.pyc +0 -0
- package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
- package/core/cognition/dreaming.py +368 -0
- package/core/cognition/dreams_reader.py +141 -0
- package/core/cognition/retrieval.py +383 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +23 -3
- package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/ollama_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/path_resolver.cpython-313.pyc +0 -0
- package/core/runtime/llm_provider.py +8 -1
- package/core/runtime/ollama_provider.py +144 -0
- package/departments/brand/agents/design-ops/design-ops-lead.yaml +78 -0
- package/departments/brand/agents/design-ops/extraction-script-writer.yaml +74 -0
- package/departments/brand/agents/design-ops/shadcn-padronizer.yaml +76 -0
- package/departments/brand/agents/design-ops/wcag-auditor.yaml +76 -0
- package/departments/dev/skills/mcp/SKILL.md +16 -0
- package/installer/cli.js +8 -1
- package/installer/doctor.js +13 -1
- package/installer/index.js +6 -3
- package/installer/system-tools.js +79 -2
- package/package.json +1 -1
- 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))
|
|
Binary file
|
|
@@ -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
|
|
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
|
|
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")
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|