"""Pure filesystem helpers: path checks, formatting, diffs, AI text plumbing.
Nothing here touches a `Tool` instance — `tool` composes these.
"""
import difflib
import re
from datetime import datetime
from pathlib import Path
from navi.config import settings
from .._internal.areas import allowed_areas, resolve_in_areas
from .._internal.base import (
current_session_id,
current_user_id,
current_user_role,
current_working_directory,
)
_READ_WARN_BYTES = 100_000 # 100 KB — add size warning in output
_READ_HARD_BYTES = 1_000_000 # 1 MB — refuse full read without offset/limit
_LIST_MAX_ENTRIES = 500
_FIND_MAX_RESULTS = 200
# AI actions: ~20k tokens of file content per chunk (4 chars ≈ 1 token)
_AI_CHUNK_CHARS = 80_000
_AI_OVERLAP_LINES = 30
# smart_edit: refuse files larger than ~50k tokens (full file must fit in one call)
_AI_EDIT_MAX_CHARS = 200_000
# ── System prompts ────────────────────────────────────────────────────────────
_QUERY_SINGLE_SYSTEM = (
"You are a precise file analysis assistant. "
"Answer the question based strictly on the file content shown. "
"Be specific and concise. Include line numbers when relevant."
)
_QUERY_CHUNK_SYSTEM = (
"You are analyzing one section of a larger file. "
"Answer the question using only the lines shown. "
"If the answer is not present in this section, respond with exactly: NOT_FOUND\n"
"Otherwise be specific and include line numbers."
)
_QUERY_SYNTHESIS_SYSTEM = (
"Combine these partial findings from different sections of a file into one clear answer. "
"Remove duplicates. Be direct and concise."
)
_EDIT_SYSTEM = (
"You are a precise file editor. "
"Given file content with line numbers and an instruction, output ONLY a JSON array of edit operations. "
"No explanation, no markdown — just the JSON.\n\n"
"Format:\n"
"[\n"
' {"op": "replace", "start": LINE, "end": LINE, "content": "new text\\nmore lines"},\n'
' {"op": "delete", "start": LINE, "end": LINE},\n'
' {"op": "insert", "after": LINE, "content": "text to insert"}\n'
"]\n\n"
"Rules:\n"
"- Line numbers are 1-based and inclusive\n"
"- Use \\n in content strings for embedded newlines\n"
"- Make MINIMAL changes to accomplish the instruction\n"
"- 'insert' after=0 inserts before the first line\n"
"- If no changes are needed, return []"
)
# ── Path helpers ──────────────────────────────────────────────────────────────
def _resolve_relative_path(
p: Path, user_id: str | None, role: str | None, cwd: str | None = None
) -> Path:
"""Resolve a relative path against the session cwd when available.
In single-user/legacy/admin mode, uses the client-provided working directory
so that `filesystem read src/main.py` works from the project directory the
user launched Navi Code in, regardless of the server's cwd.
"""
if user_id and role != "admin":
# Sandbox mode keeps its own resolution rules.
return p
cwd = cwd or current_working_directory.get(None)
if cwd:
return (Path(cwd) / p).resolve()
return p.resolve()
def _check_path(
path_str: str,
user_id: str | None = None,
role: str | None = None,
cwd: str | None = None,
) -> Path | None:
"""Return resolved Path if access is allowed, else None.
When a user_id is active (multi-user mode), all paths are resolved
inside user_data/<user_id>/ or inside the current session directory.
This prevents users from accessing each other's files or random OS
directories, while still letting a user read what an upload put in the
session and what share_file hands back.
"""
if not path_str or path_str.strip() == "":
return None
try:
p = Path(path_str)
except Exception:
return None
user_id = user_id or current_user_id.get(None)
role = role or current_user_role.get()
# Admins bypass sandbox and use FS_ALLOWED_PATHS directly
if user_id and role != "admin":
# Sandboxed mode: the user's own sandbox, plus the current session
# directory. Relative paths keep resolving inside the sandbox.
areas = allowed_areas(user_id, current_session_id.get(None))
return resolve_in_areas(p, areas)
# Fallback to FS_ALLOWED_PATHS for single-user / legacy mode / admin
try:
if not p.is_absolute():
p = _resolve_relative_path(p, user_id, role, cwd=cwd)
else:
p = p.expanduser().resolve()
except Exception:
return None
if settings.fs_allowed_paths.strip() == "*":
return p
allowed = [Path(r).expanduser().resolve() for r in settings.fs_allowed_paths_list]
for root in allowed:
try:
p.relative_to(root)
return p
except ValueError:
continue
return None
def _fmt_size(n: int) -> str:
if n < 1024:
return f"{n} B"
if n < 1024**2:
return f"{n / 1024:.1f} KB"
if n < 1024**3:
return f"{n / 1024**2:.1f} MB"
return f"{n / 1024**3:.1f} GB"
def _fmt_time(ts: float) -> str:
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
# ── AI helpers (module-level, no self) ────────────────────────────────────────
def _number_lines(lines: list[str], start: int = 1) -> str:
"""Return file lines with 1-based line numbers, right-aligned."""
width = len(str(start + len(lines) - 1))
return "\n".join(f"{start + i:>{width}}: {line}" for i, line in enumerate(lines))
def _make_chunks(lines: list[str], target_chars: int, overlap: int) -> list[tuple[int, int]]:
"""
Split lines into (start_idx, end_idx) chunks of at most target_chars characters.
Consecutive chunks overlap by `overlap` lines to preserve boundary context.
"""
if not lines:
return [(0, 0)]
total = len(lines)
total_chars = sum(len(line) + 1 for line in lines)
if total_chars <= target_chars:
return [(0, total)]
chunks: list[tuple[int, int]] = []
start = 0
while start < total:
chars = 0
end = start
while end < total and chars < target_chars:
chars += len(lines[end]) + 1
end += 1
chunks.append((start, end))
if end >= total:
break
start = max(start + 1, end - overlap) # always make progress
return chunks or [(0, total)]
def _validate_ops(ops: list, total_lines: int) -> list[str]:
errors: list[str] = []
for i, op in enumerate(ops):
if not isinstance(op, dict):
errors.append(f"op[{i}] is not a dict")
continue
kind = op.get("op")
if kind not in ("replace", "delete", "insert"):
errors.append(f"op[{i}] unknown type {kind!r}")
continue
if kind in ("replace", "delete"):
s, e = op.get("start"), op.get("end")
if not isinstance(s, int) or not isinstance(e, int):
errors.append(f"op[{i}] start/end must be integers")
elif s < 1 or e > total_lines or s > e:
errors.append(f"op[{i}] range {s}-{e} out of bounds (file has {total_lines} lines)")
elif kind == "insert":
after = op.get("after")
if not isinstance(after, int):
errors.append(f"op[{i}] 'after' must be integer")
elif after < 0 or after > total_lines:
errors.append(f"op[{i}] 'after'={after} out of bounds (0–{total_lines})")
return errors
def _apply_ops(lines: list[str], ops: list[dict]) -> list[str]:
"""Apply edit operations bottom-up (highest line first) to preserve line numbers."""
sorted_ops = sorted(
ops,
key=lambda o: o.get("start", o.get("after", 0)),
reverse=True,
)
result = list(lines)
for op in sorted_ops:
kind = op["op"]
if kind == "replace":
s = op["start"] - 1 # 0-based
e = op["end"] # exclusive (1-based end = exclusive 0-based end)
new = op.get("content", "").split("\n")
result[s:e] = new
elif kind == "delete":
s = op["start"] - 1
e = op["end"]
del result[s:e]
elif kind == "insert":
after = op["after"] # insert after this 1-based line (0 = before line 1)
new = op.get("content", "").split("\n")
result[after:after] = new
return result
_DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
def _number_diff(diff_lines: list[str]) -> list[str]:
"""Prefix each unified-diff content line with its real file line number.
Numbers come from the enclosing ``@@ -old_start,n +new_start,n @@`` hunk:
removed and context lines use the old (from) line number, added lines use the
new (to) line number. The format ``{marker} {num:>width}│ {content}`` keeps the
``+``/``-``/`` `` marker first so existing ``startswith``-based highlighting
stays valid; a fixed space separates the marker from the right-aligned number
column. Hunk headers, file headers (``---``/``+++``) and the
``\\ No newline`` marker are left unchanged.
"""
# First pass: assign numbers, track the max for column width.
numbered: list[tuple[str, int | None, str]] = [] # (marker, num, content)
max_num = 0
old_line = 0
new_line = 0
for raw_line in diff_lines:
# difflib.unified_diff keeps a trailing "\n" on content lines when the
# input was fed with explicit newlines (see _unified_diff, which adds
# "\n" so difflib does not emit a "\ No newline at end of file" marker).
# Strip it here so the later "\n".join does not produce a blank line
# between every diff line. No-op for the _diff action, whose lines come
# from splitlines() without trailing newlines.
line = raw_line.rstrip("\n")
if line.startswith("@@"):
m = _DIFF_HUNK_RE.match(line)
if m:
old_line = int(m.group(1))
new_line = int(m.group(3))
numbered.append(("", None, line))
continue
if line.startswith("---") or line.startswith("+++") or line.startswith("\\"):
numbered.append(("", None, line))
continue
marker = line[0] if line else " "
content = line[1:]
if marker == "+":
num = new_line
new_line += 1
elif marker == "-":
num = old_line
old_line += 1
else: # " " context — present in both old and new
num = old_line
old_line += 1
new_line += 1
if num > max_num:
max_num = num
numbered.append((marker, num, content))
width = len(str(max_num)) if max_num else 1
out: list[str] = []
for marker, num, content in numbered:
if num is None:
out.append(content)
else:
out.append(f"{marker} {num:>{width}}│ {content}")
return out
def _unified_diff(original: list[str], modified: list[str], path: Path) -> str:
diff = list(
difflib.unified_diff(
[line + "\n" for line in original],
[line + "\n" for line in modified],
fromfile=f"a/{path.name}",
tofile=f"b/{path.name}",
lineterm="",
)
)
return "\n".join(_number_diff(diff))