Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Detailed `README_AI.md` truncation preserves complete file sections** (GH
#178). The 10KB navigation budget now reserves its notice first, then cuts
at the last complete Markdown heading (`##` through `######`) instead of
raw bytes inside a `### file` symbol list. The fallback notice now directs
agents to the relevant source files rather than assuming per-module README
files exist. Tiny configured budgets remain bounded too.

## [0.37.0] - 2026-08-15

### Removed (BREAKING)
Expand Down
2 changes: 1 addition & 1 deletion src/codeindex/writers/README_AI.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- codeindex navigation index — agent: drill into source via Read/Grep for precise mechanism; do not treat this as final word. -->
<!-- Generated by codeindex (detailed) at 2026-08-15T23:23:40.946606 -->
<!-- Generated by codeindex (detailed) at 2026-08-18T16:06:36.207750 -->

# writers

Expand Down
35 changes: 24 additions & 11 deletions src/codeindex/writers/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,19 +299,32 @@ def truncate_content(content: str, max_size: int) -> tuple[str, bool]:
if len(content_bytes) <= max_size:
return content, False

# Find a good truncation point
truncated = content_bytes[:max_size - 200].decode('utf-8', errors='ignore')

# Try to truncate at a section boundary
last_section = truncated.rfind("\n## ")
if last_section > len(truncated) // 2:
truncated = truncated[:last_section]

# Add truncation notice
truncated += (
notice = (
"\n\n---\n"
"_Content truncated due to size limit. "
"See individual module README files for details._\n"
"Read the relevant source files for details._\n"
)
if len(notice.encode('utf-8')) > max_size:
notice = "_Truncated._\n"
if len(notice.encode('utf-8')) > max_size:
return notice[:max_size], True
budget = max(0, max_size - len(notice.encode('utf-8')))
truncated = content_bytes[:budget].decode('utf-8', errors='ignore')

# README generators use ## for document sections and ### for files. Prefer
# the last complete heading boundary so detailed output never ends midway
# through a file's symbol list.
last_section = max(
truncated.rfind(f"\n{'#' * level} ")
for level in range(2, 7)
)
if last_section >= 0:
truncated = truncated[:last_section]
else:
last_line = truncated.rfind("\n")
if last_line > 0:
truncated = truncated[:last_line]

truncated = truncated.rstrip() + notice

return truncated, True
2 changes: 1 addition & 1 deletion tests/test_smart_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ def test_smart_writer_size_limit():

assert result.success
assert result.truncated
assert result.size_bytes <= 1024 + 200 # Allow some margin for truncation notice
assert result.size_bytes <= 1024


def test_determine_level():
Expand Down
21 changes: 21 additions & 0 deletions tests/writers/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,30 @@ def test_truncates_at_section_boundary(self):
result, truncated = truncate_content(sections, 60)
assert truncated
assert "truncated" in result.lower()
assert len(result.encode("utf-8")) <= 60

def test_truncation_adds_notice(self):
content = "x" * 2000
result, truncated = truncate_content(content, 500)
assert truncated
assert "Content truncated" in result

def test_detailed_file_section_is_never_cut_mid_list_item(self):
"""Detailed output uses ``### file`` headings, not only ``##``."""
content = (
"# cli\n\n"
"## Files\n\n"
"### alpha.py\n"
"- `def alpha() -> None`\n\n"
"### beta.py\n"
"- `def beta_with_a_very_long_signature(" + "arg, " * 80 + ") -> None`\n"
)

result, truncated = truncate_content(content, 300)

assert truncated
assert "### alpha.py" in result
assert "### beta.py" not in result
assert result.count("`") % 2 == 0
assert "Read the relevant source files" in result
assert len(result.encode("utf-8")) <= 300
Loading