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 +1 -1
- package/arka/skills/dreams/SKILL.md +78 -0
- package/config/cognition/schedules.yaml +6 -0
- package/core/agents/__pycache__/loader.cpython-313.pyc +0 -0
- package/core/agents/__pycache__/schema.cpython-313.pyc +0 -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/dreaming.py +368 -0
- package/core/cognition/dreams_reader.py +141 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +23 -3
- 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/llm_provider.py +8 -1
- package/core/runtime/ollama_provider.py +144 -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
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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()
|
|
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
|
|
@@ -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({
|
|
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": {
|
package/installer/doctor.js
CHANGED
|
@@ -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)",
|
package/installer/index.js
CHANGED
|
@@ -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
|
-
|
|
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