arkaos 2.22.1 → 2.28.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/README.md +105 -9
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -0
- package/arka/skills/bootstrap-agent/SKILL.md +76 -0
- package/arka/skills/comfyui/SKILL.md +2 -2
- package/arka/skills/comfyui/references/workflows.md +6 -6
- package/arka/skills/costs/SKILL.md +11 -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/research/SKILL.md +117 -0
- package/config/cognition/prompts/dreaming.md +3 -3
- package/config/cognition/prompts/research.md +4 -4
- package/config/constitution.yaml +4 -0
- package/config/hooks/post-tool-use.sh +17 -0
- package/config/hooks/user-prompt-submit.sh +22 -1
- 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__/auto_documentor.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/capture/collector.py +10 -3
- package/core/cognition/retrieval.py +383 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/daemon.py +5 -0
- package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
- package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/obsidian/writer.py +5 -4
- package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/path_resolver.cpython-313.pyc +0 -0
- package/core/runtime/path_resolver.py +202 -0
- package/core/shared/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
- package/core/specs/SPEC-installer-cross-os.md +227 -0
- package/core/specs/SPEC-paths-portability.md +209 -0
- package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -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/departments/dev/skills/scaffold/SKILL.md +4 -4
- package/departments/kb/skills/knowledge/SKILL.md +1 -1
- package/departments/ops/skills/operations/SKILL.md +1 -1
- package/installer/cli.js +2 -1
- package/installer/doctor.js +48 -0
- package/installer/index.js +26 -1
- package/installer/migrate-user-data.js +17 -1
- package/installer/migrations/v3_path_schema.js +109 -0
- package/installer/package-manager.js +191 -0
- package/installer/path-resolver.js +124 -0
- package/installer/system-tools.js +243 -0
- package/installer/update.js +24 -3
- package/knowledge/obsidian-config.json +1 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Hooks-as-retrieval prototype for the ArkaOS Cognitive Layer.
|
|
2
|
+
|
|
3
|
+
After each tool call, the PostToolUse hook calls into this module to
|
|
4
|
+
extract likely entities from the tool output, find relevant notes in the
|
|
5
|
+
Obsidian vault, and write a small JSON cache that the UserPromptSubmit
|
|
6
|
+
hook injects into the next turn as an ``[arka:context]`` advisory.
|
|
7
|
+
|
|
8
|
+
This is the prototype unblocked by Claude Code 2.1.122 (late-binding
|
|
9
|
+
``ToolSearch`` + hook-block isolation, see KB note
|
|
10
|
+
2026-04-29-claude-code-2-1-122-and-2-1-123). It uses filesystem grep
|
|
11
|
+
(ripgrep when available, Python fallback otherwise) instead of running
|
|
12
|
+
a separate MCP server with embeddings — a deliberate "smallest thing
|
|
13
|
+
that could possibly work" choice per the 2026-05-13 Conclave ADR.
|
|
14
|
+
|
|
15
|
+
Performance budget: ≤ 800 ms p95 per ``capture_context`` call. The
|
|
16
|
+
PostToolUse hook enforces this with a hard timeout; the function itself
|
|
17
|
+
caps entities (20), vault hits (5), and ripgrep wall time (1 s).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
from dataclasses import asdict, dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from core.runtime.user_paths import user_data_root
|
|
31
|
+
|
|
32
|
+
_CACHE_DIRNAME = "context-cache"
|
|
33
|
+
_DEFAULT_TTL_SECONDS = 600
|
|
34
|
+
_REAPER_MAX_AGE_SECONDS = 24 * 60 * 60 # cache files older than 24h get pruned
|
|
35
|
+
_MAX_ENTITIES = 20
|
|
36
|
+
_MAX_VAULT_HITS = 5
|
|
37
|
+
_RIPGREP_TIMEOUT_S = 1.0
|
|
38
|
+
_PY_FALLBACK_MAX_FILES = 500
|
|
39
|
+
|
|
40
|
+
# Common-word stoplist — kept tiny on purpose; we want to capture short
|
|
41
|
+
# domain terms like "auth" or "DSL" but cut out the highest-frequency
|
|
42
|
+
# English filler tokens that dominate any tool output.
|
|
43
|
+
_STOPLIST = frozenset(
|
|
44
|
+
{
|
|
45
|
+
"The", "And", "But", "For", "Not", "With", "From", "This", "That",
|
|
46
|
+
"When", "Where", "Then", "Else", "True", "False", "None", "Null",
|
|
47
|
+
"Error", "Warning", "Info", "Debug", "Trace", "Yes", "No", "Run",
|
|
48
|
+
"Read", "Write", "Edit", "Delete", "Show", "Find", "List",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_FILE_PATH = re.compile(r"(?:^|\s)((?:[\w.-]+/)+[\w.-]+\.[a-zA-Z]{1,6})")
|
|
53
|
+
_CAMEL_OR_PASCAL = re.compile(r"\b([A-Z][a-z]+(?:[A-Z][a-z]+)+)\b")
|
|
54
|
+
_AT_MENTION = re.compile(r"@([\w./_-]+)")
|
|
55
|
+
_PROPER_NOUN = re.compile(r"\b([A-Z][a-zA-Z]{2,})\b")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ContextHit:
|
|
60
|
+
"""One vault note that matches one or more extracted entities."""
|
|
61
|
+
|
|
62
|
+
entity: str
|
|
63
|
+
vault_path: str
|
|
64
|
+
snippet: str
|
|
65
|
+
score: float = 1.0
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> dict:
|
|
68
|
+
return asdict(self)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class ContextCache:
|
|
73
|
+
"""The JSON payload persisted per session."""
|
|
74
|
+
|
|
75
|
+
session_id: str
|
|
76
|
+
captured_at: str
|
|
77
|
+
ttl_seconds: int
|
|
78
|
+
hits: list[ContextHit] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"session_id": self.session_id,
|
|
83
|
+
"captured_at": self.captured_at,
|
|
84
|
+
"ttl_seconds": self.ttl_seconds,
|
|
85
|
+
"hits": [h.to_dict() for h in self.hits],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_entities(text: str) -> list[str]:
|
|
90
|
+
"""Pull file paths, identifiers, and proper nouns out of *text*.
|
|
91
|
+
|
|
92
|
+
Returns a deduplicated, capped list ready for vault search. Cheap
|
|
93
|
+
enough to call on every PostToolUse invocation.
|
|
94
|
+
"""
|
|
95
|
+
if not text:
|
|
96
|
+
return []
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
out: list[str] = []
|
|
99
|
+
|
|
100
|
+
def push(tok: str) -> None:
|
|
101
|
+
tok = tok.strip(" .,;:!?\"'()[]{}")
|
|
102
|
+
if not tok or tok in _STOPLIST or tok in seen:
|
|
103
|
+
return
|
|
104
|
+
if tok.isdigit():
|
|
105
|
+
return
|
|
106
|
+
seen.add(tok)
|
|
107
|
+
out.append(tok)
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
for m in _FILE_PATH.finditer(text):
|
|
111
|
+
push(m.group(1))
|
|
112
|
+
for m in _AT_MENTION.finditer(text):
|
|
113
|
+
push(m.group(1))
|
|
114
|
+
for m in _CAMEL_OR_PASCAL.finditer(text):
|
|
115
|
+
push(m.group(1))
|
|
116
|
+
for m in _PROPER_NOUN.finditer(text):
|
|
117
|
+
if len(out) >= _MAX_ENTITIES:
|
|
118
|
+
break
|
|
119
|
+
push(m.group(1))
|
|
120
|
+
return out[:_MAX_ENTITIES]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def search_vault(entities: list[str], vault_path: str) -> list[ContextHit]:
|
|
124
|
+
"""Find Obsidian notes that mention any of the extracted entities.
|
|
125
|
+
|
|
126
|
+
Uses ripgrep when available (sub-second across thousands of notes),
|
|
127
|
+
falls back to a pure-Python scan capped at ``_PY_FALLBACK_MAX_FILES``
|
|
128
|
+
files so the hook timeout stays safe on Windows or minimal Linux
|
|
129
|
+
installs without ripgrep.
|
|
130
|
+
"""
|
|
131
|
+
vault = Path(vault_path).expanduser()
|
|
132
|
+
if not entities or not vault.is_dir():
|
|
133
|
+
return []
|
|
134
|
+
if _ripgrep_available():
|
|
135
|
+
hits = _search_ripgrep(entities, vault)
|
|
136
|
+
else:
|
|
137
|
+
hits = _search_python(entities, vault)
|
|
138
|
+
return hits[:_MAX_VAULT_HITS]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def capture_context(
|
|
142
|
+
session_id: str,
|
|
143
|
+
tool_output: str,
|
|
144
|
+
vault_path: str,
|
|
145
|
+
cache_dir: Path | None = None,
|
|
146
|
+
) -> ContextCache:
|
|
147
|
+
"""Run extract + search and persist the cache. Returns the cache.
|
|
148
|
+
|
|
149
|
+
Idempotent across overlapping calls in the same second: a second
|
|
150
|
+
invocation overwrites the first. Empty hits still produce a file
|
|
151
|
+
so the UserPromptSubmit reader sees a fresh ``captured_at`` and
|
|
152
|
+
doesn't mistake a quiet turn for stale data.
|
|
153
|
+
"""
|
|
154
|
+
entities = extract_entities(tool_output)
|
|
155
|
+
hits = search_vault(entities, vault_path) if entities else []
|
|
156
|
+
cache = ContextCache(
|
|
157
|
+
session_id=session_id,
|
|
158
|
+
captured_at=datetime.now(timezone.utc).isoformat(),
|
|
159
|
+
ttl_seconds=_DEFAULT_TTL_SECONDS,
|
|
160
|
+
hits=hits,
|
|
161
|
+
)
|
|
162
|
+
_write_cache(cache, cache_dir or _default_cache_dir())
|
|
163
|
+
return cache
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def read_context(
|
|
167
|
+
session_id: str,
|
|
168
|
+
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
|
169
|
+
cache_dir: Path | None = None,
|
|
170
|
+
) -> list[ContextHit]:
|
|
171
|
+
"""Read cached hits for *session_id* if still within TTL."""
|
|
172
|
+
path = (cache_dir or _default_cache_dir()) / f"{session_id}.json"
|
|
173
|
+
if not path.exists():
|
|
174
|
+
return []
|
|
175
|
+
try:
|
|
176
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
177
|
+
except (json.JSONDecodeError, OSError):
|
|
178
|
+
return []
|
|
179
|
+
captured = data.get("captured_at", "")
|
|
180
|
+
if not _within_ttl(captured, ttl_seconds):
|
|
181
|
+
return []
|
|
182
|
+
return [ContextHit(**h) for h in data.get("hits", [])]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def format_advisory(hits: list[ContextHit], max_chars: int = 1200) -> str:
|
|
186
|
+
"""Render hits into a compact `[arka:context]` advisory string."""
|
|
187
|
+
if not hits:
|
|
188
|
+
return ""
|
|
189
|
+
lines = ["[arka:context] KB matches from last turn:"]
|
|
190
|
+
for h in hits:
|
|
191
|
+
line = f"- {h.entity} → {h.vault_path}: {h.snippet[:160]}"
|
|
192
|
+
if sum(len(l) for l in lines) + len(line) > max_chars:
|
|
193
|
+
break
|
|
194
|
+
lines.append(line)
|
|
195
|
+
return "\n".join(lines)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _default_cache_dir() -> Path:
|
|
199
|
+
return user_data_root() / _CACHE_DIRNAME
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _write_cache(cache: ContextCache, cache_dir: Path) -> None:
|
|
203
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
path = cache_dir / f"{cache.session_id}.json"
|
|
205
|
+
tmp = cache_dir / f"{cache.session_id}.json.tmp"
|
|
206
|
+
tmp.write_text(json.dumps(cache.to_dict(), indent=2), encoding="utf-8")
|
|
207
|
+
os.replace(tmp, path)
|
|
208
|
+
_prune_stale_cache(cache_dir)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _prune_stale_cache(cache_dir: Path) -> int:
|
|
212
|
+
"""Delete cache files older than 24h to bound disk growth.
|
|
213
|
+
|
|
214
|
+
Called from ``_write_cache`` so cleanup amortises across normal use
|
|
215
|
+
without requiring a separate cron job. Per Marta's PR4 follow-up
|
|
216
|
+
observation: the read-side TTL hides stale data but on-disk files
|
|
217
|
+
accumulate one-per-session forever. Returns the count of pruned files.
|
|
218
|
+
"""
|
|
219
|
+
if not cache_dir.is_dir():
|
|
220
|
+
return 0
|
|
221
|
+
cutoff = datetime.now(timezone.utc).timestamp() - _REAPER_MAX_AGE_SECONDS
|
|
222
|
+
pruned = 0
|
|
223
|
+
for child in cache_dir.glob("*.json"):
|
|
224
|
+
try:
|
|
225
|
+
if child.stat().st_mtime < cutoff:
|
|
226
|
+
child.unlink()
|
|
227
|
+
pruned += 1
|
|
228
|
+
except OSError:
|
|
229
|
+
continue
|
|
230
|
+
return pruned
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _within_ttl(captured_iso: str, ttl_seconds: int) -> bool:
|
|
234
|
+
try:
|
|
235
|
+
captured = datetime.fromisoformat(captured_iso)
|
|
236
|
+
except ValueError:
|
|
237
|
+
return False
|
|
238
|
+
now = datetime.now(timezone.utc)
|
|
239
|
+
if captured.tzinfo is None:
|
|
240
|
+
captured = captured.replace(tzinfo=timezone.utc)
|
|
241
|
+
return (now - captured).total_seconds() <= ttl_seconds
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ripgrep_available() -> bool:
|
|
245
|
+
try:
|
|
246
|
+
subprocess.run(
|
|
247
|
+
["rg", "--version"], check=True, capture_output=True, timeout=1.0
|
|
248
|
+
)
|
|
249
|
+
return True
|
|
250
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _search_ripgrep(entities: list[str], vault: Path) -> list[ContextHit]:
|
|
255
|
+
pattern = "|".join(re.escape(e) for e in entities)
|
|
256
|
+
cmd = [
|
|
257
|
+
"rg", "--json", "--max-count", "1", "--smart-case",
|
|
258
|
+
"-tmd", "-e", pattern, str(vault),
|
|
259
|
+
]
|
|
260
|
+
try:
|
|
261
|
+
result = subprocess.run(
|
|
262
|
+
cmd, capture_output=True, text=True, timeout=_RIPGREP_TIMEOUT_S
|
|
263
|
+
)
|
|
264
|
+
except subprocess.SubprocessError:
|
|
265
|
+
return []
|
|
266
|
+
return _parse_ripgrep_json(result.stdout, vault, entities)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _parse_ripgrep_json(stdout: str, vault: Path, entities: list[str]) -> list[ContextHit]:
|
|
270
|
+
hits: list[ContextHit] = []
|
|
271
|
+
seen: set[str] = set()
|
|
272
|
+
for line in stdout.splitlines():
|
|
273
|
+
try:
|
|
274
|
+
event = json.loads(line)
|
|
275
|
+
except json.JSONDecodeError:
|
|
276
|
+
continue
|
|
277
|
+
if event.get("type") != "match":
|
|
278
|
+
continue
|
|
279
|
+
data = event.get("data", {})
|
|
280
|
+
path = data.get("path", {}).get("text", "")
|
|
281
|
+
if not path or path in seen:
|
|
282
|
+
continue
|
|
283
|
+
seen.add(path)
|
|
284
|
+
text = data.get("lines", {}).get("text", "").strip()
|
|
285
|
+
matched_entity = _pick_matched_entity(text, entities)
|
|
286
|
+
rel = _relative_to_vault(path, vault)
|
|
287
|
+
hits.append(ContextHit(entity=matched_entity, vault_path=rel, snippet=text))
|
|
288
|
+
return hits
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _search_python(entities: list[str], vault: Path) -> list[ContextHit]:
|
|
292
|
+
pattern = re.compile("|".join(re.escape(e) for e in entities), re.IGNORECASE)
|
|
293
|
+
files = list(vault.rglob("*.md"))[:_PY_FALLBACK_MAX_FILES]
|
|
294
|
+
hits: list[ContextHit] = []
|
|
295
|
+
for f in files:
|
|
296
|
+
try:
|
|
297
|
+
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
298
|
+
except OSError:
|
|
299
|
+
continue
|
|
300
|
+
match = pattern.search(text)
|
|
301
|
+
if not match:
|
|
302
|
+
continue
|
|
303
|
+
snippet_start = max(0, match.start() - 40)
|
|
304
|
+
snippet_end = min(len(text), match.end() + 80)
|
|
305
|
+
snippet = text[snippet_start:snippet_end].replace("\n", " ").strip()
|
|
306
|
+
hits.append(
|
|
307
|
+
ContextHit(
|
|
308
|
+
entity=match.group(0),
|
|
309
|
+
vault_path=str(f.relative_to(vault)),
|
|
310
|
+
snippet=snippet,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
if len(hits) >= _MAX_VAULT_HITS:
|
|
314
|
+
break
|
|
315
|
+
return hits
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _pick_matched_entity(text: str, entities: list[str]) -> str:
|
|
319
|
+
lower = text.lower()
|
|
320
|
+
for e in entities:
|
|
321
|
+
if e.lower() in lower:
|
|
322
|
+
return e
|
|
323
|
+
return entities[0] if entities else ""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _relative_to_vault(absolute: str, vault: Path) -> str:
|
|
327
|
+
try:
|
|
328
|
+
return str(Path(absolute).relative_to(vault))
|
|
329
|
+
except ValueError:
|
|
330
|
+
return absolute
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _vault_path_or_none() -> str | None:
|
|
334
|
+
"""Resolve the configured vault path or return None when missing.
|
|
335
|
+
|
|
336
|
+
Hooks call this; they must never crash because profile.json is absent.
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
from core.runtime.path_resolver import load_profile
|
|
340
|
+
return load_profile().vault_path
|
|
341
|
+
except Exception:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _cli_capture(argv: list[str]) -> int:
|
|
346
|
+
"""Stdin → capture_context. Used by the PostToolUse hook."""
|
|
347
|
+
import sys
|
|
348
|
+
if len(argv) < 2:
|
|
349
|
+
return 2
|
|
350
|
+
session_id = argv[1]
|
|
351
|
+
vault = _vault_path_or_none()
|
|
352
|
+
if not vault:
|
|
353
|
+
return 0 # silent skip when no profile
|
|
354
|
+
tool_output = sys.stdin.read()
|
|
355
|
+
capture_context(session_id, tool_output, vault)
|
|
356
|
+
return 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _cli_inject(argv: list[str]) -> int:
|
|
360
|
+
"""Print advisory for *session_id*. Used by the UserPromptSubmit hook."""
|
|
361
|
+
if len(argv) < 2:
|
|
362
|
+
return 2
|
|
363
|
+
session_id = argv[1]
|
|
364
|
+
advisory = format_advisory(read_context(session_id))
|
|
365
|
+
if advisory:
|
|
366
|
+
print(advisory)
|
|
367
|
+
return 0
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def main(argv: list[str]) -> int:
|
|
371
|
+
if len(argv) < 2:
|
|
372
|
+
return 2
|
|
373
|
+
cmd = argv[1]
|
|
374
|
+
if cmd == "capture":
|
|
375
|
+
return _cli_capture(argv[1:])
|
|
376
|
+
if cmd == "inject":
|
|
377
|
+
return _cli_inject(argv[1:])
|
|
378
|
+
return 2
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
import sys
|
|
383
|
+
sys.exit(main(sys.argv))
|
|
Binary file
|
|
@@ -144,6 +144,11 @@ class ArkaScheduler:
|
|
|
144
144
|
claude_bin = self._resolve_claude_binary()
|
|
145
145
|
prompt_path = os.path.expanduser(schedule.prompt_file)
|
|
146
146
|
prompt_content = Path(prompt_path).read_text(encoding="utf-8")
|
|
147
|
+
try:
|
|
148
|
+
from core.runtime.path_resolver import resolve
|
|
149
|
+
prompt_content = resolve(prompt_content)
|
|
150
|
+
except Exception:
|
|
151
|
+
pass # fall back to raw template if profile unavailable
|
|
147
152
|
return [claude_bin, "-p", prompt_content, "--dangerously-skip-permissions"]
|
|
148
153
|
|
|
149
154
|
# ------------------------------------------------------------------
|
|
Binary file
|
|
Binary file
|
package/core/obsidian/writer.py
CHANGED
|
@@ -130,14 +130,15 @@ class ObsidianWriter:
|
|
|
130
130
|
|
|
131
131
|
if config_path.exists():
|
|
132
132
|
try:
|
|
133
|
+
from core.runtime.path_resolver import resolve
|
|
133
134
|
config = json.loads(config_path.read_text())
|
|
134
|
-
vault = config.get("vault_path", "")
|
|
135
|
-
if vault and Path(vault).exists():
|
|
135
|
+
vault = resolve(config.get("vault_path", ""))
|
|
136
|
+
if vault and not vault.startswith("${") and Path(vault).exists():
|
|
136
137
|
return Path(vault)
|
|
137
|
-
except (json.JSONDecodeError, OSError):
|
|
138
|
+
except (json.JSONDecodeError, OSError, Exception):
|
|
138
139
|
pass
|
|
139
140
|
|
|
140
|
-
# 3. Environment variable
|
|
141
|
+
# 3. Environment variable (legacy fallback only — path_resolver handles ARKAOS_VAULT_PATH)
|
|
141
142
|
env_vault = os.environ.get("ARKAOS_VAULT", "")
|
|
142
143
|
if env_vault and Path(env_vault).exists():
|
|
143
144
|
return Path(env_vault)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Template-token path resolver for ArkaOS.
|
|
2
|
+
|
|
3
|
+
Resolves user-specific paths from ``~/.arkaos/profile.json`` and environment
|
|
4
|
+
variables, so source files can use neutral tokens like ``${VAULT_PATH}`` and
|
|
5
|
+
``${ARKA_OS_REPOS}`` instead of hardcoded absolute paths.
|
|
6
|
+
|
|
7
|
+
Token precedence (highest → lowest):
|
|
8
|
+
1. Environment variable (e.g. ``ARKAOS_VAULT_PATH``)
|
|
9
|
+
2. profile.json field (e.g. ``vaultPath``)
|
|
10
|
+
3. For ``${GIT_HOST}`` only: hardcoded default ``github.com``
|
|
11
|
+
|
|
12
|
+
If ``~/.arkaos/profile.json`` is missing or unparseable and an
|
|
13
|
+
unconfigured token is requested, ``ProfileMissingError`` is raised
|
|
14
|
+
with a clear remediation message. Unknown tokens pass through
|
|
15
|
+
unchanged so that prompt strings containing ``${SOME_BASH_VAR}`` for
|
|
16
|
+
the agent are preserved.
|
|
17
|
+
|
|
18
|
+
See ``core/specs/SPEC-paths-portability.md`` for the full contract.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from core.runtime.user_paths import user_data_root
|
|
30
|
+
|
|
31
|
+
_PROFILE_FILENAME = "profile.json"
|
|
32
|
+
_TOKEN_PATTERN = re.compile(r"\$\{([A-Z_]+)\}")
|
|
33
|
+
|
|
34
|
+
_DEFAULT_PROJECT_ROOTS = ["~/Herd", "~/Work", "~/AIProjects"]
|
|
35
|
+
_DEFAULT_REPOS_ROOT = "~/AIProjects"
|
|
36
|
+
_DEFAULT_GIT_HOST = "github.com"
|
|
37
|
+
|
|
38
|
+
_PROFILE_CACHE: "ProfileV3 | None" = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ProfileMissingError(RuntimeError):
|
|
42
|
+
"""Raised when ``~/.arkaos/profile.json`` cannot be loaded."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ProfileV3:
|
|
47
|
+
"""In-memory representation of profile.json v3."""
|
|
48
|
+
|
|
49
|
+
version: str
|
|
50
|
+
vault_path: str
|
|
51
|
+
repos_root: str
|
|
52
|
+
project_roots: list[str] = field(default_factory=list)
|
|
53
|
+
raw: dict = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def profile_path() -> Path:
|
|
57
|
+
"""Absolute path to ``~/.arkaos/profile.json``."""
|
|
58
|
+
return user_data_root() / _PROFILE_FILENAME
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_profile(*, refresh: bool = False) -> ProfileV3:
|
|
62
|
+
"""Load and validate profile.json.
|
|
63
|
+
|
|
64
|
+
Cached per process; pass ``refresh=True`` to force a re-read.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ProfileMissingError: file absent, unreadable, or unparseable JSON.
|
|
68
|
+
"""
|
|
69
|
+
global _PROFILE_CACHE
|
|
70
|
+
if _PROFILE_CACHE is not None and not refresh:
|
|
71
|
+
return _PROFILE_CACHE
|
|
72
|
+
|
|
73
|
+
path = profile_path()
|
|
74
|
+
if not path.exists():
|
|
75
|
+
raise ProfileMissingError(
|
|
76
|
+
f"~/.arkaos/profile.json not found at {path}. "
|
|
77
|
+
"Run /arka setup to configure ArkaOS paths."
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
82
|
+
raise ProfileMissingError(
|
|
83
|
+
f"~/.arkaos/profile.json could not be parsed ({exc}). "
|
|
84
|
+
"Run /arka setup to repair, or restore from a .bak file."
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
_PROFILE_CACHE = _project_from_raw(raw)
|
|
88
|
+
return _PROFILE_CACHE
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def reset_cache() -> None:
|
|
92
|
+
"""Drop the cached profile (for tests)."""
|
|
93
|
+
global _PROFILE_CACHE
|
|
94
|
+
_PROFILE_CACHE = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resolve(template: str) -> str:
|
|
98
|
+
"""Substitute known ``${VAR}`` tokens in *template*.
|
|
99
|
+
|
|
100
|
+
Tokens recognised:
|
|
101
|
+
- ``${VAULT_PATH}`` → ``ARKAOS_VAULT_PATH`` env or ``profile.vaultPath``
|
|
102
|
+
- ``${ARKA_OS_REPOS}`` → ``ARKAOS_REPOS_ROOT`` env or ``profile.reposRoot``
|
|
103
|
+
- ``${PROJECT_ROOTS}`` → ``os.pathsep``-joined profile.projectRoots
|
|
104
|
+
- ``${GIT_HOST}`` → ``ARKAOS_GIT_HOST`` env, default ``github.com``
|
|
105
|
+
- ``${HOME}`` → ``os.path.expanduser("~")``
|
|
106
|
+
|
|
107
|
+
Unknown tokens pass through unchanged.
|
|
108
|
+
"""
|
|
109
|
+
return _TOKEN_PATTERN.sub(_resolve_token_match, template)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_dict(obj):
|
|
113
|
+
"""Recursively resolve every string value in nested dicts/lists."""
|
|
114
|
+
if isinstance(obj, str):
|
|
115
|
+
return resolve(obj)
|
|
116
|
+
if isinstance(obj, dict):
|
|
117
|
+
return {k: resolve_dict(v) for k, v in obj.items()}
|
|
118
|
+
if isinstance(obj, list):
|
|
119
|
+
return [resolve_dict(item) for item in obj]
|
|
120
|
+
return obj
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def project_root_regex() -> re.Pattern[str]:
|
|
124
|
+
"""Compile a regex matching paths under any configured project root.
|
|
125
|
+
|
|
126
|
+
Used by ``core/cognition/capture/collector.py`` to detect project paths
|
|
127
|
+
inside captured session digests across macOS, Linux and Windows.
|
|
128
|
+
"""
|
|
129
|
+
profile = load_profile()
|
|
130
|
+
roots = [os.path.expanduser(r) for r in profile.project_roots] or [
|
|
131
|
+
os.path.expanduser(r) for r in _DEFAULT_PROJECT_ROOTS
|
|
132
|
+
]
|
|
133
|
+
alternation = "|".join(re.escape(r.rstrip("/").rstrip("\\")) for r in roots)
|
|
134
|
+
return re.compile(rf"({alternation})[/\\]([^\s/\\]+)")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _resolve_token_match(match: re.Match[str]) -> str:
|
|
138
|
+
return _resolve_token(match.group(1), match.group(0))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _resolve_token(name: str, original: str) -> str:
|
|
142
|
+
if name == "HOME":
|
|
143
|
+
return os.path.expanduser("~")
|
|
144
|
+
if name == "GIT_HOST":
|
|
145
|
+
return _env_or("ARKAOS_GIT_HOST", _DEFAULT_GIT_HOST)
|
|
146
|
+
if name == "VAULT_PATH":
|
|
147
|
+
env_val = _env_or("ARKAOS_VAULT_PATH", _env_or("ARKAOS_VAULT", ""))
|
|
148
|
+
return env_val or load_profile().vault_path
|
|
149
|
+
if name == "ARKA_OS_REPOS":
|
|
150
|
+
env_val = _env_or("ARKAOS_REPOS_ROOT", "")
|
|
151
|
+
return env_val or load_profile().repos_root
|
|
152
|
+
if name == "PROJECT_ROOTS":
|
|
153
|
+
env_val = _env_or("ARKAOS_PROJECT_ROOTS", "")
|
|
154
|
+
if env_val:
|
|
155
|
+
return env_val
|
|
156
|
+
return os.pathsep.join(load_profile().project_roots)
|
|
157
|
+
return original
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _env_or(name: str, fallback: str) -> str:
|
|
161
|
+
value = os.environ.get(name, "")
|
|
162
|
+
return value if value else fallback
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _project_from_raw(raw: dict) -> ProfileV3:
|
|
166
|
+
vault_path = raw.get("vaultPath") or raw.get("vault_path") or ""
|
|
167
|
+
if not vault_path:
|
|
168
|
+
raise ProfileMissingError(
|
|
169
|
+
"profile.json has no vaultPath. Run /arka setup to configure it."
|
|
170
|
+
)
|
|
171
|
+
repos_root = (
|
|
172
|
+
raw.get("reposRoot")
|
|
173
|
+
or raw.get("repos_root")
|
|
174
|
+
or _DEFAULT_REPOS_ROOT
|
|
175
|
+
)
|
|
176
|
+
project_roots = list(raw.get("projectRoots") or [])
|
|
177
|
+
if not project_roots:
|
|
178
|
+
project_roots = _derive_project_roots(raw.get("projectsDir", ""))
|
|
179
|
+
return ProfileV3(
|
|
180
|
+
version=str(raw.get("version", "2")),
|
|
181
|
+
vault_path=vault_path,
|
|
182
|
+
repos_root=repos_root,
|
|
183
|
+
project_roots=project_roots,
|
|
184
|
+
raw=raw,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _derive_project_roots(projects_dir_text: str) -> list[str]:
|
|
189
|
+
"""Best-effort parse of the legacy free-text ``projectsDir`` field.
|
|
190
|
+
|
|
191
|
+
Looks for absolute paths on macOS/Linux/Windows. Falls back to
|
|
192
|
+
``_DEFAULT_PROJECT_ROOTS`` when no path can be extracted. This keeps
|
|
193
|
+
the ~20K legacy users functional until ``npx arkaos update`` rewrites
|
|
194
|
+
their profile.json with the new ``projectRoots`` field.
|
|
195
|
+
"""
|
|
196
|
+
if not projects_dir_text:
|
|
197
|
+
return list(_DEFAULT_PROJECT_ROOTS)
|
|
198
|
+
posix = r"(?:/Users|/home)/\S+?/(?:Herd|Work|AIProjects|code|repos)"
|
|
199
|
+
windows = r"[A-Z]:\\Users\\[^\s\\]+\\(?:Herd|Work|AIProjects|code|repos)"
|
|
200
|
+
pattern = re.compile(rf"({posix}|{windows})")
|
|
201
|
+
found = pattern.findall(projects_dir_text)
|
|
202
|
+
return found or list(_DEFAULT_PROJECT_ROOTS)
|
|
Binary file
|
|
Binary file
|