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,26 @@
1
+ """Command types for the slash commands system."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+
6
+
7
+ class CommandType(str, Enum):
8
+ LOCAL = "local"
9
+ PROMPT = "prompt"
10
+ WIDGET = "widget"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Command:
15
+ name: str
16
+ description: str
17
+ type: CommandType
18
+ aliases: list[str] = field(default_factory=list)
19
+ is_hidden: bool = False
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CommandResult:
24
+ output: str = ""
25
+ should_query: bool = False
26
+ should_exit: bool = False
File without changes
@@ -0,0 +1,241 @@
1
+ """Main compaction logic — summarizes long conversations to reclaim context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from openai import AsyncOpenAI
9
+
10
+ from navaia.api.types import (
11
+ AssistantMessage,
12
+ CompactBoundaryMessage,
13
+ ContentBlock,
14
+ Message,
15
+ SystemMessage,
16
+ TextBlock,
17
+ ToolResultBlock,
18
+ ToolUseBlock,
19
+ UserMessage,
20
+ )
21
+ from navaia.compact.prompt import BASE_COMPACT_PROMPT
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Constants
25
+ # ---------------------------------------------------------------------------
26
+
27
+ COMPACT_THRESHOLD = 0.85 # 85% of context window
28
+
29
+ _MODEL_CONTEXT_WINDOWS: dict[str, int] = {
30
+ "claude-3-opus-20240229": 200_000,
31
+ "claude-3-5-sonnet-20241022": 200_000,
32
+ "claude-3-haiku-20240307": 200_000,
33
+ "claude-sonnet-4-20250514": 200_000,
34
+ "claude-opus-4-20250514": 200_000,
35
+ "gpt-4o": 128_000,
36
+ "gpt-4-turbo": 128_000,
37
+ "gpt-4": 8_192,
38
+ "gpt-3.5-turbo": 16_385,
39
+ }
40
+
41
+ DEFAULT_CONTEXT_WINDOW = 128_000
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Result type
46
+ # ---------------------------------------------------------------------------
47
+
48
+ @dataclass(frozen=True)
49
+ class CompactResult:
50
+ """Immutable result of a compaction operation."""
51
+
52
+ messages: list[Message]
53
+ summary: str
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Token estimation
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def estimate_tokens(messages: list[Message]) -> int:
61
+ """Rough token count — approximately 4 characters per token."""
62
+ total_chars = 0
63
+ for msg in messages:
64
+ total_chars += _message_char_count(msg)
65
+ return total_chars // 4
66
+
67
+
68
+ def _message_char_count(msg: Message) -> int:
69
+ """Count characters in a single message."""
70
+ if isinstance(msg, UserMessage):
71
+ return _content_char_count(msg.content)
72
+ if isinstance(msg, AssistantMessage):
73
+ return _content_char_count(msg.content)
74
+ if isinstance(msg, SystemMessage):
75
+ return len(msg.text)
76
+ if isinstance(msg, CompactBoundaryMessage):
77
+ return len(msg.summary)
78
+ return 0
79
+
80
+
81
+ def _content_char_count(content: list[ContentBlock] | str) -> int:
82
+ """Count characters in message content."""
83
+ if isinstance(content, str):
84
+ return len(content)
85
+ total = 0
86
+ for block in content:
87
+ if isinstance(block, TextBlock):
88
+ total += len(block.text)
89
+ elif isinstance(block, ToolUseBlock):
90
+ total += len(block.name) + len(str(block.input))
91
+ elif isinstance(block, ToolResultBlock):
92
+ if isinstance(block.content, str):
93
+ total += len(block.content)
94
+ else:
95
+ total += len(str(block.content))
96
+ else:
97
+ # ThinkingBlock or unknown
98
+ total += len(str(getattr(block, "thinking", "")))
99
+ return total
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Context window lookup
104
+ # ---------------------------------------------------------------------------
105
+
106
+ def get_model_context_window(model: str) -> int:
107
+ """Return the context window size for a model, defaulting to 128k."""
108
+ # Check exact match first
109
+ if model in _MODEL_CONTEXT_WINDOWS:
110
+ return _MODEL_CONTEXT_WINDOWS[model]
111
+ # Check if the model name contains a known key (e.g. "openai/gpt-4o")
112
+ for known_model, window in _MODEL_CONTEXT_WINDOWS.items():
113
+ if known_model in model:
114
+ return window
115
+ return DEFAULT_CONTEXT_WINDOW
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Auto-compact check
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def should_auto_compact(messages: list[Message], model: str) -> bool:
123
+ """Return True when estimated tokens exceed the compaction threshold."""
124
+ if not messages:
125
+ return False
126
+ context_window = get_model_context_window(model)
127
+ token_estimate = estimate_tokens(messages)
128
+ return token_estimate >= int(context_window * COMPACT_THRESHOLD)
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Message formatting
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def format_messages_for_compact(messages: list[Message]) -> str:
136
+ """Format conversation messages as plain text for the summarization prompt.
137
+
138
+ Images and binary content are stripped; only text is preserved.
139
+ """
140
+ parts: list[str] = []
141
+ for msg in messages:
142
+ formatted = _format_single_message(msg)
143
+ if formatted:
144
+ parts.append(formatted)
145
+ return "\n\n".join(parts)
146
+
147
+
148
+ def _format_single_message(msg: Message) -> str:
149
+ """Format one message as a labeled text block."""
150
+ if isinstance(msg, UserMessage):
151
+ body = _content_to_text(msg.content)
152
+ return f"[User]\n{body}"
153
+ if isinstance(msg, AssistantMessage):
154
+ body = _content_to_text(msg.content)
155
+ return f"[Assistant]\n{body}"
156
+ if isinstance(msg, SystemMessage):
157
+ return f"[System ({msg.subtype})]\n{msg.text}"
158
+ if isinstance(msg, CompactBoundaryMessage):
159
+ return f"[Previous Summary]\n{msg.summary}"
160
+ return ""
161
+
162
+
163
+ def _content_to_text(content: list[ContentBlock] | str) -> str:
164
+ """Extract text from message content, stripping images."""
165
+ if isinstance(content, str):
166
+ return content
167
+ parts: list[str] = []
168
+ for block in content:
169
+ if isinstance(block, TextBlock):
170
+ parts.append(block.text)
171
+ elif isinstance(block, ToolUseBlock):
172
+ parts.append(f"[Tool call: {block.name}({_truncate(str(block.input), 500)})]")
173
+ elif isinstance(block, ToolResultBlock):
174
+ result_text = block.content if isinstance(block.content, str) else str(block.content)
175
+ prefix = "[Tool error]" if block.is_error else "[Tool result]"
176
+ parts.append(f"{prefix} {_truncate(result_text, 1000)}")
177
+ # ThinkingBlock and unknown blocks are intentionally skipped
178
+ return "\n".join(parts)
179
+
180
+
181
+ def _truncate(text: str, max_len: int) -> str:
182
+ """Truncate text with an ellipsis if it exceeds max_len."""
183
+ if len(text) <= max_len:
184
+ return text
185
+ return text[: max_len - 3] + "..."
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Summary extraction
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def extract_summary_block(raw: str) -> str:
193
+ """Extract content between <summary> tags from model output.
194
+
195
+ Returns the raw text if no tags are found (graceful fallback).
196
+ """
197
+ match = re.search(r"<summary>(.*?)</summary>", raw, re.DOTALL)
198
+ if match:
199
+ return match.group(1).strip()
200
+ return raw.strip()
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Main compaction pipeline
205
+ # ---------------------------------------------------------------------------
206
+
207
+ async def compact_conversation(
208
+ messages: list[Message],
209
+ client: AsyncOpenAI,
210
+ model: str,
211
+ ) -> CompactResult:
212
+ """Summarize the conversation and return a compacted message list.
213
+
214
+ Pipeline:
215
+ 1. Format messages as plain text (strip images, binary data).
216
+ 2. Call model with BASE_COMPACT_PROMPT to produce a summary.
217
+ 3. Extract the <summary> block from the response.
218
+ 4. Return a CompactResult with a single CompactBoundaryMessage.
219
+ """
220
+ formatted_text = format_messages_for_compact(messages)
221
+
222
+ prompt_content = (
223
+ f"{BASE_COMPACT_PROMPT}\n\n"
224
+ f"---\nConversation to summarize:\n---\n\n"
225
+ f"{formatted_text}"
226
+ )
227
+
228
+ response = await client.chat.completions.create(
229
+ model=model,
230
+ messages=[{"role": "user", "content": prompt_content}],
231
+ temperature=0.0,
232
+ max_tokens=8192,
233
+ )
234
+
235
+ raw_output = response.choices[0].message.content or ""
236
+ summary = extract_summary_block(raw_output)
237
+
238
+ boundary = CompactBoundaryMessage(summary=summary)
239
+ compacted_messages: list[Message] = [boundary]
240
+
241
+ return CompactResult(messages=compacted_messages, summary=summary)
@@ -0,0 +1,22 @@
1
+ """Compaction prompt template for conversation summarization."""
2
+
3
+ BASE_COMPACT_PROMPT = """
4
+ Your task is to create a detailed summary of the conversation so far.
5
+ This summary will replace the conversation for context window management.
6
+
7
+ Structure the summary as follows:
8
+
9
+ 1. Primary Request and Intent — what the user asked for, their goals
10
+ 2. Key Technical Concepts — technologies, patterns, decisions made
11
+ 3. Files and Code Sections — important files with snippets and why they matter
12
+ 4. Errors and Fixes — problems encountered and how they were resolved
13
+ 5. Problem Solving — approaches tried and outcomes
14
+ 6. All User Messages — every user message verbatim (not tool results)
15
+ 7. Pending Tasks — incomplete work, todos
16
+ 8. Current Work — most recent work with file names and relevant code
17
+ 9. Optional Next Step — direct quote from user's latest intent
18
+
19
+ Wrap your analysis in <analysis> tags.
20
+ Wrap the final summary in <summary> tags.
21
+ Be thorough — this summary is the ONLY context the next turn will have.
22
+ """
@@ -0,0 +1,91 @@
1
+ """Post-compact restoration — re-injects recent file context after compaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from navaia.api.types import (
6
+ AssistantMessage,
7
+ ContentBlock,
8
+ Message,
9
+ TextBlock,
10
+ ToolResultBlock,
11
+ ToolUseBlock,
12
+ UserMessage,
13
+ )
14
+
15
+
16
+ def restore_recent_files(messages: list[Message], max_files: int = 5) -> list[Message]:
17
+ """Extract the last N file-read results from pre-compact messages.
18
+
19
+ Scans messages in reverse to find tool results that look like file reads
20
+ (tool_use blocks named "Read", "read_file", etc.) and returns them as
21
+ synthetic UserMessage entries to append after the compact boundary so the
22
+ model retains recent file context.
23
+ """
24
+ _READ_TOOL_NAMES = frozenset({"Read", "read_file", "ReadFile", "read"})
25
+
26
+ file_read_pairs: list[Message] = []
27
+ seen_files: set[str] = set()
28
+
29
+ for msg in reversed(messages):
30
+ if len(file_read_pairs) >= max_files * 2:
31
+ break
32
+ if not isinstance(msg, (UserMessage, AssistantMessage)):
33
+ continue
34
+
35
+ content = msg.content
36
+ if isinstance(content, str):
37
+ continue
38
+
39
+ for block in content:
40
+ if len(seen_files) >= max_files:
41
+ break
42
+ if isinstance(block, ToolUseBlock) and block.name in _READ_TOOL_NAMES:
43
+ file_path = _extract_file_path(block.input)
44
+ if file_path and file_path not in seen_files:
45
+ result_block = _find_result_for_tool(messages, block.id)
46
+ if result_block is not None:
47
+ seen_files.add(file_path)
48
+ file_read_pairs.append(
49
+ UserMessage(
50
+ content=[
51
+ TextBlock(
52
+ text=(
53
+ f"[Restored file context: {file_path}]\n"
54
+ f"{_result_to_text(result_block)}"
55
+ )
56
+ )
57
+ ]
58
+ )
59
+ )
60
+
61
+ # Reverse to restore chronological order
62
+ file_read_pairs.reverse()
63
+ return file_read_pairs
64
+
65
+
66
+ def _extract_file_path(tool_input: dict) -> str | None:
67
+ """Pull the file path from a Read tool's input dict."""
68
+ for key in ("file_path", "path", "filePath", "filename"):
69
+ value = tool_input.get(key)
70
+ if isinstance(value, str) and value:
71
+ return value
72
+ return None
73
+
74
+
75
+ def _find_result_for_tool(messages: list[Message], tool_use_id: str) -> ToolResultBlock | None:
76
+ """Find the ToolResultBlock matching a given tool_use_id."""
77
+ for msg in messages:
78
+ content: list[ContentBlock] | str = getattr(msg, "content", "")
79
+ if isinstance(content, str):
80
+ continue
81
+ for block in content:
82
+ if isinstance(block, ToolResultBlock) and block.tool_use_id == tool_use_id:
83
+ return block
84
+ return None
85
+
86
+
87
+ def _result_to_text(block: ToolResultBlock) -> str:
88
+ """Convert a ToolResultBlock's content to plain text."""
89
+ if isinstance(block.content, str):
90
+ return block.content
91
+ return str(block.content)
File without changes
navaia/config/env.py ADDED
@@ -0,0 +1,53 @@
1
+ """Environment variable overrides for Navaia settings.
2
+
3
+ Any ``NAVAIA_*`` variable is mapped to the corresponding Settings field:
4
+
5
+ NAVAIA_MODEL -> default_model
6
+ NAVAIA_VERBOSE -> verbose (truthy: "1", "true", "yes")
7
+ NAVAIA_THEME -> theme
8
+ NAVAIA_VIM_MODE -> vim_mode (truthy: "1", "true", "yes")
9
+ NAVAIA_MAX_THINKING -> max_thinking_tokens
10
+ NAVAIA_THINKING -> always_thinking_enabled (truthy)
11
+ """
12
+
13
+ import os
14
+
15
+ _TRUTHY = frozenset({"1", "true", "yes"})
16
+
17
+ # Mapping: env var name -> (settings key, coerce function)
18
+ _ENV_MAP: dict[str, tuple[str, type]] = {
19
+ "NAVAIA_PROVIDER": ("provider", str),
20
+ "NAVAIA_MODEL": ("default_model", str),
21
+ "NAVAIA_VERBOSE": ("verbose", lambda v: v.lower() in _TRUTHY),
22
+ "NAVAIA_THEME": ("theme", str),
23
+ "NAVAIA_VIM_MODE": ("vim_mode", lambda v: v.lower() in _TRUTHY),
24
+ "NAVAIA_MAX_THINKING": ("max_thinking_tokens", int),
25
+ "NAVAIA_THINKING": (
26
+ "always_thinking_enabled",
27
+ lambda v: v.lower() in _TRUTHY,
28
+ ),
29
+ }
30
+
31
+
32
+ def get_env_overrides() -> dict:
33
+ """Read NAVAIA_* environment variables and return as a dict.
34
+
35
+ Only variables that are actually set in the environment are included.
36
+ Invalid values (e.g. non-numeric for max_thinking_tokens) are silently
37
+ skipped so a single bad variable does not prevent startup.
38
+ """
39
+ overrides: dict = {}
40
+ for env_var, (settings_key, coerce) in _ENV_MAP.items():
41
+ raw = os.environ.get(env_var)
42
+ if raw is None:
43
+ continue
44
+ try:
45
+ overrides[settings_key] = coerce(raw)
46
+ except (ValueError, TypeError):
47
+ # Skip malformed values rather than crashing at startup
48
+ import logging
49
+
50
+ logging.getLogger(__name__).warning(
51
+ "Ignoring invalid value for %s: %r", env_var, raw
52
+ )
53
+ return overrides
@@ -0,0 +1,43 @@
1
+ """Global configuration at ~/.navaia/."""
2
+
3
+ from pathlib import Path
4
+ import json
5
+
6
+
7
+ def get_navaia_home() -> Path:
8
+ """Return ~/.navaia/ directory, creating if needed."""
9
+ home = Path.home() / ".navaia"
10
+ home.mkdir(parents=True, exist_ok=True)
11
+ return home
12
+
13
+
14
+ def load_global_config() -> dict:
15
+ """Load ~/.navaia/config.json.
16
+
17
+ Returns an empty dict if the file does not exist or contains
18
+ invalid JSON.
19
+ """
20
+ path = get_navaia_home() / "config.json"
21
+ if not path.exists():
22
+ return {}
23
+ try:
24
+ return json.loads(path.read_text(encoding="utf-8"))
25
+ except (json.JSONDecodeError, OSError) as exc:
26
+ # Log but don't crash — fall back to defaults
27
+ import logging
28
+
29
+ logging.getLogger(__name__).warning(
30
+ "Failed to read global config at %s: %s", path, exc
31
+ )
32
+ return {}
33
+
34
+
35
+ def save_global_config(config: dict) -> None:
36
+ """Save to ~/.navaia/config.json.
37
+
38
+ Creates the parent directory if it does not exist.
39
+ """
40
+ if not isinstance(config, dict):
41
+ raise TypeError("config must be a dict")
42
+ path = get_navaia_home() / "config.json"
43
+ path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
@@ -0,0 +1,53 @@
1
+ """Per-project configuration at .navaia/ (falls back to .claude/ for compatibility)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import json
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def find_git_root(cwd: str) -> str | None:
13
+ """Walk up from *cwd* to find the nearest git root directory.
14
+
15
+ Returns the absolute path as a string, or None if no git root is
16
+ found before reaching the filesystem root.
17
+ """
18
+ current = Path(cwd).resolve()
19
+ while True:
20
+ if (current / ".git").exists():
21
+ return str(current)
22
+ parent = current.parent
23
+ if parent == current:
24
+ # Reached filesystem root
25
+ return None
26
+ current = parent
27
+
28
+
29
+ def load_project_config(cwd: str) -> dict:
30
+ """Load project config from .navaia/config.json (or .claude/config.json fallback).
31
+
32
+ Searches for the git root first; if found, looks for
33
+ ``<git_root>/.navaia/config.json`` then ``<git_root>/.claude/config.json``.
34
+ Falls back to ``<cwd>/`` when there is no git root.
35
+
36
+ Returns an empty dict when no config file is found or unreadable.
37
+ """
38
+ root = find_git_root(cwd)
39
+ base = Path(root) if root else Path(cwd)
40
+
41
+ # Try .navaia first, then .claude for backwards compatibility
42
+ for config_dir in [".navaia", ".claude"]:
43
+ config_path = base / config_dir / "config.json"
44
+ if config_path.exists():
45
+ try:
46
+ return json.loads(config_path.read_text(encoding="utf-8"))
47
+ except (json.JSONDecodeError, OSError) as exc:
48
+ logger.warning(
49
+ "Failed to read project config at %s: %s", config_path, exc
50
+ )
51
+ return {}
52
+
53
+ return {}