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.
Files changed (67) hide show
  1. package/README.md +105 -9
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +1 -0
  4. package/arka/skills/bootstrap-agent/SKILL.md +76 -0
  5. package/arka/skills/comfyui/SKILL.md +2 -2
  6. package/arka/skills/comfyui/references/workflows.md +6 -6
  7. package/arka/skills/costs/SKILL.md +11 -0
  8. package/arka/skills/design-ops/SKILL.md +68 -0
  9. package/arka/skills/design-ops/scripts/extract-colors.py +86 -0
  10. package/arka/skills/design-ops/scripts/shadcn-tokens.py +59 -0
  11. package/arka/skills/design-ops/scripts/wcag-contrast.py +92 -0
  12. package/arka/skills/research/SKILL.md +117 -0
  13. package/config/cognition/prompts/dreaming.md +3 -3
  14. package/config/cognition/prompts/research.md +4 -4
  15. package/config/constitution.yaml +4 -0
  16. package/config/hooks/post-tool-use.sh +17 -0
  17. package/config/hooks/user-prompt-submit.sh +22 -1
  18. package/core/agents/__pycache__/loader.cpython-313.pyc +0 -0
  19. package/core/agents/__pycache__/schema.cpython-313.pyc +0 -0
  20. package/core/agents/loader.py +4 -1
  21. package/core/agents/schema.py +29 -0
  22. package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
  23. package/core/cognition/__pycache__/retrieval.cpython-313.pyc +0 -0
  24. package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
  25. package/core/cognition/capture/collector.py +10 -3
  26. package/core/cognition/retrieval.py +383 -0
  27. package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
  28. package/core/cognition/scheduler/daemon.py +5 -0
  29. package/core/jobs/__pycache__/auto_doc_worker.cpython-313.pyc +0 -0
  30. package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
  31. package/core/obsidian/writer.py +5 -4
  32. package/core/runtime/__pycache__/base.cpython-313.pyc +0 -0
  33. package/core/runtime/__pycache__/claude_code.cpython-313.pyc +0 -0
  34. package/core/runtime/__pycache__/codex_cli.cpython-313.pyc +0 -0
  35. package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
  36. package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
  37. package/core/runtime/__pycache__/path_resolver.cpython-313.pyc +0 -0
  38. package/core/runtime/path_resolver.py +202 -0
  39. package/core/shared/__pycache__/__init__.cpython-313.pyc +0 -0
  40. package/core/shared/__pycache__/safe_session_id.cpython-313.pyc +0 -0
  41. package/core/specs/SPEC-installer-cross-os.md +227 -0
  42. package/core/specs/SPEC-paths-portability.md +209 -0
  43. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  44. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  45. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  46. package/core/workflow/__pycache__/marker_cache.cpython-313.pyc +0 -0
  47. package/core/workflow/__pycache__/research_gate.cpython-313.pyc +0 -0
  48. package/departments/brand/agents/design-ops/design-ops-lead.yaml +78 -0
  49. package/departments/brand/agents/design-ops/extraction-script-writer.yaml +74 -0
  50. package/departments/brand/agents/design-ops/shadcn-padronizer.yaml +76 -0
  51. package/departments/brand/agents/design-ops/wcag-auditor.yaml +76 -0
  52. package/departments/dev/skills/mcp/SKILL.md +16 -0
  53. package/departments/dev/skills/scaffold/SKILL.md +4 -4
  54. package/departments/kb/skills/knowledge/SKILL.md +1 -1
  55. package/departments/ops/skills/operations/SKILL.md +1 -1
  56. package/installer/cli.js +2 -1
  57. package/installer/doctor.js +48 -0
  58. package/installer/index.js +26 -1
  59. package/installer/migrate-user-data.js +17 -1
  60. package/installer/migrations/v3_path_schema.js +109 -0
  61. package/installer/package-manager.js +191 -0
  62. package/installer/path-resolver.js +124 -0
  63. package/installer/system-tools.js +243 -0
  64. package/installer/update.js +24 -3
  65. package/knowledge/obsidian-config.json +1 -1
  66. package/package.json +1 -1
  67. 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))
@@ -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
  # ------------------------------------------------------------------
@@ -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)
@@ -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)