arkaos 2.22.0 → 2.25.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/comfyui/SKILL.md +2 -2
- package/arka/skills/comfyui/references/workflows.md +6 -6
- package/arka/skills/costs/SKILL.md +11 -0
- package/config/cognition/prompts/dreaming.md +3 -3
- package/config/cognition/prompts/research.md +4 -4
- package/config/hooks/user-prompt-submit.sh +6 -1
- package/core/cognition/__pycache__/auto_documentor.cpython-313.pyc +0 -0
- package/core/cognition/auto_documentor.py +127 -12
- package/core/cognition/capture/__pycache__/collector.cpython-313.pyc +0 -0
- package/core/cognition/capture/collector.py +10 -3
- 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/jobs/auto_doc_worker.py +5 -3
- package/core/obsidian/__pycache__/writer.cpython-313.pyc +0 -0
- package/core/obsidian/writer.py +5 -4
- package/core/runtime/__pycache__/__init__.cpython-313.pyc +0 -0
- 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__/cursor.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/gemini_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_cost_telemetry.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_cost_telemetry_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/__pycache__/pricing.cpython-313.pyc +0 -0
- package/core/runtime/claude_code.py +22 -16
- package/core/runtime/codex_cli.py +23 -6
- package/core/runtime/gemini_cli.py +135 -11
- package/core/runtime/llm_provider.py +14 -9
- package/core/runtime/path_resolver.py +202 -0
- package/core/shared/__init__.py +6 -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/shared/safe_session_id.py +41 -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/synapse/kb_cache.py +7 -6
- package/core/synapse/layers.py +7 -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/core/workflow/flow_enforcer.py +6 -14
- package/core/workflow/marker_cache.py +6 -8
- package/core/workflow/research_gate.py +11 -9
- 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
|
@@ -120,7 +120,7 @@ class ClaudeCodeAdapter(RuntimeAdapter):
|
|
|
120
120
|
max_tokens: int = 2000,
|
|
121
121
|
system: str = "",
|
|
122
122
|
) -> "LLMResponse":
|
|
123
|
-
from core.runtime.llm_provider import
|
|
123
|
+
from core.runtime.llm_provider import LLMUnavailable
|
|
124
124
|
|
|
125
125
|
binary = shutil.which("claude")
|
|
126
126
|
if binary is None:
|
|
@@ -131,27 +131,28 @@ class ClaudeCodeAdapter(RuntimeAdapter):
|
|
|
131
131
|
cmd = [binary, "-p", prompt, "--output-format", "json"]
|
|
132
132
|
if system:
|
|
133
133
|
cmd.extend(["--append-system-prompt", system])
|
|
134
|
-
|
|
135
|
-
proc = subprocess.run(
|
|
136
|
-
cmd,
|
|
137
|
-
capture_output=True,
|
|
138
|
-
text=True,
|
|
139
|
-
timeout=60,
|
|
140
|
-
check=False,
|
|
141
|
-
)
|
|
142
|
-
except subprocess.TimeoutExpired as exc:
|
|
143
|
-
raise LLMUnavailable("claude CLI timed out after 60s") from exc
|
|
144
|
-
except OSError as exc:
|
|
145
|
-
raise LLMUnavailable(f"claude CLI subprocess failed: {exc}") from exc
|
|
146
|
-
|
|
134
|
+
proc = _run_claude_cli(cmd)
|
|
147
135
|
if proc.returncode != 0:
|
|
148
136
|
raise LLMUnavailable(
|
|
149
137
|
f"claude CLI exited {proc.returncode}: {proc.stderr.strip()[:200]}"
|
|
150
138
|
)
|
|
151
|
-
return
|
|
139
|
+
return _parse_claude_cli_output(proc.stdout)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_claude_cli(cmd: list[str]) -> subprocess.CompletedProcess:
|
|
143
|
+
from core.runtime.llm_provider import LLMUnavailable
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
return subprocess.run(
|
|
147
|
+
cmd, capture_output=True, text=True, timeout=60, check=False
|
|
148
|
+
)
|
|
149
|
+
except subprocess.TimeoutExpired as exc:
|
|
150
|
+
raise LLMUnavailable("claude CLI timed out after 60s") from exc
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
raise LLMUnavailable(f"claude CLI subprocess failed: {exc}") from exc
|
|
152
153
|
|
|
153
154
|
|
|
154
|
-
def
|
|
155
|
+
def _parse_claude_cli_output(stdout: str) -> "LLMResponse":
|
|
155
156
|
from core.runtime.llm_provider import LLMResponse
|
|
156
157
|
|
|
157
158
|
payload = json.loads(stdout) if stdout.strip() else {}
|
|
@@ -170,3 +171,8 @@ def _parse_claude_json(stdout: str) -> "LLMResponse":
|
|
|
170
171
|
cached_tokens=cache_read,
|
|
171
172
|
model=model,
|
|
172
173
|
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Backward compatibility alias — tests and external importers that used
|
|
177
|
+
# the old helper name continue to work without modification.
|
|
178
|
+
_parse_claude_json = _parse_claude_cli_output
|
|
@@ -94,11 +94,28 @@ class CodexCliAdapter(RuntimeAdapter):
|
|
|
94
94
|
"codex CLI not found on PATH — install Codex CLI to "
|
|
95
95
|
"enable headless completion."
|
|
96
96
|
)
|
|
97
|
-
# TODO(llm-agnostic):
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
97
|
+
# TODO(llm-agnostic): Implement real headless completion.
|
|
98
|
+
#
|
|
99
|
+
# Status as of 2026-04-20: Codex CLI is NOT installed on the
|
|
100
|
+
# development machine, so actual invocation syntax could not
|
|
101
|
+
# be verified. Until a local install is available, refuse
|
|
102
|
+
# rather than ship guessed arguments.
|
|
103
|
+
#
|
|
104
|
+
# Verification checklist for whoever picks this up:
|
|
105
|
+
# 1. Install: npm install -g @openai/codex-cli
|
|
106
|
+
# 2. Discover: codex --help (confirm non-interactive flag)
|
|
107
|
+
# 3. Pattern: likely `codex exec "<prompt>"` or
|
|
108
|
+
# `codex --prompt "<prompt>" --format json`
|
|
109
|
+
# 4. Wire the subprocess call (mirror the Gemini adapter —
|
|
110
|
+
# list-form args, 60s timeout, stderr clipped, JSON parse
|
|
111
|
+
# with plain-text fallback, token estimate on miss).
|
|
112
|
+
#
|
|
113
|
+
# SubagentProvider cleanly falls back to anthropic-direct or
|
|
114
|
+
# stub when this raises, so the chain keeps working.
|
|
101
115
|
raise NotImplementedError(
|
|
102
|
-
"Codex CLI headless
|
|
103
|
-
"
|
|
116
|
+
"Codex CLI headless mode requires local `codex` CLI. "
|
|
117
|
+
"Install: `npm install -g @openai/codex-cli` (verified 2026-04-20). "
|
|
118
|
+
"Verify syntax: `codex --help`. "
|
|
119
|
+
"See TODO(llm-agnostic) in this file. "
|
|
120
|
+
"SubagentProvider will cleanly fall back to anthropic-direct or stub."
|
|
104
121
|
)
|
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
"""Gemini CLI runtime adapter.
|
|
2
2
|
|
|
3
3
|
Google's Gemini CLI. Uses GEMINI.md for instructions and activate_skill for skills.
|
|
4
|
+
|
|
5
|
+
Headless invocation reference (verified against
|
|
6
|
+
https://github.com/google-gemini/gemini-cli docs — Context7 query
|
|
7
|
+
on 2026-04-20):
|
|
8
|
+
|
|
9
|
+
gemini -p "<prompt>" --output-format json
|
|
10
|
+
|
|
11
|
+
The JSON payload contains a ``response`` key (the model's text) and a
|
|
12
|
+
``stats`` block with ``totalTokenCount`` / token counts. On failure the
|
|
13
|
+
payload includes an ``error`` block with diagnostic details. If JSON
|
|
14
|
+
parsing fails we fall back to treating stdout as raw text and estimate
|
|
15
|
+
tokens via a ``len(text) // 4`` heuristic — better than losing cost
|
|
16
|
+
telemetry entirely.
|
|
4
17
|
"""
|
|
5
18
|
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
6
22
|
import shutil
|
|
23
|
+
import subprocess
|
|
7
24
|
from pathlib import Path
|
|
8
25
|
from os.path import expanduser
|
|
9
26
|
from typing import TYPE_CHECKING
|
|
@@ -14,6 +31,11 @@ if TYPE_CHECKING:
|
|
|
14
31
|
from core.runtime.llm_provider import LLMResponse
|
|
15
32
|
|
|
16
33
|
|
|
34
|
+
_TIMEOUT_SECONDS = 60
|
|
35
|
+
_TOKEN_ESTIMATE_DIVISOR = 4 # Rough chars-per-token heuristic.
|
|
36
|
+
_STDERR_CLIP = 200
|
|
37
|
+
|
|
38
|
+
|
|
17
39
|
class GeminiCliAdapter(RuntimeAdapter):
|
|
18
40
|
"""Adapter for Google's Gemini CLI."""
|
|
19
41
|
|
|
@@ -73,10 +95,7 @@ class GeminiCliAdapter(RuntimeAdapter):
|
|
|
73
95
|
raise NotImplementedError("Use Gemini CLI's native content search")
|
|
74
96
|
|
|
75
97
|
def headless_supported(self) -> bool:
|
|
76
|
-
|
|
77
|
-
# current release. Returning False lets SubagentProvider fall
|
|
78
|
-
# back gracefully rather than shell out blindly.
|
|
79
|
-
return False
|
|
98
|
+
return shutil.which("gemini") is not None
|
|
80
99
|
|
|
81
100
|
def headless_complete(
|
|
82
101
|
self,
|
|
@@ -85,17 +104,122 @@ class GeminiCliAdapter(RuntimeAdapter):
|
|
|
85
104
|
max_tokens: int = 2000,
|
|
86
105
|
system: str = "",
|
|
87
106
|
) -> "LLMResponse":
|
|
107
|
+
from core.runtime.llm_provider import LLMUnavailable
|
|
108
|
+
|
|
88
109
|
binary = shutil.which("gemini")
|
|
89
110
|
if binary is None:
|
|
90
111
|
raise NotImplementedError(
|
|
91
112
|
"gemini CLI not found on PATH — install Gemini CLI to "
|
|
92
113
|
"enable headless completion."
|
|
93
114
|
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
effective_prompt = _merge_system_prompt(prompt, system)
|
|
116
|
+
cmd = [binary, "-p", effective_prompt, "--output-format", "json"]
|
|
117
|
+
proc = _run_gemini_cli(cmd)
|
|
118
|
+
if proc.returncode != 0:
|
|
119
|
+
stderr_tail = proc.stderr.strip()[:_STDERR_CLIP]
|
|
120
|
+
raise LLMUnavailable(
|
|
121
|
+
f"gemini CLI exited {proc.returncode}: {stderr_tail}"
|
|
122
|
+
)
|
|
123
|
+
return _parse_gemini_cli_output(proc.stdout)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _merge_system_prompt(prompt: str, system: str) -> str:
|
|
127
|
+
# Gemini CLI's -p flag accepts a single prompt; prepend the system
|
|
128
|
+
# text when provided so downstream behaviour matches Claude Code.
|
|
129
|
+
if not system:
|
|
130
|
+
return prompt
|
|
131
|
+
return f"{system}\n\n---\n\n{prompt}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_gemini_cli(cmd: list[str]) -> subprocess.CompletedProcess:
|
|
135
|
+
from core.runtime.llm_provider import LLMUnavailable
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
return subprocess.run(
|
|
139
|
+
cmd,
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
timeout=_TIMEOUT_SECONDS,
|
|
143
|
+
check=False,
|
|
144
|
+
)
|
|
145
|
+
except subprocess.TimeoutExpired as exc:
|
|
146
|
+
raise LLMUnavailable(
|
|
147
|
+
f"gemini CLI timed out after {_TIMEOUT_SECONDS}s"
|
|
148
|
+
) from exc
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
raise LLMUnavailable(f"gemini CLI subprocess failed: {exc}") from exc
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_gemini_cli_output(stdout: str) -> "LLMResponse":
|
|
154
|
+
from core.runtime.llm_provider import LLMResponse
|
|
155
|
+
|
|
156
|
+
stripped = stdout.strip()
|
|
157
|
+
if not stripped:
|
|
158
|
+
return LLMResponse(
|
|
159
|
+
text="", tokens_in=0, tokens_out=0, cached_tokens=0, model=""
|
|
160
|
+
)
|
|
161
|
+
payload = _safe_loads(stripped)
|
|
162
|
+
if payload is None:
|
|
163
|
+
# Non-JSON fallback: treat stdout as raw text, estimate tokens.
|
|
164
|
+
return _response_from_plain_text(stripped)
|
|
165
|
+
return _response_from_json_payload(payload)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _safe_loads(text: str) -> dict | None:
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(text)
|
|
171
|
+
except (json.JSONDecodeError, ValueError):
|
|
172
|
+
return None
|
|
173
|
+
return data if isinstance(data, dict) else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _response_from_plain_text(text: str) -> "LLMResponse":
|
|
177
|
+
from core.runtime.llm_provider import LLMResponse
|
|
178
|
+
|
|
179
|
+
estimate = max(1, len(text) // _TOKEN_ESTIMATE_DIVISOR)
|
|
180
|
+
return LLMResponse(
|
|
181
|
+
text=text,
|
|
182
|
+
tokens_in=0,
|
|
183
|
+
tokens_out=estimate,
|
|
184
|
+
cached_tokens=0,
|
|
185
|
+
model="",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _response_from_json_payload(payload: dict) -> "LLMResponse":
|
|
190
|
+
from core.runtime.llm_provider import LLMResponse, LLMUnavailable
|
|
191
|
+
|
|
192
|
+
error = payload.get("error")
|
|
193
|
+
if isinstance(error, dict) and error:
|
|
194
|
+
message = str(error.get("message") or error).strip()[:_STDERR_CLIP]
|
|
195
|
+
raise LLMUnavailable(f"gemini CLI returned error: {message}")
|
|
196
|
+
|
|
197
|
+
text = str(payload.get("response") or payload.get("result") or "")
|
|
198
|
+
tokens_in, tokens_out = _extract_token_counts(payload, text)
|
|
199
|
+
model = str(payload.get("model") or "")
|
|
200
|
+
return LLMResponse(
|
|
201
|
+
text=text,
|
|
202
|
+
tokens_in=tokens_in,
|
|
203
|
+
tokens_out=tokens_out,
|
|
204
|
+
cached_tokens=0,
|
|
205
|
+
model=model,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _extract_token_counts(payload: dict, text: str) -> tuple[int, int]:
|
|
210
|
+
stats = payload.get("stats") or payload.get("usageMetadata") or {}
|
|
211
|
+
if isinstance(stats, dict):
|
|
212
|
+
tokens_in = int(stats.get("promptTokenCount") or stats.get("input_tokens") or 0)
|
|
213
|
+
tokens_out = int(
|
|
214
|
+
stats.get("candidatesTokenCount")
|
|
215
|
+
or stats.get("output_tokens")
|
|
216
|
+
or 0
|
|
101
217
|
)
|
|
218
|
+
# Fall back to the rolled-up total when per-side counts are absent.
|
|
219
|
+
if tokens_in == 0 and tokens_out == 0:
|
|
220
|
+
total = int(stats.get("totalTokenCount") or 0)
|
|
221
|
+
if total > 0:
|
|
222
|
+
return 0, total
|
|
223
|
+
return tokens_in, tokens_out
|
|
224
|
+
# No stats block at all — estimate output from text length.
|
|
225
|
+
return 0, max(1, len(text) // _TOKEN_ESTIMATE_DIVISOR)
|
|
@@ -199,6 +199,19 @@ class AnthropicDirectProvider:
|
|
|
199
199
|
}
|
|
200
200
|
]
|
|
201
201
|
|
|
202
|
+
def _build_anthropic_payload(
|
|
203
|
+
self, prompt: str, system: str, max_tokens: int, model: str
|
|
204
|
+
) -> dict[str, object]:
|
|
205
|
+
payload: dict[str, object] = {
|
|
206
|
+
"model": model,
|
|
207
|
+
"max_tokens": max_tokens,
|
|
208
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
209
|
+
}
|
|
210
|
+
system_blocks = self._build_system_blocks(system)
|
|
211
|
+
if system_blocks:
|
|
212
|
+
payload["system"] = system_blocks
|
|
213
|
+
return payload
|
|
214
|
+
|
|
202
215
|
def complete(
|
|
203
216
|
self,
|
|
204
217
|
prompt: str,
|
|
@@ -213,15 +226,7 @@ class AnthropicDirectProvider:
|
|
|
213
226
|
"cannot select a model."
|
|
214
227
|
)
|
|
215
228
|
client = self._build_client()
|
|
216
|
-
payload
|
|
217
|
-
"model": model,
|
|
218
|
-
"max_tokens": max_tokens,
|
|
219
|
-
"messages": [{"role": "user", "content": prompt}],
|
|
220
|
-
}
|
|
221
|
-
system_blocks = self._build_system_blocks(system)
|
|
222
|
-
if system_blocks:
|
|
223
|
-
payload["system"] = system_blocks
|
|
224
|
-
|
|
229
|
+
payload = self._build_anthropic_payload(prompt, system, max_tokens, model)
|
|
225
230
|
try:
|
|
226
231
|
raw = client.messages.create(**payload) # type: ignore[attr-defined]
|
|
227
232
|
except Exception as exc: # noqa: BLE001
|
|
@@ -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)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Cross-cutting primitives shared by multiple ArkaOS core packages.
|
|
2
|
+
|
|
3
|
+
Keep this package lean — only primitives that two or more sibling
|
|
4
|
+
packages already duplicate belong here. It is NOT a dumping ground for
|
|
5
|
+
utilities; each addition must delete a duplicate elsewhere.
|
|
6
|
+
"""
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Shared session-id allowlist — path-traversal / injection guard.
|
|
2
|
+
|
|
3
|
+
A session id is considered safe iff it matches ``[A-Za-z0-9._-]{1,128}``.
|
|
4
|
+
Any other character (``/``, ``\\``, whitespace, control char, unicode,
|
|
5
|
+
NUL, ``..``) rejects — callers MUST treat ``None`` as "do not use this
|
|
6
|
+
id for any filesystem or shell path".
|
|
7
|
+
|
|
8
|
+
Why this lives here: the exact same regex + helper was duplicated in 6
|
|
9
|
+
modules (flow_enforcer, marker_cache, research_gate, kb_cache,
|
|
10
|
+
auto_documentor, auto_doc_worker). A single source of truth prevents
|
|
11
|
+
drift — if the allowlist ever tightens, it tightens everywhere.
|
|
12
|
+
|
|
13
|
+
Historic aliases remain at each call site as module-level re-exports
|
|
14
|
+
so external importers that did ``from core.workflow.flow_enforcer
|
|
15
|
+
import SAFE_SESSION_ID_RE`` continue to work.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def safe_session_id(session_id: str) -> str | None:
|
|
27
|
+
"""Validate ``session_id`` against the strict allowlist.
|
|
28
|
+
|
|
29
|
+
Returns the id unchanged when safe, or ``None`` when it contains
|
|
30
|
+
path separators, ``..`` traversal fragments, whitespace, unicode,
|
|
31
|
+
NUL bytes, or any character outside ``[A-Za-z0-9._-]``. Length is
|
|
32
|
+
capped at 128 characters to prevent pathological filesystem paths.
|
|
33
|
+
|
|
34
|
+
Callers MUST treat ``None`` as reject — never construct a path or
|
|
35
|
+
shell argument from the raw input when this returns ``None``.
|
|
36
|
+
"""
|
|
37
|
+
if not session_id or not isinstance(session_id, str):
|
|
38
|
+
return None
|
|
39
|
+
if not SAFE_SESSION_ID_RE.match(session_id):
|
|
40
|
+
return None
|
|
41
|
+
return session_id
|