codegraph-cli 2.1.1__py3-none-any.whl → 2.1.3__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.
Files changed (44) hide show
  1. codegraph_cli/__init__.py +1 -1
  2. codegraph_cli/agents.py +59 -3
  3. codegraph_cli/chat_agent.py +58 -11
  4. codegraph_cli/cli.py +569 -54
  5. codegraph_cli/cli_chat.py +200 -95
  6. codegraph_cli/cli_diagnose.py +13 -2
  7. codegraph_cli/cli_docs.py +207 -0
  8. codegraph_cli/cli_explore.py +1053 -0
  9. codegraph_cli/cli_export.py +941 -0
  10. codegraph_cli/cli_groups.py +33 -0
  11. codegraph_cli/cli_health.py +316 -0
  12. codegraph_cli/cli_history.py +213 -0
  13. codegraph_cli/cli_onboard.py +380 -0
  14. codegraph_cli/cli_quickstart.py +256 -0
  15. codegraph_cli/cli_refactor.py +17 -3
  16. codegraph_cli/cli_setup.py +12 -12
  17. codegraph_cli/cli_suggestions.py +90 -0
  18. codegraph_cli/cli_test.py +17 -3
  19. codegraph_cli/cli_tui.py +210 -0
  20. codegraph_cli/cli_v2.py +24 -4
  21. codegraph_cli/cli_watch.py +158 -0
  22. codegraph_cli/cli_workflows.py +255 -0
  23. codegraph_cli/codegen_agent.py +15 -1
  24. codegraph_cli/config.py +18 -5
  25. codegraph_cli/context_manager.py +117 -15
  26. codegraph_cli/crew_agents.py +29 -10
  27. codegraph_cli/crew_chat.py +152 -13
  28. codegraph_cli/crew_tools.py +31 -4
  29. codegraph_cli/embeddings.py +95 -5
  30. codegraph_cli/llm.py +42 -55
  31. codegraph_cli/project_context.py +64 -1
  32. codegraph_cli/rag.py +282 -19
  33. codegraph_cli/security_scanner.py +39 -1
  34. codegraph_cli/storage.py +310 -14
  35. codegraph_cli/vector_store.py +126 -22
  36. codegraph_cli-2.1.3.dist-info/METADATA +431 -0
  37. codegraph_cli-2.1.3.dist-info/RECORD +55 -0
  38. codegraph_cli-2.1.3.dist-info/entry_points.txt +2 -0
  39. codegraph_cli-2.1.1.dist-info/METADATA +0 -364
  40. codegraph_cli-2.1.1.dist-info/RECORD +0 -43
  41. codegraph_cli-2.1.1.dist-info/entry_points.txt +0 -2
  42. {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.3.dist-info}/WHEEL +0 -0
  43. {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.3.dist-info}/licenses/LICENSE +0 -0
  44. {codegraph_cli-2.1.1.dist-info → codegraph_cli-2.1.3.dist-info}/top_level.txt +0 -0
@@ -4,6 +4,7 @@ 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
@@ -44,14 +45,24 @@ class CrewChatAgent:
44
45
  # ── Silence CrewAI / LiteLLM noise ────────────────
45
46
  for logger_name in (
46
47
  "crewai", "crewai.agents", "crewai.crews",
47
- "crewai.tasks", "litellm", "httpx",
48
+ "crewai.tasks", "litellm", "litellm.proxy",
49
+ "litellm.litellm_core_utils", "litellm.utils",
50
+ "litellm.proxy.proxy_server", "httpx", "httpcore",
48
51
  ):
49
52
  logging.getLogger(logger_name).setLevel(logging.CRITICAL)
50
53
  os.environ.setdefault("CREWAI_TELEMETRY_OPT_OUT", "true")
54
+ # Suppress LiteLLM's fastapi proxy import spam
55
+ os.environ.setdefault("LITELLM_LOG", "ERROR")
56
+ try:
57
+ import litellm
58
+ litellm.suppress_debug_info = True
59
+ litellm.set_verbose = False
60
+ except ImportError:
61
+ pass
51
62
 
52
63
  # ── Provider env vars for LiteLLM compatibility ───
53
64
  if llm.api_key:
54
- provider = (llm.provider or "").lower()
65
+ provider = (llm.provider_name or "").lower()
55
66
  if provider == "openrouter" or (llm.endpoint and "openrouter.ai" in llm.endpoint):
56
67
  os.environ["OPENROUTER_API_KEY"] = llm.api_key
57
68
  if llm.endpoint:
@@ -87,27 +98,53 @@ class CrewChatAgent:
87
98
  self.file_ops_agent = create_file_ops_agent(tools["file_ops"], crew_llm, project_ctx)
88
99
  self.code_gen_agent = create_code_gen_agent(tools["all"], crew_llm, project_ctx)
89
100
  self.code_analysis_agent = create_code_analysis_agent(tools["code_analysis"], crew_llm, project_ctx)
90
- self.coordinator = create_coordinator_agent(crew_llm, project_ctx)
101
+ # Give coordinator ALL tools so it can read files, search code, and delegate
102
+ self.coordinator = create_coordinator_agent(crew_llm, project_ctx, tools=tools["all"])
103
+
104
+ # ── Conversation memory for multi-turn continuity ─
105
+ self._history: list[dict] = []
106
+ self._max_history_pairs: int = 10
91
107
 
92
108
  # ── Public API ────────────────────────────────────────
93
109
 
94
110
  def process_message(self, user_message: str) -> str:
95
- """Process a user message via CrewAI multi-agent pipeline."""
111
+ """Process a user message via CrewAI multi-agent pipeline.
112
+
113
+ Injects conversation history into the task description so agents
114
+ can follow up on previous suggestions and requests.
115
+ """
96
116
  try:
97
117
  proj_summary = self.context.get_project_summary()
98
- context_str = (
118
+
119
+ # ── Build context with project info + conversation history ──
120
+ context_parts = [
99
121
  f"Project: {proj_summary.get('project_name', 'Unknown')}, "
100
122
  f"Root: {proj_summary.get('source_path', '.')}, "
101
- f"Indexed files: {proj_summary.get('indexed_files', 0)}\n\n"
102
- )
123
+ f"Indexed files: {proj_summary.get('indexed_files', 0)}",
124
+ ]
125
+
126
+ history_block = self._format_history()
127
+ if history_block:
128
+ context_parts.append(
129
+ "\n── PREVIOUS CONVERSATION (use this to understand follow-up requests) ──\n"
130
+ f"{history_block}\n"
131
+ "── END PREVIOUS CONVERSATION ──"
132
+ )
133
+
134
+ context_parts.append(f"\nCurrent User Request: {user_message}")
103
135
 
104
136
  task = Task(
105
- description=f"{context_str}User Request: {user_message}",
137
+ description="\n".join(context_parts),
106
138
  expected_output=(
107
- "A specific, concrete answer based on actual project files and code. "
108
- "Use tools to explore the project. For code changes, use write_file or "
109
- "patch_file tools which automatically create backups. "
110
- "Don't give generic explanations."
139
+ "Concrete results based on actual project files.\n"
140
+ "CRITICAL: If the user refers to something from the previous conversation "
141
+ "(e.g. 'apply those changes', 'implement what you suggested', 'do it', "
142
+ "'make those improvements'), you MUST look at the PREVIOUS CONVERSATION "
143
+ "section above to understand exactly what was discussed, then ACT on it "
144
+ "using write_file or patch_file tools.\n"
145
+ "For code changes: MUST use write_file or patch_file tools to actually "
146
+ "modify files. Don't just describe changes — actually apply them and "
147
+ "confirm they were applied. After making changes, read the file to verify."
111
148
  ),
112
149
  agent=self.coordinator,
113
150
  )
@@ -125,11 +162,113 @@ class CrewChatAgent:
125
162
  )
126
163
 
127
164
  result = crew.kickoff()
128
- return str(result.raw) if hasattr(result, "raw") else str(result)
165
+ raw = str(result.raw) if hasattr(result, "raw") else str(result)
166
+ response = self._clean_response(raw)
167
+
168
+ # ── Store in conversation memory ──
169
+ self._history.append({"role": "user", "content": user_message})
170
+ self._history.append({"role": "assistant", "content": response})
171
+ self._trim_history()
172
+
173
+ return response
129
174
 
130
175
  except Exception as e:
131
176
  return f"❌ Error: {e}\n\nPlease try rephrasing your request."
132
177
 
178
+ # ── Response sanitisation ─────────────────────────────
179
+
180
+ @staticmethod
181
+ def _clean_response(text: str) -> str:
182
+ """Strip LLM reasoning artifacts from the crew output.
183
+
184
+ Some models (e.g. DeepSeek, StepFun) put their real answer inside
185
+ <think>…</think> tags and only emit a tiny fragment afterwards.
186
+ If stripping <think> would leave less than 100 useful chars, we
187
+ extract the *content* of the think block as the real answer.
188
+ """
189
+ # ── Phase 1: Handle <think> blocks smartly ────────
190
+ think_match = re.search(r"<think>([\s\S]*?)</think>", text)
191
+ if think_match:
192
+ think_content = think_match.group(1).strip()
193
+ without_think = re.sub(r"<think>[\s\S]*?</think>", "", text)
194
+ without_think = re.sub(r"</?think>", "", without_think).strip()
195
+ # If the part outside <think> is too short, the real answer
196
+ # is inside the think block
197
+ if len(without_think) < 100 and len(think_content) > len(without_think):
198
+ text = think_content
199
+ else:
200
+ text = without_think
201
+ else:
202
+ # Stray opening / closing think tags (no matched pair)
203
+ text = re.sub(r"</?think>", "", text)
204
+
205
+ # ── Phase 2: Strip tool-call / XML artifacts ──────
206
+ text = re.sub(r"<tool_call>[\s\S]*?</tool_call>", "", text)
207
+ text = re.sub(r"</?tool_call>", "", text)
208
+ text = re.sub(
209
+ r"</?(?:function|parameter|result|output|observation)(?:=[^>]*)?>\s*",
210
+ "", text,
211
+ )
212
+
213
+ # ── Phase 3: Clean up whitespace ──────────────────
214
+ text = re.sub(r"\n{3,}", "\n\n", text)
215
+ return text.strip()
216
+
217
+ # ── Conversation memory helpers ───────────────────────
218
+
219
+ def _format_history(self) -> str:
220
+ """Format conversation history for injection into task context.
221
+
222
+ Keeps the last 4 exchanges (8 messages). The most recent 2
223
+ exchanges are preserved in full (up to a cap); older ones are
224
+ compressed to ~400 chars each to stay within LLM context limits.
225
+ """
226
+ if not self._history:
227
+ return ""
228
+
229
+ # Show at most the last 8 messages (4 exchanges)
230
+ recent = self._history[-8:]
231
+ lines: list[str] = []
232
+ total = len(recent)
233
+
234
+ for i, msg in enumerate(recent):
235
+ role = "User" if msg["role"] == "user" else "Assistant"
236
+ content = msg["content"]
237
+ # Last 2 exchanges (4 msgs) get generous space; older ones compressed
238
+ is_recent = i >= total - 4
239
+ max_len = 1500 if is_recent else 400
240
+ if len(content) > max_len:
241
+ content = content[:max_len] + "\n... (truncated)"
242
+ lines.append(f"[{role}]:\n{content}")
243
+
244
+ if len(self._history) > 8:
245
+ lines.insert(0, f"... ({len(self._history) - 8} older messages omitted)")
246
+
247
+ return "\n\n".join(lines)
248
+
249
+ def _trim_history(self):
250
+ """Keep only the last N exchange pairs to avoid unbounded growth."""
251
+ max_messages = self._max_history_pairs * 2
252
+ if len(self._history) > max_messages:
253
+ self._history = self._history[-max_messages:]
254
+
255
+ def clear_history(self):
256
+ """Clear conversation memory (called on /clear and /new)."""
257
+ self._history.clear()
258
+
259
+ def load_session_history(self, session) -> None:
260
+ """Load conversation history from a persisted ChatSession.
261
+
262
+ Called when resuming a previous session so the crew retains
263
+ context from earlier exchanges.
264
+ """
265
+ self._history.clear()
266
+ for msg in session.messages:
267
+ role = msg.role if hasattr(msg, "role") else msg["role"]
268
+ content = msg.content if hasattr(msg, "content") else msg["content"]
269
+ self._history.append({"role": role, "content": content})
270
+ self._trim_history()
271
+
133
272
  # ── Rollback helpers (called from REPL) ───────────────
134
273
 
135
274
  def list_all_backups(self) -> List[Dict]:
@@ -18,7 +18,23 @@ except ImportError:
18
18
  def __init_subclass__(cls, **kwargs): pass
19
19
  def __init__(self, **kwargs): pass
20
20
  CREWAI_AVAILABLE = False
21
- from pydantic import BaseModel, Field, PrivateAttr
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)
22
38
 
23
39
  if TYPE_CHECKING:
24
40
  from .project_context import ProjectContext
@@ -192,7 +208,12 @@ class ReadFileTool(ContextTool):
192
208
  numbered = []
193
209
  for i, line in enumerate(content.split("\n"), 1):
194
210
  numbered.append(f"{i:4d} │ {line}")
195
- return f"── {path} ──\n" + "\n".join(numbered)
211
+ result = f"── {path} ──\n" + "\n".join(numbered)
212
+ # Truncate to avoid blowing up LLM context window
213
+ max_chars = 12_000
214
+ if len(result) > max_chars:
215
+ result = result[:max_chars] + f"\n... (truncated — file has {len(content)} chars total, use grep_in_project to find specific sections)"
216
+ return result
196
217
  except Exception as e:
197
218
  return f"Error reading file: {e}"
198
219
 
@@ -273,6 +294,10 @@ class DeleteFileTool(ContextTool):
273
294
  backup_id = _backup_file(ctx.source_path, path, tag="delete")
274
295
  full_path = ctx.source_path / path
275
296
  full_path.unlink()
297
+
298
+ # Remove stale nodes from the code graph
299
+ ctx.remove_from_index(path)
300
+
276
301
  return f"✅ Deleted {path}\n Backup: {backup_id} (use rollback_file to restore)"
277
302
  except Exception as e:
278
303
  return f"Error deleting file: {e}"
@@ -334,16 +359,18 @@ class SearchCodeTool(BaseTool):
334
359
 
335
360
  def _run(self, query: str) -> str:
336
361
  try:
337
- results = self._rag.search(query, top_k=8)
362
+ results = self._rag.search(query, top_k=5)
338
363
  if not results:
339
364
  return f"No results found for: {query}"
340
365
  lines = [f"Found {len(results)} results for '{query}':\n"]
341
366
  for i, r in enumerate(results, 1):
367
+ # Truncate preview to keep context compact
368
+ preview = r.snippet[:200].replace("\n", " ")
342
369
  lines.append(
343
370
  f"{i}. [{r.node_type}] {r.qualname}\n"
344
371
  f" File: {r.file_path}:{r.start_line}-{r.end_line}\n"
345
372
  f" Score: {r.score:.2f}\n"
346
- f" Preview: {r.snippet[:120]}…\n"
373
+ f" Preview: {preview}…\n"
347
374
  )
348
375
  return "\n".join(lines)
349
376
  except Exception as e:
@@ -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 L2-normalised vectors."""
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
- return sum(a * b for a, b in zip(vec_a, vec_b))
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 == 0:
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 using curl (workaround for Python urllib timeout)."""
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 subprocess
67
- import tempfile
66
+ import requests
68
67
 
69
- # Create JSON payload
70
- payload = json.dumps({
71
- "model": self.model,
72
- "messages": [{"role": "user", "content": prompt}],
73
- "temperature": 0.1,
74
- "max_tokens": 1024,
75
- })
76
-
77
- # Use curl (which works!) instead of Python HTTP libraries
78
- result = subprocess.run(
79
- [
80
- "curl", "-s", "-X", "POST",
81
- self.endpoint,
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
- if result.returncode == 0 and result.stdout:
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 subprocess
363
+ import requests
376
364
 
377
- payload = json.dumps({
378
- "model": self.model,
379
- "messages": messages,
380
- "temperature": temperature,
381
- "max_tokens": max_tokens,
382
- })
383
-
384
- result = subprocess.run(
385
- [
386
- "curl", "-s", "-X", "POST",
387
- self.provider.endpoint,
388
- "-H", "Content-Type: application/json",
389
- "-H", f"Authorization: Bearer {self.api_key}",
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
- if result.returncode == 0 and result.stdout:
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:
@@ -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 = self.metadata.get("source_path")
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