codrspot-processor-mcp 0.1.0__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 (100) hide show
  1. codepreproc_client/__init__.py +14 -0
  2. codepreproc_client/__main__.py +9 -0
  3. codepreproc_client/layer1_business/__init__.py +0 -0
  4. codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
  5. codepreproc_client/layer1_business/api_client/__init__.py +0 -0
  6. codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
  7. codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
  8. codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
  9. codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
  10. codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
  11. codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
  12. codepreproc_client/layer1_business/cli/__init__.py +0 -0
  13. codepreproc_client/layer1_business/cli/init.py +578 -0
  14. codepreproc_client/layer1_business/cli/main.py +78 -0
  15. codepreproc_client/layer1_business/config.py +104 -0
  16. codepreproc_client/layer1_business/git_utils.py +67 -0
  17. codepreproc_client/layer1_business/license/__init__.py +0 -0
  18. codepreproc_client/layer1_business/license/fingerprint.py +61 -0
  19. codepreproc_client/layer1_business/license/jwt_client.py +60 -0
  20. codepreproc_client/layer1_business/license/project_lock.py +44 -0
  21. codepreproc_client/layer1_business/local_registry.py +40 -0
  22. codepreproc_client/layer1_business/manifest/__init__.py +0 -0
  23. codepreproc_client/layer1_business/manifest/credentials.py +35 -0
  24. codepreproc_client/layer1_business/mcp/__init__.py +0 -0
  25. codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
  26. codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
  27. codepreproc_client/layer1_business/mcp/server.py +656 -0
  28. codepreproc_client/layer1_business/mcp/tools.py +1665 -0
  29. codepreproc_client/layer1_business/project_registry.py +99 -0
  30. codepreproc_client/layer2_tooling/__init__.py +0 -0
  31. codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
  32. codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
  33. codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
  34. codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
  35. codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
  36. codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
  37. codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
  38. codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
  39. codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
  40. codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
  41. codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
  42. codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
  43. codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
  44. codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
  45. codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
  46. codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
  47. codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
  48. codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
  49. codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
  50. codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
  51. codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
  52. codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
  53. codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
  54. codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
  55. codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
  56. codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
  57. codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
  58. codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
  59. codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
  60. codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
  61. codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
  62. codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
  63. codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
  64. codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
  65. codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
  66. codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
  67. codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
  68. codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
  69. codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
  70. codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
  71. codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
  72. codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
  73. codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
  74. codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
  75. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
  76. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
  77. codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
  78. codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
  79. codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
  80. codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
  81. codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
  82. codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
  83. codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
  84. codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
  85. codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
  86. codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
  87. codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
  88. codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
  89. codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
  90. codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
  91. codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
  92. codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
  93. codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
  94. codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
  95. codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
  96. codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
  97. codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
  98. contracts/__init__.py +0 -0
  99. contracts/dtos.py +1239 -0
  100. contracts/ir.py +102 -0
@@ -0,0 +1,1665 @@
1
+ """MCP tool implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import uuid
12
+ from collections import defaultdict
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Awaitable, Callable
17
+
18
+ from git import Repo
19
+ from pydantic import BaseModel, Field
20
+
21
+ from codepreproc_client.layer2_tooling.condensation.chunking.ast_chunker import AstChunker
22
+ from codepreproc_client.layer2_tooling.coupling.domain import CouplingExpandRequest, CouplingProjectRequest, CouplingReport
23
+ from codepreproc_client.layer2_tooling.coupling.graph import CouplingGraph
24
+ from codepreproc_client.layer2_tooling.coupling.projector import CouplingProjector
25
+ from codepreproc_client.layer1_business.git_utils import get_current_branch, get_current_sha, get_dirty_files
26
+ from codepreproc_client.layer2_tooling.condensation.ast_graph import AstGraph
27
+ from codepreproc_client.layer2_tooling.condensation.bm25_store import BM25Store
28
+ from codepreproc_client.layer2_tooling.condensation.scanning.repo_mapper import NeedsMonorepoConfig, RepoMapAnnotator, RepoMapper
29
+ from codepreproc_client.layer2_tooling.condensation.embeddings import Embedder
30
+ from codepreproc_client.layer2_tooling.condensation.indexer import ProjectIndexer
31
+ from codepreproc_client.layer2_tooling.condensation.qdrant_store import QdrantStore
32
+ from codepreproc_client.layer1_business.api_client.reasoning_client import LLMRuntime
33
+ from codepreproc_client.layer2_tooling.condensation.extract.compactor import ContextCompactor
34
+ from codepreproc_client.layer1_business.api_client.reasoning_client import IntentExtractor
35
+ from codepreproc_client.layer1_business.mcp.action_monitor import ActionMonitor, ToolExecutionError
36
+ from contracts.dtos import (
37
+ ProjectConfig,
38
+ Settings as SettingsModel,
39
+ StoredAssemblyTask,
40
+ StoredFilesystemTask,
41
+ StoredSemanticTask,
42
+ )
43
+ from codepreproc_client.layer2_tooling.materialization.pipeline.filesystem_reorg import FilesystemReorganizationPipeline
44
+ from codepreproc_client.layer2_tooling.materialization.pipeline.execute_semantic import SemanticExecutionPipeline
45
+ from codepreproc_client.layer2_tooling.materialization.snippet.loader import SnippetLoader
46
+ from codepreproc_client.layer2_tooling.materialization.snippet.assembler import SnippetAssembler
47
+ from codepreproc_client.layer2_tooling.materialization.semantic.target_locator import TargetLocator
48
+ from codepreproc_client.layer1_business.project_registry import ProjectRegistry
49
+ from codepreproc_client.layer2_tooling.retrieval.graph_walk import GraphWalker
50
+ from codepreproc_client.layer2_tooling.retrieval.hybrid import HybridRetriever
51
+ from codepreproc_client.layer2_tooling.retrieval.reranker import Reranker
52
+ from codepreproc_client.layer2_tooling.materialization.semantic import ApplicabilityChecker, RegionResolver, SemanticEditPlanner, SemanticSynthesizer
53
+ from contracts.ir import BusinessLogicDiff, bld_from_plan
54
+ from codepreproc_client.layer1_business.api_client.superkg_client import EnrichResult, SuperKGClient
55
+
56
+ _CONTEXT_MAP_SYSTEM_PROMPT = """\
57
+ You are a code architecture analyst. Given a set of retrieved code components and their dependency relationships, \
58
+ produce a concise narrative (3-8 sentences) explaining how these components collaborate to implement the queried \
59
+ functionality. Focus on data flow, call chains, and architectural boundaries — not on re-describing each function. \
60
+ Use exact symbol names from the context. Output plain text only, no Markdown headers or code fences.\
61
+ """
62
+
63
+ _DOC_GENERATION_SYSTEM_PROMPT = """\
64
+ You are a technical documentation expert specializing in software architecture documentation.
65
+ Using ONLY the code context provided, produce the requested document as raw Markdown.
66
+
67
+ Apply ALL of the following structural patterns where applicable:
68
+
69
+ REQUIRED SECTIONS (include every section that has supporting evidence in the context):
70
+ 1. **Visión general + principios**: 2–3 sentences on what the system does, then 4–6 bullet points \
71
+ on guiding design principles extracted from the code philosophy (e.g. "Estado sobre patch", thresholds as contracts).
72
+ 2. **Component inventory table**: | Componente | Tipo | Ruta | Rol | — one row per package/module, \
73
+ "Tipo" is Package/App/Specs/etc., "Rol" is a one-line description.
74
+ 3. **End-to-end flow diagram**: ASCII art with boxes (┌─┐ │ └─┘), arrows (→ ▼ ▲), \
75
+ and numbered step labels (PASO 1: …, PASO 2: …). Show the full lifecycle from input to output.
76
+ 4. **Per-component sections**: For EACH component include:
77
+ - Exact file path, internal dependencies (package names only)
78
+ - Exported functions/classes listed verbatim from the code (not paraphrased)
79
+ - Constants, thresholds, enum values with EXACT numeric values (e.g. score ≥ 0.72)
80
+ - Input/output types using exact type names from the code
81
+ - Sub-file breakdown if the component has multiple source files
82
+ 5. **Contracts section**: State machine transitions (e.g. `draft → ready → reflecting → canonicalized → committed`), \
83
+ numeric thresholds table with exact values, validation dimensions, blocking conditions.
84
+ 6. **External integrations table**: | Integration | Protocol | Detail |
85
+ 7. **Directed dependency graph**: ASCII diagram with directed arrows (◄ ▲) showing dependency direction. \
86
+ Higher-level packages at top depending on lower-level ones at bottom.
87
+ 8. **Entry/exit points table**: | Canal | Punto de entrada | Tipo | for inputs; same for outputs.
88
+
89
+ EXTRACTION RULES (critical — the expensive approach always does these):
90
+ - Extract EXACT function/method names from exports, never paraphrase them.
91
+ - Include EXACT numeric constants: scores, thresholds, limits, port numbers, counts.
92
+ - Include EXACT TypeScript/Python type names for inputs and outputs.
93
+ - Include ALL enum/union values (e.g. `os_spec | state_config | idea_state | seed_pack`).
94
+ - Group MCP tools / REST endpoints by category in tables.
95
+ - Use the same technical terminology as the codebase — do not translate or simplify.
96
+ - When listing required artifacts, list ALL of them by name.
97
+
98
+ OUTPUT: Raw Markdown only. No preamble. No trailing explanation. No code fence wrapping the whole document.\
99
+ """
100
+
101
+ _BUSINESS_LOGIC_SYSTEM_PROMPT = """\
102
+ You are a business analyst and domain expert. Given code context from a software repository, \
103
+ extract and document the BUSINESS LOGIC — what the system does, not how it is implemented technically.
104
+
105
+ Produce a Markdown document with the following sections (include only those with evidence in the context):
106
+
107
+ 1. **Overview**: 2–4 sentences describing what this system does from a business perspective.
108
+ 2. **Domain Model**: Named business entities, their attributes, and relationships. Use a table: | Entity | Key Attributes | Relationships |
109
+ 3. **Business Rules**: Numbered list of explicit invariants, constraints, and validations enforced by the code \
110
+ (e.g. "An order cannot be shipped if payment is not confirmed"). Extract exact thresholds and enum values.
111
+ 4. **Workflows / Processes**: For each significant workflow or use case, describe the steps as a numbered list \
112
+ or state-machine diagram (states and transitions). Use exact state/status names from the code.
113
+ 5. **Entry Points**: How the system receives requests (REST endpoints, MCP tools, CLI commands, events). \
114
+ Table: | Entry Point | Input | Trigger | Output |
115
+ 6. **External Integrations**: Services, databases, or APIs the system relies on. \
116
+ Table: | Integration | Purpose | Protocol |
117
+ 7. **Error Conditions & Edge Cases**: Explicit failure modes and how the system handles them.
118
+
119
+ RULES:
120
+ - Focus on WHAT the business logic does, not HOW the code implements it.
121
+ - Use exact names from the code for entities, statuses, and operations — do not paraphrase.
122
+ - Extract exact numeric thresholds and enum values verbatim.
123
+ - Do NOT describe implementation details like classes, imports, or algorithms unless they encode a business rule.
124
+ - If a rule is enforced by a constant or threshold in the code, state both the rule and the exact value.
125
+
126
+ OUTPUT: Raw Markdown only. No preamble. No trailing explanation. No code fence wrapping the whole document.\
127
+ """
128
+
129
+ _BUSINESS_LOGIC_QUERIES = [
130
+ "business logic domain rules validation services workflows",
131
+ "domain models entities state machine transitions status",
132
+ "business rules invariants constraints validation error handling",
133
+ "service layer use cases commands queries operations",
134
+ "API endpoints entry points request handling",
135
+ ]
136
+
137
+
138
+ class SwitchProjectInput(BaseModel):
139
+ project: str
140
+
141
+
142
+ class AnalyzeRequestInput(BaseModel):
143
+ prompt: str
144
+ project: str | None = None
145
+
146
+
147
+ class AnalyzeFilesystemReorgInput(BaseModel):
148
+ prompt: str
149
+ project: str | None = None
150
+
151
+
152
+ class PreviewTaskInput(BaseModel):
153
+ task_id: str
154
+
155
+
156
+ class DisambiguateInput(BaseModel):
157
+ task_id: str
158
+ chosen_region_id: str
159
+
160
+
161
+ class ApplyPatchInput(BaseModel):
162
+ task_id: str
163
+ patch_index: int | None = None
164
+
165
+
166
+ class ApplyFilesystemPlanInput(BaseModel):
167
+ task_id: str
168
+
169
+
170
+ class UsageReportInput(BaseModel):
171
+ project: str | None = None
172
+ since_days: int = 30
173
+
174
+
175
+ class SetPolicyInput(BaseModel):
176
+ project: str
177
+ policy: str
178
+
179
+
180
+ class SearchContextInput(BaseModel):
181
+ query: str
182
+ project: str | None = None
183
+ top_k: int = 10
184
+
185
+
186
+ class MapContextInput(BaseModel):
187
+ query: str
188
+ project: str | None = None
189
+ top_k: int = 10
190
+ include_bodies: bool = True
191
+ synthesize: bool = True
192
+
193
+
194
+ class GenerateDocumentInput(BaseModel):
195
+ prompt: str
196
+ output_path: str
197
+ project: str | None = None
198
+ top_k: int = 15
199
+
200
+
201
+ class GenerateBusinessLogicInput(BaseModel):
202
+ project: str | None = None
203
+ output_path: str = "BUSINESS_LOGIC.md"
204
+ top_k: int = 20
205
+
206
+
207
+ class ProjectPseudocodeInput(BaseModel):
208
+ project: str | None = None
209
+ file_pattern: str | None = None
210
+ include_bodies: bool = False
211
+ exclude_tests: bool = True
212
+ max_files: int = 200
213
+
214
+
215
+ class ListSnippetsInput(BaseModel):
216
+ framework: str | None = None
217
+ language: str | None = None
218
+ layer: str | None = None
219
+
220
+
221
+ class AssembleFromSnippetsInput(BaseModel):
222
+ prompt: str
223
+ project: str | None = None
224
+
225
+
226
+ class ReindexInput(BaseModel):
227
+ project: str | None = None
228
+ full: bool = False
229
+
230
+
231
+ class ProjectStatusInput(BaseModel):
232
+ project: str | None = None
233
+
234
+
235
+ class InitProjectInput(BaseModel):
236
+ path: str | None = None
237
+
238
+
239
+ class CouplingProjectInput(BaseModel):
240
+ anchors: list[str] = Field(default_factory=list)
241
+ intent: str | None = None
242
+
243
+
244
+ class CouplingExpandInput(BaseModel):
245
+ handle_id: str
246
+ edge_id: str | None = None
247
+ offset: int = 0
248
+ limit: int = 50
249
+
250
+
251
+ @dataclass
252
+ class SessionState:
253
+ """Per-client session cache."""
254
+
255
+ active_project: str | None = None
256
+ user_id: str | None = field(default_factory=lambda: os.environ.get("CODEPREPROC_USER_ID"))
257
+ roots: list[Path] = field(default_factory=list)
258
+ loaded_indexers: dict[str, ProjectIndexer] = field(default_factory=dict)
259
+ semantic_tasks: dict[str, StoredSemanticTask] = field(default_factory=dict)
260
+ filesystem_tasks: dict[str, StoredFilesystemTask] = field(default_factory=dict)
261
+ assembly_tasks: dict[str, StoredAssemblyTask] = field(default_factory=dict)
262
+ coupling_reports: dict[str, CouplingReport] = field(default_factory=dict)
263
+ policy_overrides: dict[str, str] = field(default_factory=dict)
264
+
265
+
266
+ class GenerateRepoMapInput(BaseModel):
267
+ project: str | None = None
268
+ artifacts_folder: str | None = None
269
+ modules_folder: str | None = None
270
+
271
+
272
+ class KGContextInput(BaseModel):
273
+ project: str | None = None
274
+ mode: str = "narrative"
275
+
276
+
277
+ class KGInsightsInput(BaseModel):
278
+ project: str | None = None
279
+
280
+
281
+ class CorrectGraphInput(BaseModel):
282
+ change: str
283
+ before: list[str] = Field(default_factory=list)
284
+ after: list[str] = Field(default_factory=list)
285
+ rules_added: list[str] = Field(default_factory=list)
286
+ rules_removed: list[str] = Field(default_factory=list)
287
+ rules_modified: list[str] = Field(default_factory=list)
288
+ project: str | None = None
289
+
290
+
291
+ class CodepreprocTools:
292
+ """Concrete MCP tool handlers."""
293
+
294
+ def __init__(
295
+ self,
296
+ settings: SettingsModel,
297
+ registry: ProjectRegistry,
298
+ qdrant_store: QdrantStore,
299
+ llm_runtime: LLMRuntime,
300
+ logger: logging.Logger,
301
+ superkg: SuperKGClient | None = None,
302
+ snippet_loader: SnippetLoader | None = None,
303
+ coupling_graph: CouplingGraph | None = None,
304
+ jwt_provider: Callable[[str], Awaitable[str]] | None = None,
305
+ ) -> None:
306
+ self.settings = settings
307
+ self.registry = registry
308
+ self.qdrant_store = qdrant_store
309
+ self.llm_runtime = llm_runtime
310
+ self.logger = logger
311
+
312
+ async def _disabled_jwt_provider(project_id: str) -> str:
313
+ raise RuntimeError("no jwt_provider configured - SuperKG proxy calls are unavailable")
314
+
315
+ self._jwt_provider = jwt_provider or _disabled_jwt_provider
316
+ self.superkg = superkg or SuperKGClient(
317
+ base_url="http://localhost:8443", jwt_provider=self._jwt_provider, enabled=False
318
+ )
319
+ self.embedder = Embedder.get(settings.defaults)
320
+ self.reranker = Reranker.get(settings.defaults)
321
+ if snippet_loader is None:
322
+ raise ValueError("snippet_loader is required — pass SnippetLoader.from_api() result")
323
+ self._snippet_loader = snippet_loader
324
+ self._coupling_graph = coupling_graph or CouplingGraph(
325
+ settings.indexes_dir / "coupling.db"
326
+ )
327
+ self._coupling_projector = CouplingProjector()
328
+
329
+ async def list_projects(self, session: SessionState) -> dict:
330
+ projects = []
331
+ for project in self.registry.list_projects():
332
+ indexer = self._get_indexer(session, project)
333
+ state = indexer.get_index_status()
334
+ projects.append(
335
+ {
336
+ "id": project.project_id,
337
+ "root": str(project.root),
338
+ "languages": project.languages,
339
+ "llm_policy": session.policy_overrides.get(project.project_id, project.llm_policy),
340
+ "indexed": indexer.ast_graph.get_last_indexed_sha() is not None and state["index_state"] == "ready",
341
+ "last_indexed_sha": indexer.ast_graph.get_last_indexed_sha(),
342
+ "chunk_count": indexer.ast_graph.get_chunk_count(),
343
+ "index_state": state["index_state"],
344
+ "last_index_error": state.get("last_index_error"),
345
+ }
346
+ )
347
+ return {"projects": projects}
348
+
349
+ async def switch_project(self, session: SessionState, arguments: dict) -> dict:
350
+ payload = SwitchProjectInput.model_validate(arguments)
351
+ status = "ok" if payload.project in self.registry.registry.projects else "not_found"
352
+ if status == "ok":
353
+ session.active_project = payload.project
354
+ return {"active_project": session.active_project, "status": status}
355
+
356
+ async def analyze_request(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
357
+ payload = AnalyzeRequestInput.model_validate(arguments)
358
+ project = self._resolve_project(session, payload.project)
359
+ pipeline = self._make_pipeline(session, project)
360
+ stored = await pipeline.run(
361
+ payload.prompt,
362
+ policy_override=session.policy_overrides.get(project.project_id),
363
+ monitor=monitor,
364
+ )
365
+ session.semantic_tasks[stored.pack.task_id] = stored
366
+ return stored.pack.model_dump(mode="json")
367
+
368
+ async def analyze_filesystem_reorg(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
369
+ payload = AnalyzeFilesystemReorgInput.model_validate(arguments)
370
+ project = self._resolve_project(session, payload.project)
371
+ pipeline = self._make_filesystem_pipeline(project)
372
+ stored = await pipeline.run(
373
+ payload.prompt,
374
+ policy_override=session.policy_overrides.get(project.project_id),
375
+ monitor=monitor,
376
+ )
377
+ session.filesystem_tasks[stored.pack.task_id] = stored
378
+ return stored.pack.model_dump(mode="json")
379
+
380
+ async def preview_semantic_plan(self, session: SessionState, arguments: dict) -> dict:
381
+ payload = PreviewTaskInput.model_validate(arguments)
382
+ self._prune_tasks(session)
383
+ item = session.semantic_tasks.get(payload.task_id)
384
+ if item is None:
385
+ return {"error": "not_found"}
386
+ return {"semantic_plan": item.pack.semantic_plan.model_dump(mode="json") if item.pack.semantic_plan else None}
387
+
388
+ async def preview_filesystem_plan(self, session: SessionState, arguments: dict) -> dict:
389
+ payload = PreviewTaskInput.model_validate(arguments)
390
+ self._prune_tasks(session)
391
+ item = session.filesystem_tasks.get(payload.task_id)
392
+ if item is None:
393
+ return {"error": "not_found"}
394
+ return {
395
+ "filesystem_plan": item.pack.filesystem_plan.model_dump(mode="json") if item.pack.filesystem_plan else None,
396
+ "validation": item.pack.validation.model_dump(mode="json") if item.pack.validation else None,
397
+ "failure": item.pack.failure.model_dump(mode="json") if item.pack.failure else None,
398
+ }
399
+
400
+ async def preview_patch(self, session: SessionState, arguments: dict) -> dict:
401
+ payload = PreviewTaskInput.model_validate(arguments)
402
+ self._prune_tasks(session)
403
+ item = session.semantic_tasks.get(payload.task_id)
404
+ if item is None:
405
+ return {"error": "not_found"}
406
+ return {
407
+ "patches": [
408
+ {"file_path": patch.file_path, "diff": patch.unified_diff}
409
+ for patch in sorted(item.pack.patches, key=lambda patch: patch.file_path)
410
+ ]
411
+ }
412
+
413
+ async def validate_patch(self, session: SessionState, arguments: dict) -> dict:
414
+ payload = PreviewTaskInput.model_validate(arguments)
415
+ self._prune_tasks(session)
416
+ item = session.semantic_tasks.get(payload.task_id)
417
+ if item is None:
418
+ return {"error": "not_found"}
419
+ if item.pack.validation is None:
420
+ return {"valid": False, "errors": ["missing_validation"], "warnings": []}
421
+ errors = [error for layer in item.pack.validation.layers for error in layer.errors]
422
+ warnings = [warning for layer in item.pack.validation.layers for warning in layer.warnings]
423
+ return {"valid": item.pack.validation.valid, "errors": errors, "warnings": warnings}
424
+
425
+ async def disambiguate_region(self, session: SessionState, arguments: dict) -> dict:
426
+ payload = DisambiguateInput.model_validate(arguments)
427
+ self._prune_tasks(session)
428
+ item = session.semantic_tasks.get(payload.task_id)
429
+ if item is None:
430
+ return {"error": "not_found"}
431
+ project = self.registry.get(item.project_id)
432
+ pipeline = self._make_pipeline(session, project)
433
+ stored = await pipeline.continue_with_region(item, payload.chosen_region_id)
434
+ session.semantic_tasks[payload.task_id] = stored
435
+ return stored.pack.model_dump(mode="json")
436
+
437
+ async def apply_patch(self, session: SessionState, arguments: dict) -> dict:
438
+ payload = ApplyPatchInput.model_validate(arguments)
439
+ self._prune_tasks(session)
440
+ item = session.semantic_tasks.get(payload.task_id)
441
+ if item is None:
442
+ return {"applied": False, "files_changed": [], "error": "not_found"}
443
+ patches = item.pack.patches
444
+ if payload.patch_index is not None:
445
+ if payload.patch_index < 0 or payload.patch_index >= len(patches):
446
+ return {"applied": False, "files_changed": [], "error": "patch_index_out_of_range"}
447
+ patches = [patches[payload.patch_index]]
448
+ combined = "".join(patch.unified_diff for patch in patches)
449
+ project = self.registry.get(item.project_id)
450
+ proc = subprocess.run(
451
+ ["git", "apply", "--ignore-whitespace", "--whitespace=nowarn", "-"],
452
+ input=combined,
453
+ text=True,
454
+ encoding="utf-8",
455
+ capture_output=True,
456
+ cwd=project.root,
457
+ check=False,
458
+ )
459
+ if proc.returncode != 0:
460
+ return {"applied": False, "files_changed": [], "error": (proc.stderr or proc.stdout).strip() or "git apply failed"}
461
+ touched = [patch.file_path for patch in patches]
462
+ for file_path in touched:
463
+ newline_style = item.newline_styles.get(file_path)
464
+ if newline_style == "crlf":
465
+ full_path = project.root / file_path
466
+ full_path.write_text(full_path.read_text(encoding="utf-8").replace("\n", "\r\n"), encoding="utf-8", newline="")
467
+
468
+ # Transactional SuperKG write: fires only after disk mutation succeeds
469
+ superkg_system_id = await self._ensure_superkg_system(project)
470
+ if superkg_system_id:
471
+ plan = item.pack.semantic_plan
472
+ bld = bld_from_plan(plan) if plan else None
473
+ intent_text = getattr(item.pack.intent, "intent", "") if item.pack.intent else ""
474
+ await self.superkg.record_decision(
475
+ project_id=project.project_id,
476
+ system_id=superkg_system_id,
477
+ session_id=None,
478
+ intent=intent_text,
479
+ plan_summary={"task_id": item.pack.task_id, "applied": True},
480
+ bld=bld,
481
+ )
482
+
483
+ return {"applied": True, "files_changed": touched, "error": None}
484
+
485
+ async def apply_filesystem_plan(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
486
+ payload = ApplyFilesystemPlanInput.model_validate(arguments)
487
+ self._prune_tasks(session)
488
+ item = session.filesystem_tasks.get(payload.task_id)
489
+ if item is None:
490
+ return {"applied": False, "operations_applied": [], "error": "not_found"}
491
+ project = self.registry.get(item.project_id)
492
+ pipeline = self._make_filesystem_pipeline(project)
493
+ result = await pipeline.apply(item, monitor=monitor)
494
+ if result.get("applied"):
495
+ session.filesystem_tasks.pop(payload.task_id, None)
496
+ return result
497
+
498
+ async def usage_report(self, session: SessionState, arguments: dict) -> dict:
499
+ payload = UsageReportInput.model_validate(arguments)
500
+ return self.llm_runtime.cost_tracker.usage_report(project=payload.project, since_days=payload.since_days)
501
+
502
+ async def set_llm_policy(self, session: SessionState, arguments: dict) -> dict:
503
+ payload = SetPolicyInput.model_validate(arguments)
504
+ project = self.registry.get(payload.project)
505
+ previous = session.policy_overrides.get(project.project_id, project.llm_policy)
506
+ session.policy_overrides[project.project_id] = payload.policy
507
+ return {"applied": True, "previous_policy": previous}
508
+
509
+ async def reindex(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
510
+ payload = ReindexInput.model_validate(arguments)
511
+
512
+ # Reload the local project registry mirror so config changes (e.g. languages)
513
+ # take effect without restart - no server round trip needed to list projects.
514
+ from codepreproc_client.layer1_business.config import apply_env_overrides, get_home_dir
515
+ from codepreproc_client.layer1_business.local_registry import load_local_projects
516
+ from contracts.dtos import Registry as _Registry
517
+ new_data = apply_env_overrides(
518
+ _Registry(defaults=self.registry.registry.defaults, projects=load_local_projects(get_home_dir()))
519
+ )
520
+ self.registry.registry = new_data
521
+
522
+ project = self._resolve_project(session, payload.project)
523
+ # Drop any cached indexer so it is rebuilt with the refreshed project config
524
+ session.loaded_indexers.pop(project.project_id, None)
525
+
526
+ if monitor is not None:
527
+ await monitor.stage("health_check", f"reindex: verificando Qdrant para {project.project_id}")
528
+ try:
529
+ await asyncio.to_thread(self.qdrant_store.health_check)
530
+ except Exception as exc:
531
+ raise ToolExecutionError(
532
+ "qdrant_unavailable",
533
+ f"Qdrant no responde en {self.qdrant_store._host}: {exc}",
534
+ details={"host": self.qdrant_store._host, "grpc_port": self.qdrant_store._grpc_port},
535
+ ) from exc
536
+ indexer = self._get_indexer(session, project)
537
+ stats = await (indexer.full_reindex(monitor=monitor) if payload.full else indexer.incremental_reindex(monitor=monitor))
538
+ result = stats.model_dump(mode="json")
539
+
540
+ # Log indexing activity in SuperKG (fire-and-forget)
541
+ superkg_system_id = await self._ensure_superkg_system(project)
542
+ if superkg_system_id:
543
+ await self.superkg.log_activity(
544
+ project_id=project.project_id,
545
+ system_id=superkg_system_id,
546
+ activity_type="reindex",
547
+ details={
548
+ "mode": result.get("mode", "full" if payload.full else "incremental"),
549
+ "files_indexed": result.get("files_indexed", 0),
550
+ "chunks_created": result.get("chunks_created", 0),
551
+ "duration_ms": result.get("duration_ms", 0),
552
+ "project_id": project.project_id,
553
+ },
554
+ )
555
+
556
+ return result
557
+
558
+ async def project_status(self, session: SessionState, arguments: dict) -> dict:
559
+ payload = ProjectStatusInput.model_validate(arguments)
560
+ project = self._resolve_project(session, payload.project)
561
+ indexer = self._get_indexer(session, project)
562
+ repo = Repo(project.root)
563
+ current_sha = get_current_sha(repo)
564
+ status = indexer.get_index_status()
565
+ result: dict = {
566
+ "project": project.project_id,
567
+ "git_sha": current_sha,
568
+ "branch": get_current_branch(repo),
569
+ "dirty_files": len(get_dirty_files(repo)),
570
+ "last_indexed_sha": indexer.ast_graph.get_last_indexed_sha(),
571
+ "drift": indexer.ast_graph.get_last_indexed_sha() != current_sha,
572
+ "llm_policy": session.policy_overrides.get(project.project_id, project.llm_policy),
573
+ "index_state": status["index_state"],
574
+ "last_index_error": status.get("last_index_error"),
575
+ "superkg_system_id": project.superkg_system_id,
576
+ "kg_snapshot": None,
577
+ }
578
+ if project.superkg_system_id:
579
+ result["kg_snapshot"] = await self.superkg.get_snapshot(project.project_id, project.superkg_system_id)
580
+ return result
581
+
582
+ async def search_context(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
583
+ payload = SearchContextInput.model_validate(arguments)
584
+ project = self._resolve_project(session, payload.project)
585
+ indexer = self._get_indexer(session, project)
586
+ retriever = HybridRetriever(
587
+ self.embedder,
588
+ self.qdrant_store,
589
+ indexer.bm25_store,
590
+ self.settings.defaults.retrieval,
591
+ )
592
+ if monitor is not None:
593
+ await monitor.stage("retrieve", "search_context: recuperando chunks relevantes")
594
+ retrieved = await retriever.retrieve(
595
+ project,
596
+ payload.query,
597
+ overlay_collection=None,
598
+ collection_name=indexer.active_collection_name(),
599
+ user_collection_name=indexer.active_user_collection_name(),
600
+ monitor=monitor,
601
+ ast_graph=indexer.ast_graph,
602
+ )
603
+ if monitor is not None:
604
+ await monitor.stage("rerank", "search_context: reranking")
605
+ top_k = max(1, min(payload.top_k, 30))
606
+ reranked = await self.reranker.rerank(payload.query, retrieved, top_k)
607
+
608
+ enrich_result: EnrichResult | None = None
609
+ superkg_system_id = await self._ensure_superkg_system(project)
610
+ if superkg_system_id:
611
+ candidates = [
612
+ {"chunk_id": item.chunk.chunk_id, "file_path": item.chunk.file_path, "score": item.score}
613
+ for item in reranked
614
+ ]
615
+ enrich_result = await self.superkg.enrich(project.project_id, superkg_system_id, payload.query, candidates)
616
+
617
+ kg_scores = enrich_result.kg_scores if enrich_result else {}
618
+ trace_id = enrich_result.trace_id if enrich_result else None
619
+
620
+ def _final_score(item) -> float:
621
+ kg = kg_scores.get(item.chunk.chunk_id)
622
+ if kg:
623
+ return item.score * 0.7 + kg.kg_score * 0.3
624
+ return item.score
625
+
626
+ if kg_scores:
627
+ reranked = sorted(reranked, key=_final_score, reverse=True)
628
+
629
+ snippets = []
630
+ for item in reranked:
631
+ chunk = item.chunk
632
+ kg = kg_scores.get(chunk.chunk_id)
633
+ entry: dict = {
634
+ "file_path": chunk.file_path,
635
+ "symbol": chunk.symbol,
636
+ "signature": chunk.signature or "",
637
+ "score": round(_final_score(item), 4),
638
+ "body": chunk.body,
639
+ }
640
+ if kg:
641
+ entry["kg_score"] = round(kg.kg_score, 4)
642
+ entry["kg_signals"] = kg.signals
643
+ snippets.append(entry)
644
+
645
+ result: dict = {
646
+ "query": payload.query,
647
+ "project": project.project_id,
648
+ "chunks": snippets,
649
+ "total": len(snippets),
650
+ }
651
+ if trace_id:
652
+ result["trace_id"] = trace_id
653
+ if enrich_result and enrich_result.pattern_suggestions:
654
+ result["pattern_suggestions"] = enrich_result.pattern_suggestions
655
+ if enrich_result and enrich_result.warnings:
656
+ result["kg_warnings"] = enrich_result.warnings
657
+ return result
658
+
659
+ async def map_context(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
660
+ payload = MapContextInput.model_validate(arguments)
661
+ project = self._resolve_project(session, payload.project)
662
+ indexer = self._get_indexer(session, project)
663
+ top_k = max(1, min(payload.top_k, 20))
664
+ active_collection = indexer.active_collection_name()
665
+
666
+ retriever = HybridRetriever(
667
+ self.embedder,
668
+ self.qdrant_store,
669
+ indexer.bm25_store,
670
+ self.settings.defaults.retrieval,
671
+ )
672
+
673
+ if monitor is not None:
674
+ await monitor.stage("retrieve", "map_context: recuperando chunks relevantes")
675
+ retrieved = await retriever.retrieve(
676
+ project,
677
+ payload.query,
678
+ overlay_collection=None,
679
+ collection_name=active_collection,
680
+ user_collection_name=indexer.active_user_collection_name(),
681
+ monitor=monitor,
682
+ ast_graph=indexer.ast_graph,
683
+ )
684
+
685
+ if monitor is not None:
686
+ await monitor.stage("rerank", "map_context: reranking")
687
+ reranked = await self.reranker.rerank(payload.query, retrieved, top_k)
688
+
689
+ enrich_result: EnrichResult | None = None
690
+ superkg_system_id = await self._ensure_superkg_system(project)
691
+ if superkg_system_id:
692
+ candidates = [
693
+ {"chunk_id": item.chunk.chunk_id, "file_path": item.chunk.file_path, "score": item.score}
694
+ for item in reranked
695
+ ]
696
+ enrich_result = await self.superkg.enrich(project.project_id, superkg_system_id, payload.query, candidates)
697
+
698
+ if enrich_result and enrich_result.kg_scores:
699
+ kg_scores = enrich_result.kg_scores
700
+
701
+ def _merged(item) -> float:
702
+ kg = kg_scores.get(item.chunk.chunk_id)
703
+ return item.score * 0.7 + kg.kg_score * 0.3 if kg else item.score
704
+
705
+ reranked = sorted(reranked, key=_merged, reverse=True)
706
+
707
+ if monitor is not None:
708
+ await monitor.stage("graph_walk", "map_context: expandiendo grafo de dependencias")
709
+ seed_chunks = [item.chunk for item in reranked]
710
+ expanded = GraphWalker().expand(
711
+ seed_chunks,
712
+ indexer.ast_graph,
713
+ lambda ids: indexer.qdrant_store.get_chunks_by_ids(active_collection, ids) if active_collection else [],
714
+ depth=self.settings.defaults.retrieval.graph_walk_depth,
715
+ )
716
+
717
+ seed_ids = {c.chunk_id for c in seed_chunks}
718
+ seed_scores = {item.chunk.chunk_id: item.score for item in reranked}
719
+
720
+ # Build dependency graph and per-chunk caller/callee info — batch SQL + single Qdrant fetch
721
+ seed_symbols = [c.symbol for c in seed_chunks]
722
+ seed_chunk_ids_list = [c.chunk_id for c in seed_chunks]
723
+ callers_by_symbol = indexer.ast_graph.find_callers_batch(seed_symbols)
724
+ callees_by_chunk = indexer.ast_graph.find_callees_batch(seed_chunk_ids_list)
725
+
726
+ all_resolve_ids = list(dict.fromkeys(
727
+ cid
728
+ for ids in (*callees_by_chunk.values(), *callers_by_symbol.values())
729
+ for cid in ids
730
+ ))
731
+ id_to_symbol: dict[str, str] = {}
732
+ if all_resolve_ids and active_collection:
733
+ fetched = indexer.qdrant_store.get_chunks_by_ids(active_collection, all_resolve_ids)
734
+ id_to_symbol = {c.chunk_id: c.symbol for c in fetched}
735
+
736
+ dependency_graph: dict[str, list[str]] = {}
737
+ chunk_callers: dict[str, list[str]] = {}
738
+ chunk_callees: dict[str, list[str]] = {}
739
+ for chunk in seed_chunks:
740
+ callee_ids = callees_by_chunk.get(chunk.chunk_id, [])
741
+ callee_symbols = [id_to_symbol[cid] for cid in callee_ids if cid in id_to_symbol]
742
+ caller_ids = callers_by_symbol.get(chunk.symbol, [])
743
+ caller_symbols = [id_to_symbol[cid] for cid in caller_ids if cid in id_to_symbol]
744
+ dependency_graph[chunk.symbol] = callee_symbols
745
+ chunk_callers[chunk.chunk_id] = caller_symbols
746
+ chunk_callees[chunk.chunk_id] = callee_symbols
747
+
748
+ # Build file index
749
+ file_index: dict[str, list[str]] = defaultdict(list)
750
+ seen_ids: set[str] = set()
751
+ all_chunks = []
752
+ for chunk in seed_chunks + expanded:
753
+ if chunk.chunk_id not in seen_ids:
754
+ seen_ids.add(chunk.chunk_id)
755
+ all_chunks.append(chunk)
756
+ if chunk.symbol not in file_index[chunk.file_path]:
757
+ file_index[chunk.file_path].append(chunk.symbol)
758
+
759
+ # Assemble component list
760
+ components = []
761
+ for chunk in all_chunks:
762
+ is_seed = chunk.chunk_id in seed_ids
763
+ entry: dict = {
764
+ "file_path": chunk.file_path,
765
+ "symbol": chunk.symbol,
766
+ "kind": chunk.kind,
767
+ "signature": chunk.signature or "",
768
+ "start_line": chunk.start_line,
769
+ "end_line": chunk.end_line,
770
+ "relevance_score": round(seed_scores[chunk.chunk_id], 4) if is_seed else None,
771
+ "calls": chunk_callees.get(chunk.chunk_id, []),
772
+ "called_by": chunk_callers.get(chunk.chunk_id, []),
773
+ }
774
+ if payload.include_bodies and is_seed:
775
+ entry["body"] = chunk.body
776
+ components.append(entry)
777
+
778
+ # Optional LLM synthesis (always local, never remote)
779
+ summary = ""
780
+ if payload.synthesize:
781
+ if monitor is not None:
782
+ await monitor.stage("synthesize", "map_context: sintetizando mapa arquitectónico")
783
+ context_lines = []
784
+ for chunk in seed_chunks:
785
+ sig = chunk.signature or chunk.symbol
786
+ callee_list = ", ".join(chunk_callees.get(chunk.chunk_id, [])) or "none"
787
+ caller_list = ", ".join(chunk_callers.get(chunk.chunk_id, [])) or "none"
788
+ context_lines.append(
789
+ f"{chunk.file_path}::{chunk.symbol} ({chunk.kind})\n"
790
+ f" signature: {sig}\n"
791
+ f" calls: {callee_list}\n"
792
+ f" called_by: {caller_list}\n"
793
+ f" body_preview: {chunk.body[:400]}"
794
+ )
795
+ context_text = "\n\n".join(context_lines)
796
+ try:
797
+ result, _ = await self.llm_runtime.router.chat(
798
+ task_type="context_map",
799
+ policy="local_only",
800
+ messages=[
801
+ {"role": "system", "content": _CONTEXT_MAP_SYSTEM_PROMPT},
802
+ {"role": "user", "content": f"Query: {payload.query}\n\nComponents:\n{context_text}"},
803
+ ],
804
+ project_id=project.project_id,
805
+ task_id=uuid.uuid4().hex,
806
+ )
807
+ summary = result.strip()
808
+ except Exception:
809
+ summary = ""
810
+
811
+ map_result: dict = {
812
+ "query": payload.query,
813
+ "project": project.project_id,
814
+ "summary": summary,
815
+ "components": components,
816
+ "dependency_graph": dependency_graph,
817
+ "file_index": dict(file_index),
818
+ "total_components": len(components),
819
+ }
820
+ if enrich_result:
821
+ if enrich_result.trace_id:
822
+ map_result["trace_id"] = enrich_result.trace_id
823
+ if enrich_result.pattern_suggestions:
824
+ map_result["pattern_suggestions"] = enrich_result.pattern_suggestions
825
+ if enrich_result.warnings:
826
+ map_result["kg_warnings"] = enrich_result.warnings
827
+ return map_result
828
+
829
+ async def generate_document(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
830
+ payload = GenerateDocumentInput.model_validate(arguments)
831
+ project = self._resolve_project(session, payload.project)
832
+ indexer = self._get_indexer(session, project)
833
+ effective_policy = session.policy_overrides.get(project.project_id, project.llm_policy)
834
+ task_id = uuid.uuid4().hex
835
+
836
+ retriever = HybridRetriever(
837
+ self.embedder,
838
+ self.qdrant_store,
839
+ indexer.bm25_store,
840
+ self.settings.defaults.retrieval,
841
+ )
842
+ active_collection = indexer.active_collection_name()
843
+
844
+ if monitor is not None:
845
+ await monitor.stage("retrieve", "generate_document: recuperando chunks relevantes")
846
+ retrieved = await retriever.retrieve(
847
+ project,
848
+ payload.prompt,
849
+ overlay_collection=None,
850
+ collection_name=active_collection,
851
+ user_collection_name=indexer.active_user_collection_name(),
852
+ monitor=monitor,
853
+ )
854
+
855
+ if monitor is not None:
856
+ await monitor.stage("rerank", "generate_document: reranking")
857
+ top_k = max(1, min(payload.top_k, 30))
858
+ reranked = await self.reranker.rerank(payload.prompt, retrieved, top_k)
859
+
860
+ if monitor is not None:
861
+ await monitor.stage("graph_walk", "generate_document: expandiendo grafo de dependencias")
862
+ seed_chunks = [item.chunk for item in reranked]
863
+ expanded = GraphWalker().expand(
864
+ seed_chunks,
865
+ indexer.ast_graph,
866
+ lambda ids: indexer.qdrant_store.get_chunks_by_ids(active_collection, ids) if active_collection else [],
867
+ depth=self.settings.defaults.retrieval.graph_walk_depth,
868
+ )
869
+ seen: set[str] = set()
870
+ all_chunks = []
871
+ for chunk in seed_chunks + expanded:
872
+ if chunk.chunk_id not in seen:
873
+ seen.add(chunk.chunk_id)
874
+ all_chunks.append(chunk)
875
+
876
+ context_text = self._format_doc_context(all_chunks)
877
+
878
+ # Enrich with SuperKG narrative context if available (fire-and-forget)
879
+ kg_narrative = ""
880
+ superkg_system_id = await self._ensure_superkg_system(project)
881
+ if superkg_system_id:
882
+ kg_data = await self.superkg.get_context(project.project_id, superkg_system_id, mode="narrative")
883
+ if isinstance(kg_data, dict):
884
+ kg_narrative = kg_data.get("content") or kg_data.get("narrative") or kg_data.get("text") or ""
885
+ if not kg_narrative and "context" in kg_data:
886
+ kg_narrative = str(kg_data["context"])
887
+
888
+ if monitor is not None:
889
+ await monitor.stage("generate", "generate_document: generando contenido con LLM")
890
+
891
+ kg_section = f"\n\nSuperKG project context:\n{kg_narrative}" if kg_narrative else ""
892
+ result, usage = await self.llm_runtime.router.chat(
893
+ task_type="document_generation",
894
+ policy=effective_policy, # type: ignore[arg-type]
895
+ messages=[
896
+ {"role": "system", "content": _DOC_GENERATION_SYSTEM_PROMPT},
897
+ {
898
+ "role": "user",
899
+ "content": (
900
+ f"Request: {payload.prompt}\n\n"
901
+ f"Output file: {payload.output_path}\n\n"
902
+ f"Code context ({len(all_chunks)} chunks from {len({c.file_path for c in all_chunks})} files):\n\n"
903
+ f"{context_text}{kg_section}"
904
+ ),
905
+ },
906
+ ],
907
+ schema=None,
908
+ max_tokens=6000,
909
+ temperature=0.2,
910
+ project_id=project.project_id,
911
+ task_id=task_id,
912
+ )
913
+
914
+ content: str = result["content"] if isinstance(result, dict) else str(result)
915
+
916
+ output_path = Path(payload.output_path)
917
+ if output_path.is_absolute():
918
+ full_path = output_path
919
+ else:
920
+ full_path = project.root / output_path
921
+ full_path.parent.mkdir(parents=True, exist_ok=True)
922
+ full_path.write_text(content, encoding="utf-8")
923
+
924
+ return {
925
+ "success": True,
926
+ "file_path": str(full_path),
927
+ "bytes_written": len(content.encode("utf-8")),
928
+ "chunks_used": len(all_chunks),
929
+ "llm_usage": usage.model_dump(mode="json"),
930
+ }
931
+
932
+ async def generate_business_logic(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
933
+ payload = GenerateBusinessLogicInput.model_validate(arguments)
934
+ project = self._resolve_project(session, payload.project)
935
+ indexer = self._get_indexer(session, project)
936
+ effective_policy = session.policy_overrides.get(project.project_id, project.llm_policy)
937
+ task_id = uuid.uuid4().hex
938
+
939
+ retriever = HybridRetriever(
940
+ self.embedder,
941
+ self.qdrant_store,
942
+ indexer.bm25_store,
943
+ self.settings.defaults.retrieval,
944
+ )
945
+ active_collection = indexer.active_collection_name()
946
+
947
+ if monitor is not None:
948
+ await monitor.stage("retrieve", "generate_business_logic: recuperando chunks de lógica de negocio")
949
+
950
+ seen: set[str] = set()
951
+ all_chunks = []
952
+ top_k_per_query = max(5, payload.top_k // len(_BUSINESS_LOGIC_QUERIES))
953
+ for query in _BUSINESS_LOGIC_QUERIES:
954
+ retrieved = await retriever.retrieve(
955
+ project,
956
+ query,
957
+ overlay_collection=None,
958
+ collection_name=active_collection,
959
+ user_collection_name=indexer.active_user_collection_name(),
960
+ monitor=None,
961
+ )
962
+ reranked = await self.reranker.rerank(query, retrieved, top_k_per_query)
963
+ for item in reranked:
964
+ if item.chunk.chunk_id not in seen:
965
+ seen.add(item.chunk.chunk_id)
966
+ all_chunks.append(item.chunk)
967
+
968
+ if monitor is not None:
969
+ await monitor.stage("graph_walk", "generate_business_logic: expandiendo grafo")
970
+ expanded = GraphWalker().expand(
971
+ all_chunks,
972
+ indexer.ast_graph,
973
+ lambda ids: indexer.qdrant_store.get_chunks_by_ids(active_collection, ids) if active_collection else [],
974
+ depth=self.settings.defaults.retrieval.graph_walk_depth,
975
+ )
976
+ for chunk in expanded:
977
+ if chunk.chunk_id not in seen:
978
+ seen.add(chunk.chunk_id)
979
+ all_chunks.append(chunk)
980
+
981
+ context_text = self._format_doc_context(all_chunks)
982
+
983
+ kg_narrative = ""
984
+ superkg_system_id = await self._ensure_superkg_system(project)
985
+ if superkg_system_id:
986
+ kg_data = await self.superkg.get_context(project.project_id, superkg_system_id, mode="narrative")
987
+ if isinstance(kg_data, dict):
988
+ kg_narrative = kg_data.get("content") or kg_data.get("narrative") or kg_data.get("text") or ""
989
+ if not kg_narrative and "context" in kg_data:
990
+ kg_narrative = str(kg_data["context"])
991
+
992
+ if monitor is not None:
993
+ await monitor.stage("generate", "generate_business_logic: sintetizando lógica de negocio con LLM")
994
+
995
+ kg_section = f"\n\nSuperKG project context:\n{kg_narrative}" if kg_narrative else ""
996
+ result, usage = await self.llm_runtime.router.chat(
997
+ task_type="document_generation",
998
+ policy=effective_policy, # type: ignore[arg-type]
999
+ messages=[
1000
+ {"role": "system", "content": _BUSINESS_LOGIC_SYSTEM_PROMPT},
1001
+ {
1002
+ "role": "user",
1003
+ "content": (
1004
+ f"Repository: {project.root}\n\n"
1005
+ f"Code context ({len(all_chunks)} chunks from {len({c.file_path for c in all_chunks})} files):\n\n"
1006
+ f"{context_text}{kg_section}"
1007
+ ),
1008
+ },
1009
+ ],
1010
+ schema=None,
1011
+ max_tokens=8000,
1012
+ temperature=0.1,
1013
+ project_id=project.project_id,
1014
+ task_id=task_id,
1015
+ )
1016
+
1017
+ content: str = result["content"] if isinstance(result, dict) else str(result)
1018
+
1019
+ output_path = Path(payload.output_path)
1020
+ full_path = output_path if output_path.is_absolute() else project.root / output_path
1021
+ full_path.parent.mkdir(parents=True, exist_ok=True)
1022
+ full_path.write_text(content, encoding="utf-8")
1023
+
1024
+ return {
1025
+ "success": True,
1026
+ "file_path": str(full_path),
1027
+ "bytes_written": len(content.encode("utf-8")),
1028
+ "chunks_used": len(all_chunks),
1029
+ "overwritten": full_path.exists(),
1030
+ "llm_usage": usage.model_dump(mode="json"),
1031
+ }
1032
+
1033
+ async def list_snippets(self, session: SessionState, arguments: dict) -> dict:
1034
+ payload = ListSnippetsInput.model_validate(arguments)
1035
+ snippets = self._snippet_loader.list_snippets(
1036
+ framework=payload.framework,
1037
+ language=payload.language,
1038
+ layer=payload.layer,
1039
+ )
1040
+ return {
1041
+ "snippets": [
1042
+ {
1043
+ "id": s.id,
1044
+ "framework": s.framework,
1045
+ "language": s.language,
1046
+ "layer": s.layer,
1047
+ "tags": s.tags,
1048
+ "produces": s.produces,
1049
+ "requires": s.requires,
1050
+ }
1051
+ for s in snippets
1052
+ ],
1053
+ "total": len(snippets),
1054
+ }
1055
+
1056
+ async def assemble_from_snippets(
1057
+ self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None
1058
+ ) -> dict:
1059
+ payload = AssembleFromSnippetsInput.model_validate(arguments)
1060
+ project = self._resolve_project(session, payload.project)
1061
+ effective_policy = session.policy_overrides.get(project.project_id, project.llm_policy)
1062
+ task_id = uuid.uuid4().hex
1063
+
1064
+ assembler = SnippetAssembler(
1065
+ loader=self._snippet_loader,
1066
+ router=self.llm_runtime.router,
1067
+ project=project,
1068
+ logger=self.logger,
1069
+ )
1070
+
1071
+ if monitor is not None:
1072
+ await monitor.stage("intent", "assemble_from_snippets: resolviendo intent")
1073
+ pack = await assembler.assemble(
1074
+ user_prompt=payload.prompt,
1075
+ task_id=task_id,
1076
+ policy=effective_policy, # type: ignore[arg-type]
1077
+ )
1078
+
1079
+ stored = StoredAssemblyTask(
1080
+ pack=pack,
1081
+ user_prompt=payload.prompt,
1082
+ project_id=project.project_id,
1083
+ effective_policy=effective_policy, # type: ignore[arg-type]
1084
+ expires_at=datetime.now(timezone.utc).replace(microsecond=0) + timedelta(hours=2),
1085
+ )
1086
+ session.assembly_tasks[task_id] = stored
1087
+
1088
+ result: dict = {
1089
+ "success": pack.success,
1090
+ "task_id": task_id,
1091
+ "project": project.project_id,
1092
+ }
1093
+ if pack.intent:
1094
+ result["intent"] = pack.intent.model_dump()
1095
+ if pack.selection:
1096
+ result["selected_snippets"] = pack.selection.selected_snippet_ids
1097
+ if pack.variables:
1098
+ result["variables"] = pack.variables.model_dump(exclude={"extra"})
1099
+ if pack.result:
1100
+ result["files_created"] = pack.result.files_created
1101
+ result["files_modified"] = pack.result.files_modified
1102
+ result["integration_notes"] = pack.result.integration_notes
1103
+ if pack.failure:
1104
+ result["failure"] = pack.failure.model_dump()
1105
+ result["llm_usage"] = [u.model_dump(mode="json") for u in pack.llm_usage]
1106
+ return result
1107
+
1108
+ @staticmethod
1109
+ def _format_doc_context(all_chunks: list) -> str:
1110
+ """Group chunks by package/module and extract invariants for richer LLM context."""
1111
+ # Group by top-level package or module (first 2 path segments)
1112
+ groups: dict[str, list] = defaultdict(list)
1113
+ for chunk in all_chunks[:32]:
1114
+ parts = Path(chunk.file_path).parts
1115
+ group_key = "/".join(parts[:2]) if len(parts) >= 2 else parts[0]
1116
+ groups[group_key].append(chunk)
1117
+
1118
+ # Extract invariants: constants, thresholds, exports, state values
1119
+ _INVARIANT_RE = re.compile(
1120
+ r"^\s*(?:"
1121
+ r"(?:export\s+(?:const|function|class|type|interface|enum)\s+\w+)" # TS exports
1122
+ r"|(?:[A-Z][A-Z0-9_]{2,}\s*=\s*.+)" # UPPERCASE_CONSTANTS
1123
+ r"|(?:.*=\s*0\.\d{2,})" # score thresholds
1124
+ r"|(?:status[:\s]+['\"]?\w+['\"]?)" # status values
1125
+ r")",
1126
+ re.MULTILINE,
1127
+ )
1128
+
1129
+ sections: list[str] = []
1130
+ for group_key in sorted(groups):
1131
+ chunks = groups[group_key]
1132
+ section_lines = [f"## `{group_key}`\n"]
1133
+
1134
+ invariants: list[str] = []
1135
+ for chunk in chunks:
1136
+ for line in chunk.body.splitlines():
1137
+ if _INVARIANT_RE.match(line):
1138
+ stripped = line.strip()
1139
+ if stripped and stripped not in invariants:
1140
+ invariants.append(stripped)
1141
+
1142
+ if invariants:
1143
+ section_lines.append("**Exports / constants / thresholds:**")
1144
+ section_lines.extend(f" - `{inv}`" for inv in invariants[:20])
1145
+ section_lines.append("")
1146
+
1147
+ for chunk in chunks:
1148
+ header = f"### `{chunk.symbol}` — `{chunk.file_path}`"
1149
+ if chunk.signature:
1150
+ header += f"\n*{chunk.signature}*"
1151
+ body_preview = "\n".join(chunk.body.splitlines()[:60])
1152
+ section_lines.append(f"{header}\n```\n{body_preview}\n```")
1153
+
1154
+ sections.append("\n".join(section_lines))
1155
+
1156
+ return "\n\n---\n\n".join(sections)
1157
+
1158
+ async def init_project(self, session: SessionState, arguments: dict) -> dict:
1159
+ payload = InitProjectInput.model_validate(arguments)
1160
+
1161
+ if payload.path:
1162
+ target = Path(payload.path)
1163
+ elif session.roots:
1164
+ target = session.roots[0]
1165
+ else:
1166
+ return {"success": False, "message": "No path provided and no session root available."}
1167
+
1168
+ from codepreproc_client.layer1_business.cli.init import init_project as _cli_init
1169
+ from codepreproc_client.layer1_business.config import apply_env_overrides, get_home_dir
1170
+ from codepreproc_client.layer1_business.local_registry import load_local_projects
1171
+ from contracts.dtos import Registry as _Registry
1172
+
1173
+ # verbose=False avoids any print() calls that would corrupt MCP stdio
1174
+ exit_code = await asyncio.to_thread(_cli_init, path=target, verbose=False)
1175
+
1176
+ if exit_code != 0:
1177
+ return {"success": False, "message": f"Failed to register project at '{target}'. Check that the path exists and is accessible."}
1178
+
1179
+ # Hot-reload the local registry mirror so the new project is immediately
1180
+ # available (cli.init.init_project already registered it server-side).
1181
+ new_data = apply_env_overrides(
1182
+ _Registry(defaults=self.registry.registry.defaults, projects=load_local_projects(get_home_dir()))
1183
+ )
1184
+ self.registry.registry = new_data
1185
+
1186
+ target_resolved = str(target.resolve())
1187
+ for pid, pconf in new_data.projects.items():
1188
+ if str(pconf.root) == target_resolved:
1189
+ return {
1190
+ "success": True,
1191
+ "project_id": pid,
1192
+ "root": str(pconf.root),
1193
+ "languages": pconf.languages,
1194
+ "qdrant_collection": pconf.qdrant_collection,
1195
+ "message": f"Project '{pid}' registered. Run reindex to build the index.",
1196
+ }
1197
+
1198
+ return {"success": True, "project_id": None, "message": "Project was already registered. No changes made."}
1199
+
1200
+ async def generate_repo_map(self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None) -> dict:
1201
+ payload = GenerateRepoMapInput.model_validate(arguments)
1202
+ project = self._resolve_project(session, payload.project)
1203
+ indexer = self._get_indexer(session, project)
1204
+
1205
+ if monitor is not None:
1206
+ await monitor.stage("build_map", "generate_repo_map: construyendo mapa del repositorio")
1207
+
1208
+ db_path = indexer.ast_graph.db_path
1209
+ mapper = RepoMapper(project, db_path)
1210
+
1211
+ try:
1212
+ repo_map = await asyncio.to_thread(
1213
+ mapper.build,
1214
+ artifacts_folder=payload.artifacts_folder,
1215
+ modules_folder=payload.modules_folder,
1216
+ )
1217
+ except NeedsMonorepoConfig as exc:
1218
+ return {
1219
+ "success": False,
1220
+ "needs_clarification": True,
1221
+ "project_id": project.project_id,
1222
+ "questions": [
1223
+ {
1224
+ "param": "artifacts_folder",
1225
+ "question": (
1226
+ "Which top-level folder contains the deployable artifacts (apps)? "
1227
+ f"Available root directories: {exc.root_dirs}"
1228
+ ),
1229
+ },
1230
+ {
1231
+ "param": "modules_folder",
1232
+ "question": (
1233
+ "Which top-level folder contains the reusable shared modules (packages)? "
1234
+ f"Available root directories: {exc.root_dirs}"
1235
+ ),
1236
+ },
1237
+ ],
1238
+ "hint": (
1239
+ "Re-invoke generate_repo_map with artifacts_folder and modules_folder set "
1240
+ "to the correct directory names."
1241
+ ),
1242
+ }
1243
+
1244
+ if monitor is not None:
1245
+ await monitor.stage("annotate", "generate_repo_map: enriqueciendo con KG y LLM")
1246
+
1247
+ superkg_system_id = await self._ensure_superkg_system(project)
1248
+ policy = session.policy_overrides.get(project.project_id, project.llm_policy)
1249
+ annotator = RepoMapAnnotator(
1250
+ router=self.llm_runtime.router,
1251
+ superkg=self.superkg,
1252
+ cache_path=db_path.parent / "repo_map_cache.json",
1253
+ )
1254
+ final_map = await annotator.annotate(
1255
+ raw_map=repo_map,
1256
+ policy=policy,
1257
+ project_id=project.project_id,
1258
+ project=project,
1259
+ superkg_system_id=superkg_system_id,
1260
+ )
1261
+
1262
+ if monitor is not None:
1263
+ await monitor.stage("write_json", "generate_repo_map: escribiendo repo_map.json")
1264
+
1265
+ output_path = project.root / "repo_map.json"
1266
+ output_path.write_text(json.dumps(final_map, ensure_ascii=False, indent=2), encoding="utf-8")
1267
+
1268
+ if superkg_system_id:
1269
+ await self.superkg.ingest_repo_map(
1270
+ project_id=project.project_id,
1271
+ system_id=superkg_system_id,
1272
+ repo_map=final_map,
1273
+ output_path=str(output_path),
1274
+ )
1275
+ await self.superkg.log_activity(
1276
+ project_id=project.project_id,
1277
+ system_id=superkg_system_id,
1278
+ activity_type="repo_map_generated",
1279
+ details={
1280
+ "project_id": project.project_id,
1281
+ "output_path": str(output_path),
1282
+ **final_map["stats"],
1283
+ },
1284
+ )
1285
+
1286
+ return {
1287
+ "success": True,
1288
+ "project_id": project.project_id,
1289
+ "output_path": str(output_path),
1290
+ "stats": final_map["stats"],
1291
+ }
1292
+
1293
+ async def kg_context(self, session: SessionState, arguments: dict) -> dict:
1294
+ payload = KGContextInput.model_validate(arguments)
1295
+ project = self._resolve_project(session, payload.project)
1296
+ superkg_system_id = await self._ensure_superkg_system(project)
1297
+ if not superkg_system_id:
1298
+ return {"error": "project_not_registered_in_superkg", "project": project.project_id}
1299
+ data = await self.superkg.get_context(project.project_id, superkg_system_id, mode=payload.mode)
1300
+ return {
1301
+ "project": project.project_id,
1302
+ "system_id": superkg_system_id,
1303
+ "mode": payload.mode,
1304
+ "context": data,
1305
+ }
1306
+
1307
+ async def kg_insights(self, session: SessionState, arguments: dict) -> dict:
1308
+ payload = KGInsightsInput.model_validate(arguments)
1309
+ project = self._resolve_project(session, payload.project)
1310
+ superkg_system_id = await self._ensure_superkg_system(project)
1311
+ if not superkg_system_id:
1312
+ return {"error": "project_not_registered_in_superkg", "project": project.project_id, "insights": []}
1313
+ items = await self.superkg.get_insights(project.project_id, superkg_system_id)
1314
+ return {
1315
+ "project": project.project_id,
1316
+ "system_id": superkg_system_id,
1317
+ "insights": items,
1318
+ "total": len(items),
1319
+ }
1320
+
1321
+ async def kg_patterns(self, session: SessionState, arguments: dict) -> dict:
1322
+ # Patterns aren't really project-scoped (SuperKG observes them across all
1323
+ # systems), but the /v1/superkg/query proxy authorizes every call against
1324
+ # one owned project+system_id - callers must have at least one active,
1325
+ # SuperKG-registered project to see cross-project patterns at all.
1326
+ project = self._resolve_project(session, None)
1327
+ superkg_system_id = await self._ensure_superkg_system(project)
1328
+ if not superkg_system_id:
1329
+ return {"patterns": [], "total": 0}
1330
+ items = await self.superkg.get_patterns(project.project_id, superkg_system_id)
1331
+ return {"patterns": items, "total": len(items)}
1332
+
1333
+ async def correct_graph(self, session: SessionState, arguments: dict) -> dict:
1334
+ payload = CorrectGraphInput.model_validate(arguments)
1335
+ project = self._resolve_project(session, payload.project)
1336
+ superkg_system_id = await self._ensure_superkg_system(project)
1337
+ if not superkg_system_id:
1338
+ return {"recorded": False, "error": "project_not_registered_in_superkg"}
1339
+ bld = BusinessLogicDiff(
1340
+ change=payload.change,
1341
+ before=payload.before,
1342
+ after=payload.after,
1343
+ rules_added=payload.rules_added,
1344
+ rules_removed=payload.rules_removed,
1345
+ rules_modified=payload.rules_modified,
1346
+ )
1347
+ await self.superkg.record_decision(
1348
+ project_id=project.project_id,
1349
+ system_id=superkg_system_id,
1350
+ session_id=None,
1351
+ intent=f"graph_correction: {payload.change}",
1352
+ plan_summary={"source": "human_correction"},
1353
+ bld=bld,
1354
+ )
1355
+ return {"recorded": True, "system_id": superkg_system_id, "change": payload.change}
1356
+
1357
+ async def project_pseudocode(self, session: SessionState, arguments: dict) -> dict:
1358
+ import fnmatch
1359
+
1360
+ from codepreproc_client.layer1_business.git_utils import get_current_sha
1361
+ from codepreproc_client.layer2_tooling.condensation.ppl.emitter import PPLEmitter
1362
+ from git import Repo
1363
+
1364
+ payload = ProjectPseudocodeInput.model_validate(arguments)
1365
+ project = self._resolve_project(session, payload.project)
1366
+ indexer = self._get_indexer(session, project)
1367
+
1368
+ rows = indexer.ast_graph.get_all_symbols()
1369
+
1370
+ if payload.file_pattern:
1371
+ pat = payload.file_pattern
1372
+ rows = [r for r in rows if fnmatch.fnmatch(r[2].replace("\\", "/"), pat)]
1373
+ if payload.exclude_tests:
1374
+ _TEST_MARKERS = ("/test/", "/tests/", "\\test\\", "\\tests\\", "_test.py", "test_.py")
1375
+ rows = [r for r in rows if not any(m in r[2] for m in _TEST_MARKERS)]
1376
+
1377
+ by_file: dict[str, list[tuple[str, str, str, str | None]]] = defaultdict(list)
1378
+ for chunk_id, symbol, file_path, kind, signature in rows:
1379
+ by_file[file_path].append((chunk_id, symbol, kind, signature))
1380
+
1381
+ all_files = sorted(by_file.keys())
1382
+ truncated = len(all_files) > payload.max_files
1383
+ files = all_files[: payload.max_files]
1384
+
1385
+ # Always fetch bodies — needed for @bind hash computation
1386
+ active_collection = indexer.active_collection_name()
1387
+ all_ids = [r[0] for fp in files for r in by_file[fp]]
1388
+ chunk_map: dict[str, object] = {}
1389
+ if all_ids and active_collection:
1390
+ fetched = indexer.qdrant_store.get_chunks_by_ids(active_collection, all_ids)
1391
+ chunk_map = {c.chunk_id: c for c in fetched}
1392
+
1393
+ # Fetch caller/callee cross-references from AST graph (batch)
1394
+ chunk_ids_list = list(chunk_map.keys())
1395
+ symbols_list = [sym for fp in files for _, sym, _, _ in by_file[fp]]
1396
+ callees_by_chunk = indexer.ast_graph.find_callees_batch(chunk_ids_list)
1397
+ callers_by_symbol = indexer.ast_graph.find_callers_batch(symbols_list)
1398
+ id_to_symbol = {r[0]: r[1] for r in indexer.ast_graph.get_all_symbols()}
1399
+
1400
+ # Build ordered CodeChunk list for PPL emission
1401
+ ordered_chunks = []
1402
+ for fp in files:
1403
+ file_chunks = [
1404
+ chunk_map[cid]
1405
+ for cid, *_ in by_file[fp]
1406
+ if cid in chunk_map
1407
+ ]
1408
+ file_chunks.sort(key=lambda c: c.start_line)
1409
+ ordered_chunks.extend(file_chunks)
1410
+
1411
+ repo = Repo(project.root)
1412
+ try:
1413
+ git_sha = get_current_sha(repo)
1414
+ finally:
1415
+ repo.close()
1416
+
1417
+ ppl_document = PPLEmitter().emit(
1418
+ project_id=project.project_id,
1419
+ git_sha=git_sha,
1420
+ chunks=ordered_chunks,
1421
+ callers_by_symbol=callers_by_symbol,
1422
+ callees_by_chunk_id=callees_by_chunk,
1423
+ id_to_symbol=id_to_symbol,
1424
+ )
1425
+
1426
+ return {
1427
+ "project": project.project_id,
1428
+ "total_files": len(files),
1429
+ "total_symbols": sum(len(by_file[f]) for f in files),
1430
+ "truncated": truncated,
1431
+ "ppl_document": ppl_document,
1432
+ # Legacy key kept for backward compatibility
1433
+ "pseudocode": ppl_document,
1434
+ }
1435
+
1436
+ async def _ensure_superkg_system(self, project: ProjectConfig) -> str | None:
1437
+ if project.superkg_system_id:
1438
+ return project.superkg_system_id
1439
+ if not getattr(self.superkg, "enabled", False):
1440
+ return None
1441
+
1442
+ system_id = await self.superkg.bootstrap(
1443
+ project_id=project.project_id,
1444
+ name=project.root.name or project.project_id,
1445
+ description=project.project_context or f"Codepreproc project at {project.root}",
1446
+ )
1447
+ if not system_id:
1448
+ return None
1449
+
1450
+ project.superkg_system_id = system_id
1451
+ registered = self.registry.registry.projects.get(project.project_id)
1452
+ if registered is not None:
1453
+ registered.superkg_system_id = system_id
1454
+
1455
+ try:
1456
+ from codepreproc_client.layer1_business.api_client.registry_client import set_superkg_id
1457
+ from codepreproc_client.layer1_business.config import get_home_dir, get_server_url
1458
+ from codepreproc_client.layer1_business.local_registry import load_local_projects, save_local_project
1459
+
1460
+ jwt = await self._jwt_provider(project.project_id)
1461
+ await set_superkg_id(
1462
+ base_url=get_server_url(), jwt=jwt, project_id=project.project_id, system_id=system_id
1463
+ )
1464
+ home_dir = get_home_dir()
1465
+ local = load_local_projects(home_dir)
1466
+ local_project = local.get(project.project_id)
1467
+ if local_project is not None:
1468
+ save_local_project(home_dir, local_project.model_copy(update={"superkg_system_id": system_id}))
1469
+ except Exception as exc:
1470
+ self.logger.debug("superkg_system_id persistence failed for %s: %s", project.project_id, exc)
1471
+
1472
+ return system_id
1473
+
1474
+ def _resolve_project(self, session: SessionState, explicit_project: str | None) -> ProjectConfig:
1475
+ try:
1476
+ return self.registry.resolve_project(
1477
+ explicit_project=explicit_project,
1478
+ active_project=session.active_project,
1479
+ roots=session.roots,
1480
+ fallback_path=session.roots[0] if session.roots else None,
1481
+ )
1482
+ except KeyError as exc:
1483
+ available = [project.project_id for project in self.registry.list_projects()]
1484
+ raise ValueError({"error": str(exc), "available_projects": available}) from exc
1485
+
1486
+ def _get_indexer(self, session: SessionState, project: ProjectConfig) -> ProjectIndexer:
1487
+ if project.project_id not in session.loaded_indexers:
1488
+ project_dir = self.settings.indexes_dir / project.project_id
1489
+ session.loaded_indexers[project.project_id] = ProjectIndexer(
1490
+ project=project,
1491
+ settings=self.settings,
1492
+ qdrant_store=self.qdrant_store,
1493
+ bm25_store=BM25Store(project_dir / "bm25.pkl"),
1494
+ ast_graph=AstGraph(project_dir / "ast_graph.sqlite"),
1495
+ chunker=AstChunker(self.settings.defaults.chunking),
1496
+ embedder=self.embedder,
1497
+ coupling_graph=self._coupling_graph,
1498
+ user_id=session.user_id,
1499
+ )
1500
+ return session.loaded_indexers[project.project_id]
1501
+
1502
+ def _make_pipeline(self, session: SessionState, project: ProjectConfig) -> SemanticExecutionPipeline:
1503
+ indexer = self._get_indexer(session, project)
1504
+ return SemanticExecutionPipeline(
1505
+ project=project,
1506
+ settings=self.settings,
1507
+ indexer=indexer,
1508
+ retriever=HybridRetriever(self.embedder, self.qdrant_store, indexer.bm25_store, self.settings.defaults.retrieval),
1509
+ reranker=self.reranker,
1510
+ graph_walker=GraphWalker(),
1511
+ intent_extractor=IntentExtractor(self.llm_runtime.router),
1512
+ context_compactor=ContextCompactor(self.llm_runtime.router),
1513
+ target_locator=TargetLocator(
1514
+ primary_limit=self.settings.defaults.semantic_editing.max_primary_targets,
1515
+ secondary_limit=self.settings.defaults.semantic_editing.max_secondary_targets,
1516
+ ),
1517
+ region_resolver=RegionResolver(),
1518
+ planner=SemanticEditPlanner(self.llm_runtime.router),
1519
+ applicability_checker=ApplicabilityChecker(),
1520
+ synthesizer=SemanticSynthesizer(
1521
+ self.llm_runtime.router,
1522
+ micro_editor_temperature=self.settings.defaults.semantic_editing.micro_editor_temperature,
1523
+ ),
1524
+ logger=self.logger,
1525
+ )
1526
+
1527
+ def _make_filesystem_pipeline(self, project: ProjectConfig) -> FilesystemReorganizationPipeline:
1528
+ return FilesystemReorganizationPipeline(
1529
+ project=project,
1530
+ settings=self.settings,
1531
+ router=self.llm_runtime.router,
1532
+ )
1533
+
1534
+ async def coupling_project(
1535
+ self, session: SessionState, arguments: dict, monitor: ActionMonitor | None = None
1536
+ ) -> dict:
1537
+ """Project cross-repo coupling structure for a set of repos in one call."""
1538
+ payload = CouplingProjectInput.model_validate(arguments)
1539
+ req = CouplingProjectRequest(anchors=payload.anchors, intent=payload.intent)
1540
+
1541
+ # Resolve intent → project_ids when anchors not given
1542
+ if not req.is_concrete():
1543
+ if not req.intent:
1544
+ return {"error": "provide anchors (project_id list) or intent string"}
1545
+ all_ids = [p.project_id for p in self.registry.list_projects()]
1546
+ intent_lower = req.intent.lower()
1547
+ anchors = [pid for pid in all_ids if intent_lower in pid.lower()]
1548
+ if not anchors:
1549
+ anchors = all_ids
1550
+ else:
1551
+ anchors = payload.anchors
1552
+
1553
+ # Validate all anchors are registered projects
1554
+ known = {p.project_id for p in self.registry.list_projects()}
1555
+ unknown = [a for a in anchors if a not in known]
1556
+ if unknown:
1557
+ return {"error": f"unknown project_ids: {unknown}", "known": sorted(known)}
1558
+
1559
+ if monitor is not None:
1560
+ await monitor.stage("hydrate", "coupling_project: hidratando edges")
1561
+
1562
+ # Hydrate edge superset from coupling graph (Layer 1 — candidate level)
1563
+ edges = await asyncio.to_thread(
1564
+ self._coupling_graph.get_edges_for_projects, anchors
1565
+ )
1566
+
1567
+ # Apply Layer 2 gate: disambiguate ambiguous edges and revalidate stale ones.
1568
+ # Uses export_surface_hash rather than last_indexed_sha so body-only commits
1569
+ # in dst projects do not trigger re-resolution.
1570
+ if monitor is not None:
1571
+ await monitor.stage("revalidate", "coupling_project: aplicando gate Layer 2")
1572
+ from codepreproc_client.layer2_tooling.coupling.layer2_gate import apply_layer2_gate
1573
+ edges, stale_count = await asyncio.to_thread(
1574
+ apply_layer2_gate, edges, self.settings.indexes_dir, self._coupling_graph
1575
+ )
1576
+
1577
+ if monitor is not None:
1578
+ await monitor.stage("project", "coupling_project: proyectando métricas")
1579
+
1580
+ summary = await asyncio.to_thread(
1581
+ self._coupling_projector.project, anchors, edges, stale_count
1582
+ )
1583
+
1584
+ handle_id = uuid.uuid4().hex
1585
+ report = CouplingReport(
1586
+ handle_id=handle_id,
1587
+ anchors=anchors,
1588
+ edges=edges,
1589
+ summary=summary,
1590
+ )
1591
+ session.coupling_reports[handle_id] = report
1592
+
1593
+ from codepreproc_client.layer2_tooling.coupling.domain import CouplingHandle
1594
+ handle = CouplingHandle(
1595
+ handle_id=handle_id,
1596
+ summary=summary,
1597
+ provenance_level="resolved",
1598
+ )
1599
+ return handle.model_dump(mode="json")
1600
+
1601
+ async def coupling_expand(self, session: SessionState, arguments: dict) -> dict:
1602
+ """Expand a coupling handle to retrieve edge details on demand."""
1603
+ payload = CouplingExpandInput.model_validate(arguments)
1604
+ report = session.coupling_reports.get(payload.handle_id)
1605
+ if report is None:
1606
+ return {"error": "handle_not_found", "handle_id": payload.handle_id}
1607
+
1608
+ if payload.edge_id:
1609
+ # Single edge detail
1610
+ edge = next((e for e in report.edges if e.edge_id == payload.edge_id), None)
1611
+ if edge is None:
1612
+ return {"error": "edge_not_found", "edge_id": payload.edge_id}
1613
+ # Enrich with file paths from local AstGraph
1614
+ from codepreproc_client.layer2_tooling.coupling.domain import EdgeDetail
1615
+ src_file = ""
1616
+ dst_file = ""
1617
+ src_project = self.registry.registry.projects.get(edge.src_project_id)
1618
+ dst_project = self.registry.registry.projects.get(edge.dst_project_id)
1619
+ if src_project:
1620
+ src_indexer_dir = self.settings.indexes_dir / edge.src_project_id
1621
+ try:
1622
+ import sqlite3 as _sqlite3
1623
+ conn = _sqlite3.connect(str(src_indexer_dir / "ast_graph.sqlite"))
1624
+ row = conn.execute("SELECT file_path FROM symbols WHERE chunk_id = ?", (edge.src_chunk_id,)).fetchone()
1625
+ conn.close()
1626
+ src_file = row[0] if row else ""
1627
+ except Exception:
1628
+ pass
1629
+ if dst_project:
1630
+ dst_indexer_dir = self.settings.indexes_dir / edge.dst_project_id
1631
+ try:
1632
+ import sqlite3 as _sqlite3
1633
+ conn = _sqlite3.connect(str(dst_indexer_dir / "ast_graph.sqlite"))
1634
+ row = conn.execute("SELECT file_path FROM symbols WHERE chunk_id = ?", (edge.dst_chunk_id,)).fetchone()
1635
+ conn.close()
1636
+ dst_file = row[0] if row else ""
1637
+ except Exception:
1638
+ pass
1639
+ detail = EdgeDetail(edge=edge, src_file_path=src_file, dst_file_path=dst_file)
1640
+ return detail.model_dump(mode="json")
1641
+
1642
+ # Paginated edge list
1643
+ total = len(report.edges)
1644
+ page = report.edges[payload.offset: payload.offset + payload.limit]
1645
+ return {
1646
+ "handle_id": payload.handle_id,
1647
+ "anchors": report.anchors,
1648
+ "total_edges": total,
1649
+ "offset": payload.offset,
1650
+ "limit": payload.limit,
1651
+ "edges": [e.model_dump(mode="json") for e in page],
1652
+ }
1653
+
1654
+ @staticmethod
1655
+ def _prune_tasks(session: SessionState) -> None:
1656
+ now = datetime.now(timezone.utc)
1657
+ expired = [task_id for task_id, item in session.semantic_tasks.items() if item.expires_at <= now]
1658
+ for task_id in expired:
1659
+ session.semantic_tasks.pop(task_id, None)
1660
+ expired_filesystem = [task_id for task_id, item in session.filesystem_tasks.items() if item.expires_at <= now]
1661
+ for task_id in expired_filesystem:
1662
+ session.filesystem_tasks.pop(task_id, None)
1663
+ expired_assembly = [task_id for task_id, item in session.assembly_tasks.items() if item.expires_at <= now]
1664
+ for task_id in expired_assembly:
1665
+ session.assembly_tasks.pop(task_id, None)