klapcontext 0.1.2__tar.gz → 0.2.0__tar.gz

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 (51) hide show
  1. {klapcontext-0.1.2/src/klapcontext.egg-info → klapcontext-0.2.0}/PKG-INFO +20 -2
  2. {klapcontext-0.1.2 → klapcontext-0.2.0}/README.md +14 -1
  3. {klapcontext-0.1.2 → klapcontext-0.2.0}/pyproject.toml +1 -1
  4. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext/__init__.py +1 -1
  5. klapcontext-0.2.0/src/klapcontext/agent/__init__.py +5 -0
  6. klapcontext-0.2.0/src/klapcontext/agent/capabilities.py +66 -0
  7. klapcontext-0.2.0/src/klapcontext/agent/compiler.py +64 -0
  8. klapcontext-0.2.0/src/klapcontext/agent/impact.py +18 -0
  9. klapcontext-0.2.0/src/klapcontext/agent/intents.py +39 -0
  10. klapcontext-0.2.0/src/klapcontext/agent/models.py +21 -0
  11. klapcontext-0.2.0/src/klapcontext/agent/planner.py +32 -0
  12. klapcontext-0.2.0/src/klapcontext/agent/verification.py +19 -0
  13. klapcontext-0.2.0/src/klapcontext/agent/workflows.py +31 -0
  14. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext/cli.py +14 -4
  15. klapcontext-0.2.0/src/klapcontext/context_builder.py +121 -0
  16. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext/detector.py +22 -2
  17. klapcontext-0.2.0/src/klapcontext/evidence.py +39 -0
  18. klapcontext-0.2.0/src/klapcontext/frameworks/base.py +17 -0
  19. klapcontext-0.2.0/src/klapcontext/frameworks/fastapi.py +37 -0
  20. klapcontext-0.2.0/src/klapcontext/frameworks/generic_php.py +32 -0
  21. klapcontext-0.2.0/src/klapcontext/frameworks/laravel.py +187 -0
  22. klapcontext-0.2.0/src/klapcontext/frameworks/node.py +32 -0
  23. klapcontext-0.2.0/src/klapcontext/git.py +60 -0
  24. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext/portal.py +7 -1
  25. klapcontext-0.2.0/src/klapcontext/providers/__init__.py +6 -0
  26. klapcontext-0.2.0/src/klapcontext/providers/base.py +14 -0
  27. klapcontext-0.2.0/src/klapcontext/providers/code_intelligence.py +233 -0
  28. klapcontext-0.2.0/src/klapcontext/providers/graphify.py +22 -0
  29. klapcontext-0.2.0/src/klapcontext/providers/javascript_intelligence.py +38 -0
  30. klapcontext-0.2.0/src/klapcontext/providers/python_intelligence.py +44 -0
  31. klapcontext-0.2.0/src/klapcontext/semantic.py +169 -0
  32. {klapcontext-0.1.2 → klapcontext-0.2.0/src/klapcontext.egg-info}/PKG-INFO +20 -2
  33. klapcontext-0.2.0/src/klapcontext.egg-info/SOURCES.txt +42 -0
  34. klapcontext-0.2.0/src/klapcontext.egg-info/requires.txt +11 -0
  35. klapcontext-0.2.0/tests/test_klapcontext.py +234 -0
  36. klapcontext-0.1.2/src/klapcontext/context_builder.py +0 -72
  37. klapcontext-0.1.2/src/klapcontext/evidence.py +0 -11
  38. klapcontext-0.1.2/src/klapcontext/frameworks/laravel.py +0 -74
  39. klapcontext-0.1.2/src/klapcontext/git.py +0 -34
  40. klapcontext-0.1.2/src/klapcontext.egg-info/SOURCES.txt +0 -22
  41. klapcontext-0.1.2/src/klapcontext.egg-info/requires.txt +0 -6
  42. klapcontext-0.1.2/tests/test_klapcontext.py +0 -68
  43. {klapcontext-0.1.2 → klapcontext-0.2.0}/LICENSE +0 -0
  44. {klapcontext-0.1.2 → klapcontext-0.2.0}/setup.cfg +0 -0
  45. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext/agent_context.py +0 -0
  46. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext/frameworks/__init__.py +0 -0
  47. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext/graphify.py +0 -0
  48. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext/system.py +0 -0
  49. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext.egg-info/dependency_links.txt +0 -0
  50. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext.egg-info/entry_points.txt +0 -0
  51. {klapcontext-0.1.2 → klapcontext-0.2.0}/src/klapcontext.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: klapcontext
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Make your repository understandable to humans and AI.
5
5
  Author: Willy Cordon
6
6
  License-Expression: Apache-2.0
@@ -21,6 +21,11 @@ Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: graphifyy>=0.9.66
24
+ Requires-Dist: tree-sitter<0.26,>=0.24
25
+ Requires-Dist: tree-sitter-php<0.25,>=0.24
26
+ Requires-Dist: tree-sitter-javascript<0.26,>=0.23
27
+ Requires-Dist: tree-sitter-typescript<0.26,>=0.23
28
+ Requires-Dist: mcp<2,>=1.0
24
29
  Provides-Extra: dev
25
30
  Requires-Dist: build>=1.2; extra == "dev"
26
31
  Requires-Dist: pytest>=8; extra == "dev"
@@ -29,7 +34,20 @@ Dynamic: license-file
29
34
 
30
35
  # KlapContext
31
36
 
32
- Make your repository understandable to humans and AI.
37
+ **Understand an unfamiliar codebase before changing it.**
38
+
39
+ KlapContext convierte evidencia de código en un modelo del sistema: puntos de
40
+ entrada, flujos, automatizaciones, integraciones y sus incertidumbres.
41
+ Graphify funciona como proveedor de análisis técnico; KlapContext es la capa
42
+ que transforma esa evidencia en comprensión para personas y agentes de IA.
43
+
44
+ ## Agent Intelligence
45
+
46
+ KlapContext planifica el **mínimo contexto relevante** antes de que un agente
47
+ empiece a cambiar código. Según la intención —entender, modificar, depurar,
48
+ probar, refactorizar o medir impacto— selecciona capacidades y proveedores
49
+ necesarios, sin acoplarse a una herramienta externa ni cargar archivos enteros
50
+ por defecto. La compilación de Task Context llegará en los siguientes sprints.
33
51
 
34
52
  KlapContext convierte un repositorio en contexto de ingeniería reutilizable: un
35
53
  portal estático para personas y un briefing compacto para agentes de IA. Usa
@@ -1,6 +1,19 @@
1
1
  # KlapContext
2
2
 
3
- Make your repository understandable to humans and AI.
3
+ **Understand an unfamiliar codebase before changing it.**
4
+
5
+ KlapContext convierte evidencia de código en un modelo del sistema: puntos de
6
+ entrada, flujos, automatizaciones, integraciones y sus incertidumbres.
7
+ Graphify funciona como proveedor de análisis técnico; KlapContext es la capa
8
+ que transforma esa evidencia en comprensión para personas y agentes de IA.
9
+
10
+ ## Agent Intelligence
11
+
12
+ KlapContext planifica el **mínimo contexto relevante** antes de que un agente
13
+ empiece a cambiar código. Según la intención —entender, modificar, depurar,
14
+ probar, refactorizar o medir impacto— selecciona capacidades y proveedores
15
+ necesarios, sin acoplarse a una herramienta externa ni cargar archivos enteros
16
+ por defecto. La compilación de Task Context llegará en los siguientes sprints.
4
17
 
5
18
  KlapContext convierte un repositorio en contexto de ingeniería reutilizable: un
6
19
  portal estático para personas y un briefing compacto para agentes de IA. Usa
@@ -13,7 +13,7 @@ license-files = ["LICENSE"]
13
13
  authors = [{name = "Willy Cordon"}]
14
14
  keywords = ["codebase", "developer-tools", "engineering-context", "graphify", "ai-agents"]
15
15
  classifiers = ["Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Documentation"]
16
- dependencies = ["graphifyy>=0.9.66"]
16
+ dependencies = ["graphifyy>=0.9.66", "tree-sitter>=0.24,<0.26", "tree-sitter-php>=0.24,<0.25", "tree-sitter-javascript>=0.23,<0.26", "tree-sitter-typescript>=0.23,<0.26", "mcp>=1.0,<2"]
17
17
 
18
18
  [project.optional-dependencies]
19
19
  dev = ["build>=1.2", "pytest>=8", "twine>=6"]
@@ -1,3 +1,3 @@
1
1
  """KlapContext local engineering-context generator."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Planning layer for compact, task-specific agent context."""
2
+
3
+ from .planner import ContextPlanner
4
+
5
+ __all__ = ["ContextPlanner"]
@@ -0,0 +1,66 @@
1
+ """Capabilities are stable; tools that implement them are replaceable."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+
8
+ class Capability(str, Enum):
9
+ FRAMEWORK_ANALYSIS = "framework_analysis"
10
+ ENTRY_POINT_DISCOVERY = "entry_point_discovery"
11
+ FLOW_RECONSTRUCTION = "flow_reconstruction"
12
+ DEPENDENCY_GRAPH = "dependency_graph"
13
+ PATH_SEARCH = "path_search"
14
+ TEST_DISCOVERY = "test_discovery"
15
+ INTEGRATION_DISCOVERY = "integration_discovery"
16
+ AUTOMATION_DISCOVERY = "automation_discovery"
17
+ CONFIG_DISCOVERY = "config_discovery"
18
+ SYMBOL_LOOKUP = "symbol_lookup"
19
+ CALL_GRAPH = "call_graph"
20
+ CALLERS = "callers"
21
+ CALLEES = "callees"
22
+ IMPACT_ANALYSIS = "impact_analysis"
23
+ MINIMAL_EDIT_CONTEXT = "minimal_edit_context"
24
+ GIT_HISTORY = "git_history"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ProviderInfo:
29
+ name: str
30
+ capabilities: frozenset[Capability]
31
+ available: bool
32
+ optional: bool = False
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class CapabilityResolution:
37
+ capability: Capability
38
+ provider: str | None
39
+ status: str
40
+ fallbacks: tuple[str, ...] = ()
41
+
42
+
43
+ class CapabilityRouter:
44
+ """Select an available provider without leaking provider names to callers."""
45
+ def __init__(self, providers: list[ProviderInfo]):
46
+ self.providers = providers
47
+
48
+ def resolve(self, capability: Capability) -> CapabilityResolution:
49
+ candidates = [provider for provider in self.providers if capability in provider.capabilities]
50
+ ready = next((provider for provider in candidates if provider.available), None)
51
+ if ready:
52
+ return CapabilityResolution(capability, ready.name, "READY", tuple(provider.name for provider in candidates if provider.name != ready.name))
53
+ return CapabilityResolution(capability, None, "UNAVAILABLE", tuple(provider.name for provider in candidates))
54
+
55
+
56
+ def default_providers(graphify_ready: bool | None = None) -> list[ProviderInfo]:
57
+ from ..providers.code_intelligence import available as php_code_intelligence_available
58
+ if graphify_ready is None:
59
+ from ..graphify import command_prefix
60
+ graphify_ready = command_prefix() is not None
61
+ return [
62
+ ProviderInfo("klap-native", frozenset({Capability.FRAMEWORK_ANALYSIS, Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION, Capability.TEST_DISCOVERY, Capability.INTEGRATION_DISCOVERY, Capability.AUTOMATION_DISCOVERY, Capability.CONFIG_DISCOVERY}), True),
63
+ ProviderInfo("graphify", frozenset({Capability.DEPENDENCY_GRAPH, Capability.PATH_SEARCH, Capability.FLOW_RECONSTRUCTION}), graphify_ready),
64
+ ProviderInfo("php-tree-sitter", frozenset({Capability.SYMBOL_LOOKUP, Capability.CALL_GRAPH, Capability.CALLERS, Capability.CALLEES, Capability.IMPACT_ANALYSIS, Capability.MINIMAL_EDIT_CONTEXT}), php_code_intelligence_available()),
65
+ ProviderInfo("tree-sitter-mcp", frozenset({Capability.SYMBOL_LOOKUP, Capability.CALL_GRAPH, Capability.CALLERS, Capability.CALLEES, Capability.IMPACT_ANALYSIS, Capability.MINIMAL_EDIT_CONTEXT}), False, True),
66
+ ]
@@ -0,0 +1,64 @@
1
+ """Compile a small, explainable agent context from the system model."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .planner import ContextPlanner
9
+ from ..context_builder import build
10
+ from .verification import reconcile, sanitize
11
+
12
+ DETAIL_LIMITS = {"minimal": 3, "standard": 6, "deep": 12}
13
+
14
+
15
+ def _terms(query: str, area: str) -> set[str]:
16
+ return {item for item in re.findall(r"[a-zA-Z_]{4,}", f"{query} {area}".casefold()) if item not in {"quiero", "modificar", "cambiar", "sobre", "para"}}
17
+
18
+
19
+ def _rank(items: list[dict], terms: set[str], limit: int) -> list[dict]:
20
+ ranked = []
21
+ for original in items:
22
+ text = json.dumps(original, ensure_ascii=False).casefold(); matches = sum(term in text for term in terms)
23
+ if matches or not terms:
24
+ item = dict(original); item["relevance"] = "directa" if matches else "contextual"; item["reason"] = "Coincide con la tarea" if matches else "Contexto estructural cercano"; ranked.append((matches, item))
25
+ return [item for _, item in sorted(ranked, key=lambda pair: pair[0], reverse=True)[:limit]]
26
+
27
+
28
+ def compile_context(root: Path, query: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
29
+ plan = ContextPlanner().plan(query, detail=detail, max_tokens=max_tokens)
30
+ context = build(root, {"nodes": []}); model = context["system_model"]; limit = min(DETAIL_LIMITS[detail], max(1, max_tokens // 350)); terms = _terms(query, plan.area)
31
+ entries = _rank(model["entry_points"], terms, limit); flows = _rank(model["main_flows"], terms, limit); files = _rank(model["important_files"], terms, limit)
32
+ read_first = ([{"path": item.get("file"), "reason": "Punto de entrada relacionado", "relevance": item["relevance"]} for item in entries if item.get("file")] + files)[:limit]
33
+ result = {"intent": plan.intent, "confidence": plan.confidence, "query": query, "area": plan.area, "detail": detail, "max_tokens": max_tokens, "entry_points": entries, "flows": flows, "symbols": _rank(model["semantic_model"]["components"], terms, limit), "files": files, "dependencies": _rank(model["dependencies"], terms, limit), "tests": _rank(context["tests"], terms, limit), "read_first": read_first, "evidence": [evidence for group in entries + flows for evidence in group.get("evidence", [])][:limit], "uncertainties": model["unknowns"], "strategy": plan.strategy}
34
+ result["verification"] = reconcile(result["evidence"])
35
+ result = sanitize(result)
36
+ result["estimated_tokens"] = min(max_tokens, len(json.dumps(result, ensure_ascii=False)) // 4)
37
+ result["execution"] = {"capabilities": plan.capabilities, "providers": plan.providers, "files_selected": len(result["read_first"]), "sections_selected": len(plan.sections), "adaptive": True}
38
+ return result
39
+
40
+
41
+ def system_context(root: Path, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
42
+ return compile_context(root, "understand system", detail=detail, max_tokens=max_tokens)
43
+
44
+
45
+ def area_context(root: Path, area: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
46
+ return compile_context(root, f"understand {area}", detail=detail, max_tokens=max_tokens)
47
+
48
+
49
+ def change_context(root: Path, task: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
50
+ result = compile_context(root, task, detail=detail, max_tokens=max_tokens)
51
+ result["context_type"] = "CHANGE"; result["risks"] = result["uncertainties"]
52
+ return result
53
+
54
+
55
+ def debug_context(root: Path, task: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
56
+ result = compile_context(root, task, detail=detail, max_tokens=max_tokens)
57
+ result["context_type"] = "DEBUG"; result["investigation_points"] = result["entry_points"] + result["flows"]
58
+ return result
59
+
60
+
61
+ def compile_test_context(root: Path, task: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
62
+ result = compile_context(root, f"test {task}", detail=detail, max_tokens=max_tokens)
63
+ result["context_type"] = "TEST"
64
+ return result
@@ -0,0 +1,18 @@
1
+ """Explain structural impact; it never predicts that a change will break code."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from ..context_builder import build
7
+ from ..providers.code_intelligence import PhpCodeIntelligenceProvider
8
+
9
+
10
+ def analyze_impact(root: Path, symbol: str) -> dict:
11
+ structural = PhpCodeIntelligenceProvider(root).impact(symbol)
12
+ model = build(root, {"nodes": []})["system_model"]
13
+ callers = structural["direct_callers"] + [{"source_symbol": item, "confidence": "INFERRED"} for item in structural["indirect_callers"]]
14
+ names = {symbol, *(item["source_symbol"] for item in callers)}
15
+ flows = [item for item in model["main_flows"] if any(any(name.endswith(step["name"]) or step["name"].endswith(name) for name in names) for step in item["steps"])]
16
+ entries = [item for item in model["entry_points"] if any(item.get("handler", "").endswith(name) or name.endswith(item.get("handler", "")) for name in names)]
17
+ tests = sorted(set(structural["related_tests"] + [item["path"] for item in model["important_files"] if "test" in item["path"].casefold()]))
18
+ return {"symbol": symbol, "statement": "Elementos potencialmente afectados según relaciones estructurales; no predice fallas.", "direct_callers": structural["direct_callers"], "indirect_callers": structural["indirect_callers"], "entry_points": entries, "flows": flows, "tests": tests, "configuration": [item for item in model["important_files"] if item["path"].startswith("config/")], "integrations": model["external_systems"], "uncertainties": model["unknowns"]}
@@ -0,0 +1,39 @@
1
+ """Deterministic Spanish/English intent and area detection for planning."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class IntentMatch:
9
+ intent: str
10
+ confidence: float
11
+ area: str
12
+
13
+
14
+ RULES = (
15
+ ("DEBUG", ("error", "falla", "fallo", "bug", "exception", "500", "debug")),
16
+ ("IMPACT", ("impacto", "qué rompe", "que rompe", "qué usa", "que usa", "afecta", "impact")),
17
+ ("REFACTOR", ("refactor", "limpiar", "separar", "reestructurar")),
18
+ ("TEST", ("test", "prueba", "coverage", "cobertura")),
19
+ ("CHANGE", ("modificar", "cambiar", "agregar", "implementar", "añadir", "fix", "update", "edit")),
20
+ ("UNDERSTAND", ("qué hace", "que hace", "entender", "explicar", "comprender", "how does", "what does")),
21
+ )
22
+ AREAS = (
23
+ ("authentication", ("auth", "autentic", "login", "logout", "jwt", "oauth", "sanctum", "passport", "password")),
24
+ ("orders", ("order", "pedido", "orden")),
25
+ ("files", ("archivo", "file", "upload", "sftp", "ftp", "storage")),
26
+ ("payments", ("payment", "pago", "checkout", "stripe")),
27
+ ("notifications", ("notification", "notific", "mail", "email", "sms")),
28
+ )
29
+
30
+
31
+ def detect(query: str) -> IntentMatch:
32
+ text = query.casefold()
33
+ intent, confidence = "UNDERSTAND", .45
34
+ for candidate, words in RULES:
35
+ if any(word in text for word in words):
36
+ intent, confidence = candidate, .9
37
+ break
38
+ area = next((name for name, words in AREAS if any(word in text for word in words)), "unknown")
39
+ return IntentMatch(intent, confidence, area)
@@ -0,0 +1,21 @@
1
+ """Small serializable model for the future context compiler."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class AgentContextPlan:
9
+ query: str
10
+ intent: str
11
+ confidence: float
12
+ area: str
13
+ detail: str
14
+ max_tokens: int
15
+ capabilities: list[str]
16
+ providers: list[dict]
17
+ sections: list[str]
18
+ strategy: str
19
+
20
+ def as_dict(self) -> dict:
21
+ return asdict(self)
@@ -0,0 +1,32 @@
1
+ """Choose the smallest evidence plan that can answer an agent's task."""
2
+ from __future__ import annotations
3
+
4
+ from .capabilities import Capability, CapabilityRouter, default_providers
5
+ from .intents import detect
6
+ from .models import AgentContextPlan
7
+
8
+
9
+ PLANS = {
10
+ "UNDERSTAND": ([Capability.FRAMEWORK_ANALYSIS, Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION, Capability.INTEGRATION_DISCOVERY, Capability.AUTOMATION_DISCOVERY], ["purpose", "entry_points", "flows", "integrations", "automations", "uncertainties"]),
11
+ "CHANGE": ([Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION, Capability.SYMBOL_LOOKUP, Capability.CALLERS, Capability.IMPACT_ANALYSIS, Capability.CONFIG_DISCOVERY, Capability.TEST_DISCOVERY], ["entry_points", "flows", "files", "symbols", "configuration", "tests", "impact", "evidence", "uncertainties"]),
12
+ "DEBUG": ([Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION, Capability.INTEGRATION_DISCOVERY, Capability.CONFIG_DISCOVERY, Capability.TEST_DISCOVERY], ["entry_points", "flows", "configuration", "tests", "investigation_points", "evidence", "uncertainties"]),
13
+ "TEST": ([Capability.TEST_DISCOVERY, Capability.ENTRY_POINT_DISCOVERY, Capability.FLOW_RECONSTRUCTION], ["tests", "related_code", "entry_points", "flows", "uncertainties"]),
14
+ "REFACTOR": ([Capability.SYMBOL_LOOKUP, Capability.CALLERS, Capability.CALLEES, Capability.IMPACT_ANALYSIS, Capability.TEST_DISCOVERY], ["symbols", "callers", "callees", "impact", "tests", "evidence"]),
15
+ "IMPACT": ([Capability.SYMBOL_LOOKUP, Capability.CALLERS, Capability.IMPACT_ANALYSIS, Capability.TEST_DISCOVERY], ["symbols", "callers", "impact", "tests", "uncertainties"]),
16
+ }
17
+
18
+
19
+ class ContextPlanner:
20
+ def __init__(self, router: CapabilityRouter | None = None):
21
+ self.router = router or CapabilityRouter(default_providers())
22
+
23
+ def plan(self, query: str, *, detail: str = "standard", max_tokens: int = 5000) -> AgentContextPlan:
24
+ if detail not in {"minimal", "standard", "deep"}:
25
+ raise ValueError("detail must be minimal, standard or deep")
26
+ if max_tokens < 200:
27
+ raise ValueError("max_tokens must be at least 200")
28
+ match = detect(query)
29
+ capabilities, sections = PLANS[match.intent]
30
+ if detail == "minimal": sections = sections[:max(3, len(sections) // 2)]
31
+ resolutions = [self.router.resolve(capability) for capability in capabilities]
32
+ return AgentContextPlan(query, match.intent, match.confidence, match.area, detail, max_tokens, [capability.value for capability in capabilities], [{"capability": resolution.capability.value, "provider": resolution.provider, "status": resolution.status, "fallbacks": list(resolution.fallbacks)} for resolution in resolutions], sections, "Prioriza evidencia directa, luego relaciones de flujo y finalmente contexto periférico hasta agotar el presupuesto.")
@@ -0,0 +1,19 @@
1
+ """Normalize evidence and prevent secrets from entering agent output."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ SECRET = re.compile(r"(?i)(password|secret|token|api[_-]?key|private[_-]?key)\s*[:=]\s*[^\s,]+")
7
+
8
+ def sanitize(value):
9
+ if isinstance(value, str): return SECRET.sub(lambda match: match.group(1) + "=[REDACTED]", value)
10
+ if isinstance(value, list): return [sanitize(item) for item in value]
11
+ if isinstance(value, dict): return {key: sanitize(item) for key, item in value.items() if key.casefold() not in {"password", "secret", "token", "api_key", "private_key"}}
12
+ return value
13
+
14
+ def reconcile(evidence: list[dict]) -> dict:
15
+ unique, seen = [], set()
16
+ for item in evidence:
17
+ clean = sanitize(item); key = (clean.get("path"), clean.get("line"), clean.get("symbol"), clean.get("reason"))
18
+ if key not in seen: seen.add(key); unique.append(clean)
19
+ return {"evidence": unique, "conflicts": [], "status": "CONFIRMED" if unique else "UNKNOWN"}
@@ -0,0 +1,31 @@
1
+ """Agent workflows combine compact context with Git and impact evidence."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from .compiler import change_context
7
+ from .impact import analyze_impact
8
+ from ..git import intelligence
9
+ from ..git import dirty_files, run_git
10
+
11
+
12
+ def prepare_change(root: Path, task: str, *, detail: str = "standard", max_tokens: int = 5000) -> dict:
13
+ result = change_context(root, task, detail=detail, max_tokens=max_tokens)
14
+ paths = [item.get("path") for item in result["read_first"] if item.get("path")]
15
+ result["git"] = intelligence(root, paths)
16
+ result["workflow"] = "PREPARE_CHANGE"
17
+ return result
18
+
19
+
20
+ def change_impact(root: Path, symbol: str) -> dict:
21
+ result = analyze_impact(root, symbol); result["git"] = intelligence(root); result["workflow"] = "CHANGE_IMPACT"
22
+ return result
23
+
24
+
25
+ def verify_change(root: Path) -> dict:
26
+ changed = dirty_files(root)
27
+ diff = run_git(root, "diff", "--name-only") or ""
28
+ changed = sorted(set(changed + [line for line in diff.splitlines() if line]))
29
+ context = prepare_change(root, "verificar cambio", detail="minimal", max_tokens=1200)
30
+ tests = [item for item in context["tests"] if item.get("path")]
31
+ return {"workflow": "VERIFY_CHANGE", "changed_files": changed, "related_tests": tests, "potential_risks": context["uncertainties"], "statement": "Hallazgos verificables del diff actual; no aprueba ni rechaza el cambio automáticamente."}
@@ -6,18 +6,20 @@ from . import __version__
6
6
  from .agent_context import write as write_agent
7
7
  from .context_builder import build
8
8
  from .git import commit, dirty_files, exclude_klap, is_repository
9
- from .graphify import GraphifyError, copy_outputs, generate, load_graph, version
9
+ from .graphify import GraphifyError
10
10
  from .portal import write as write_portal
11
+ from .providers import default_provider
11
12
 
12
13
  def root_path(value: str | None) -> Path: return Path(value or os.getcwd()).resolve()
13
14
 
14
15
  def generate_all(root: Path, update: bool=False) -> dict:
15
16
  if not is_repository(root): raise RuntimeError(f"Not a Git repository: {root}")
16
17
  exclude_klap(root); klap=root/".klap"; klap.mkdir(exist_ok=True)
17
- graph_path=generate(root, update); files=copy_outputs(root, klap/"graphify")
18
- context=build(root, load_graph(graph_path)); (klap/"context.json").write_text(json.dumps(context, indent=2)+"\n", encoding="utf-8")
18
+ provider=default_provider()
19
+ graph_path=provider.generate(root, update); files=provider.copy_outputs(root, klap/"graphify")
20
+ context=build(root, provider.load_graph(graph_path)); (klap/"context.json").write_text(json.dumps(context, indent=2)+"\n", encoding="utf-8")
19
21
  agent=write_agent(context, klap/"agent-context.md"); write_portal(context, agent, files, klap/"index.html")
20
- state={"schema_version":"0.1","generated_at":datetime.now(timezone.utc).isoformat(),"git_commit":commit(root),"graphify_version":version(),"klap_version":__version__}
22
+ state={"schema_version":"0.2","generated_at":datetime.now(timezone.utc).isoformat(),"git_commit":commit(root),"provider":{"name":provider.name,"version":provider.version()},"klap_version":__version__}
21
23
  (klap/"state.json").write_text(json.dumps(state, indent=2)+"\n", encoding="utf-8")
22
24
  return context
23
25
 
@@ -55,6 +57,13 @@ def cmd_agent(args):
55
57
  print("Graphify MCP (stdio):\npython -m graphify.serve " + str(graph) + "\n\nGeneric configuration:\n{\n \"mcpServers\": {\n \"graphify\": {\n \"command\": \"python\",\n \"args\": [\"-m\", \"graphify.serve\", \"" + str(graph).replace('\\','\\\\') + "\"]\n }\n }\n}")
56
58
  return 0
57
59
 
60
+ def cmd_context(args):
61
+ from .agent.compiler import compile_context
62
+ result=compile_context(root_path(args.path), args.query, detail=args.detail, max_tokens=args.max_tokens)
63
+ if args.json: print(json.dumps(result, ensure_ascii=False, indent=2))
64
+ else: print(f"Intent: {result['intent']}\nÁrea: {result['area']}\nRead first:\n" + "\n".join(f"- {item.get('path', item.get('name'))}: {item['reason']}" for item in result['read_first']))
65
+ return 0
66
+
58
67
  def main(argv=None):
59
68
  # PowerShell's legacy cp1252 console otherwise raises on the intended status symbols.
60
69
  if hasattr(sys.stdout, "reconfigure"):
@@ -66,6 +75,7 @@ def main(argv=None):
66
75
  subs=parser.add_subparsers(dest="command", required=True)
67
76
  for name, func, help_text in (("init",cmd_init,"Generate engineering context"),("update",cmd_update,"Refresh engineering context"),("status",cmd_status,"Show context freshness"),("open",cmd_open,"Open the human portal"),("agent",cmd_agent,"Show Graphify MCP setup")):
68
77
  p=subs.add_parser(name, help=help_text); p.add_argument("path", nargs="?", help="Repository root (defaults to current directory)"); p.set_defaults(func=func)
78
+ p=subs.add_parser("context", help="Compile compact context for an agent task"); p.add_argument("query"); p.add_argument("path", nargs="?", default="."); p.add_argument("--detail", choices=("minimal", "standard", "deep"), default="standard"); p.add_argument("--max-tokens", type=int, default=5000); p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_context)
69
79
  args=parser.parse_args(argv); return args.func(args)
70
80
 
71
81
  if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1,121 @@
1
+ """Build a product-level model from framework-neutral semantic evidence."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from .detector import detect_components, detect_project
8
+ from .frameworks.generic_php import GenericPhpAdapter
9
+ from .frameworks.laravel import LaravelAdapter
10
+ from .frameworks.fastapi import FastAPIAdapter
11
+ from .frameworks.node import NodeAdapter
12
+ from .git import commit
13
+ from .semantic import SemanticModel
14
+ from .system import deployment, documents, external_systems, purpose
15
+
16
+
17
+ def _nodes(graph: dict) -> list[dict]:
18
+ return graph.get("nodes", []) if isinstance(graph.get("nodes"), list) else graph.get("graph", {}).get("nodes", [])
19
+
20
+
21
+ def _modules(nodes: list[dict]) -> list[dict]:
22
+ return [{"name": item.get("label") or item.get("name") or item.get("id"), "path": item.get("file") or item.get("path"), "kind": item.get("type", "module"), "description": None, "status": "CONFIRMED", "evidence": []} for item in nodes if (item.get("label") or item.get("name") or item.get("id")) and (item.get("file") or item.get("path"))][:100]
23
+
24
+
25
+ def _runtime(points: list[dict], background: list[dict], frameworks: list[str]) -> dict:
26
+ types = {item["type"].casefold() for item in points}
27
+ modes, descriptions = [], []
28
+ if "http" in types: modes.append("http"); descriptions.append("Las solicitudes HTTP ingresan por las rutas detectadas.")
29
+ if "cli" in types: modes.append("cli"); descriptions.append("La aplicación expone comandos ejecutables.")
30
+ if background: modes.append("scheduler"); descriptions.append("La aplicación declara procesamiento en segundo plano.")
31
+ return {"modes": modes, "layers": frameworks, "description": " ".join(descriptions) or None, "status": "CONFIRMED" if descriptions else "UNKNOWN"}
32
+
33
+
34
+ def _io(points: list[dict], systems: list[dict], stores: list[dict]) -> tuple[list[dict], list[dict]]:
35
+ inputs, outputs, types = [], [], {item["type"].casefold() for item in points}
36
+ if "http" in types:
37
+ inputs.append({"type": "http_request", "description": "Solicitudes HTTP", "status": "CONFIRMED"}); outputs.append({"type": "http_response", "description": "Respuestas HTTP", "status": "INFERRED"})
38
+ if "cli" in types: inputs.append({"type": "cli", "description": "Invocación por línea de comandos", "status": "CONFIRMED"})
39
+ if stores:
40
+ inputs.append({"type": "datastore", "description": "Base de datos", "status": "INFERRED"}); outputs.append({"type": "datastore", "description": "Escrituras de base de datos", "status": "INFERRED"})
41
+ return inputs, outputs
42
+
43
+
44
+ def _flows(model: dict) -> list[dict]:
45
+ """Reconstruct compact flows from generic entry points and transitions only."""
46
+ components = {item.get("symbol") or item["name"]: item for item in model["components"]}
47
+ outgoing: dict[str, list[dict]] = {}
48
+ for edge in model["transitions"]: outgoing.setdefault(edge["source"], []).append(edge)
49
+ flows = []
50
+ for entry in model["entry_points"][:10]:
51
+ steps = [{"name": entry["name"], "kind": entry["type"].casefold(), "path": entry.get("file"), "status": entry["status"]}]
52
+ current = entry["name"]
53
+ if entry.get("handler"):
54
+ steps.append({"name": entry["handler"], "kind": "handler", "path": entry.get("file"), "status": entry["status"]}); current = entry["handler"]
55
+ seen = {entry["name"], current}
56
+ for _ in range(4):
57
+ candidates = outgoing.get(current, []) or [item for source, values in outgoing.items() if source.endswith(current) for item in values]
58
+ edge = next((item for item in candidates if item["target"] not in seen), None)
59
+ if not edge: break
60
+ target = edge["target"]; component = components.get(target, {})
61
+ steps.append({"name": target, "kind": edge["type"].casefold(), "path": component.get("file") or edge.get("file"), "status": edge["status"]})
62
+ seen.add(target); current = target
63
+ if len(steps) > 1:
64
+ status = "CONFIRMED" if all(item["status"] == "CONFIRMED" for item in steps) else "INFERRED"
65
+ flows.append({"name": entry["name"], "trigger": entry["type"].casefold(), "entry_point": entry["name"], "steps": steps, "status": status, "confidence": 1.0 if status == "CONFIRMED" else .7, "evidence": entry["evidence"], "inputs": [], "outputs": [], "external_systems": [], "datastores": [], "tests": []})
66
+ return flows
67
+
68
+
69
+ def _interactions(flows: list[dict], systems: list[dict], stores: list[dict]) -> dict:
70
+ nodes, edges = [], []
71
+ def add(name: str, kind: str):
72
+ if not any(item["name"] == name for item in nodes): nodes.append({"name": name, "type": kind})
73
+ for flow in flows:
74
+ previous = None
75
+ for step in flow["steps"]:
76
+ add(step["name"], step["kind"])
77
+ if previous: edges.append({"source": previous, "target": step["name"], "label": "calls"})
78
+ previous = step["name"]
79
+ for store in stores: add(store["name"], "datastore")
80
+ for system in systems: add(system["name"], "external_system")
81
+ return {"nodes": nodes[:30], "edges": edges[:45], "status": "INFERRED" if edges else "UNKNOWN"}
82
+
83
+
84
+ def _empty_raw() -> dict:
85
+ return {"routes": [], "commands": [], "scheduled_processes": [], "queue_jobs": [], "events": [], "listeners": [], "components": [], "dependencies": [], "runtime_surfaces": [], "important_files": [], "framework": None}
86
+
87
+
88
+ def _semantic(root: Path, stack: dict) -> tuple[SemanticModel, dict]:
89
+ if "Laravel" in stack["frameworks"]:
90
+ model = LaravelAdapter().analyze(root)
91
+ return model, model.metadata["raw"]
92
+ if "FastAPI" in stack["frameworks"]:
93
+ return FastAPIAdapter().analyze(root), _empty_raw()
94
+ for framework in ("Express", "NestJS"):
95
+ if framework in stack["frameworks"]: return NodeAdapter(framework).analyze(root), _empty_raw()
96
+ if "PHP" in stack["languages"]: return GenericPhpAdapter().analyze(root), _empty_raw()
97
+ return SemanticModel(stack["languages"][0] if stack["languages"] else None), _empty_raw()
98
+
99
+
100
+ def _analysis_evidence(raw: dict) -> list[dict]:
101
+ seen, result = set(), []
102
+ for group in ("routes", "commands", "scheduled_processes", "queue_jobs", "events", "listeners", "components", "dependencies"):
103
+ for item in raw.get(group, []):
104
+ for evidence in item.get("evidence", []):
105
+ key = (evidence.get("path"), evidence.get("line"), evidence.get("reason"), evidence.get("symbol"))
106
+ if key not in seen: seen.add(key); result.append(evidence)
107
+ return result
108
+
109
+
110
+ def build(root: Path, graph: dict) -> dict:
111
+ stack, detector_evidence = detect_project(root)
112
+ docs = documents(root); value = purpose(root, docs); systems, stores = external_systems(root); deploy, observability, _ = deployment(root)
113
+ semantic, raw = _semantic(root, stack); model = semantic.as_dict(); model["project_components"] = detect_components(root); points, background = model["entry_points"], model["background_tasks"]
114
+ runtime = _runtime(points, background, stack["frameworks"]); inputs, outputs = _io(points, systems, stores); flows = _flows(model)
115
+ important = raw["important_files"] or ([{"path": "README.md", "role": "Documentación", "reason": "README detectado"}] if (root / "README.md").exists() else [])
116
+ tests = [{"path": item, "reason": "Tests detectados"} for item in ("tests", "test", "phpunit.xml", "pytest.ini") if (root / item).exists()]
117
+ story = " ".join(item for item in (value["text"], runtime["description"], "La aplicación está contenerizada con Docker." if "Docker" in deploy["tools"] else None) if item)
118
+ system = {"purpose": value, "project_story": {"text": story or None, "status": "CONFIRMED" if story else "UNKNOWN"}, "runtime": runtime, "framework": raw["framework"], "runtime_surfaces": raw["runtime_surfaces"], "routes": raw["routes"], "commands": raw["commands"], "scheduled_processes": raw["scheduled_processes"], "queue_jobs": raw["queue_jobs"], "events": raw["events"], "listeners": raw["listeners"], "components": raw["components"], "dependencies": raw["dependencies"], "semantic_model": model, "entry_points": points, "main_flows": flows, "system_interactions": _interactions(flows, systems, stores), "capabilities": _modules(_nodes(graph)), "inputs": inputs, "outputs": outputs, "external_systems": systems, "background_processes": background, "datastores": stores, "deployment": deploy, "observability": observability, "important_files": important, "start_here": [], "unknowns": [item for item, found in (("Propósito de negocio del proyecto", value["text"]), ("Plataforma de despliegue en producción", deploy["tools"])) if not found]}
119
+ technical = {"languages": stack["languages"], "frameworks": stack["frameworks"], "framework": raw["framework"], "dependencies": raw["dependencies"], "graph": {"nodes": len(_nodes(graph)), "edges": len(graph.get("edges", graph.get("graph", {}).get("edges", [])))}, "documents": [{"path": item["path"]} for item in docs], "tests": tests, "infrastructure": stack["infrastructure"]}
120
+ project = {"name": root.name, "type": stack["frameworks"][0] if stack["frameworks"] else None, "root": str(root.resolve()), "generated_at": datetime.now(timezone.utc).isoformat(), "git_commit": commit(root)}
121
+ return {"schema_version": "0.5", "project": project, "technical_model": technical, "system_model": system, "semantic_model": model, "human_context": {"purpose": None, "users": None, "important_processes": [], "notes": []}, "stack": stack, "architecture": {"style": "MVC" if "Laravel" in stack["frameworks"] else None, "status": "INFERRED" if "Laravel" in stack["frameworks"] else "UNKNOWN", "confidence": None, "evidence": []}, "entry_points": points, "modules": system["capabilities"], "flows": flows, "api": raw["routes"], "jobs": background, "datastores": stores, "external_systems": systems, "deployment": deploy, "observability": observability, "tests": tests, "evidence": [item.as_dict() for item in detector_evidence] + value["evidence"] + _analysis_evidence(raw)}
@@ -18,17 +18,37 @@ def detect_project(root: Path) -> tuple[dict, list[Evidence]]:
18
18
  try: deps = {**json.loads(composer.read_text(encoding="utf-8")).get("require", {}), **json.loads(composer.read_text(encoding="utf-8")).get("require-dev", {})}
19
19
  except json.JSONDecodeError: deps = {}
20
20
  if "laravel/framework" in deps: add("frameworks", "Laravel", "composer.json", "laravel/framework dependency detected")
21
+ elif any(root.rglob("*.php")):
22
+ # A standalone PHP repository has no Composer manifest to identify it,
23
+ # but language analysis can still provide a useful generic model.
24
+ first_php = next(root.rglob("*.php"))
25
+ add("languages", "PHP", str(first_php.relative_to(root)).replace("\\", "/"), "PHP source file detected")
21
26
  package = root / "package.json"
22
27
  if package.exists():
23
28
  add("languages", "JavaScript", "package.json", "npm manifest detected")
24
29
  try: deps = {**json.loads(package.read_text(encoding="utf-8")).get("dependencies", {}), **json.loads(package.read_text(encoding="utf-8")).get("devDependencies", {})}
25
30
  except json.JSONDecodeError: deps = {}
26
- for name, label in (("next", "Next.js"), ("vue", "Vue"), ("react", "React"), ("express", "Express")):
31
+ for name, label in (("next", "Next.js"), ("vue", "Vue"), ("react", "React"), ("express", "Express"), ("@nestjs/core", "NestJS")):
27
32
  if name in deps: add("frameworks", label, "package.json", f"{name} dependency detected")
28
- if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists(): add("languages", "Python", "pyproject.toml" if (root / "pyproject.toml").exists() else "requirements.txt", "Python manifest detected")
33
+ if (root / "pyproject.toml").exists() or (root / "requirements.txt").exists():
34
+ python_manifest = root / "pyproject.toml" if (root / "pyproject.toml").exists() else root / "requirements.txt"
35
+ add("languages", "Python", python_manifest.name, "Python manifest detected")
36
+ if "fastapi" in python_manifest.read_text(encoding="utf-8", errors="ignore").casefold(): add("frameworks", "FastAPI", python_manifest.name, "FastAPI dependency detected")
29
37
  if (root / "manage.py").exists(): add("frameworks", "Django", "manage.py", "Django entry point detected")
30
38
  if list(root.glob("*.csproj")): add("languages", "C#", next(root.glob("*.csproj")).name, ".NET project detected")
31
39
  if (root / "Dockerfile").exists(): add("infrastructure", "Docker", "Dockerfile", "Dockerfile detected")
32
40
  if list(root.glob("docker-compose*")) or list(root.glob("compose*")): add("infrastructure", "Docker Compose", "docker-compose", "Compose configuration detected")
33
41
  if (root / ".github" / "workflows").exists(): add("infrastructure", "GitHub Actions", ".github/workflows", "GitHub Actions workflows detected")
34
42
  return stack, evidence
43
+
44
+
45
+ def detect_components(root: Path) -> list[dict]:
46
+ """Find independent project boundaries without assuming a monorepo layout."""
47
+ result = []
48
+ for manifest in [*root.rglob("composer.json"), *root.rglob("package.json"), *root.rglob("pyproject.toml"), *root.rglob("requirements.txt")]:
49
+ if any(part in {".git", ".klap", "node_modules", "vendor", ".venv", "venv"} for part in manifest.relative_to(root).parts): continue
50
+ directory = manifest.parent
51
+ if directory == root: continue
52
+ stack, _ = detect_project(directory)
53
+ result.append({"name": directory.name, "path": directory.relative_to(root).as_posix(), "languages": stack["languages"], "frameworks": stack["frameworks"], "manifest": manifest.name, "status": "CONFIRMED"})
54
+ return result