emdash-core 0.1.7__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.
- emdash_core/__init__.py +3 -0
- emdash_core/agent/__init__.py +37 -0
- emdash_core/agent/agents.py +225 -0
- emdash_core/agent/code_reviewer.py +476 -0
- emdash_core/agent/compaction.py +143 -0
- emdash_core/agent/context_manager.py +140 -0
- emdash_core/agent/events.py +338 -0
- emdash_core/agent/handlers.py +224 -0
- emdash_core/agent/inprocess_subagent.py +377 -0
- emdash_core/agent/mcp/__init__.py +50 -0
- emdash_core/agent/mcp/client.py +346 -0
- emdash_core/agent/mcp/config.py +302 -0
- emdash_core/agent/mcp/manager.py +496 -0
- emdash_core/agent/mcp/tool_factory.py +213 -0
- emdash_core/agent/prompts/__init__.py +38 -0
- emdash_core/agent/prompts/main_agent.py +104 -0
- emdash_core/agent/prompts/subagents.py +131 -0
- emdash_core/agent/prompts/workflow.py +136 -0
- emdash_core/agent/providers/__init__.py +34 -0
- emdash_core/agent/providers/base.py +143 -0
- emdash_core/agent/providers/factory.py +80 -0
- emdash_core/agent/providers/models.py +220 -0
- emdash_core/agent/providers/openai_provider.py +463 -0
- emdash_core/agent/providers/transformers_provider.py +217 -0
- emdash_core/agent/research/__init__.py +81 -0
- emdash_core/agent/research/agent.py +143 -0
- emdash_core/agent/research/controller.py +254 -0
- emdash_core/agent/research/critic.py +428 -0
- emdash_core/agent/research/macros.py +469 -0
- emdash_core/agent/research/planner.py +449 -0
- emdash_core/agent/research/researcher.py +436 -0
- emdash_core/agent/research/state.py +523 -0
- emdash_core/agent/research/synthesizer.py +594 -0
- emdash_core/agent/reviewer_profile.py +475 -0
- emdash_core/agent/rules.py +123 -0
- emdash_core/agent/runner.py +601 -0
- emdash_core/agent/session.py +262 -0
- emdash_core/agent/spec_schema.py +66 -0
- emdash_core/agent/specification.py +479 -0
- emdash_core/agent/subagent.py +397 -0
- emdash_core/agent/subagent_prompts.py +13 -0
- emdash_core/agent/toolkit.py +482 -0
- emdash_core/agent/toolkits/__init__.py +64 -0
- emdash_core/agent/toolkits/base.py +96 -0
- emdash_core/agent/toolkits/explore.py +47 -0
- emdash_core/agent/toolkits/plan.py +55 -0
- emdash_core/agent/tools/__init__.py +141 -0
- emdash_core/agent/tools/analytics.py +436 -0
- emdash_core/agent/tools/base.py +131 -0
- emdash_core/agent/tools/coding.py +484 -0
- emdash_core/agent/tools/github_mcp.py +592 -0
- emdash_core/agent/tools/history.py +13 -0
- emdash_core/agent/tools/modes.py +153 -0
- emdash_core/agent/tools/plan.py +206 -0
- emdash_core/agent/tools/plan_write.py +135 -0
- emdash_core/agent/tools/search.py +412 -0
- emdash_core/agent/tools/spec.py +341 -0
- emdash_core/agent/tools/task.py +262 -0
- emdash_core/agent/tools/task_output.py +204 -0
- emdash_core/agent/tools/tasks.py +454 -0
- emdash_core/agent/tools/traversal.py +588 -0
- emdash_core/agent/tools/web.py +179 -0
- emdash_core/analytics/__init__.py +5 -0
- emdash_core/analytics/engine.py +1286 -0
- emdash_core/api/__init__.py +5 -0
- emdash_core/api/agent.py +308 -0
- emdash_core/api/agents.py +154 -0
- emdash_core/api/analyze.py +264 -0
- emdash_core/api/auth.py +173 -0
- emdash_core/api/context.py +77 -0
- emdash_core/api/db.py +121 -0
- emdash_core/api/embed.py +131 -0
- emdash_core/api/feature.py +143 -0
- emdash_core/api/health.py +93 -0
- emdash_core/api/index.py +162 -0
- emdash_core/api/plan.py +110 -0
- emdash_core/api/projectmd.py +210 -0
- emdash_core/api/query.py +320 -0
- emdash_core/api/research.py +122 -0
- emdash_core/api/review.py +161 -0
- emdash_core/api/router.py +76 -0
- emdash_core/api/rules.py +116 -0
- emdash_core/api/search.py +119 -0
- emdash_core/api/spec.py +99 -0
- emdash_core/api/swarm.py +223 -0
- emdash_core/api/tasks.py +109 -0
- emdash_core/api/team.py +120 -0
- emdash_core/auth/__init__.py +17 -0
- emdash_core/auth/github.py +389 -0
- emdash_core/config.py +74 -0
- emdash_core/context/__init__.py +52 -0
- emdash_core/context/models.py +50 -0
- emdash_core/context/providers/__init__.py +11 -0
- emdash_core/context/providers/base.py +74 -0
- emdash_core/context/providers/explored_areas.py +183 -0
- emdash_core/context/providers/touched_areas.py +360 -0
- emdash_core/context/registry.py +73 -0
- emdash_core/context/reranker.py +199 -0
- emdash_core/context/service.py +260 -0
- emdash_core/context/session.py +352 -0
- emdash_core/core/__init__.py +104 -0
- emdash_core/core/config.py +454 -0
- emdash_core/core/exceptions.py +55 -0
- emdash_core/core/models.py +265 -0
- emdash_core/core/review_config.py +57 -0
- emdash_core/db/__init__.py +67 -0
- emdash_core/db/auth.py +134 -0
- emdash_core/db/models.py +91 -0
- emdash_core/db/provider.py +222 -0
- emdash_core/db/providers/__init__.py +5 -0
- emdash_core/db/providers/supabase.py +452 -0
- emdash_core/embeddings/__init__.py +24 -0
- emdash_core/embeddings/indexer.py +534 -0
- emdash_core/embeddings/models.py +192 -0
- emdash_core/embeddings/providers/__init__.py +7 -0
- emdash_core/embeddings/providers/base.py +112 -0
- emdash_core/embeddings/providers/fireworks.py +141 -0
- emdash_core/embeddings/providers/openai.py +104 -0
- emdash_core/embeddings/registry.py +146 -0
- emdash_core/embeddings/service.py +215 -0
- emdash_core/graph/__init__.py +26 -0
- emdash_core/graph/builder.py +134 -0
- emdash_core/graph/connection.py +692 -0
- emdash_core/graph/schema.py +416 -0
- emdash_core/graph/writer.py +667 -0
- emdash_core/ingestion/__init__.py +7 -0
- emdash_core/ingestion/change_detector.py +150 -0
- emdash_core/ingestion/git/__init__.py +5 -0
- emdash_core/ingestion/git/commit_analyzer.py +196 -0
- emdash_core/ingestion/github/__init__.py +6 -0
- emdash_core/ingestion/github/pr_fetcher.py +296 -0
- emdash_core/ingestion/github/task_extractor.py +100 -0
- emdash_core/ingestion/orchestrator.py +540 -0
- emdash_core/ingestion/parsers/__init__.py +10 -0
- emdash_core/ingestion/parsers/base_parser.py +66 -0
- emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
- emdash_core/ingestion/parsers/class_extractor.py +154 -0
- emdash_core/ingestion/parsers/function_extractor.py +202 -0
- emdash_core/ingestion/parsers/import_analyzer.py +119 -0
- emdash_core/ingestion/parsers/python_parser.py +123 -0
- emdash_core/ingestion/parsers/registry.py +72 -0
- emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
- emdash_core/ingestion/parsers/typescript_parser.py +278 -0
- emdash_core/ingestion/repository.py +346 -0
- emdash_core/models/__init__.py +38 -0
- emdash_core/models/agent.py +68 -0
- emdash_core/models/index.py +77 -0
- emdash_core/models/query.py +113 -0
- emdash_core/planning/__init__.py +7 -0
- emdash_core/planning/agent_api.py +413 -0
- emdash_core/planning/context_builder.py +265 -0
- emdash_core/planning/feature_context.py +232 -0
- emdash_core/planning/feature_expander.py +646 -0
- emdash_core/planning/llm_explainer.py +198 -0
- emdash_core/planning/similarity.py +509 -0
- emdash_core/planning/team_focus.py +821 -0
- emdash_core/server.py +153 -0
- emdash_core/sse/__init__.py +5 -0
- emdash_core/sse/stream.py +196 -0
- emdash_core/swarm/__init__.py +17 -0
- emdash_core/swarm/merge_agent.py +383 -0
- emdash_core/swarm/session_manager.py +274 -0
- emdash_core/swarm/swarm_runner.py +226 -0
- emdash_core/swarm/task_definition.py +137 -0
- emdash_core/swarm/worker_spawner.py +319 -0
- emdash_core/swarm/worktree_manager.py +278 -0
- emdash_core/templates/__init__.py +10 -0
- emdash_core/templates/defaults/agent-builder.md.template +82 -0
- emdash_core/templates/defaults/focus.md.template +115 -0
- emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
- emdash_core/templates/defaults/pr-review.md.template +80 -0
- emdash_core/templates/defaults/project.md.template +85 -0
- emdash_core/templates/defaults/research_critic.md.template +112 -0
- emdash_core/templates/defaults/research_planner.md.template +85 -0
- emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
- emdash_core/templates/defaults/reviewer.md.template +81 -0
- emdash_core/templates/defaults/spec.md.template +41 -0
- emdash_core/templates/defaults/tasks.md.template +78 -0
- emdash_core/templates/loader.py +296 -0
- emdash_core/utils/__init__.py +45 -0
- emdash_core/utils/git.py +84 -0
- emdash_core/utils/image.py +502 -0
- emdash_core/utils/logger.py +51 -0
- emdash_core-0.1.7.dist-info/METADATA +35 -0
- emdash_core-0.1.7.dist-info/RECORD +187 -0
- emdash_core-0.1.7.dist-info/WHEEL +4 -0
- emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Re-ranker for filtering context items by query relevance.
|
|
2
|
+
|
|
3
|
+
Uses a cross-encoder model to score context items against the current query,
|
|
4
|
+
keeping only the most relevant items to save tokens in the LLM context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
# Disable tokenizers parallelism to avoid fork warnings when running in threads
|
|
11
|
+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
|
12
|
+
|
|
13
|
+
from .models import ContextItem
|
|
14
|
+
from ..utils.logger import log
|
|
15
|
+
|
|
16
|
+
# Model singleton to avoid reloading
|
|
17
|
+
_reranker_model = None
|
|
18
|
+
_model_load_attempted = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_reranker_model():
|
|
22
|
+
"""Get or load the re-ranker model (singleton).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
CrossEncoder model or None if not available
|
|
26
|
+
"""
|
|
27
|
+
global _reranker_model, _model_load_attempted
|
|
28
|
+
|
|
29
|
+
if _model_load_attempted:
|
|
30
|
+
return _reranker_model
|
|
31
|
+
|
|
32
|
+
_model_load_attempted = True
|
|
33
|
+
|
|
34
|
+
# Check if re-ranking is enabled
|
|
35
|
+
if os.getenv("CONTEXT_RERANK_ENABLED", "true").lower() != "true":
|
|
36
|
+
log.debug("Context re-ranking disabled via CONTEXT_RERANK_ENABLED")
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from sentence_transformers import CrossEncoder
|
|
41
|
+
|
|
42
|
+
model_name = os.getenv(
|
|
43
|
+
"CONTEXT_RERANK_MODEL", "mixedbread-ai/mxbai-rerank-xsmall-v1"
|
|
44
|
+
)
|
|
45
|
+
log.info(f"Loading re-ranker model: {model_name}")
|
|
46
|
+
_reranker_model = CrossEncoder(model_name)
|
|
47
|
+
log.info("Re-ranker model loaded successfully")
|
|
48
|
+
return _reranker_model
|
|
49
|
+
except ImportError:
|
|
50
|
+
log.warning("sentence-transformers not installed, re-ranking disabled")
|
|
51
|
+
return None
|
|
52
|
+
except Exception as e:
|
|
53
|
+
log.warning(f"Failed to load re-ranker model: {e}")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def item_to_text(item: ContextItem) -> str:
|
|
58
|
+
"""Convert a ContextItem to text for re-ranking.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
item: Context item to convert
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Text representation for scoring
|
|
65
|
+
"""
|
|
66
|
+
parts = [item.qualified_name]
|
|
67
|
+
|
|
68
|
+
if item.entity_type:
|
|
69
|
+
parts.append(f"({item.entity_type})")
|
|
70
|
+
|
|
71
|
+
if item.description:
|
|
72
|
+
parts.append(f": {item.description[:200]}")
|
|
73
|
+
|
|
74
|
+
if item.file_path:
|
|
75
|
+
# Just include the filename, not full path
|
|
76
|
+
filename = os.path.basename(item.file_path)
|
|
77
|
+
parts.append(f" [file: {filename}]")
|
|
78
|
+
|
|
79
|
+
return " ".join(parts)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def rerank_context_items(
|
|
83
|
+
items: list[ContextItem],
|
|
84
|
+
query: str,
|
|
85
|
+
top_k: Optional[int] = None,
|
|
86
|
+
top_percent: Optional[float] = None,
|
|
87
|
+
) -> list[ContextItem]:
|
|
88
|
+
"""Re-rank context items by relevance to query.
|
|
89
|
+
|
|
90
|
+
Uses a cross-encoder model to score each item against the query,
|
|
91
|
+
then returns the top K or top N% most relevant items.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
items: List of context items to re-rank
|
|
95
|
+
query: The user's query/task description
|
|
96
|
+
top_k: Keep top K items (default from env: CONTEXT_RERANK_TOP_K=20)
|
|
97
|
+
top_percent: Keep top N% items (overrides top_k if set)
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Filtered and sorted list of context items (most relevant first)
|
|
101
|
+
"""
|
|
102
|
+
import time
|
|
103
|
+
|
|
104
|
+
original_count = len(items)
|
|
105
|
+
|
|
106
|
+
if not items:
|
|
107
|
+
return items
|
|
108
|
+
|
|
109
|
+
if not query or not query.strip():
|
|
110
|
+
log.debug("No query provided for re-ranking, returning original items")
|
|
111
|
+
return items
|
|
112
|
+
|
|
113
|
+
model = get_reranker_model()
|
|
114
|
+
if model is None:
|
|
115
|
+
log.debug("Re-ranker model not available, returning original items")
|
|
116
|
+
return items
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
start_time = time.time()
|
|
120
|
+
|
|
121
|
+
# Convert items to text for scoring
|
|
122
|
+
texts = [item_to_text(item) for item in items]
|
|
123
|
+
|
|
124
|
+
# Create query-document pairs
|
|
125
|
+
pairs = [(query, text) for text in texts]
|
|
126
|
+
|
|
127
|
+
# Score all pairs
|
|
128
|
+
scores = model.predict(pairs)
|
|
129
|
+
|
|
130
|
+
# Combine items with scores
|
|
131
|
+
scored_items = list(zip(items, scores))
|
|
132
|
+
|
|
133
|
+
# Sort by score descending
|
|
134
|
+
scored_items.sort(key=lambda x: x[1], reverse=True)
|
|
135
|
+
|
|
136
|
+
# Determine how many to keep
|
|
137
|
+
if top_percent is not None:
|
|
138
|
+
keep_count = max(1, int(len(items) * top_percent))
|
|
139
|
+
elif top_k is not None:
|
|
140
|
+
keep_count = min(top_k, len(items))
|
|
141
|
+
else:
|
|
142
|
+
# Default from environment
|
|
143
|
+
default_top_k = int(os.getenv("CONTEXT_RERANK_TOP_K", "20"))
|
|
144
|
+
keep_count = min(default_top_k, len(items))
|
|
145
|
+
|
|
146
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
147
|
+
|
|
148
|
+
# Log statistics
|
|
149
|
+
if scored_items:
|
|
150
|
+
max_score = scored_items[0][1]
|
|
151
|
+
min_score = scored_items[-1][1]
|
|
152
|
+
filtered_count = original_count - keep_count
|
|
153
|
+
log.info(
|
|
154
|
+
f"Re-ranked context: {original_count} -> {keep_count} items "
|
|
155
|
+
f"(filtered {filtered_count}) in {duration_ms:.0f}ms | "
|
|
156
|
+
f"scores [{min_score:.3f}-{max_score:.3f}] | "
|
|
157
|
+
f"query: '{query[:40]}...'"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Return top items (without scores)
|
|
161
|
+
return [item for item, score in scored_items[:keep_count]]
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
log.warning(f"Re-ranking failed: {e}, returning original items")
|
|
165
|
+
return items
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_rerank_scores(
|
|
169
|
+
items: list[ContextItem], query: str
|
|
170
|
+
) -> list[tuple[ContextItem, float]]:
|
|
171
|
+
"""Get re-rank scores for context items without filtering.
|
|
172
|
+
|
|
173
|
+
Useful for debugging and analysis.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
items: List of context items
|
|
177
|
+
query: Query to score against
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List of (item, score) tuples sorted by score descending
|
|
181
|
+
"""
|
|
182
|
+
if not items or not query:
|
|
183
|
+
return [(item, 0.0) for item in items]
|
|
184
|
+
|
|
185
|
+
model = get_reranker_model()
|
|
186
|
+
if model is None:
|
|
187
|
+
return [(item, 0.0) for item in items]
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
texts = [item_to_text(item) for item in items]
|
|
191
|
+
pairs = [(query, text) for text in texts]
|
|
192
|
+
scores = model.predict(pairs)
|
|
193
|
+
|
|
194
|
+
scored = list(zip(items, scores))
|
|
195
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
196
|
+
return scored
|
|
197
|
+
except Exception as e:
|
|
198
|
+
log.warning(f"Failed to get rerank scores: {e}")
|
|
199
|
+
return [(item, 0.0) for item in items]
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Context service - facade over providers and session management."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .models import ContextItem
|
|
9
|
+
from .reranker import rerank_context_items
|
|
10
|
+
from .registry import ContextProviderRegistry
|
|
11
|
+
from .session import SessionContextManager
|
|
12
|
+
from ..graph.connection import KuzuConnection, get_connection
|
|
13
|
+
from ..utils.logger import log
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContextService:
|
|
17
|
+
"""High-level service for managing session context.
|
|
18
|
+
|
|
19
|
+
Provides a unified interface for:
|
|
20
|
+
- Detecting modified files (via git diff)
|
|
21
|
+
- Extracting context from providers
|
|
22
|
+
- Managing session persistence
|
|
23
|
+
- Formatting context for LLM prompts
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, connection: Optional[KuzuConnection] = None, repo_root: Optional[str] = None):
|
|
27
|
+
"""Initialize context service.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
connection: Kuzu database connection (uses global if not provided)
|
|
31
|
+
repo_root: Repository root path for git operations
|
|
32
|
+
"""
|
|
33
|
+
self.connection = connection or get_connection()
|
|
34
|
+
self.repo_root = repo_root or os.getcwd()
|
|
35
|
+
self.session_manager = SessionContextManager(self.connection)
|
|
36
|
+
self._providers: Optional[list[str]] = None
|
|
37
|
+
self._min_score = float(os.getenv("CONTEXT_MIN_SCORE", "0.5"))
|
|
38
|
+
self._max_items = int(os.getenv("CONTEXT_MAX_ITEMS", "50"))
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def providers(self) -> list[str]:
|
|
42
|
+
"""Get list of enabled provider names from config."""
|
|
43
|
+
if self._providers is None:
|
|
44
|
+
env_val = os.getenv("CONTEXT_PROVIDERS", "touched_areas,explored_areas")
|
|
45
|
+
self._providers = [p.strip() for p in env_val.split(",") if p.strip()]
|
|
46
|
+
return self._providers
|
|
47
|
+
|
|
48
|
+
def detect_modified_files(self) -> list[str]:
|
|
49
|
+
"""Detect files modified since last commit.
|
|
50
|
+
|
|
51
|
+
Uses git diff to find modified files.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of modified file paths (absolute)
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
# Get unstaged changes
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["git", "diff", "--name-only"],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
cwd=self.repo_root,
|
|
63
|
+
timeout=10,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
files = []
|
|
67
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
68
|
+
files.extend(result.stdout.strip().split("\n"))
|
|
69
|
+
|
|
70
|
+
# Also get staged changes
|
|
71
|
+
result_staged = subprocess.run(
|
|
72
|
+
["git", "diff", "--name-only", "--cached"],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
cwd=self.repo_root,
|
|
76
|
+
timeout=10,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if result_staged.returncode == 0 and result_staged.stdout.strip():
|
|
80
|
+
files.extend(result_staged.stdout.strip().split("\n"))
|
|
81
|
+
|
|
82
|
+
# Convert to absolute paths and deduplicate
|
|
83
|
+
abs_files = []
|
|
84
|
+
seen = set()
|
|
85
|
+
for f in files:
|
|
86
|
+
if f and f not in seen:
|
|
87
|
+
seen.add(f)
|
|
88
|
+
abs_path = os.path.join(self.repo_root, f)
|
|
89
|
+
if os.path.exists(abs_path):
|
|
90
|
+
abs_files.append(abs_path)
|
|
91
|
+
|
|
92
|
+
return abs_files
|
|
93
|
+
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
log.warning("Git diff timed out")
|
|
96
|
+
return []
|
|
97
|
+
except FileNotFoundError:
|
|
98
|
+
log.warning("Git not found")
|
|
99
|
+
return []
|
|
100
|
+
except Exception as e:
|
|
101
|
+
log.warning(f"Failed to detect modified files: {e}")
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
def update_context(
|
|
105
|
+
self,
|
|
106
|
+
terminal_id: str,
|
|
107
|
+
modified_files: Optional[list[str]] = None,
|
|
108
|
+
exploration_steps: Optional[list] = None,
|
|
109
|
+
):
|
|
110
|
+
"""Update session context after changes.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
terminal_id: Terminal session identifier
|
|
114
|
+
modified_files: List of modified files (auto-detected if not provided)
|
|
115
|
+
exploration_steps: List of ExplorationStep objects from AgentSession
|
|
116
|
+
"""
|
|
117
|
+
if modified_files is None:
|
|
118
|
+
modified_files = self.detect_modified_files()
|
|
119
|
+
|
|
120
|
+
# Get or create session
|
|
121
|
+
session = self.session_manager.get_or_create_session(terminal_id)
|
|
122
|
+
|
|
123
|
+
# Extract context from all enabled providers
|
|
124
|
+
all_items = []
|
|
125
|
+
for provider_name in self.providers:
|
|
126
|
+
try:
|
|
127
|
+
# Import providers to ensure registration
|
|
128
|
+
from .providers import explored_areas, touched_areas # noqa: F401
|
|
129
|
+
|
|
130
|
+
provider = ContextProviderRegistry.get_provider(provider_name, self.connection)
|
|
131
|
+
|
|
132
|
+
# Different providers need different inputs
|
|
133
|
+
if provider_name == "touched_areas" and modified_files:
|
|
134
|
+
items = provider.extract_context(modified_files)
|
|
135
|
+
elif provider_name == "explored_areas" and exploration_steps:
|
|
136
|
+
items = provider.extract_context(exploration_steps)
|
|
137
|
+
else:
|
|
138
|
+
# Skip provider if no relevant input
|
|
139
|
+
log.debug(f"Skipping provider '{provider_name}' - no relevant input")
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
all_items.extend(items)
|
|
143
|
+
log.debug(f"Provider '{provider_name}' extracted {len(items)} items")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
log.warning(f"Provider '{provider_name}' failed: {e}")
|
|
146
|
+
|
|
147
|
+
# Add items to session
|
|
148
|
+
if all_items:
|
|
149
|
+
self.session_manager.add_context_items(session.session_id, all_items)
|
|
150
|
+
else:
|
|
151
|
+
log.debug("No context items extracted from any provider")
|
|
152
|
+
|
|
153
|
+
def get_context_prompt(self, terminal_id: str, query: Optional[str] = None) -> str:
|
|
154
|
+
"""Get formatted context for LLM system prompt.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
terminal_id: Terminal session identifier
|
|
158
|
+
query: Optional query to re-rank context by relevance
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Formatted context string for system prompt
|
|
162
|
+
"""
|
|
163
|
+
session = self.session_manager.get_or_create_session(terminal_id)
|
|
164
|
+
items = self.session_manager.get_context(session.session_id, self._min_score)
|
|
165
|
+
|
|
166
|
+
if not items:
|
|
167
|
+
return ""
|
|
168
|
+
|
|
169
|
+
# Re-rank by query relevance if query provided
|
|
170
|
+
if query:
|
|
171
|
+
items = rerank_context_items(items, query, top_k=self._max_items)
|
|
172
|
+
else:
|
|
173
|
+
# Limit number of items without re-ranking
|
|
174
|
+
items = items[: self._max_items]
|
|
175
|
+
|
|
176
|
+
# Deduplicate by file_path + qualified_name to avoid repetition
|
|
177
|
+
seen_keys = set()
|
|
178
|
+
unique_items = []
|
|
179
|
+
for item in items:
|
|
180
|
+
# Create unique key from file path and qualified name
|
|
181
|
+
key = (item.file_path or "", item.qualified_name)
|
|
182
|
+
if key not in seen_keys:
|
|
183
|
+
seen_keys.add(key)
|
|
184
|
+
unique_items.append(item)
|
|
185
|
+
items = unique_items
|
|
186
|
+
|
|
187
|
+
# Format as markdown
|
|
188
|
+
lines = [
|
|
189
|
+
"## Session Context",
|
|
190
|
+
"",
|
|
191
|
+
"The following code entities were recently modified or are related to recent changes:",
|
|
192
|
+
"",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
for item in items:
|
|
196
|
+
score_indicator = "***" if item.score > 0.8 else "**" if item.score > 0.5 else "*"
|
|
197
|
+
lines.append(f"### {score_indicator}{item.entity_type}: {item.qualified_name}{score_indicator}")
|
|
198
|
+
|
|
199
|
+
if item.file_path:
|
|
200
|
+
# Show relative path if possible
|
|
201
|
+
try:
|
|
202
|
+
rel_path = os.path.relpath(item.file_path, self.repo_root)
|
|
203
|
+
lines.append(f"- File: `{rel_path}`")
|
|
204
|
+
except ValueError:
|
|
205
|
+
lines.append(f"- File: `{item.file_path}`")
|
|
206
|
+
|
|
207
|
+
if item.description:
|
|
208
|
+
# Truncate long descriptions
|
|
209
|
+
desc = item.description.strip()
|
|
210
|
+
if len(desc) > 200:
|
|
211
|
+
desc = desc[:197] + "..."
|
|
212
|
+
lines.append(f"- Description: {desc}")
|
|
213
|
+
|
|
214
|
+
if item.neighbors:
|
|
215
|
+
neighbor_str = ", ".join(f"`{n}`" for n in item.neighbors[:5])
|
|
216
|
+
if len(item.neighbors) > 5:
|
|
217
|
+
neighbor_str += f" (+{len(item.neighbors) - 5} more)"
|
|
218
|
+
lines.append(f"- Related: {neighbor_str}")
|
|
219
|
+
|
|
220
|
+
lines.append("")
|
|
221
|
+
|
|
222
|
+
return "\n".join(lines)
|
|
223
|
+
|
|
224
|
+
def get_context_items(self, terminal_id: str) -> list[ContextItem]:
|
|
225
|
+
"""Get raw context items for a session.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
terminal_id: Terminal session identifier
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
List of context items
|
|
232
|
+
"""
|
|
233
|
+
session = self.session_manager.get_or_create_session(terminal_id)
|
|
234
|
+
return self.session_manager.get_context(session.session_id, self._min_score)
|
|
235
|
+
|
|
236
|
+
def clear_context(self, terminal_id: str):
|
|
237
|
+
"""Clear all context for a session.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
terminal_id: Terminal session identifier
|
|
241
|
+
"""
|
|
242
|
+
session = self.session_manager.get_or_create_session(terminal_id)
|
|
243
|
+
self.session_manager.clear_session(session.session_id)
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def get_terminal_id() -> str:
|
|
247
|
+
"""Get or generate terminal ID.
|
|
248
|
+
|
|
249
|
+
Uses EMDASH_TERMINAL_ID env var or generates a new one.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Terminal ID string
|
|
253
|
+
"""
|
|
254
|
+
import uuid
|
|
255
|
+
|
|
256
|
+
terminal_id = os.getenv("EMDASH_TERMINAL_ID")
|
|
257
|
+
if not terminal_id:
|
|
258
|
+
terminal_id = str(uuid.uuid4())
|
|
259
|
+
os.environ["EMDASH_TERMINAL_ID"] = terminal_id
|
|
260
|
+
return terminal_id
|