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.
Files changed (42) 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 +204 -94
  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 +32 -8
  27. codegraph_cli/crew_chat.py +146 -13
  28. codegraph_cli/crew_tools.py +30 -2
  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/storage.py +310 -14
  34. codegraph_cli/vector_store.py +110 -8
  35. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/METADATA +75 -21
  36. codegraph_cli-2.1.2.dist-info/RECORD +55 -0
  37. codegraph_cli-2.1.2.dist-info/entry_points.txt +2 -0
  38. codegraph_cli-2.1.0.dist-info/RECORD +0 -43
  39. codegraph_cli-2.1.0.dist-info/entry_points.txt +0 -2
  40. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/WHEEL +0 -0
  41. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/licenses/LICENSE +0 -0
  42. {codegraph_cli-2.1.0.dist-info → codegraph_cli-2.1.2.dist-info}/top_level.txt +0 -0
@@ -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
- from crewai import Agent, Crew, Task
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.provider or "").lower()
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
- self.coordinator = create_coordinator_agent(crew_llm, project_ctx)
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
- context_str = (
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)}\n\n"
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=f"{context_str}User Request: {user_message}",
127
+ description="\n".join(context_parts),
102
128
  expected_output=(
103
- "A specific, concrete answer based on actual project files and code. "
104
- "Use tools to explore the project. For code changes, use write_file or "
105
- "patch_file tools which automatically create backups. "
106
- "Don't give generic explanations."
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
- return str(result.raw) if hasattr(result, "raw") else str(result)
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]:
@@ -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
- from crewai.tools import BaseTool
13
- from pydantic import BaseModel, Field, PrivateAttr
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}"
@@ -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