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
navaia/commands/types.py
ADDED
|
@@ -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)
|
navaia/compact/prompt.py
ADDED
|
@@ -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 {}
|