Skip to content

Commit 326f90d

Browse files
Scot Campbellprefrontalsysclaude
authored
feat: Add content preview mode to reduce search context usage (v0.7.0) (#83)
* feat: Add content preview mode to reduce search context usage (v0.7.0) **Problem:** - Search results returned full memory content, causing context overload - 10 results with 2000 chars each = 20,000+ chars consumed immediately - Inconsistent behavior between STM (full content) and LTM (truncated to 500) **Solution:** - Add `preview_length` parameter to search_memory and search_unified - Default to 300 characters (90% reduction in context usage) - Configurable via CORTEXGRAPH_SEARCH_PREVIEW_LENGTH env var - Full content available by passing `preview_length=0` **Changes:** - config.py: Add `search_default_preview_length` config (default: 300) - search.py: Add `preview_length` parameter and `_truncate_content()` helper - search_unified.py: Add `preview_length` parameter with consistent truncation - Both tools now apply preview consistently across STM and LTM results **Benefits:** - 90% reduction in context usage for typical searches - Consistent truncation behavior across all search types - User can opt-in to full content when needed - Backward compatible (just changes default behavior) **Testing:** - Standalone tests verify truncation logic (6 test cases) - Config validation ensures preview_length in valid range (0-5000) - Type checking passes (mypy) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: Fix mocked config in embedding tests for preview_length Three tests in test_tools_search.py were failing in CI because they mock get_config() but didn't include the new search_default_preview_length property added in the content preview feature. Fixed tests: - test_search_with_embeddings (line 310) - test_search_embeddings_disabled (line 336) - test_search_embedding_import_error (line 354) Each now includes: mock_config.return_value.search_default_preview_length = 300 This resolves all 9 CI failures (3 OSes × 3 Python versions). --------- Co-authored-by: Prefrontal Systems <prefrontalsys@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43467ba commit 326f90d

4 files changed

Lines changed: 91 additions & 12 deletions

File tree

src/cortexgraph/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,12 @@ class Config(BaseModel):
276276
description="Weight for LTM results in unified search",
277277
ge=0,
278278
)
279+
search_default_preview_length: int = Field(
280+
default=300,
281+
description="Default number of characters to return in search results (0 = full content)",
282+
ge=0,
283+
le=5000,
284+
)
279285

280286
# Legacy Integration (deprecated) — removed
281287
basic_memory_path: Path | None | None = None
@@ -439,6 +445,8 @@ def from_env(cls) -> "Config":
439445
config_dict["search_stm_weight"] = float(search_stm_weight)
440446
if search_ltm_weight := os.getenv("SEARCH_LTM_WEIGHT"):
441447
config_dict["search_ltm_weight"] = float(search_ltm_weight)
448+
if search_preview_length := os.getenv("CORTEXGRAPH_SEARCH_PREVIEW_LENGTH"):
449+
config_dict["search_default_preview_length"] = int(search_preview_length)
442450

443451
# Legacy Integration (ignored)
444452

src/cortexgraph/tools/search.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,23 @@ def _generate_query_embedding(query: str) -> list[float] | None:
7272
return None
7373

7474

75+
def _truncate_content(content: str, max_length: int | None) -> str:
76+
"""
77+
Truncate content to specified length with ellipsis.
78+
79+
Args:
80+
content: The content to truncate.
81+
max_length: Maximum length (None or 0 = no truncation).
82+
83+
Returns:
84+
Truncated content with "..." appended if truncated.
85+
"""
86+
if max_length is None or max_length == 0 or len(content) <= max_length:
87+
return content
88+
89+
return content[:max_length].rstrip() + "..."
90+
91+
7592
@mcp.tool()
7693
@time_operation("search_memory")
7794
def search_memory(
@@ -84,6 +101,7 @@ def search_memory(
84101
include_review_candidates: bool = True,
85102
page: int | None = None,
86103
page_size: int | None = None,
104+
preview_length: int | None = None,
87105
) -> dict[str, Any]:
88106
"""
89107
Search for memories with optional filters and scoring.
@@ -92,6 +110,10 @@ def search_memory(
92110
for review into results when they're relevant. This creates the "Maslow
93111
effect" - natural reinforcement through conversation.
94112
113+
**Content Preview (v0.7.0):** By default, returns first 300 characters of each
114+
memory to reduce context usage. Pass `preview_length=0` for full content, or
115+
set a custom length (1-5000 characters).
116+
95117
**Pagination:** Results are paginated to help you find specific memories across
96118
large result sets. Use `page` and `page_size` to navigate through results.
97119
If a search term isn't found on the first page, increment `page` to see more results.
@@ -106,6 +128,7 @@ def search_memory(
106128
include_review_candidates: Blend in memories due for review (default True).
107129
page: Page number to retrieve (1-indexed, default: 1).
108130
page_size: Number of memories per page (default: 10, max: 100).
131+
preview_length: Content preview length in chars (default: 300, 0 = full content).
109132
110133
Returns:
111134
Dictionary with paginated results including:
@@ -115,14 +138,14 @@ def search_memory(
115138
Some results may be review candidates that benefit from reinforcement.
116139
117140
Examples:
118-
# Get first page (10 results)
141+
# Get first page with previews (default 300 chars)
119142
search_memory(query="authentication", page=1, page_size=10)
120143
121-
# Get next page
122-
search_memory(query="authentication", page=2, page_size=10)
144+
# Get full content
145+
search_memory(query="authentication", preview_length=0)
123146
124-
# Larger page size
125-
search_memory(query="authentication", page=1, page_size=25)
147+
# Custom preview length
148+
search_memory(query="authentication", preview_length=500)
126149
127150
Raises:
128151
ValueError: If any input fails validation.
@@ -148,10 +171,21 @@ def search_memory(
148171
if min_score is not None:
149172
min_score = validate_score(min_score, "min_score")
150173

174+
# Validate preview_length
175+
if preview_length is not None:
176+
preview_length = validate_positive_int(
177+
preview_length, "preview_length", min_value=0, max_value=5000
178+
)
179+
151180
# Only validate pagination if explicitly requested
152181
pagination_requested = page is not None or page_size is not None
153182

154183
config = get_config()
184+
185+
# Use config default if preview_length not specified
186+
if preview_length is None:
187+
preview_length = config.search_default_preview_length
188+
155189
now = int(time.time())
156190

157191
memories = db.search_memories(
@@ -263,7 +297,7 @@ def search_memory(
263297
"results": [
264298
{
265299
"id": r.memory.id,
266-
"content": r.memory.content,
300+
"content": _truncate_content(r.memory.content, preview_length),
267301
"tags": r.memory.meta.tags,
268302
"score": round(r.score, 4),
269303
"similarity": round(r.similarity, 4) if r.similarity else None,
@@ -286,7 +320,7 @@ def search_memory(
286320
"results": [
287321
{
288322
"id": r.memory.id,
289-
"content": r.memory.content,
323+
"content": _truncate_content(r.memory.content, preview_length),
290324
"tags": r.memory.meta.tags,
291325
"score": round(r.score, 4),
292326
"similarity": round(r.similarity, 4) if r.similarity else None,

src/cortexgraph/tools/search_unified.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,23 @@
2020
from ..storage.ltm_index import LTMIndex
2121

2222

23+
def _truncate_content(content: str, max_length: int | None) -> str:
24+
"""
25+
Truncate content to specified length with ellipsis.
26+
27+
Args:
28+
content: The content to truncate.
29+
max_length: Maximum length (None or 0 = no truncation).
30+
31+
Returns:
32+
Truncated content with "..." appended if truncated.
33+
"""
34+
if max_length is None or max_length == 0 or len(content) <= max_length:
35+
return content
36+
37+
return content[:max_length].rstrip() + "..."
38+
39+
2340
class UnifiedSearchResult:
2441
"""Result from unified search across STM and LTM."""
2542

@@ -72,10 +89,15 @@ def search_unified(
7289
min_score: float | None = None,
7390
page: int | None = None,
7491
page_size: int | None = None,
92+
preview_length: int | None = None,
7593
) -> dict[str, Any]:
7694
"""
7795
Search across both STM and LTM with unified ranking.
7896
97+
**Content Preview (v0.7.0):** By default, returns first 300 characters of each
98+
memory to reduce context usage. Pass `preview_length=0` for full content, or
99+
set a custom length (1-5000 characters).
100+
79101
**Pagination:** Results are paginated to help you find specific memories across
80102
large result sets from both short-term and long-term memory. Use `page` and `page_size`
81103
to navigate through results. If a search term isn't found on the first page,
@@ -91,18 +113,19 @@ def search_unified(
91113
min_score: Minimum score threshold for STM memories (0.0-1.0).
92114
page: Page number to retrieve (1-indexed, default: 1).
93115
page_size: Number of memories per page (default: 10, max: 100).
116+
preview_length: Content preview length in chars (default: 300, 0 = full content).
94117
95118
Returns:
96119
Dictionary with paginated results including:
97120
- results: List of matching memories from STM and LTM for current page
98121
- pagination: Metadata (page, page_size, total_count, total_pages, has_more)
99122
100123
Examples:
101-
# Get first page (10 results)
124+
# Get first page with previews (default 300 chars)
102125
search_unified(query="architecture", page=1, page_size=10)
103126
104-
# Get next page
105-
search_unified(query="architecture", page=2, page_size=10)
127+
# Get full content
128+
search_unified(query="architecture", preview_length=0)
106129
107130
Raises:
108131
ValueError: If any input fails validation.
@@ -129,10 +152,21 @@ def search_unified(
129152
if min_score is not None:
130153
min_score = validate_score(min_score, "min_score")
131154

155+
# Validate preview_length
156+
if preview_length is not None:
157+
preview_length = validate_positive_int(
158+
preview_length, "preview_length", min_value=0, max_value=5000
159+
)
160+
132161
# Only validate pagination if explicitly requested
133162
pagination_requested = page is not None or page_size is not None
134163

135164
config = get_config()
165+
166+
# Use config default if preview_length not specified
167+
if preview_length is None:
168+
preview_length = config.search_default_preview_length
169+
136170
results: list[UnifiedSearchResult] = []
137171

138172
# Search STM
@@ -154,7 +188,7 @@ def search_unified(
154188

155189
results.append(
156190
UnifiedSearchResult(
157-
content=memory.content,
191+
content=_truncate_content(memory.content, preview_length),
158192
title=f"Memory {memory.id[:8]}",
159193
source="stm",
160194
score=score * stm_weight,
@@ -203,7 +237,7 @@ def search_unified(
203237

204238
results.append(
205239
UnifiedSearchResult(
206-
content=doc.content[:500],
240+
content=_truncate_content(doc.content, preview_length),
207241
title=doc.title,
208242
source="ltm",
209243
score=relevance_score * ltm_weight,

tests/test_tools_search.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ def test_search_with_embeddings(self, mock_transformer, mock_config, temp_storag
307307
# Setup mocks
308308
mock_config.return_value.enable_embeddings = True
309309
mock_config.return_value.embed_model = "test-model"
310+
mock_config.return_value.search_default_preview_length = 300
310311
mock_model = MagicMock()
311312
mock_embedding = MagicMock()
312313
mock_embedding.tolist.return_value = [0.1, 0.2, 0.3]
@@ -332,6 +333,7 @@ def test_search_with_embeddings(self, mock_transformer, mock_config, temp_storag
332333
def test_search_embeddings_disabled(self, mock_config, temp_storage):
333334
"""Test that embeddings not used when disabled."""
334335
mock_config.return_value.enable_embeddings = False
336+
mock_config.return_value.search_default_preview_length = 300
335337

336338
mem = Memory(id="mem-1", content="Test", embed=[0.1, 0.2])
337339
temp_storage.save_memory(mem)
@@ -349,6 +351,7 @@ def test_search_embeddings_disabled(self, mock_config, temp_storage):
349351
def test_search_embedding_import_error(self, mock_transformer, mock_config, temp_storage):
350352
"""Test graceful handling of embedding import errors."""
351353
mock_config.return_value.enable_embeddings = True
354+
mock_config.return_value.search_default_preview_length = 300
352355
mock_transformer.side_effect = ImportError("No model")
353356

354357
mem = Memory(id=make_test_uuid("mem-1"), content="Test")

0 commit comments

Comments
 (0)