navaia-code 1.0.50__py3-none-any.whl
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.
- navaia/__init__.py +3 -0
- navaia/api/__init__.py +0 -0
- navaia/api/client.py +72 -0
- navaia/api/normalise.py +148 -0
- navaia/api/retry.py +114 -0
- navaia/api/streaming.py +341 -0
- navaia/api/types.py +213 -0
- navaia/commands/__init__.py +0 -0
- navaia/commands/builtin/__init__.py +0 -0
- navaia/commands/builtin/commands.py +206 -0
- navaia/commands/dispatcher.py +38 -0
- navaia/commands/parser.py +25 -0
- navaia/commands/registry.py +48 -0
- navaia/commands/skills.py +150 -0
- navaia/commands/types.py +26 -0
- navaia/compact/__init__.py +0 -0
- navaia/compact/compact.py +241 -0
- navaia/compact/prompt.py +22 -0
- navaia/compact/restore.py +91 -0
- navaia/config/__init__.py +0 -0
- navaia/config/env.py +53 -0
- navaia/config/global_config.py +43 -0
- navaia/config/project_config.py +53 -0
- navaia/config/providers.py +234 -0
- navaia/config/settings.py +113 -0
- navaia/context/__init__.py +0 -0
- navaia/context/cache.py +29 -0
- navaia/context/claudemd.py +252 -0
- navaia/context/system_prompt.py +172 -0
- navaia/effort/__init__.py +0 -0
- navaia/effort/effort.py +47 -0
- navaia/hooks/__init__.py +0 -0
- navaia/hooks/executor.py +153 -0
- navaia/hooks/settings.py +82 -0
- navaia/hooks/types.py +53 -0
- navaia/main.py +462 -0
- navaia/mcp/__init__.py +0 -0
- navaia/mcp/bootstrap.py +88 -0
- navaia/mcp/client.py +157 -0
- navaia/mcp/settings.py +80 -0
- navaia/mcp/tools.py +118 -0
- navaia/mcp/types.py +29 -0
- navaia/memory/__init__.py +0 -0
- navaia/memory/memdir.py +70 -0
- navaia/memory/paths.py +17 -0
- navaia/memory/scanner.py +85 -0
- navaia/memory/types.py +27 -0
- navaia/permissions/__init__.py +0 -0
- navaia/permissions/checker.py +147 -0
- navaia/permissions/rules.py +88 -0
- navaia/permissions/types.py +39 -0
- navaia/query/__init__.py +0 -0
- navaia/query/engine.py +477 -0
- navaia/query/types.py +43 -0
- navaia/session/__init__.py +0 -0
- navaia/session/history.py +64 -0
- navaia/session/serialise.py +184 -0
- navaia/session/state.py +20 -0
- navaia/session/storage.py +102 -0
- navaia/session/store.py +202 -0
- navaia/state/__init__.py +0 -0
- navaia/tasks/__init__.py +0 -0
- navaia/tasks/cron.py +113 -0
- navaia/tasks/manager.py +112 -0
- navaia/tasks/persistence.py +128 -0
- navaia/tasks/task.py +34 -0
- navaia/thinking/__init__.py +0 -0
- navaia/thinking/budget.py +42 -0
- navaia/thinking/config.py +55 -0
- navaia/tools/__init__.py +0 -0
- navaia/tools/agent_tool/__init__.py +0 -0
- navaia/tools/agent_tool/tool.py +148 -0
- navaia/tools/ask_user/__init__.py +0 -0
- navaia/tools/ask_user/bus.py +51 -0
- navaia/tools/ask_user/tool.py +64 -0
- navaia/tools/base.py +51 -0
- navaia/tools/bash/__init__.py +0 -0
- navaia/tools/bash/background.py +123 -0
- navaia/tools/bash/tool.py +234 -0
- navaia/tools/executor.py +111 -0
- navaia/tools/file_edit/__init__.py +0 -0
- navaia/tools/file_edit/tool.py +206 -0
- navaia/tools/file_read/__init__.py +0 -0
- navaia/tools/file_read/tool.py +209 -0
- navaia/tools/file_write/__init__.py +0 -0
- navaia/tools/file_write/tool.py +112 -0
- navaia/tools/glob_tool/__init__.py +0 -0
- navaia/tools/glob_tool/tool.py +97 -0
- navaia/tools/grep_tool/__init__.py +0 -0
- navaia/tools/grep_tool/tool.py +292 -0
- navaia/tools/monitor/__init__.py +0 -0
- navaia/tools/monitor/tool.py +101 -0
- navaia/tools/plan_mode/__init__.py +0 -0
- navaia/tools/plan_mode/enter.py +38 -0
- navaia/tools/plan_mode/exit.py +36 -0
- navaia/tools/registry.py +71 -0
- navaia/tools/result_storage.py +60 -0
- navaia/tools/skill_tool/__init__.py +0 -0
- navaia/tools/skill_tool/loader.py +147 -0
- navaia/tools/skill_tool/tool.py +88 -0
- navaia/tools/task_tools/__init__.py +0 -0
- navaia/tools/task_tools/create.py +60 -0
- navaia/tools/task_tools/get.py +52 -0
- navaia/tools/task_tools/list.py +39 -0
- navaia/tools/task_tools/manager.py +66 -0
- navaia/tools/task_tools/update.py +88 -0
- navaia/tools/todo_write/__init__.py +0 -0
- navaia/tools/todo_write/tool.py +121 -0
- navaia/tools/tool_search/__init__.py +0 -0
- navaia/tools/tool_search/tool.py +106 -0
- navaia/tools/web_fetch/__init__.py +0 -0
- navaia/tools/web_fetch/tool.py +88 -0
- navaia/tools/worktree/__init__.py +0 -0
- navaia/tools/worktree/enter.py +66 -0
- navaia/tools/worktree/exit.py +51 -0
- navaia/tools/worktree/manager.py +130 -0
- navaia/ui/__init__.py +0 -0
- navaia/ui/app.py +605 -0
- navaia/ui/bidi.py +70 -0
- navaia/ui/input/__init__.py +0 -0
- navaia/ui/input/history.py +84 -0
- navaia/ui/input/suggestions.py +72 -0
- navaia/ui/messages/__init__.py +0 -0
- navaia/ui/messages/assistant_text.py +46 -0
- navaia/ui/messages/bash_output.py +68 -0
- navaia/ui/messages/system_msg.py +25 -0
- navaia/ui/messages/tool_result.py +38 -0
- navaia/ui/messages/tool_use.py +70 -0
- navaia/ui/messages/user_prompt.py +27 -0
- navaia/ui/screens/__init__.py +0 -0
- navaia/ui/screens/repl.py +136 -0
- navaia/ui/styles/app.tcss +48 -0
- navaia/ui/widgets/__init__.py +0 -0
- navaia/ui/widgets/logo.py +48 -0
- navaia/ui/widgets/markdown_view.py +87 -0
- navaia/ui/widgets/message_list.py +387 -0
- navaia/ui/widgets/permission_prompt.py +137 -0
- navaia/ui/widgets/prompt_footer.py +67 -0
- navaia/ui/widgets/prompt_input.py +203 -0
- navaia/ui/widgets/question_prompt.py +58 -0
- navaia/ui/widgets/spinner.py +110 -0
- navaia/ui/widgets/thinking_view.py +124 -0
- navaia_code-1.0.50.dist-info/METADATA +17 -0
- navaia_code-1.0.50.dist-info/RECORD +146 -0
- navaia_code-1.0.50.dist-info/WHEEL +4 -0
- navaia_code-1.0.50.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Provider profiles for multi-backend LLM configuration.
|
|
2
|
+
|
|
3
|
+
Connection details are resolved from three layers (highest wins):
|
|
4
|
+
|
|
5
|
+
1. Environment variables (NAVAIA_<PROVIDER>_BASE_URL, etc.)
|
|
6
|
+
2. Config files (~/.navaia/config.json, .navaia/config.json)
|
|
7
|
+
3. Built-in defaults in this file
|
|
8
|
+
|
|
9
|
+
Example ~/.navaia/config.json:
|
|
10
|
+
{
|
|
11
|
+
"provider": "internal",
|
|
12
|
+
"providers": {
|
|
13
|
+
"internal": {
|
|
14
|
+
"base_url": "https://ngx.tawakkalna.nic.gov.sa/v1",
|
|
15
|
+
"model": "/models/gpt-oss-120b"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Example .env:
|
|
21
|
+
NAVAIA_PROVIDER=internal
|
|
22
|
+
NAVAIA_INTERNAL_BASE_URL=https://ngx.tawakkalna.nic.gov.sa/v1
|
|
23
|
+
NAVAIA_INTERNAL_MODEL=/models/gpt-oss-120b
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from dataclasses import dataclass, replace
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ProviderProfile:
|
|
34
|
+
"""Immutable connection profile for an LLM provider."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
base_url: str
|
|
38
|
+
model: str
|
|
39
|
+
api_key_env: str # env var name that holds the key (empty = no key needed)
|
|
40
|
+
default_api_key: str # fallback when env var is unset (e.g. "empty" for internal)
|
|
41
|
+
timeout: float
|
|
42
|
+
verify_ssl: bool
|
|
43
|
+
retry_on_rate_limit: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Built-in defaults — last-resort fallback only.
|
|
47
|
+
_DEFAULTS: dict[str, ProviderProfile] = {
|
|
48
|
+
"openrouter": ProviderProfile(
|
|
49
|
+
name="openrouter",
|
|
50
|
+
base_url="https://openrouter.ai/api/v1",
|
|
51
|
+
# GPT-OSS-120B via OpenRouter.
|
|
52
|
+
# Override via NAVAIA_OPENROUTER_MODEL (e.g. moonshotai/kimi-k2,
|
|
53
|
+
# minimax/minimax-m2, anthropic/claude-opus-4, openai/gpt-4o, etc.)
|
|
54
|
+
model="gpt-oss-120b",
|
|
55
|
+
api_key_env="OPENROUTER_API_KEY",
|
|
56
|
+
default_api_key="",
|
|
57
|
+
timeout=120.0,
|
|
58
|
+
verify_ssl=True,
|
|
59
|
+
retry_on_rate_limit=True,
|
|
60
|
+
),
|
|
61
|
+
"internal": ProviderProfile(
|
|
62
|
+
name="internal",
|
|
63
|
+
base_url="https://ngx.tawakkalna.nic.gov.sa/v1",
|
|
64
|
+
model="/models/gpt-oss-120b",
|
|
65
|
+
api_key_env="",
|
|
66
|
+
default_api_key="empty",
|
|
67
|
+
timeout=55.0, # ngx reverse proxy has 60s timeout
|
|
68
|
+
verify_ssl=False,
|
|
69
|
+
retry_on_rate_limit=False,
|
|
70
|
+
),
|
|
71
|
+
# Local Qwen (or any OpenAI-compatible server you host yourself:
|
|
72
|
+
# vLLM, llama.cpp --server, LM Studio, text-generation-webui, etc.).
|
|
73
|
+
# Placeholder URL points at vLLM's default port — override via
|
|
74
|
+
# NAVAIA_QWEN_BASE_URL / NAVAIA_QWEN_MODEL in your .env.
|
|
75
|
+
"qwen": ProviderProfile(
|
|
76
|
+
name="qwen",
|
|
77
|
+
base_url="http://localhost:8000/v1",
|
|
78
|
+
model="Qwen/Qwen2.5-7B-Instruct",
|
|
79
|
+
api_key_env="QWEN_API_KEY",
|
|
80
|
+
default_api_key="empty",
|
|
81
|
+
timeout=300.0,
|
|
82
|
+
verify_ssl=True,
|
|
83
|
+
retry_on_rate_limit=False,
|
|
84
|
+
),
|
|
85
|
+
# Ollama — local models served on the default Ollama port.
|
|
86
|
+
# Override via NAVAIA_OLLAMA_BASE_URL / NAVAIA_OLLAMA_MODEL.
|
|
87
|
+
"ollama": ProviderProfile(
|
|
88
|
+
name="ollama",
|
|
89
|
+
base_url="http://localhost:11434/v1",
|
|
90
|
+
model="qwen2.5:7b",
|
|
91
|
+
api_key_env="",
|
|
92
|
+
default_api_key="ollama",
|
|
93
|
+
timeout=300.0,
|
|
94
|
+
verify_ssl=True,
|
|
95
|
+
retry_on_rate_limit=False,
|
|
96
|
+
),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
DEFAULT_PROVIDER = "openrouter"
|
|
100
|
+
|
|
101
|
+
# Populated by load_provider_overrides() at startup
|
|
102
|
+
_config_overrides: dict[str, dict] = {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_provider_overrides(config: dict) -> None:
|
|
106
|
+
"""Load provider overrides from merged config (call once at startup).
|
|
107
|
+
|
|
108
|
+
Expects the "providers" key from the merged config dict:
|
|
109
|
+
{"providers": {"internal": {"base_url": "...", "model": "..."}}}
|
|
110
|
+
"""
|
|
111
|
+
global _config_overrides
|
|
112
|
+
_config_overrides = dict(config.get("providers", {}))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _apply_overrides(profile: ProviderProfile) -> ProviderProfile:
|
|
116
|
+
"""Apply config-file and env-var overrides to a default profile.
|
|
117
|
+
|
|
118
|
+
Priority: env vars > config files > built-in defaults.
|
|
119
|
+
"""
|
|
120
|
+
name = profile.name
|
|
121
|
+
prefix = f"NAVAIA_{name.upper()}_"
|
|
122
|
+
|
|
123
|
+
# Start with config-file overrides
|
|
124
|
+
cfg = dict(_config_overrides.get(name, {}))
|
|
125
|
+
|
|
126
|
+
# Env vars override config files
|
|
127
|
+
overrides: dict = {}
|
|
128
|
+
|
|
129
|
+
# base_url
|
|
130
|
+
env_url = os.environ.get(f"{prefix}BASE_URL")
|
|
131
|
+
if env_url:
|
|
132
|
+
overrides["base_url"] = env_url
|
|
133
|
+
elif "base_url" in cfg:
|
|
134
|
+
overrides["base_url"] = cfg["base_url"]
|
|
135
|
+
|
|
136
|
+
# model
|
|
137
|
+
env_model = os.environ.get(f"{prefix}MODEL")
|
|
138
|
+
if env_model:
|
|
139
|
+
overrides["model"] = env_model
|
|
140
|
+
elif "model" in cfg:
|
|
141
|
+
overrides["model"] = cfg["model"]
|
|
142
|
+
|
|
143
|
+
# timeout
|
|
144
|
+
env_timeout = os.environ.get(f"{prefix}TIMEOUT")
|
|
145
|
+
if env_timeout:
|
|
146
|
+
try:
|
|
147
|
+
overrides["timeout"] = float(env_timeout)
|
|
148
|
+
except ValueError:
|
|
149
|
+
pass
|
|
150
|
+
elif "timeout" in cfg:
|
|
151
|
+
try:
|
|
152
|
+
overrides["timeout"] = float(cfg["timeout"])
|
|
153
|
+
except (ValueError, TypeError):
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# api_key_env
|
|
157
|
+
if "api_key_env" in cfg:
|
|
158
|
+
overrides["api_key_env"] = cfg["api_key_env"]
|
|
159
|
+
|
|
160
|
+
# verify_ssl
|
|
161
|
+
env_ssl = os.environ.get(f"{prefix}VERIFY_SSL")
|
|
162
|
+
if env_ssl is not None:
|
|
163
|
+
overrides["verify_ssl"] = env_ssl.lower() in ("1", "true", "yes")
|
|
164
|
+
elif "verify_ssl" in cfg:
|
|
165
|
+
overrides["verify_ssl"] = bool(cfg["verify_ssl"])
|
|
166
|
+
|
|
167
|
+
if not overrides:
|
|
168
|
+
return profile
|
|
169
|
+
|
|
170
|
+
return replace(profile, **overrides)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def resolve_provider(provider_name: str = "") -> ProviderProfile:
|
|
174
|
+
"""Resolve a provider profile by name, with all overrides applied.
|
|
175
|
+
|
|
176
|
+
Falls back to NAVAIA_PROVIDER env var, then DEFAULT_PROVIDER.
|
|
177
|
+
"""
|
|
178
|
+
name = provider_name or os.environ.get("NAVAIA_PROVIDER", DEFAULT_PROVIDER)
|
|
179
|
+
name = name.strip().lower()
|
|
180
|
+
|
|
181
|
+
if name not in _DEFAULTS:
|
|
182
|
+
valid = ", ".join(sorted(_DEFAULTS))
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"Unknown provider '{name}'. Available providers: {valid}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return _apply_overrides(_DEFAULTS[name])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def resolve_api_key(profile: ProviderProfile) -> str:
|
|
191
|
+
"""Resolve the API key for a provider profile.
|
|
192
|
+
|
|
193
|
+
Priority: provider-specific env > OPENAI_API_KEY (only when
|
|
194
|
+
OPENAI_BASE_URL is also set) > profile default.
|
|
195
|
+
"""
|
|
196
|
+
# Provider-specific env var takes priority
|
|
197
|
+
if profile.api_key_env:
|
|
198
|
+
if key := os.environ.get(profile.api_key_env):
|
|
199
|
+
return key
|
|
200
|
+
# Generic overrides only apply when the user also overrides the URL
|
|
201
|
+
if os.environ.get("OPENAI_BASE_URL") or os.environ.get("ANTHROPIC_BASE_URL"):
|
|
202
|
+
if key := os.environ.get("OPENAI_API_KEY"):
|
|
203
|
+
return key
|
|
204
|
+
if key := os.environ.get("ANTHROPIC_API_KEY"):
|
|
205
|
+
return key
|
|
206
|
+
# Profile default (e.g. "empty" for internal, "ollama" for local)
|
|
207
|
+
return profile.default_api_key
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def resolve_base_url(profile: ProviderProfile) -> str:
|
|
211
|
+
"""Resolve the base URL (profile already has overrides applied)."""
|
|
212
|
+
return profile.base_url
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def resolve_model(profile: ProviderProfile) -> str:
|
|
216
|
+
"""Resolve the model identifier.
|
|
217
|
+
|
|
218
|
+
Priority: provider-scoped override (NAVAIA_<NAME>_MODEL, already applied
|
|
219
|
+
in _apply_overrides) > generic ANTHROPIC_MODEL/OPENAI_MODEL *only* when
|
|
220
|
+
the user also overrode the base URL (i.e. they are deliberately pointing
|
|
221
|
+
at a non-default backend) > profile value.
|
|
222
|
+
|
|
223
|
+
The scoping matters: an `ANTHROPIC_MODEL` left in a shell profile must
|
|
224
|
+
not silently hijack openrouter/qwen/ollama/internal.
|
|
225
|
+
"""
|
|
226
|
+
has_url_override = bool(
|
|
227
|
+
os.environ.get("OPENAI_BASE_URL") or os.environ.get("ANTHROPIC_BASE_URL")
|
|
228
|
+
)
|
|
229
|
+
if has_url_override:
|
|
230
|
+
if m := os.environ.get("ANTHROPIC_MODEL"):
|
|
231
|
+
return m
|
|
232
|
+
if m := os.environ.get("OPENAI_MODEL"):
|
|
233
|
+
return m
|
|
234
|
+
return profile.model
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Merged settings from global config, project config, and env overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .env import get_env_overrides
|
|
8
|
+
from .global_config import load_global_config
|
|
9
|
+
from .project_config import load_project_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Settings:
|
|
14
|
+
"""Immutable settings object built from merged configuration layers."""
|
|
15
|
+
|
|
16
|
+
# Permission rules
|
|
17
|
+
allowed_tools: tuple[str, ...] = ()
|
|
18
|
+
denied_tools: tuple[str, ...] = ()
|
|
19
|
+
|
|
20
|
+
# Hooks
|
|
21
|
+
hooks: dict = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
# Provider (openrouter, internal)
|
|
24
|
+
provider: str = ""
|
|
25
|
+
|
|
26
|
+
# Model preferences
|
|
27
|
+
default_model: str = ""
|
|
28
|
+
|
|
29
|
+
# UI preferences
|
|
30
|
+
vim_mode: bool = False
|
|
31
|
+
theme: str = "dark"
|
|
32
|
+
|
|
33
|
+
# Thinking
|
|
34
|
+
always_thinking_enabled: bool = False
|
|
35
|
+
max_thinking_tokens: int = 31999
|
|
36
|
+
|
|
37
|
+
# Other
|
|
38
|
+
verbose: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Keys in JSON configs that map directly to Settings fields.
|
|
42
|
+
_BOOL_FIELDS = frozenset({"vim_mode", "always_thinking_enabled", "verbose"})
|
|
43
|
+
_INT_FIELDS = frozenset({"max_thinking_tokens"})
|
|
44
|
+
_STR_FIELDS = frozenset({"provider", "default_model", "theme"})
|
|
45
|
+
_TUPLE_STR_FIELDS = frozenset({"allowed_tools", "denied_tools"})
|
|
46
|
+
_DICT_FIELDS = frozenset({"hooks"})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _coerce(key: str, value: object) -> object:
|
|
50
|
+
"""Coerce a raw config value to the type expected by Settings."""
|
|
51
|
+
if key in _BOOL_FIELDS:
|
|
52
|
+
return bool(value)
|
|
53
|
+
if key in _INT_FIELDS:
|
|
54
|
+
return int(value)
|
|
55
|
+
if key in _STR_FIELDS:
|
|
56
|
+
return str(value)
|
|
57
|
+
if key in _TUPLE_STR_FIELDS:
|
|
58
|
+
if isinstance(value, (list, tuple)):
|
|
59
|
+
return tuple(str(v) for v in value)
|
|
60
|
+
return ()
|
|
61
|
+
if key in _DICT_FIELDS:
|
|
62
|
+
return dict(value) if isinstance(value, dict) else {}
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_ALL_FIELDS = _BOOL_FIELDS | _INT_FIELDS | _STR_FIELDS | _TUPLE_STR_FIELDS | _DICT_FIELDS
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _merge_layer(accumulated: dict, layer: dict) -> dict:
|
|
70
|
+
"""Return a new dict with *layer* values merged on top of *accumulated*.
|
|
71
|
+
|
|
72
|
+
Only keys that correspond to Settings fields are kept.
|
|
73
|
+
"""
|
|
74
|
+
result = dict(accumulated)
|
|
75
|
+
for key, value in layer.items():
|
|
76
|
+
if key in _ALL_FIELDS:
|
|
77
|
+
result[key] = _coerce(key, value)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_settings(cwd: str) -> Settings:
|
|
82
|
+
"""Load and merge settings from global config, project config, and env.
|
|
83
|
+
|
|
84
|
+
Precedence (highest wins): env overrides > project config > global config.
|
|
85
|
+
Also loads provider connection overrides from the "providers" key in
|
|
86
|
+
config files so that base_url, model, timeout, etc. are configurable.
|
|
87
|
+
"""
|
|
88
|
+
from .providers import load_provider_overrides
|
|
89
|
+
|
|
90
|
+
global_cfg = load_global_config()
|
|
91
|
+
project_cfg = load_project_config(cwd)
|
|
92
|
+
|
|
93
|
+
# Merge provider overrides: global < project (project wins)
|
|
94
|
+
provider_overrides: dict = {}
|
|
95
|
+
if "providers" in global_cfg:
|
|
96
|
+
provider_overrides.update(global_cfg["providers"])
|
|
97
|
+
if "providers" in project_cfg:
|
|
98
|
+
for name, overrides in project_cfg["providers"].items():
|
|
99
|
+
if name in provider_overrides:
|
|
100
|
+
merged_provider = dict(provider_overrides[name])
|
|
101
|
+
merged_provider.update(overrides)
|
|
102
|
+
provider_overrides[name] = merged_provider
|
|
103
|
+
else:
|
|
104
|
+
provider_overrides[name] = overrides
|
|
105
|
+
|
|
106
|
+
load_provider_overrides({"providers": provider_overrides})
|
|
107
|
+
|
|
108
|
+
# Merge Settings fields (excluding "providers" which is handled above)
|
|
109
|
+
merged: dict = {}
|
|
110
|
+
merged = _merge_layer(merged, global_cfg)
|
|
111
|
+
merged = _merge_layer(merged, project_cfg)
|
|
112
|
+
merged = _merge_layer(merged, get_env_overrides())
|
|
113
|
+
return Settings(**merged)
|
|
File without changes
|
navaia/context/cache.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Cache boundary splitting for Anthropic prompt caching.
|
|
2
|
+
|
|
3
|
+
The system prompt is split at a dynamic boundary marker so that the static
|
|
4
|
+
portion can be sent with ``cache_control`` to take advantage of the 5-minute
|
|
5
|
+
prompt-cache TTL.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
DYNAMIC_BOUNDARY = "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def split_system_prompt_for_cache(system: str) -> tuple[str, str]:
|
|
14
|
+
"""Split *system* at ``DYNAMIC_BOUNDARY``.
|
|
15
|
+
|
|
16
|
+
Returns ``(static, dynamic)`` where:
|
|
17
|
+
- *static* — everything before the boundary (gets ``cache_control``).
|
|
18
|
+
- *dynamic* — everything after the boundary (changes per session).
|
|
19
|
+
|
|
20
|
+
If the boundary is not found the entire prompt is treated as static and
|
|
21
|
+
*dynamic* is an empty string.
|
|
22
|
+
"""
|
|
23
|
+
idx = system.find(DYNAMIC_BOUNDARY)
|
|
24
|
+
if idx == -1:
|
|
25
|
+
return system, ""
|
|
26
|
+
|
|
27
|
+
static = system[:idx].rstrip()
|
|
28
|
+
dynamic = system[idx + len(DYNAMIC_BOUNDARY):].lstrip()
|
|
29
|
+
return static, dynamic
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""NAVAIA.md hierarchy discovery — mirrors claudeMd.ts.
|
|
2
|
+
|
|
3
|
+
Discovers, parses, and combines NAVAIA.md (and legacy CLAUDE.md) files
|
|
4
|
+
from multiple locations in priority order (lower priority first,
|
|
5
|
+
deeper directories = higher priority).
|
|
6
|
+
|
|
7
|
+
Backwards-compatible: .claude/ paths are checked as fallbacks so existing
|
|
8
|
+
Claude Code setups work without changes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_git_root(cwd: str) -> Optional[Path]:
|
|
21
|
+
"""Walk up from *cwd* to find the nearest directory containing .git."""
|
|
22
|
+
current = Path(cwd).resolve()
|
|
23
|
+
while True:
|
|
24
|
+
if (current / ".git").exists():
|
|
25
|
+
return current
|
|
26
|
+
parent = current.parent
|
|
27
|
+
if parent == current:
|
|
28
|
+
return None
|
|
29
|
+
current = parent
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def discover_context_files(cwd: str) -> list[tuple[Path, str]]:
|
|
33
|
+
"""Return (path, content) pairs in ascending priority order.
|
|
34
|
+
|
|
35
|
+
Discovery order (low -> high priority):
|
|
36
|
+
1. /etc/navaia/NAVAIA.md (managed / system-wide)
|
|
37
|
+
2. ~/.navaia/NAVAIA.md (user global)
|
|
38
|
+
3. For each directory from git root down to cwd:
|
|
39
|
+
a. NAVAIA.md (or CLAUDE.md fallback)
|
|
40
|
+
b. .navaia/NAVAIA.md (or .claude/CLAUDE.md fallback)
|
|
41
|
+
c. .navaia/rules/*.md (or .claude/rules/*.md fallback)
|
|
42
|
+
d. .navaia/NAVAIA.local.md (or .claude/CLAUDE.local.md fallback)
|
|
43
|
+
"""
|
|
44
|
+
results: list[tuple[Path, str]] = []
|
|
45
|
+
|
|
46
|
+
# --- 1. Managed (system-wide) ---
|
|
47
|
+
_try_append(Path("/etc/navaia/NAVAIA.md"), results)
|
|
48
|
+
|
|
49
|
+
# --- 2. User global ---
|
|
50
|
+
_try_append(Path.home() / ".navaia" / "NAVAIA.md", results)
|
|
51
|
+
|
|
52
|
+
# --- 3. Per-directory from git root down to cwd ---
|
|
53
|
+
git_root = find_git_root(cwd)
|
|
54
|
+
dirs_to_scan = _dirs_from_root_to_cwd(git_root, cwd)
|
|
55
|
+
|
|
56
|
+
for directory in dirs_to_scan:
|
|
57
|
+
# Root-level context file
|
|
58
|
+
_try_first(
|
|
59
|
+
[directory / "NAVAIA.md", directory / "CLAUDE.md"],
|
|
60
|
+
results,
|
|
61
|
+
)
|
|
62
|
+
# Nested config directory
|
|
63
|
+
_try_first(
|
|
64
|
+
[directory / ".navaia" / "NAVAIA.md", directory / ".claude" / "CLAUDE.md"],
|
|
65
|
+
results,
|
|
66
|
+
)
|
|
67
|
+
# Rules directory
|
|
68
|
+
_collect_rules_first(
|
|
69
|
+
[directory / ".navaia" / "rules", directory / ".claude" / "rules"],
|
|
70
|
+
results,
|
|
71
|
+
)
|
|
72
|
+
# Local overrides
|
|
73
|
+
_try_first(
|
|
74
|
+
[directory / ".navaia" / "NAVAIA.local.md", directory / ".claude" / "CLAUDE.local.md"],
|
|
75
|
+
results,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return results
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Keep the old name as an alias for any existing callers
|
|
82
|
+
discover_claude_md_files = discover_context_files
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# Parsing helpers
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
90
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
91
|
+
_INCLUDE_RE = re.compile(r"^@include\s+(.+)$", re.MULTILINE)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
95
|
+
"""Extract optional YAML frontmatter from *content*.
|
|
96
|
+
|
|
97
|
+
Returns (metadata_dict, body_without_frontmatter).
|
|
98
|
+
If no frontmatter is present, metadata_dict is empty.
|
|
99
|
+
"""
|
|
100
|
+
match = _FRONTMATTER_RE.match(content)
|
|
101
|
+
if not match:
|
|
102
|
+
return {}, content
|
|
103
|
+
|
|
104
|
+
raw_yaml = match.group(1)
|
|
105
|
+
try:
|
|
106
|
+
metadata = yaml.safe_load(raw_yaml) or {}
|
|
107
|
+
except yaml.YAMLError:
|
|
108
|
+
metadata = {}
|
|
109
|
+
|
|
110
|
+
body = content[match.end():]
|
|
111
|
+
return metadata, body
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def strip_html_comments(text: str) -> str:
|
|
115
|
+
"""Remove ``<!-- ... -->`` comments from *text*."""
|
|
116
|
+
return _HTML_COMMENT_RE.sub("", text)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def process_includes(
|
|
120
|
+
text: str,
|
|
121
|
+
base_dir: Path,
|
|
122
|
+
visited: set[Path] | None = None,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Recursively resolve ``@include <relative-path>`` directives.
|
|
125
|
+
|
|
126
|
+
*visited* tracks already-included paths to prevent cycles.
|
|
127
|
+
"""
|
|
128
|
+
if visited is None:
|
|
129
|
+
visited = set()
|
|
130
|
+
|
|
131
|
+
def _replacer(match: re.Match) -> str:
|
|
132
|
+
rel_path = match.group(1).strip()
|
|
133
|
+
target = (base_dir / rel_path).resolve()
|
|
134
|
+
|
|
135
|
+
if target in visited or not target.is_file():
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
visited.add(target)
|
|
139
|
+
included = _read_text(target)
|
|
140
|
+
if included is None:
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
# Recurse so included files can themselves include others.
|
|
144
|
+
return process_includes(included, target.parent, visited)
|
|
145
|
+
|
|
146
|
+
return _INCLUDE_RE.sub(_replacer, text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_user_context(cwd: str) -> str:
|
|
150
|
+
"""Combine all discovered context files into a single context string.
|
|
151
|
+
|
|
152
|
+
Each file's content is processed (frontmatter stripped, comments removed,
|
|
153
|
+
includes resolved) and separated by a blank line.
|
|
154
|
+
"""
|
|
155
|
+
files = discover_context_files(cwd)
|
|
156
|
+
if not files:
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
sections: list[str] = []
|
|
160
|
+
for path, raw_content in files:
|
|
161
|
+
_meta, body = parse_frontmatter(raw_content)
|
|
162
|
+
body = strip_html_comments(body)
|
|
163
|
+
body = process_includes(body, path.parent, set())
|
|
164
|
+
body = body.strip()
|
|
165
|
+
if body:
|
|
166
|
+
sections.append(body)
|
|
167
|
+
|
|
168
|
+
return "\n\n".join(sections)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Internal helpers
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _read_text(path: Path) -> Optional[str]:
|
|
177
|
+
"""Read a file as UTF-8 text, returning None on any error."""
|
|
178
|
+
try:
|
|
179
|
+
return path.read_text(encoding="utf-8")
|
|
180
|
+
except (OSError, UnicodeDecodeError):
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _try_append(path: Path, results: list[tuple[Path, str]]) -> None:
|
|
185
|
+
"""Append *(path, content)* to *results* if *path* is a readable file."""
|
|
186
|
+
content = _read_text(path)
|
|
187
|
+
if content is not None:
|
|
188
|
+
results.append((path, content))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _try_first(
|
|
192
|
+
candidates: list[Path],
|
|
193
|
+
results: list[tuple[Path, str]],
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Try each candidate path in order; append the first one that exists."""
|
|
196
|
+
for path in candidates:
|
|
197
|
+
content = _read_text(path)
|
|
198
|
+
if content is not None:
|
|
199
|
+
results.append((path, content))
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _collect_rules(rules_dir: Path, results: list[tuple[Path, str]]) -> None:
|
|
204
|
+
"""Append all ``*.md`` files in *rules_dir* sorted alphabetically."""
|
|
205
|
+
if not rules_dir.is_dir():
|
|
206
|
+
return
|
|
207
|
+
for md_file in sorted(rules_dir.glob("*.md")):
|
|
208
|
+
if md_file.is_file():
|
|
209
|
+
_try_append(md_file, results)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _collect_rules_first(
|
|
213
|
+
candidates: list[Path],
|
|
214
|
+
results: list[tuple[Path, str]],
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Try each rules directory candidate; use the first one that has .md files."""
|
|
217
|
+
for rules_dir in candidates:
|
|
218
|
+
if rules_dir.is_dir() and any(rules_dir.glob("*.md")):
|
|
219
|
+
_collect_rules(rules_dir, results)
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _dirs_from_root_to_cwd(
|
|
224
|
+
git_root: Optional[Path],
|
|
225
|
+
cwd: str,
|
|
226
|
+
) -> list[Path]:
|
|
227
|
+
"""Return the list of directories from *git_root* down to *cwd* (inclusive).
|
|
228
|
+
|
|
229
|
+
If *git_root* is None, only *cwd* itself is returned.
|
|
230
|
+
"""
|
|
231
|
+
resolved_cwd = Path(cwd).resolve()
|
|
232
|
+
|
|
233
|
+
if git_root is None:
|
|
234
|
+
return [resolved_cwd]
|
|
235
|
+
|
|
236
|
+
git_root = git_root.resolve()
|
|
237
|
+
|
|
238
|
+
# Build path from git_root down to cwd.
|
|
239
|
+
try:
|
|
240
|
+
relative = resolved_cwd.relative_to(git_root)
|
|
241
|
+
except ValueError:
|
|
242
|
+
# cwd is not under git_root (shouldn't happen but be safe).
|
|
243
|
+
return [resolved_cwd]
|
|
244
|
+
|
|
245
|
+
dirs: list[Path] = [git_root]
|
|
246
|
+
accumulated = git_root
|
|
247
|
+
for part in relative.parts:
|
|
248
|
+
accumulated = accumulated / part
|
|
249
|
+
if accumulated != git_root:
|
|
250
|
+
dirs.append(accumulated)
|
|
251
|
+
|
|
252
|
+
return dirs
|