arkaos 2.28.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 CHANGED
@@ -1 +1 @@
1
- 2.28.0
1
+ 2.31.0
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: arka-dreams
3
+ description: >
4
+ Surface for Dreaming v2 — the nightly cognitive consolidation engine
5
+ shipped in PR8 v2.30.0. Lists insights ArkaOS surfaced from the user's
6
+ recent vault activity and session digests, optionally promotes one
7
+ into a tracked task via /pm. The cognitive layer's user-facing surface.
8
+ allowed-tools: [Read, Bash, Agent]
9
+ ---
10
+
11
+ # /arka dreams — what ArkaOS noticed last night
12
+
13
+ > The motor without an ignition is a research demo. This skill is the
14
+ > ignition: it reads what Dreaming v2 wrote to the vault and surfaces
15
+ > it so the user can act.
16
+
17
+ ## Subcommands
18
+
19
+ | Command | What it does |
20
+ | --- | --- |
21
+ | `/arka dreams` | List insights surfaced today (UTC-day, generous tolerance). |
22
+ | `/arka dreams --since 7` | Last N days of insights, newest first. |
23
+ | `/arka dreams --all` | Every insight ever surfaced (since the vault was indexed). |
24
+ | `/arka dreams promote <id>` | Convert an insight into a sprint backlog item. Delegates to `/pm` (Carolina). |
25
+ | `/arka dreams trigger` | Manually run a Dreaming pass *right now* instead of waiting for 02:00. Useful for end-to-end smoke tests after an install or a model swap. |
26
+
27
+ ## How it works
28
+
29
+ ```
30
+ 1. Read insight files from ${VAULT_PATH}/Projects/ArkaOS/Dreams/*.md
31
+ 2. Parse plugin-compat frontmatter (type: arkaos-insight)
32
+ 3. Filter by --since window (default: today)
33
+ 4. Render compact list:
34
+ [date] [confidence] [title]
35
+ sources: [[file1]], [[file2]]
36
+ body: <first 200 chars>
37
+ 5. Wait for follow-up: promote / discard / open
38
+ ```
39
+
40
+ Insights are produced by `core/cognition/dreaming.py` running locally
41
+ on a schedule defined in `~/.arkaos/schedules.yaml` (default 02:00 user
42
+ time). The engine is backend-agnostic per the 2026-05-13 Conclave
43
+ Phase 4 correction — uses Claude Code by default, Ollama if the user
44
+ opts in via `cognitiveBackend: ollama` in `profile.json`.
45
+
46
+ ## Cost & budget
47
+
48
+ Dreaming runs **once per night**, costs depend on the configured
49
+ backend:
50
+
51
+ - **Claude Code backend (default):** consumes the user's existing
52
+ Claude session tokens (~3-10k tokens / night per 20 clusters).
53
+ - **Ollama backend (opt-in):** zero token cost, ~15-30 min compute
54
+ on the user's machine.
55
+ - **Anthropic API backend:** per-call billing per the user's API key.
56
+
57
+ `/arka costs` surfaces the spend if Dreaming used a metered backend.
58
+ Variable Reward driver is the surprise of *what* ArkaOS surfaces, not
59
+ the cost of *running* the engine.
60
+
61
+ ## Boundaries
62
+
63
+ - **Read-only over the vault.** This skill never writes to or deletes
64
+ vault notes. Dreaming v2 writes, this skill only reads.
65
+ - **Promote handoff stays explicit.** `/arka dreams promote <id>`
66
+ spawns a /pm backlog item — it does not act on the insight itself.
67
+ The user is always the decision-maker.
68
+ - **No auto-dismiss.** Even insights the user ignores stay in the
69
+ vault for future retrieval. Build the data moat.
70
+
71
+ ## Cross-references
72
+
73
+ - Engine: `core/cognition/dreaming.py` (PR8 v2.30.0)
74
+ - Backend abstraction: `core/runtime/llm_provider.py`
75
+ - Strategy: [[2026-05-13-arkaos-local-personal-agi]]
76
+ - Memory: [[project_arkaos_local_personal_agi]]
77
+ - Companion skill: `/arka research` (one-off research) — different
78
+ primitive, similar fan-out pattern
@@ -6,6 +6,12 @@
6
6
  schedules:
7
7
  dreaming:
8
8
  command: dreaming
9
+ # PR9 v2.31.0: Dreaming v2 is a Python engine (see core.cognition.dreaming).
10
+ # The legacy prompt_file path is kept as fallback for installs that
11
+ # still ship the older Claude-CLI-prompt dreaming task. python_module
12
+ # wins when both are present.
13
+ python_module: "core.cognition.dreaming"
14
+ module_args: ["run"]
9
15
  prompt_file: "~/.arkaos/cognition/prompts/dreaming.md"
10
16
  time: "02:00"
11
17
  timezone: auto
@@ -0,0 +1,368 @@
1
+ """Dreaming v2 — nightly cognitive consolidation that surfaces insights.
2
+
3
+ Runs (manually or on a schedule) over the user's recent vault notes and
4
+ session digests, groups related content into clusters, asks the
5
+ configured LLM (Claude Code by default, Ollama / Anthropic / OpenAI on
6
+ opt-in) for one observation per cluster, and applies a second LLM pass
7
+ that filters noise. Accepted insights are written to the Obsidian vault
8
+ in a plugin-compat shape that a future mobile reader can consume.
9
+
10
+ Backend-agnostic by design — completion goes through
11
+ ``core.runtime.llm_provider.get_llm_provider()``. The same engine works
12
+ with any registered provider; the user picks in ``profile.json``.
13
+
14
+ See PR8 v2.30.0 and the 2026-05-13 Conclave Phase 4 correction (multi-
15
+ backend, not Ollama-only) for the architectural rationale.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import re
23
+ from dataclasses import asdict, dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ from core.runtime.llm_provider import LLMResponse, LLMUnavailable, get_llm_provider
28
+ from core.runtime.path_resolver import ProfileMissingError, load_profile
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _DEFAULT_VAULT_LOOKBACK_DAYS = 7
33
+ _DEFAULT_MIN_CLUSTER_SIZE = 3
34
+ _DEFAULT_MAX_CLUSTERS = 12
35
+ _DEFAULT_MAX_INSIGHTS = 5
36
+ _MIN_CHUNK_CHARS = 80
37
+ _MAX_CHUNK_CHARS = 1200
38
+ _CRITIC_PASS_TOKEN = "VALUABLE"
39
+
40
+
41
+ @dataclass
42
+ class Insight:
43
+ """One accepted insight ready for vault write."""
44
+
45
+ title: str
46
+ body: str
47
+ confidence: str # "high" | "medium" | "low"
48
+ sources: list[str] = field(default_factory=list)
49
+ tags: list[str] = field(default_factory=list)
50
+
51
+ def to_frontmatter(self, date_str: str) -> dict:
52
+ return {
53
+ "type": "arkaos-insight",
54
+ "date": date_str,
55
+ "status": "surfaced",
56
+ "confidence": self.confidence,
57
+ "sources": [f"[[{s}]]" for s in self.sources],
58
+ "tags": ["arkaos-dream", *self.tags],
59
+ "plugin_compat_version": "1.0",
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class Chunk:
65
+ """A piece of source text fed into clustering."""
66
+
67
+ source_path: str
68
+ text: str
69
+ kind: str # "vault" | "session-digest" | "capture"
70
+
71
+
72
+ @dataclass
73
+ class Cluster:
74
+ """A group of related chunks."""
75
+
76
+ topic: str
77
+ chunks: list[Chunk] = field(default_factory=list)
78
+
79
+
80
+ class Dreaming:
81
+ """Engine that produces nightly insights from recent activity."""
82
+
83
+ def __init__(
84
+ self,
85
+ vault_path: Path,
86
+ output_dir: Path,
87
+ digest_dir: Path | None = None,
88
+ lookback_days: int = _DEFAULT_VAULT_LOOKBACK_DAYS,
89
+ max_insights: int = _DEFAULT_MAX_INSIGHTS,
90
+ provider=None,
91
+ ) -> None:
92
+ self._vault = Path(vault_path)
93
+ self._output_dir = Path(output_dir)
94
+ self._digest_dir = Path(digest_dir) if digest_dir else None
95
+ self._lookback_days = lookback_days
96
+ self._max_insights = max_insights
97
+ self._provider = provider or get_llm_provider()
98
+
99
+ @classmethod
100
+ def from_profile(cls, output_subpath: str = "Projects/ArkaOS/Dreams") -> "Dreaming":
101
+ """Construct a Dreaming engine from the user's profile.json."""
102
+ profile = load_profile()
103
+ vault = Path(profile.vault_path)
104
+ output = vault / output_subpath
105
+ digests = Path.home() / ".arkaos" / "session-digests"
106
+ return cls(vault_path=vault, output_dir=output, digest_dir=digests)
107
+
108
+ def run(self, dry_run: bool = False) -> list[Insight]:
109
+ """Execute one dreaming pass. Returns accepted insights."""
110
+ chunks = self._collect_chunks()
111
+ if not chunks:
112
+ logger.info("Dreaming: no chunks to process — quiet night")
113
+ return []
114
+
115
+ clusters = self._cluster(chunks)
116
+ if not clusters:
117
+ logger.info("Dreaming: no clusters formed — quiet night")
118
+ return []
119
+
120
+ accepted: list[Insight] = []
121
+ for cluster in clusters[: _DEFAULT_MAX_CLUSTERS]:
122
+ insight = self._draft_insight(cluster)
123
+ if insight is None:
124
+ continue
125
+ if not self._critic_accepts(insight):
126
+ continue
127
+ accepted.append(insight)
128
+ if len(accepted) >= self._max_insights:
129
+ break
130
+
131
+ if not dry_run:
132
+ for insight in accepted:
133
+ self._write_insight(insight)
134
+ return accepted
135
+
136
+ def _collect_chunks(self) -> list[Chunk]:
137
+ chunks: list[Chunk] = []
138
+ chunks.extend(self._collect_vault_chunks())
139
+ chunks.extend(self._collect_digest_chunks())
140
+ return chunks
141
+
142
+ def _collect_vault_chunks(self) -> list[Chunk]:
143
+ if not self._vault.is_dir():
144
+ return []
145
+ cutoff = datetime.now(timezone.utc).timestamp() - self._lookback_days * 86400
146
+ out: list[Chunk] = []
147
+ for md in self._vault.rglob("*.md"):
148
+ try:
149
+ if md.stat().st_mtime < cutoff:
150
+ continue
151
+ text = md.read_text(encoding="utf-8", errors="ignore")
152
+ except OSError:
153
+ continue
154
+ relative = str(md.relative_to(self._vault))
155
+ for piece in _split_for_clustering(text):
156
+ out.append(Chunk(source_path=relative, text=piece, kind="vault"))
157
+ return out
158
+
159
+ def _collect_digest_chunks(self) -> list[Chunk]:
160
+ if not self._digest_dir or not self._digest_dir.is_dir():
161
+ return []
162
+ cutoff = datetime.now(timezone.utc).timestamp() - self._lookback_days * 86400
163
+ out: list[Chunk] = []
164
+ for f in self._digest_dir.glob("*.md"):
165
+ try:
166
+ if f.stat().st_mtime < cutoff:
167
+ continue
168
+ text = f.read_text(encoding="utf-8", errors="ignore")
169
+ except OSError:
170
+ continue
171
+ for piece in _split_for_clustering(text):
172
+ out.append(Chunk(source_path=f.name, text=piece, kind="session-digest"))
173
+ return out
174
+
175
+ def _cluster(self, chunks: list[Chunk]) -> list[Cluster]:
176
+ """Lightweight clustering by shared CamelCase / path tokens.
177
+
178
+ Embedding-based clustering is the obvious upgrade but requires
179
+ fastembed to be installed and warmed. The token-overlap baseline
180
+ ships value today and the embedding path is a follow-up.
181
+ """
182
+ buckets: dict[str, list[Chunk]] = {}
183
+ for chunk in chunks:
184
+ for token in _extract_topic_tokens(chunk.text):
185
+ buckets.setdefault(token, []).append(chunk)
186
+ clusters: list[Cluster] = []
187
+ seen_keys: set[str] = set()
188
+ for topic, items in sorted(buckets.items(), key=lambda kv: -len(kv[1])):
189
+ if len(items) < _DEFAULT_MIN_CLUSTER_SIZE:
190
+ continue
191
+ key = "|".join(sorted({c.source_path for c in items}))
192
+ if key in seen_keys:
193
+ continue
194
+ seen_keys.add(key)
195
+ clusters.append(Cluster(topic=topic, chunks=items))
196
+ return clusters
197
+
198
+ def _draft_insight(self, cluster: Cluster) -> Insight | None:
199
+ prompt = _build_insight_prompt(cluster)
200
+ try:
201
+ response = self._provider.complete(prompt, max_tokens=400)
202
+ except LLMUnavailable as exc:
203
+ logger.warning("Dreaming: provider unavailable, skipping cluster (%s)", exc)
204
+ return None
205
+ return _parse_insight(response, cluster)
206
+
207
+ def _critic_accepts(self, insight: Insight) -> bool:
208
+ prompt = _build_critic_prompt(insight)
209
+ try:
210
+ response = self._provider.complete(prompt, max_tokens=20)
211
+ except LLMUnavailable:
212
+ return False # safer to reject than to publish unchecked
213
+ return _CRITIC_PASS_TOKEN in response.text.upper()
214
+
215
+ def _write_insight(self, insight: Insight) -> Path:
216
+ self._output_dir.mkdir(parents=True, exist_ok=True)
217
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
218
+ slug = _slugify(insight.title) or "insight"
219
+ path = self._output_dir / f"{date_str}-{slug}.md"
220
+ frontmatter = insight.to_frontmatter(date_str)
221
+ path.write_text(_render_markdown(frontmatter, insight), encoding="utf-8")
222
+ return path
223
+
224
+
225
+ def _split_for_clustering(text: str) -> list[str]:
226
+ pieces = re.split(r"\n\s*\n", text)
227
+ out: list[str] = []
228
+ for p in pieces:
229
+ p = p.strip()
230
+ if len(p) < _MIN_CHUNK_CHARS:
231
+ continue
232
+ out.append(p[:_MAX_CHUNK_CHARS])
233
+ return out
234
+
235
+
236
+ def _extract_topic_tokens(text: str) -> list[str]:
237
+ tokens = re.findall(r"\b([A-Z][a-zA-Z0-9_-]{3,})\b", text)
238
+ return list({t for t in tokens if t not in _STOP_TOPIC_TOKENS})[:8]
239
+
240
+
241
+ _STOP_TOPIC_TOKENS = frozenset({
242
+ "The", "This", "That", "When", "Where", "Then", "Note", "TODO", "FIXME",
243
+ "README", "ArkaOS", "Claude", "Read", "Write", "Edit", "Run",
244
+ })
245
+
246
+
247
+ def _build_insight_prompt(cluster: Cluster) -> str:
248
+ excerpts = []
249
+ sources = []
250
+ for c in cluster.chunks[:6]:
251
+ sources.append(c.source_path)
252
+ excerpts.append(f"[{c.source_path}]\n{c.text[:400]}\n")
253
+ src_lines = "\n".join(f"- {s}" for s in sorted(set(sources)))
254
+ return (
255
+ "You are reviewing the user's recent work. Several notes share a topic.\n\n"
256
+ f"Topic anchor: {cluster.topic}\n\n"
257
+ f"Sources:\n{src_lines}\n\n"
258
+ f"Excerpts:\n{''.join(excerpts)}\n"
259
+ "Return ONE concrete observation, pattern, or action the user might "
260
+ "want to consider. Two sentences maximum. If nothing is genuinely "
261
+ "surprising or actionable, return literally: PASS\n\n"
262
+ "Format your reply as:\n"
263
+ "TITLE: <very short title>\n"
264
+ "BODY: <two sentences>\n"
265
+ "CONFIDENCE: high | medium | low\n"
266
+ )
267
+
268
+
269
+ def _build_critic_prompt(insight: Insight) -> str:
270
+ return (
271
+ "Judge this insight as if it were going to land on the user's desk "
272
+ "tomorrow morning. Is it specific, actionable, and non-generic? "
273
+ "Or is it noise that would erode trust over time?\n\n"
274
+ f"Title: {insight.title}\n"
275
+ f"Body: {insight.body}\n\n"
276
+ "Reply with exactly one word: VALUABLE or NOISE."
277
+ )
278
+
279
+
280
+ def _parse_insight(response: LLMResponse, cluster: Cluster) -> Insight | None:
281
+ text = response.text.strip()
282
+ if not text or text.upper().startswith("PASS"):
283
+ return None
284
+ title = _extract_field(text, "TITLE") or _first_line(text)
285
+ body = _extract_field(text, "BODY") or text
286
+ confidence = _extract_field(text, "CONFIDENCE") or "medium"
287
+ confidence = confidence.lower()
288
+ if confidence not in {"high", "medium", "low"}:
289
+ confidence = "medium"
290
+ sources = sorted({c.source_path for c in cluster.chunks})[:6]
291
+ tags = [cluster.topic.lower()] if cluster.topic else []
292
+ return Insight(title=title.strip(), body=body.strip(), confidence=confidence,
293
+ sources=sources, tags=tags)
294
+
295
+
296
+ def _extract_field(text: str, field_name: str) -> str | None:
297
+ pattern = re.compile(
298
+ rf"^{re.escape(field_name)}\s*:\s*(.+?)$", re.IGNORECASE | re.MULTILINE
299
+ )
300
+ match = pattern.search(text)
301
+ if match:
302
+ return match.group(1).strip()
303
+ return None
304
+
305
+
306
+ def _first_line(text: str) -> str:
307
+ for line in text.splitlines():
308
+ line = line.strip()
309
+ if line:
310
+ return line[:80]
311
+ return "Insight"
312
+
313
+
314
+ def _slugify(title: str) -> str:
315
+ slug = re.sub(r"[^a-zA-Z0-9-]+", "-", title.lower()).strip("-")
316
+ return slug[:60]
317
+
318
+
319
+ def _render_markdown(frontmatter: dict, insight: Insight) -> str:
320
+ lines = ["---"]
321
+ for key, value in frontmatter.items():
322
+ if isinstance(value, list):
323
+ lines.append(f"{key}:")
324
+ for v in value:
325
+ lines.append(f" - {v}")
326
+ else:
327
+ lines.append(f"{key}: {value}")
328
+ lines.append("---")
329
+ lines.append("")
330
+ lines.append(f"# {insight.title}")
331
+ lines.append("")
332
+ lines.append("## What I noticed")
333
+ lines.append(insight.body)
334
+ if insight.sources:
335
+ lines.append("")
336
+ lines.append("## Sources")
337
+ for s in insight.sources:
338
+ lines.append(f"- [[{s}]]")
339
+ return "\n".join(lines) + "\n"
340
+
341
+
342
+ # ─── CLI ──────────────────────────────────────────────────────────────
343
+
344
+
345
+ def main(argv: list[str]) -> int:
346
+ import argparse
347
+
348
+ parser = argparse.ArgumentParser(description=__doc__)
349
+ parser.add_argument("command", choices=["run"], help="action to perform")
350
+ parser.add_argument("--dry-run", action="store_true", help="cluster + draft without writing")
351
+ args = parser.parse_args(argv)
352
+
353
+ try:
354
+ engine = Dreaming.from_profile()
355
+ except ProfileMissingError as exc:
356
+ print(f"Cannot start Dreaming: {exc}")
357
+ return 2
358
+
359
+ insights = engine.run(dry_run=args.dry_run)
360
+ print(f"Dreaming produced {len(insights)} insight(s).")
361
+ for i, insight in enumerate(insights, start=1):
362
+ print(f"{i}. ({insight.confidence}) {insight.title}")
363
+ return 0
364
+
365
+
366
+ if __name__ == "__main__":
367
+ import sys
368
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,141 @@
1
+ """Read Dreaming v2 insights from the Obsidian vault.
2
+
3
+ Surface used by the ``/arka dreams`` skill. Parses the plugin-compat
4
+ frontmatter shape written by ``core.cognition.dreaming`` and groups
5
+ results by date so the CLI can show today, since-N-days, or all.
6
+
7
+ Read-only; never modifies vault content.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+
17
+
18
+ @dataclass
19
+ class StoredInsight:
20
+ """An insight surfaced by Dreaming v2 and parsed back from disk."""
21
+
22
+ path: Path
23
+ date: str # YYYY-MM-DD
24
+ title: str
25
+ confidence: str
26
+ sources: list[str] = field(default_factory=list)
27
+ tags: list[str] = field(default_factory=list)
28
+ body: str = ""
29
+
30
+
31
+ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)", re.DOTALL)
32
+
33
+
34
+ def list_insights(dreams_dir: Path, since_days: int = 1) -> list[StoredInsight]:
35
+ """List insights from *dreams_dir* whose date is within *since_days*.
36
+
37
+ ``since_days=1`` is "today only" (UTC-day boundary, generous tolerance
38
+ of one calendar day so a late-night dream surfaced after midnight
39
+ still counts). ``since_days=7`` returns last week, etc.
40
+ """
41
+ dreams_dir = Path(dreams_dir)
42
+ if not dreams_dir.is_dir():
43
+ return []
44
+ cutoff = datetime.now(timezone.utc).date() - timedelta(days=max(since_days - 1, 0))
45
+ out: list[StoredInsight] = []
46
+ for md in sorted(dreams_dir.glob("*.md"), reverse=True):
47
+ try:
48
+ text = md.read_text(encoding="utf-8")
49
+ except OSError:
50
+ continue
51
+ insight = parse_insight(md, text)
52
+ if insight is None:
53
+ continue
54
+ try:
55
+ insight_date = datetime.strptime(insight.date, "%Y-%m-%d").date()
56
+ except ValueError:
57
+ continue
58
+ if insight_date >= cutoff:
59
+ out.append(insight)
60
+ return out
61
+
62
+
63
+ def parse_insight(path: Path, text: str) -> StoredInsight | None:
64
+ """Parse one Dreaming v2 markdown file into a StoredInsight."""
65
+ match = _FRONTMATTER_RE.match(text)
66
+ if not match:
67
+ return None
68
+ fm_block, body = match.group(1), match.group(2)
69
+ fm = _parse_frontmatter(fm_block)
70
+ if fm.get("type") != "arkaos-insight":
71
+ return None
72
+ title = _extract_h1(body) or path.stem
73
+ return StoredInsight(
74
+ path=path,
75
+ date=str(fm.get("date", "")),
76
+ title=title,
77
+ confidence=str(fm.get("confidence", "medium")),
78
+ sources=_parse_list(fm.get("sources")),
79
+ tags=_parse_list(fm.get("tags")),
80
+ body=_extract_body(body),
81
+ )
82
+
83
+
84
+ def _parse_frontmatter(block: str) -> dict:
85
+ """Tiny YAML-ish parser. Frontmatter shape is constrained by Dreaming v2
86
+ output (`core/cognition/dreaming.py:_render_markdown`) so a full YAML
87
+ dependency is not warranted here.
88
+ """
89
+ out: dict[str, object] = {}
90
+ current_key: str | None = None
91
+ current_list: list[str] = []
92
+ for raw_line in block.splitlines():
93
+ if not raw_line.strip():
94
+ continue
95
+ if raw_line.startswith(" - "):
96
+ current_list.append(raw_line[4:].strip())
97
+ continue
98
+ if current_key is not None and current_list:
99
+ out[current_key] = list(current_list)
100
+ current_list = []
101
+ current_key = None
102
+ if ":" not in raw_line:
103
+ continue
104
+ key, _, value = raw_line.partition(":")
105
+ key = key.strip()
106
+ value = value.strip()
107
+ if value == "":
108
+ current_key = key
109
+ continue
110
+ out[key] = value
111
+ if current_key is not None and current_list:
112
+ out[current_key] = list(current_list)
113
+ return out
114
+
115
+
116
+ def _parse_list(value: object) -> list[str]:
117
+ if isinstance(value, list):
118
+ return [str(v) for v in value]
119
+ if isinstance(value, str) and value:
120
+ return [value]
121
+ return []
122
+
123
+
124
+ def _extract_h1(body: str) -> str | None:
125
+ for line in body.splitlines():
126
+ if line.startswith("# "):
127
+ return line[2:].strip()
128
+ return None
129
+
130
+
131
+ def _extract_body(body: str) -> str:
132
+ """Return the prose under '## What I noticed' if present, else the body."""
133
+ marker = "## What I noticed"
134
+ idx = body.find(marker)
135
+ if idx < 0:
136
+ return body.strip()
137
+ after = body[idx + len(marker):]
138
+ next_section = after.find("\n## ")
139
+ if next_section < 0:
140
+ return after.strip()
141
+ return after[:next_section].strip()
@@ -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
package/installer/cli.js CHANGED
@@ -20,6 +20,7 @@ const { values, positionals } = parseArgs({
20
20
  path: { type: "string", short: "p" },
21
21
  force: { type: "boolean", short: "f" },
22
22
  "no-system": { type: "boolean" },
23
+ "with-ollama": { type: "boolean" },
23
24
  },
24
25
  allowPositionals: true,
25
26
  strict: false,
@@ -75,7 +76,13 @@ async function main() {
75
76
  switch (command) {
76
77
  case "install":
77
78
  const runtime = values.runtime || await detectRuntime();
78
- await install({ runtime, path: values.path, force: values.force, skipSystem: values["no-system"] });
79
+ await install({
80
+ runtime,
81
+ path: values.path,
82
+ force: values.force,
83
+ skipSystem: values["no-system"],
84
+ withOllama: values["with-ollama"],
85
+ });
79
86
  break;
80
87
 
81
88
  case "init": {
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { execSync } from "node:child_process";
5
5
  import { getArkaosPython, getVenvPython, canImportCore, getRepoRoot } from "./python-resolver.js";
6
6
  import { IS_WINDOWS, HOOK_EXT, CMD_FINDER } from "./platform.js";
7
- import { checkNode, checkObsidian } from "./system-tools.js";
7
+ import { checkNode, checkObsidian, checkOllama } from "./system-tools.js";
8
8
 
9
9
  const INSTALL_DIR = join(homedir(), ".arkaos");
10
10
 
@@ -147,6 +147,18 @@ const checks = [
147
147
  return `Install Node.js 20+ from ${s.fallbackUrl || "https://nodejs.org/en/download"}`;
148
148
  },
149
149
  },
150
+ {
151
+ name: "ollama",
152
+ description: "Ollama present (optional — cognitive layer LLM runtime)",
153
+ severity: "warn",
154
+ check: () => checkOllama().installed,
155
+ fix: () => {
156
+ const s = checkOllama();
157
+ if (s.needsAction === "start") return `Run: ${s.suggestedCommand}`;
158
+ if (s.suggestedCommand) return `Run: ${s.suggestedCommand}`;
159
+ return `Install Ollama from ${s.fallbackUrl || "https://ollama.com/download"}`;
160
+ },
161
+ },
150
162
  {
151
163
  name: "claude-code-version",
152
164
  description: "Claude Code 2.1.122+ (ToolSearch late-binding + hooks isolation)",
@@ -12,7 +12,7 @@ const __dirname = dirname(__filename);
12
12
  const ARKAOS_ROOT = resolve(__dirname, "..");
13
13
  const VERSION = JSON.parse(readFileSync(join(ARKAOS_ROOT, "package.json"), "utf-8")).version;
14
14
 
15
- export async function install({ runtime, path, force, skipSystem }) {
15
+ export async function install({ runtime, path, force, skipSystem, withOllama }) {
16
16
  const startTime = Date.now();
17
17
  const config = getRuntimeConfig(runtime);
18
18
  const isUpgrade = existsSync(join(path || join(homedir(), ".arkaos"), "install-manifest.json"));
@@ -80,16 +80,19 @@ export async function install({ runtime, path, force, skipSystem }) {
80
80
  try {
81
81
  const { ensureSystemTools } = await import("./system-tools.js");
82
82
  const { formatSudoInstructions } = await import("./package-manager.js");
83
- const sys = ensureSystemTools({ skipSystem: false });
83
+ const sys = ensureSystemTools({ skipSystem: false, withOllama });
84
84
  if (sys.sudoCommands && sys.sudoCommands.length > 0) {
85
85
  console.log(formatSudoInstructions(sys.sudoCommands));
86
86
  }
87
- for (const tool of [sys.obsidian, sys.node]) {
87
+ for (const tool of [sys.obsidian, sys.node, sys.ollama]) {
88
88
  if (!tool) continue;
89
89
  if (tool.justInstalled) ok(`${tool.name} installed`);
90
90
  else if (tool.needsAction === "none") ok(`${tool.name} ready`);
91
91
  else warn(`${tool.name} ${tool.needsAction} — see commands above`);
92
92
  }
93
+ if (withOllama && sys.ollama?.needsAction === "none") {
94
+ ok("Ollama backend ready — cognitive layer can use local LLM inference");
95
+ }
93
96
  } catch (err) {
94
97
  warn(`System tool check failed: ${err.message}. Continuing without it.`);
95
98
  }
@@ -41,13 +41,27 @@ const NODE_PACKAGE = {
41
41
  choco: "nodejs",
42
42
  };
43
43
 
44
+ const OLLAMA_PACKAGE = {
45
+ brew: "ollama",
46
+ winget: "Ollama.Ollama",
47
+ choco: "ollama",
48
+ };
49
+
44
50
  const OBSIDIAN_FALLBACK_URL = "https://obsidian.md/download";
45
51
  const NODE_FALLBACK_URL = "https://nodejs.org/en/download";
46
52
  const PYTHON_FALLBACK_URL = "https://www.python.org/downloads/";
53
+ const OLLAMA_FALLBACK_URL = "https://ollama.com/download";
47
54
 
48
55
  /**
49
56
  * Validate every required tool, install what can be installed without
50
57
  * sudo, and collect copy-paste commands for the ones that can't.
58
+ *
59
+ * When ``options.withOllama`` is set, Ollama is included in the check
60
+ * + install set as one possible **backend** for the cognitive layer.
61
+ * Opt-in: most users keep the Claude Code backend (default) and never
62
+ * need a local LLM runtime. Multi-backend selection lives in
63
+ * ``profile.json:cognitiveBackend``; this flag only governs the
64
+ * Ollama prerequisite at install time.
51
65
  */
52
66
  export function ensureSystemTools(options = {}) {
53
67
  if (options.skipSystem) {
@@ -56,6 +70,7 @@ export function ensureSystemTools(options = {}) {
56
70
  obsidian: null,
57
71
  node: null,
58
72
  python: null,
73
+ ollama: null,
59
74
  sudoCommands: [],
60
75
  };
61
76
  }
@@ -64,7 +79,13 @@ export function ensureSystemTools(options = {}) {
64
79
  const node = ensureTool("node", checkNode, NODE_PACKAGE, NODE_FALLBACK_URL, options);
65
80
  const python = checkPython(); // never auto-install Python — leave to OS
66
81
 
67
- const sudoCommands = [obsidian, node]
82
+ let ollama = null;
83
+ if (options.withOllama) {
84
+ ollama = ensureTool("ollama", checkOllama, OLLAMA_PACKAGE, OLLAMA_FALLBACK_URL, options);
85
+ }
86
+
87
+ const tools = [obsidian, node, ollama].filter(Boolean);
88
+ const sudoCommands = tools
68
89
  .filter((t) => t?.needsSudo && t.suggestedCommand)
69
90
  .map((t) => t.suggestedCommand);
70
91
 
@@ -72,7 +93,7 @@ export function ensureSystemTools(options = {}) {
72
93
  sudoCommands.push(python.suggestedCommand);
73
94
  }
74
95
 
75
- return { skipped: false, obsidian, node, python, sudoCommands };
96
+ return { skipped: false, obsidian, node, python, ollama, sudoCommands };
76
97
  }
77
98
 
78
99
  /**
@@ -131,6 +152,40 @@ export function checkNode() {
131
152
  };
132
153
  }
133
154
 
155
+ /**
156
+ * Detect Ollama presence + whether the local service responds.
157
+ *
158
+ * Ollama is the cognitive-layer LLM runtime. Linux install uses an
159
+ * official script (``curl https://ollama.com/install.sh | sh``) that
160
+ * requires sudo — we never run it; we surface the command.
161
+ */
162
+ export function checkOllama() {
163
+ const location = findBinary("ollama");
164
+ if (!location) {
165
+ const suggested = buildOllamaSuggestion();
166
+ return {
167
+ name: "ollama",
168
+ installed: false,
169
+ needsAction: "install",
170
+ suggestedCommand: suggested.command,
171
+ needsSudo: suggested.needsSudo,
172
+ fallbackUrl: OLLAMA_FALLBACK_URL,
173
+ };
174
+ }
175
+ const reachable = isOllamaReachable();
176
+ const version = readVersion("ollama --version");
177
+ return {
178
+ name: "ollama",
179
+ installed: true,
180
+ location,
181
+ version,
182
+ needsAction: reachable ? "none" : "start",
183
+ suggestedCommand: reachable ? undefined : "ollama serve # or run the Ollama app",
184
+ needsSudo: false,
185
+ };
186
+ }
187
+
188
+
134
189
  export function checkPython() {
135
190
  const candidates = IS_WINDOWS ? ["python", "py"] : ["python3", "python3.12", "python3.13"];
136
191
  for (const cmd of candidates) {
@@ -195,6 +250,28 @@ function buildSuggestedInstall(packageMap, fallbackUrl) {
195
250
  return { command: `Download from ${fallbackUrl}`, needsSudo: false, fallbackUrl };
196
251
  }
197
252
 
253
+ function buildOllamaSuggestion() {
254
+ const os = platform();
255
+ if (os === "darwin") return { command: "brew install ollama", needsSudo: false };
256
+ if (IS_WINDOWS) return { command: "winget install --id Ollama.Ollama --silent --accept-source-agreements --accept-package-agreements", needsSudo: false };
257
+ if (os === "linux") return { command: "curl -fsSL https://ollama.com/install.sh | sh", needsSudo: true };
258
+ return { command: `Download from ${OLLAMA_FALLBACK_URL}`, needsSudo: false };
259
+ }
260
+
261
+
262
+ function isOllamaReachable() {
263
+ try {
264
+ execSync("ollama list", {
265
+ stdio: ["ignore", "ignore", "ignore"],
266
+ timeout: 1500,
267
+ });
268
+ return true;
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+
198
275
  function findBinary(name) {
199
276
  try {
200
277
  const out = execSync(`${CMD_FINDER} ${name}`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.28.0",
3
+ "version": "2.31.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.28.0"
3
+ version = "2.31.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}