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
package/core/agents/schema.py
CHANGED
|
@@ -273,6 +273,35 @@ class Agent(BaseModel):
|
|
|
273
273
|
description="Claude model override for dispatch. Falls back to tier default when None.",
|
|
274
274
|
)
|
|
275
275
|
|
|
276
|
+
parent_squad: Optional[str] = Field(
|
|
277
|
+
default=None,
|
|
278
|
+
description=(
|
|
279
|
+
"Slug of the parent squad when this agent belongs to a sub-squad. "
|
|
280
|
+
"Sub-squads are operational specialisations inside a department "
|
|
281
|
+
"(e.g. brand → design-ops). Optional and backwards-compatible: "
|
|
282
|
+
"agents without a parent_squad belong directly to their department."
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
sub_squad_role: Optional[str] = Field(
|
|
287
|
+
default=None,
|
|
288
|
+
description=(
|
|
289
|
+
"Role within the sub-squad when parent_squad is set "
|
|
290
|
+
"(e.g. 'lead', 'extraction-script-writer', 'wcag-auditor'). "
|
|
291
|
+
"Inspired by the AIOX Squad → Sub-Squad pattern (KB note "
|
|
292
|
+
"AIOX Squads, April 30 2026 live)."
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@model_validator(mode="after")
|
|
297
|
+
def validate_sub_squad_coherence(self) -> "Agent":
|
|
298
|
+
"""sub_squad_role only makes sense when parent_squad is set."""
|
|
299
|
+
if self.sub_squad_role and not self.parent_squad:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
"sub_squad_role requires parent_squad to be set"
|
|
302
|
+
)
|
|
303
|
+
return self
|
|
304
|
+
|
|
276
305
|
@model_validator(mode="after")
|
|
277
306
|
def auto_fill_memory_path(self) -> "Agent":
|
|
278
307
|
if not self.memory_path:
|
|
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()
|