codegraph-cli 2.1.0__py3-none-any.whl → 2.1.2__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.
- codegraph_cli/__init__.py +1 -1
- codegraph_cli/agents.py +59 -3
- codegraph_cli/chat_agent.py +58 -11
- codegraph_cli/cli.py +569 -54
- codegraph_cli/cli_chat.py +204 -94
- codegraph_cli/cli_diagnose.py +13 -2
- codegraph_cli/cli_docs.py +207 -0
- codegraph_cli/cli_explore.py +1053 -0
- codegraph_cli/cli_export.py +941 -0
- codegraph_cli/cli_groups.py +33 -0
- codegraph_cli/cli_health.py +316 -0
- codegraph_cli/cli_history.py +213 -0
- codegraph_cli/cli_onboard.py +380 -0
- codegraph_cli/cli_quickstart.py +256 -0
- codegraph_cli/cli_refactor.py +17 -3
- codegraph_cli/cli_setup.py +12 -12
- codegraph_cli/cli_suggestions.py +90 -0
- codegraph_cli/cli_test.py +17 -3
- codegraph_cli/cli_tui.py +210 -0
- codegraph_cli/cli_v2.py +24 -4
- codegraph_cli/cli_watch.py +158 -0
- codegraph_cli/cli_workflows.py +255 -0
- codegraph_cli/codegen_agent.py +15 -1
- codegraph_cli/config.py +18 -5
- codegraph_cli/context_manager.py +117 -15
- codegraph_cli/crew_agents.py +32 -8
- codegraph_cli/crew_chat.py +146 -13
- codegraph_cli/crew_tools.py +30 -2
- codegraph_cli/embeddings.py +95 -5
- codegraph_cli/llm.py +42 -55
- codegraph_cli/project_context.py +64 -1
- codegraph_cli/rag.py +282 -19
- codegraph_cli/storage.py +310 -14
- codegraph_cli/vector_store.py +110 -8
- {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/METADATA +75 -21
- codegraph_cli-2.1.2.dist-info/RECORD +55 -0
- codegraph_cli-2.1.2.dist-info/entry_points.txt +2 -0
- codegraph_cli-2.1.0.dist-info/RECORD +0 -43
- codegraph_cli-2.1.0.dist-info/entry_points.txt +0 -2
- {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/WHEEL +0 -0
- {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/licenses/LICENSE +0 -0
- {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/top_level.txt +0 -0
codegraph_cli/crew_chat.py
CHANGED
|
@@ -4,11 +4,16 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
from typing import TYPE_CHECKING, Dict, List
|
|
8
9
|
|
|
9
10
|
from datetime import datetime
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
try:
|
|
13
|
+
from crewai import Agent, Crew, Task
|
|
14
|
+
CREWAI_AVAILABLE = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
CREWAI_AVAILABLE = False
|
|
12
17
|
|
|
13
18
|
from .crew_agents import (
|
|
14
19
|
create_code_analysis_agent,
|
|
@@ -47,7 +52,7 @@ class CrewChatAgent:
|
|
|
47
52
|
|
|
48
53
|
# ── Provider env vars for LiteLLM compatibility ───
|
|
49
54
|
if llm.api_key:
|
|
50
|
-
provider = (llm.
|
|
55
|
+
provider = (llm.provider_name or "").lower()
|
|
51
56
|
if provider == "openrouter" or (llm.endpoint and "openrouter.ai" in llm.endpoint):
|
|
52
57
|
os.environ["OPENROUTER_API_KEY"] = llm.api_key
|
|
53
58
|
if llm.endpoint:
|
|
@@ -83,27 +88,53 @@ class CrewChatAgent:
|
|
|
83
88
|
self.file_ops_agent = create_file_ops_agent(tools["file_ops"], crew_llm, project_ctx)
|
|
84
89
|
self.code_gen_agent = create_code_gen_agent(tools["all"], crew_llm, project_ctx)
|
|
85
90
|
self.code_analysis_agent = create_code_analysis_agent(tools["code_analysis"], crew_llm, project_ctx)
|
|
86
|
-
|
|
91
|
+
# Give coordinator ALL tools so it can read files, search code, and delegate
|
|
92
|
+
self.coordinator = create_coordinator_agent(crew_llm, project_ctx, tools=tools["all"])
|
|
93
|
+
|
|
94
|
+
# ── Conversation memory for multi-turn continuity ─
|
|
95
|
+
self._history: list[dict] = []
|
|
96
|
+
self._max_history_pairs: int = 10
|
|
87
97
|
|
|
88
98
|
# ── Public API ────────────────────────────────────────
|
|
89
99
|
|
|
90
100
|
def process_message(self, user_message: str) -> str:
|
|
91
|
-
"""Process a user message via CrewAI multi-agent pipeline.
|
|
101
|
+
"""Process a user message via CrewAI multi-agent pipeline.
|
|
102
|
+
|
|
103
|
+
Injects conversation history into the task description so agents
|
|
104
|
+
can follow up on previous suggestions and requests.
|
|
105
|
+
"""
|
|
92
106
|
try:
|
|
93
107
|
proj_summary = self.context.get_project_summary()
|
|
94
|
-
|
|
108
|
+
|
|
109
|
+
# ── Build context with project info + conversation history ──
|
|
110
|
+
context_parts = [
|
|
95
111
|
f"Project: {proj_summary.get('project_name', 'Unknown')}, "
|
|
96
112
|
f"Root: {proj_summary.get('source_path', '.')}, "
|
|
97
|
-
f"Indexed files: {proj_summary.get('indexed_files', 0)}
|
|
98
|
-
|
|
113
|
+
f"Indexed files: {proj_summary.get('indexed_files', 0)}",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
history_block = self._format_history()
|
|
117
|
+
if history_block:
|
|
118
|
+
context_parts.append(
|
|
119
|
+
"\n── PREVIOUS CONVERSATION (use this to understand follow-up requests) ──\n"
|
|
120
|
+
f"{history_block}\n"
|
|
121
|
+
"── END PREVIOUS CONVERSATION ──"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
context_parts.append(f"\nCurrent User Request: {user_message}")
|
|
99
125
|
|
|
100
126
|
task = Task(
|
|
101
|
-
description=
|
|
127
|
+
description="\n".join(context_parts),
|
|
102
128
|
expected_output=(
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
129
|
+
"Concrete results based on actual project files.\n"
|
|
130
|
+
"CRITICAL: If the user refers to something from the previous conversation "
|
|
131
|
+
"(e.g. 'apply those changes', 'implement what you suggested', 'do it', "
|
|
132
|
+
"'make those improvements'), you MUST look at the PREVIOUS CONVERSATION "
|
|
133
|
+
"section above to understand exactly what was discussed, then ACT on it "
|
|
134
|
+
"using write_file or patch_file tools.\n"
|
|
135
|
+
"For code changes: MUST use write_file or patch_file tools to actually "
|
|
136
|
+
"modify files. Don't just describe changes — actually apply them and "
|
|
137
|
+
"confirm they were applied. After making changes, read the file to verify."
|
|
107
138
|
),
|
|
108
139
|
agent=self.coordinator,
|
|
109
140
|
)
|
|
@@ -121,11 +152,113 @@ class CrewChatAgent:
|
|
|
121
152
|
)
|
|
122
153
|
|
|
123
154
|
result = crew.kickoff()
|
|
124
|
-
|
|
155
|
+
raw = str(result.raw) if hasattr(result, "raw") else str(result)
|
|
156
|
+
response = self._clean_response(raw)
|
|
157
|
+
|
|
158
|
+
# ── Store in conversation memory ──
|
|
159
|
+
self._history.append({"role": "user", "content": user_message})
|
|
160
|
+
self._history.append({"role": "assistant", "content": response})
|
|
161
|
+
self._trim_history()
|
|
162
|
+
|
|
163
|
+
return response
|
|
125
164
|
|
|
126
165
|
except Exception as e:
|
|
127
166
|
return f"❌ Error: {e}\n\nPlease try rephrasing your request."
|
|
128
167
|
|
|
168
|
+
# ── Response sanitisation ─────────────────────────────
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _clean_response(text: str) -> str:
|
|
172
|
+
"""Strip LLM reasoning artifacts from the crew output.
|
|
173
|
+
|
|
174
|
+
Some models (e.g. DeepSeek, StepFun) put their real answer inside
|
|
175
|
+
<think>…</think> tags and only emit a tiny fragment afterwards.
|
|
176
|
+
If stripping <think> would leave less than 100 useful chars, we
|
|
177
|
+
extract the *content* of the think block as the real answer.
|
|
178
|
+
"""
|
|
179
|
+
# ── Phase 1: Handle <think> blocks smartly ────────
|
|
180
|
+
think_match = re.search(r"<think>([\s\S]*?)</think>", text)
|
|
181
|
+
if think_match:
|
|
182
|
+
think_content = think_match.group(1).strip()
|
|
183
|
+
without_think = re.sub(r"<think>[\s\S]*?</think>", "", text)
|
|
184
|
+
without_think = re.sub(r"</?think>", "", without_think).strip()
|
|
185
|
+
# If the part outside <think> is too short, the real answer
|
|
186
|
+
# is inside the think block
|
|
187
|
+
if len(without_think) < 100 and len(think_content) > len(without_think):
|
|
188
|
+
text = think_content
|
|
189
|
+
else:
|
|
190
|
+
text = without_think
|
|
191
|
+
else:
|
|
192
|
+
# Stray opening / closing think tags (no matched pair)
|
|
193
|
+
text = re.sub(r"</?think>", "", text)
|
|
194
|
+
|
|
195
|
+
# ── Phase 2: Strip tool-call / XML artifacts ──────
|
|
196
|
+
text = re.sub(r"<tool_call>[\s\S]*?</tool_call>", "", text)
|
|
197
|
+
text = re.sub(r"</?tool_call>", "", text)
|
|
198
|
+
text = re.sub(
|
|
199
|
+
r"</?(?:function|parameter|result|output|observation)(?:=[^>]*)?>\s*",
|
|
200
|
+
"", text,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# ── Phase 3: Clean up whitespace ──────────────────
|
|
204
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
205
|
+
return text.strip()
|
|
206
|
+
|
|
207
|
+
# ── Conversation memory helpers ───────────────────────
|
|
208
|
+
|
|
209
|
+
def _format_history(self) -> str:
|
|
210
|
+
"""Format conversation history for injection into task context.
|
|
211
|
+
|
|
212
|
+
Keeps the last 4 exchanges (8 messages). The most recent 2
|
|
213
|
+
exchanges are preserved in full; older ones are compressed to
|
|
214
|
+
~800 chars each to stay within LLM context limits.
|
|
215
|
+
"""
|
|
216
|
+
if not self._history:
|
|
217
|
+
return ""
|
|
218
|
+
|
|
219
|
+
# Show at most the last 8 messages (4 exchanges)
|
|
220
|
+
recent = self._history[-8:]
|
|
221
|
+
lines: list[str] = []
|
|
222
|
+
total = len(recent)
|
|
223
|
+
|
|
224
|
+
for i, msg in enumerate(recent):
|
|
225
|
+
role = "User" if msg["role"] == "user" else "Assistant"
|
|
226
|
+
content = msg["content"]
|
|
227
|
+
# Last 2 exchanges (4 msgs) get generous space; older ones compressed
|
|
228
|
+
is_recent = i >= total - 4
|
|
229
|
+
max_len = 3000 if is_recent else 800
|
|
230
|
+
if len(content) > max_len:
|
|
231
|
+
content = content[:max_len] + "\n... (truncated)"
|
|
232
|
+
lines.append(f"[{role}]:\n{content}")
|
|
233
|
+
|
|
234
|
+
if len(self._history) > 8:
|
|
235
|
+
lines.insert(0, f"... ({len(self._history) - 8} older messages omitted)")
|
|
236
|
+
|
|
237
|
+
return "\n\n".join(lines)
|
|
238
|
+
|
|
239
|
+
def _trim_history(self):
|
|
240
|
+
"""Keep only the last N exchange pairs to avoid unbounded growth."""
|
|
241
|
+
max_messages = self._max_history_pairs * 2
|
|
242
|
+
if len(self._history) > max_messages:
|
|
243
|
+
self._history = self._history[-max_messages:]
|
|
244
|
+
|
|
245
|
+
def clear_history(self):
|
|
246
|
+
"""Clear conversation memory (called on /clear and /new)."""
|
|
247
|
+
self._history.clear()
|
|
248
|
+
|
|
249
|
+
def load_session_history(self, session) -> None:
|
|
250
|
+
"""Load conversation history from a persisted ChatSession.
|
|
251
|
+
|
|
252
|
+
Called when resuming a previous session so the crew retains
|
|
253
|
+
context from earlier exchanges.
|
|
254
|
+
"""
|
|
255
|
+
self._history.clear()
|
|
256
|
+
for msg in session.messages:
|
|
257
|
+
role = msg.role if hasattr(msg, "role") else msg["role"]
|
|
258
|
+
content = msg.content if hasattr(msg, "content") else msg["content"]
|
|
259
|
+
self._history.append({"role": role, "content": content})
|
|
260
|
+
self._trim_history()
|
|
261
|
+
|
|
129
262
|
# ── Rollback helpers (called from REPL) ───────────────
|
|
130
263
|
|
|
131
264
|
def list_all_backups(self) -> List[Dict]:
|
codegraph_cli/crew_tools.py
CHANGED
|
@@ -9,8 +9,32 @@ from datetime import datetime
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
from
|
|
12
|
+
try:
|
|
13
|
+
from crewai.tools import BaseTool
|
|
14
|
+
CREWAI_AVAILABLE = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
# Provide a dummy base class so the module can still be imported
|
|
17
|
+
class BaseTool: # type: ignore
|
|
18
|
+
def __init_subclass__(cls, **kwargs): pass
|
|
19
|
+
def __init__(self, **kwargs): pass
|
|
20
|
+
CREWAI_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
24
|
+
except ImportError:
|
|
25
|
+
from dataclasses import dataclass, field as _field
|
|
26
|
+
|
|
27
|
+
class BaseModel: # type: ignore
|
|
28
|
+
def __init_subclass__(cls, **kwargs): pass
|
|
29
|
+
def __init__(self, **kwargs):
|
|
30
|
+
for k, v in kwargs.items():
|
|
31
|
+
setattr(self, k, v)
|
|
32
|
+
|
|
33
|
+
def Field(*args, **kwargs): # type: ignore
|
|
34
|
+
return kwargs.get("default", None)
|
|
35
|
+
|
|
36
|
+
def PrivateAttr(*args, **kwargs): # type: ignore
|
|
37
|
+
return kwargs.get("default", None)
|
|
14
38
|
|
|
15
39
|
if TYPE_CHECKING:
|
|
16
40
|
from .project_context import ProjectContext
|
|
@@ -265,6 +289,10 @@ class DeleteFileTool(ContextTool):
|
|
|
265
289
|
backup_id = _backup_file(ctx.source_path, path, tag="delete")
|
|
266
290
|
full_path = ctx.source_path / path
|
|
267
291
|
full_path.unlink()
|
|
292
|
+
|
|
293
|
+
# Remove stale nodes from the code graph
|
|
294
|
+
ctx.remove_from_index(path)
|
|
295
|
+
|
|
268
296
|
return f"✅ Deleted {path}\n Backup: {backup_id} (use rollback_file to restore)"
|
|
269
297
|
except Exception as e:
|
|
270
298
|
return f"Error deleting file: {e}"
|
codegraph_cli/embeddings.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Configurable code embedding engine with multiple model support.
|
|
2
2
|
|
|
3
|
-
Supported models (configure via ``cg set-embedding``):
|
|
3
|
+
Supported models (configure via ``cg config set-embedding``):
|
|
4
4
|
|
|
5
5
|
========== ====================================== ========= ====== ======================
|
|
6
6
|
Key HuggingFace Model Download Dim Notes
|
|
@@ -301,7 +301,7 @@ class HashEmbeddingModel:
|
|
|
301
301
|
|
|
302
302
|
Provides basic keyword-level similarity. Used as the default when
|
|
303
303
|
``torch``/``transformers`` are not installed or when ``hash`` is
|
|
304
|
-
selected via ``cg set-embedding hash``.
|
|
304
|
+
selected via ``cg config set-embedding hash``.
|
|
305
305
|
"""
|
|
306
306
|
|
|
307
307
|
def __init__(self, dim: int = 256) -> None:
|
|
@@ -396,14 +396,104 @@ def get_embedder(
|
|
|
396
396
|
# ===================================================================
|
|
397
397
|
|
|
398
398
|
def cosine_similarity(vec_a: List[float], vec_b: List[float]) -> float:
|
|
399
|
-
"""Cosine similarity between two
|
|
399
|
+
"""Cosine similarity between two vectors.
|
|
400
|
+
|
|
401
|
+
If vectors are already L2-normalised the dot product *is* the
|
|
402
|
+
cosine similarity. For safety this function still divides by
|
|
403
|
+
the product of norms so it works correctly even when vectors
|
|
404
|
+
are *not* pre-normalised.
|
|
405
|
+
|
|
406
|
+
Returns a value in ``[-1, 1]``. Zero-length or mismatched
|
|
407
|
+
vectors return ``0.0``.
|
|
408
|
+
"""
|
|
400
409
|
if not vec_a or not vec_b or len(vec_a) != len(vec_b):
|
|
401
410
|
return 0.0
|
|
402
|
-
|
|
411
|
+
dot = sum(a * b for a, b in zip(vec_a, vec_b))
|
|
412
|
+
norm_a = math.sqrt(sum(a * a for a in vec_a))
|
|
413
|
+
norm_b = math.sqrt(sum(b * b for b in vec_b))
|
|
414
|
+
if norm_a < 1e-12 or norm_b < 1e-12:
|
|
415
|
+
return 0.0
|
|
416
|
+
return dot / (norm_a * norm_b)
|
|
403
417
|
|
|
404
418
|
|
|
405
419
|
def _l2_normalize(vec: List[float]) -> List[float]:
|
|
420
|
+
"""L2-normalise *vec* in-place. Returns zero vector unchanged."""
|
|
406
421
|
norm = math.sqrt(sum(v * v for v in vec))
|
|
407
|
-
if norm
|
|
422
|
+
if norm < 1e-12:
|
|
408
423
|
return vec
|
|
409
424
|
return [v / norm for v in vec]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def embedding_norm(vec: List[float]) -> float:
|
|
428
|
+
"""Return the L2 norm of a vector."""
|
|
429
|
+
return math.sqrt(sum(v * v for v in vec))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ===================================================================
|
|
433
|
+
# Debug / Validation Utilities
|
|
434
|
+
# ===================================================================
|
|
435
|
+
|
|
436
|
+
def validate_embedding(vec: List[float], label: str = "embedding") -> Dict[str, Any]:
|
|
437
|
+
"""Validate an embedding vector and return diagnostic info.
|
|
438
|
+
|
|
439
|
+
Checks:
|
|
440
|
+
- Vector is non-empty
|
|
441
|
+
- Vector contains no NaN / Inf values
|
|
442
|
+
- L2 norm is approximately 1.0 (unit-normalised)
|
|
443
|
+
- Vector is not all-zeros
|
|
444
|
+
|
|
445
|
+
Returns a dict with ``ok``, ``norm``, ``dim``, and any ``warnings``.
|
|
446
|
+
"""
|
|
447
|
+
info: Dict[str, Any] = {
|
|
448
|
+
"label": label,
|
|
449
|
+
"dim": len(vec),
|
|
450
|
+
"ok": True,
|
|
451
|
+
"warnings": [],
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if not vec:
|
|
455
|
+
info["ok"] = False
|
|
456
|
+
info["warnings"].append("empty vector")
|
|
457
|
+
info["norm"] = 0.0
|
|
458
|
+
return info
|
|
459
|
+
|
|
460
|
+
norm = embedding_norm(vec)
|
|
461
|
+
info["norm"] = norm
|
|
462
|
+
|
|
463
|
+
if any(math.isnan(v) or math.isinf(v) for v in vec):
|
|
464
|
+
info["ok"] = False
|
|
465
|
+
info["warnings"].append("contains NaN or Inf")
|
|
466
|
+
|
|
467
|
+
if norm < 1e-9:
|
|
468
|
+
info["ok"] = False
|
|
469
|
+
info["warnings"].append("zero vector")
|
|
470
|
+
elif abs(norm - 1.0) > 0.01:
|
|
471
|
+
info["warnings"].append(f"not unit-normalised (norm={norm:.4f})")
|
|
472
|
+
|
|
473
|
+
return info
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def debug_embed(text: str, embedder: Optional[Union["TransformerEmbedder", "HashEmbeddingModel"]] = None) -> Dict[str, Any]:
|
|
477
|
+
"""Embed *text* and return detailed diagnostics.
|
|
478
|
+
|
|
479
|
+
If *embedder* is ``None`` the default embedder from config is used.
|
|
480
|
+
|
|
481
|
+
Returns dict with ``text``, ``dim``, ``norm``, ``first_5``,
|
|
482
|
+
``self_similarity``, and ``validation``.
|
|
483
|
+
"""
|
|
484
|
+
if embedder is None:
|
|
485
|
+
embedder = get_embedder()
|
|
486
|
+
|
|
487
|
+
vec = embedder.embed_text(text)
|
|
488
|
+
val = validate_embedding(vec, label=text[:60])
|
|
489
|
+
self_sim = cosine_similarity(vec, vec)
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
"text": text[:120],
|
|
493
|
+
"model": getattr(embedder, "model_key", "hash"),
|
|
494
|
+
"dim": len(vec),
|
|
495
|
+
"norm": val["norm"],
|
|
496
|
+
"first_5": vec[:5],
|
|
497
|
+
"self_similarity": self_sim,
|
|
498
|
+
"validation": val,
|
|
499
|
+
}
|
codegraph_cli/llm.py
CHANGED
|
@@ -51,7 +51,7 @@ class OllamaProvider(LLMProvider):
|
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
class GroqProvider(LLMProvider):
|
|
54
|
-
"""Groq cloud API provider
|
|
54
|
+
"""Groq cloud API provider."""
|
|
55
55
|
|
|
56
56
|
def __init__(self, model: str, api_key: str):
|
|
57
57
|
self.model = model
|
|
@@ -63,36 +63,24 @@ class GroqProvider(LLMProvider):
|
|
|
63
63
|
return None
|
|
64
64
|
|
|
65
65
|
try:
|
|
66
|
-
import
|
|
67
|
-
import tempfile
|
|
66
|
+
import requests
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"-H", "Content-Type: application/json",
|
|
83
|
-
"-H", f"Authorization: Bearer {self.api_key}",
|
|
84
|
-
"-d", payload,
|
|
85
|
-
"--max-time", "15"
|
|
86
|
-
],
|
|
87
|
-
capture_output=True,
|
|
88
|
-
text=True,
|
|
89
|
-
timeout=20
|
|
68
|
+
response = requests.post(
|
|
69
|
+
self.endpoint,
|
|
70
|
+
headers={
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
73
|
+
},
|
|
74
|
+
json={
|
|
75
|
+
"model": self.model,
|
|
76
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
77
|
+
"temperature": 0.1,
|
|
78
|
+
"max_tokens": 1024,
|
|
79
|
+
},
|
|
80
|
+
timeout=20,
|
|
90
81
|
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
response = json.loads(result.stdout)
|
|
94
|
-
return response["choices"][0]["message"]["content"]
|
|
95
|
-
return None
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
return response.json()["choices"][0]["message"]["content"]
|
|
96
84
|
except Exception:
|
|
97
85
|
return None
|
|
98
86
|
|
|
@@ -372,33 +360,24 @@ class LocalLLM:
|
|
|
372
360
|
|
|
373
361
|
def _chat_groq(self, messages: List[Dict], max_tokens: int, temperature: float) -> Optional[str]:
|
|
374
362
|
"""Chat completion for Groq."""
|
|
375
|
-
import
|
|
363
|
+
import requests
|
|
376
364
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
"
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
"-d", payload,
|
|
391
|
-
"--max-time", "30"
|
|
392
|
-
],
|
|
393
|
-
capture_output=True,
|
|
394
|
-
text=True,
|
|
395
|
-
timeout=35
|
|
365
|
+
response = requests.post(
|
|
366
|
+
self.provider.endpoint,
|
|
367
|
+
headers={
|
|
368
|
+
"Content-Type": "application/json",
|
|
369
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
370
|
+
},
|
|
371
|
+
json={
|
|
372
|
+
"model": self.model,
|
|
373
|
+
"messages": messages,
|
|
374
|
+
"temperature": temperature,
|
|
375
|
+
"max_tokens": max_tokens,
|
|
376
|
+
},
|
|
377
|
+
timeout=35,
|
|
396
378
|
)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
response = json.loads(result.stdout)
|
|
400
|
-
return response["choices"][0]["message"]["content"]
|
|
401
|
-
return None
|
|
379
|
+
response.raise_for_status()
|
|
380
|
+
return response.json()["choices"][0]["message"]["content"]
|
|
402
381
|
|
|
403
382
|
def _chat_openai(self, messages: List[Dict], max_tokens: int, temperature: float) -> Optional[str]:
|
|
404
383
|
"""Chat completion for OpenAI."""
|
|
@@ -578,6 +557,7 @@ def create_crewai_llm(local_llm: LocalLLM):
|
|
|
578
557
|
return LLM(
|
|
579
558
|
model=f"ollama/{local_llm.model}",
|
|
580
559
|
base_url=base_url,
|
|
560
|
+
max_tokens=4096,
|
|
581
561
|
)
|
|
582
562
|
|
|
583
563
|
elif provider == "groq":
|
|
@@ -585,6 +565,7 @@ def create_crewai_llm(local_llm: LocalLLM):
|
|
|
585
565
|
return LLM(
|
|
586
566
|
model=f"groq/{local_llm.model}",
|
|
587
567
|
api_key=local_llm.api_key,
|
|
568
|
+
max_tokens=4096,
|
|
588
569
|
)
|
|
589
570
|
|
|
590
571
|
elif provider == "openai":
|
|
@@ -595,25 +576,29 @@ def create_crewai_llm(local_llm: LocalLLM):
|
|
|
595
576
|
return LLM(
|
|
596
577
|
model=f"openrouter/{local_llm.model}",
|
|
597
578
|
api_key=local_llm.api_key,
|
|
579
|
+
max_tokens=4096,
|
|
598
580
|
)
|
|
599
581
|
elif local_llm.endpoint and local_llm.endpoint != "https://api.openai.com/v1/chat/completions":
|
|
600
582
|
# Other custom endpoint - use base_url
|
|
601
583
|
return LLM(
|
|
602
584
|
model=local_llm.model,
|
|
603
585
|
api_key=local_llm.api_key,
|
|
604
|
-
base_url=local_llm.endpoint.replace("/chat/completions", "")
|
|
586
|
+
base_url=local_llm.endpoint.replace("/chat/completions", ""),
|
|
587
|
+
max_tokens=4096,
|
|
605
588
|
)
|
|
606
589
|
else:
|
|
607
590
|
# Standard OpenAI
|
|
608
591
|
return LLM(
|
|
609
592
|
model=f"openai/{local_llm.model}",
|
|
610
593
|
api_key=local_llm.api_key,
|
|
594
|
+
max_tokens=4096,
|
|
611
595
|
)
|
|
612
596
|
|
|
613
597
|
elif provider == "anthropic":
|
|
614
598
|
return LLM(
|
|
615
599
|
model=f"anthropic/{local_llm.model}",
|
|
616
600
|
api_key=local_llm.api_key,
|
|
601
|
+
max_tokens=4096,
|
|
617
602
|
)
|
|
618
603
|
|
|
619
604
|
elif provider == "gemini":
|
|
@@ -621,6 +606,7 @@ def create_crewai_llm(local_llm: LocalLLM):
|
|
|
621
606
|
return LLM(
|
|
622
607
|
model=f"gemini/{local_llm.model}",
|
|
623
608
|
api_key=local_llm.api_key,
|
|
609
|
+
max_tokens=4096,
|
|
624
610
|
)
|
|
625
611
|
|
|
626
612
|
elif provider == "openrouter":
|
|
@@ -628,6 +614,7 @@ def create_crewai_llm(local_llm: LocalLLM):
|
|
|
628
614
|
return LLM(
|
|
629
615
|
model=f"openrouter/{local_llm.model}",
|
|
630
616
|
api_key=local_llm.api_key,
|
|
617
|
+
max_tokens=4096,
|
|
631
618
|
)
|
|
632
619
|
|
|
633
620
|
else:
|
codegraph_cli/project_context.py
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Dict, List, Optional
|
|
8
9
|
|
|
9
10
|
from .storage import GraphStore, ProjectManager
|
|
10
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
11
14
|
|
|
12
15
|
class ProjectContext:
|
|
13
16
|
"""Unified context for project with real file access and code graph."""
|
|
@@ -26,7 +29,10 @@ class ProjectContext:
|
|
|
26
29
|
self.metadata = self.store.get_metadata()
|
|
27
30
|
|
|
28
31
|
# Get source path from metadata
|
|
29
|
-
source_path_str =
|
|
32
|
+
source_path_str = (
|
|
33
|
+
self.metadata.get("source_path")
|
|
34
|
+
or self.metadata.get("project_root")
|
|
35
|
+
)
|
|
30
36
|
if source_path_str:
|
|
31
37
|
self.source_path = Path(source_path_str)
|
|
32
38
|
if not self.source_path.exists():
|
|
@@ -143,8 +149,65 @@ class ProjectContext:
|
|
|
143
149
|
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
150
|
|
|
145
151
|
full_path.write_text(content, encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
# Auto-reindex the file so the code graph stays current
|
|
154
|
+
self._incremental_reindex(rel_path)
|
|
155
|
+
|
|
146
156
|
return True
|
|
147
157
|
|
|
158
|
+
def _incremental_reindex(self, rel_path: str) -> None:
|
|
159
|
+
"""Parse and re-embed a single file into the code graph.
|
|
160
|
+
|
|
161
|
+
Called automatically after :meth:`write_file` so that newly
|
|
162
|
+
created or modified files are immediately searchable via RAG,
|
|
163
|
+
semantic search, and impact analysis — without requiring a
|
|
164
|
+
manual full re-index.
|
|
165
|
+
|
|
166
|
+
Silently skips if the source path is unavailable, the file
|
|
167
|
+
doesn't exist on disk, or the embedder cannot be loaded.
|
|
168
|
+
"""
|
|
169
|
+
if not self.has_source_access:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
file_path = self.source_path / rel_path
|
|
173
|
+
if not file_path.is_file():
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
from .embeddings import get_embedder
|
|
178
|
+
|
|
179
|
+
embedder = get_embedder()
|
|
180
|
+
model_key = getattr(embedder, "model_key", "hash")
|
|
181
|
+
|
|
182
|
+
count = self.store.index_single_file(
|
|
183
|
+
file_path=file_path,
|
|
184
|
+
project_root=self.source_path,
|
|
185
|
+
embedder=embedder,
|
|
186
|
+
model_key=model_key,
|
|
187
|
+
)
|
|
188
|
+
if count:
|
|
189
|
+
logger.info(
|
|
190
|
+
"Auto-indexed %d nodes for %s", count, rel_path,
|
|
191
|
+
)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
# Never let indexing failures break file operations
|
|
194
|
+
logger.debug("Incremental reindex failed for %s: %s", rel_path, exc)
|
|
195
|
+
|
|
196
|
+
def remove_from_index(self, rel_path: str) -> int:
|
|
197
|
+
"""Remove a file's nodes and edges from the code graph.
|
|
198
|
+
|
|
199
|
+
Called after deleting a file so stale symbols don't appear in
|
|
200
|
+
search results.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Number of nodes removed.
|
|
204
|
+
"""
|
|
205
|
+
try:
|
|
206
|
+
return self.store.remove_nodes_for_file(rel_path)
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
logger.debug("remove_from_index failed for %s: %s", rel_path, exc)
|
|
209
|
+
return 0
|
|
210
|
+
|
|
148
211
|
def file_exists(self, rel_path: str) -> bool:
|
|
149
212
|
"""Check if a file exists in the project.
|
|
150
213
|
|