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.
Files changed (146) hide show
  1. navaia/__init__.py +3 -0
  2. navaia/api/__init__.py +0 -0
  3. navaia/api/client.py +72 -0
  4. navaia/api/normalise.py +148 -0
  5. navaia/api/retry.py +114 -0
  6. navaia/api/streaming.py +341 -0
  7. navaia/api/types.py +213 -0
  8. navaia/commands/__init__.py +0 -0
  9. navaia/commands/builtin/__init__.py +0 -0
  10. navaia/commands/builtin/commands.py +206 -0
  11. navaia/commands/dispatcher.py +38 -0
  12. navaia/commands/parser.py +25 -0
  13. navaia/commands/registry.py +48 -0
  14. navaia/commands/skills.py +150 -0
  15. navaia/commands/types.py +26 -0
  16. navaia/compact/__init__.py +0 -0
  17. navaia/compact/compact.py +241 -0
  18. navaia/compact/prompt.py +22 -0
  19. navaia/compact/restore.py +91 -0
  20. navaia/config/__init__.py +0 -0
  21. navaia/config/env.py +53 -0
  22. navaia/config/global_config.py +43 -0
  23. navaia/config/project_config.py +53 -0
  24. navaia/config/providers.py +234 -0
  25. navaia/config/settings.py +113 -0
  26. navaia/context/__init__.py +0 -0
  27. navaia/context/cache.py +29 -0
  28. navaia/context/claudemd.py +252 -0
  29. navaia/context/system_prompt.py +172 -0
  30. navaia/effort/__init__.py +0 -0
  31. navaia/effort/effort.py +47 -0
  32. navaia/hooks/__init__.py +0 -0
  33. navaia/hooks/executor.py +153 -0
  34. navaia/hooks/settings.py +82 -0
  35. navaia/hooks/types.py +53 -0
  36. navaia/main.py +462 -0
  37. navaia/mcp/__init__.py +0 -0
  38. navaia/mcp/bootstrap.py +88 -0
  39. navaia/mcp/client.py +157 -0
  40. navaia/mcp/settings.py +80 -0
  41. navaia/mcp/tools.py +118 -0
  42. navaia/mcp/types.py +29 -0
  43. navaia/memory/__init__.py +0 -0
  44. navaia/memory/memdir.py +70 -0
  45. navaia/memory/paths.py +17 -0
  46. navaia/memory/scanner.py +85 -0
  47. navaia/memory/types.py +27 -0
  48. navaia/permissions/__init__.py +0 -0
  49. navaia/permissions/checker.py +147 -0
  50. navaia/permissions/rules.py +88 -0
  51. navaia/permissions/types.py +39 -0
  52. navaia/query/__init__.py +0 -0
  53. navaia/query/engine.py +477 -0
  54. navaia/query/types.py +43 -0
  55. navaia/session/__init__.py +0 -0
  56. navaia/session/history.py +64 -0
  57. navaia/session/serialise.py +184 -0
  58. navaia/session/state.py +20 -0
  59. navaia/session/storage.py +102 -0
  60. navaia/session/store.py +202 -0
  61. navaia/state/__init__.py +0 -0
  62. navaia/tasks/__init__.py +0 -0
  63. navaia/tasks/cron.py +113 -0
  64. navaia/tasks/manager.py +112 -0
  65. navaia/tasks/persistence.py +128 -0
  66. navaia/tasks/task.py +34 -0
  67. navaia/thinking/__init__.py +0 -0
  68. navaia/thinking/budget.py +42 -0
  69. navaia/thinking/config.py +55 -0
  70. navaia/tools/__init__.py +0 -0
  71. navaia/tools/agent_tool/__init__.py +0 -0
  72. navaia/tools/agent_tool/tool.py +148 -0
  73. navaia/tools/ask_user/__init__.py +0 -0
  74. navaia/tools/ask_user/bus.py +51 -0
  75. navaia/tools/ask_user/tool.py +64 -0
  76. navaia/tools/base.py +51 -0
  77. navaia/tools/bash/__init__.py +0 -0
  78. navaia/tools/bash/background.py +123 -0
  79. navaia/tools/bash/tool.py +234 -0
  80. navaia/tools/executor.py +111 -0
  81. navaia/tools/file_edit/__init__.py +0 -0
  82. navaia/tools/file_edit/tool.py +206 -0
  83. navaia/tools/file_read/__init__.py +0 -0
  84. navaia/tools/file_read/tool.py +209 -0
  85. navaia/tools/file_write/__init__.py +0 -0
  86. navaia/tools/file_write/tool.py +112 -0
  87. navaia/tools/glob_tool/__init__.py +0 -0
  88. navaia/tools/glob_tool/tool.py +97 -0
  89. navaia/tools/grep_tool/__init__.py +0 -0
  90. navaia/tools/grep_tool/tool.py +292 -0
  91. navaia/tools/monitor/__init__.py +0 -0
  92. navaia/tools/monitor/tool.py +101 -0
  93. navaia/tools/plan_mode/__init__.py +0 -0
  94. navaia/tools/plan_mode/enter.py +38 -0
  95. navaia/tools/plan_mode/exit.py +36 -0
  96. navaia/tools/registry.py +71 -0
  97. navaia/tools/result_storage.py +60 -0
  98. navaia/tools/skill_tool/__init__.py +0 -0
  99. navaia/tools/skill_tool/loader.py +147 -0
  100. navaia/tools/skill_tool/tool.py +88 -0
  101. navaia/tools/task_tools/__init__.py +0 -0
  102. navaia/tools/task_tools/create.py +60 -0
  103. navaia/tools/task_tools/get.py +52 -0
  104. navaia/tools/task_tools/list.py +39 -0
  105. navaia/tools/task_tools/manager.py +66 -0
  106. navaia/tools/task_tools/update.py +88 -0
  107. navaia/tools/todo_write/__init__.py +0 -0
  108. navaia/tools/todo_write/tool.py +121 -0
  109. navaia/tools/tool_search/__init__.py +0 -0
  110. navaia/tools/tool_search/tool.py +106 -0
  111. navaia/tools/web_fetch/__init__.py +0 -0
  112. navaia/tools/web_fetch/tool.py +88 -0
  113. navaia/tools/worktree/__init__.py +0 -0
  114. navaia/tools/worktree/enter.py +66 -0
  115. navaia/tools/worktree/exit.py +51 -0
  116. navaia/tools/worktree/manager.py +130 -0
  117. navaia/ui/__init__.py +0 -0
  118. navaia/ui/app.py +605 -0
  119. navaia/ui/bidi.py +70 -0
  120. navaia/ui/input/__init__.py +0 -0
  121. navaia/ui/input/history.py +84 -0
  122. navaia/ui/input/suggestions.py +72 -0
  123. navaia/ui/messages/__init__.py +0 -0
  124. navaia/ui/messages/assistant_text.py +46 -0
  125. navaia/ui/messages/bash_output.py +68 -0
  126. navaia/ui/messages/system_msg.py +25 -0
  127. navaia/ui/messages/tool_result.py +38 -0
  128. navaia/ui/messages/tool_use.py +70 -0
  129. navaia/ui/messages/user_prompt.py +27 -0
  130. navaia/ui/screens/__init__.py +0 -0
  131. navaia/ui/screens/repl.py +136 -0
  132. navaia/ui/styles/app.tcss +48 -0
  133. navaia/ui/widgets/__init__.py +0 -0
  134. navaia/ui/widgets/logo.py +48 -0
  135. navaia/ui/widgets/markdown_view.py +87 -0
  136. navaia/ui/widgets/message_list.py +387 -0
  137. navaia/ui/widgets/permission_prompt.py +137 -0
  138. navaia/ui/widgets/prompt_footer.py +67 -0
  139. navaia/ui/widgets/prompt_input.py +203 -0
  140. navaia/ui/widgets/question_prompt.py +58 -0
  141. navaia/ui/widgets/spinner.py +110 -0
  142. navaia/ui/widgets/thinking_view.py +124 -0
  143. navaia_code-1.0.50.dist-info/METADATA +17 -0
  144. navaia_code-1.0.50.dist-info/RECORD +146 -0
  145. navaia_code-1.0.50.dist-info/WHEEL +4 -0
  146. 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
@@ -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