klapcontext 0.1.3__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 (48) hide show
  1. {klapcontext-0.1.3/src/klapcontext.egg-info → klapcontext-0.2.0}/PKG-INFO +14 -1
  2. {klapcontext-0.1.3 → klapcontext-0.2.0}/README.md +8 -0
  3. {klapcontext-0.1.3 → klapcontext-0.2.0}/pyproject.toml +1 -1
  4. {klapcontext-0.1.3 → 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.3 → klapcontext-0.2.0}/src/klapcontext/cli.py +8 -0
  15. klapcontext-0.2.0/src/klapcontext/context_builder.py +121 -0
  16. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/detector.py +22 -2
  17. klapcontext-0.2.0/src/klapcontext/frameworks/base.py +17 -0
  18. klapcontext-0.2.0/src/klapcontext/frameworks/fastapi.py +37 -0
  19. klapcontext-0.2.0/src/klapcontext/frameworks/generic_php.py +32 -0
  20. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/frameworks/laravel.py +49 -0
  21. klapcontext-0.2.0/src/klapcontext/frameworks/node.py +32 -0
  22. klapcontext-0.2.0/src/klapcontext/git.py +60 -0
  23. klapcontext-0.2.0/src/klapcontext/providers/code_intelligence.py +233 -0
  24. klapcontext-0.2.0/src/klapcontext/providers/javascript_intelligence.py +38 -0
  25. klapcontext-0.2.0/src/klapcontext/providers/python_intelligence.py +44 -0
  26. klapcontext-0.2.0/src/klapcontext/semantic.py +169 -0
  27. {klapcontext-0.1.3 → klapcontext-0.2.0/src/klapcontext.egg-info}/PKG-INFO +14 -1
  28. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext.egg-info/SOURCES.txt +17 -0
  29. klapcontext-0.2.0/src/klapcontext.egg-info/requires.txt +11 -0
  30. klapcontext-0.2.0/tests/test_klapcontext.py +234 -0
  31. klapcontext-0.1.3/src/klapcontext/context_builder.py +0 -113
  32. klapcontext-0.1.3/src/klapcontext/git.py +0 -34
  33. klapcontext-0.1.3/src/klapcontext.egg-info/requires.txt +0 -6
  34. klapcontext-0.1.3/tests/test_klapcontext.py +0 -89
  35. {klapcontext-0.1.3 → klapcontext-0.2.0}/LICENSE +0 -0
  36. {klapcontext-0.1.3 → klapcontext-0.2.0}/setup.cfg +0 -0
  37. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/agent_context.py +0 -0
  38. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/evidence.py +0 -0
  39. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/frameworks/__init__.py +0 -0
  40. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/graphify.py +0 -0
  41. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/portal.py +0 -0
  42. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/providers/__init__.py +0 -0
  43. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/providers/base.py +0 -0
  44. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/providers/graphify.py +0 -0
  45. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext/system.py +0 -0
  46. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext.egg-info/dependency_links.txt +0 -0
  47. {klapcontext-0.1.3 → klapcontext-0.2.0}/src/klapcontext.egg-info/entry_points.txt +0 -0
  48. {klapcontext-0.1.3 → 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.3
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"
@@ -36,6 +41,14 @@ entrada, flujos, automatizaciones, integraciones y sus incertidumbres.
36
41
  Graphify funciona como proveedor de análisis técnico; KlapContext es la capa
37
42
  que transforma esa evidencia en comprensión para personas y agentes de IA.
38
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.
51
+
39
52
  KlapContext convierte un repositorio en contexto de ingeniería reutilizable: un
40
53
  portal estático para personas y un briefing compacto para agentes de IA. Usa
41
54
  [Graphify](https://github.com/Graphify-Labs/graphify) como motor local de
@@ -7,6 +7,14 @@ entrada, flujos, automatizaciones, integraciones y sus incertidumbres.
7
7
  Graphify funciona como proveedor de análisis técnico; KlapContext es la capa
8
8
  que transforma esa evidencia en comprensión para personas y agentes de IA.
9
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.
17
+
10
18
  KlapContext convierte un repositorio en contexto de ingeniería reutilizable: un
11
19
  portal estático para personas y un briefing compacto para agentes de IA. Usa
12
20
  [Graphify](https://github.com/Graphify-Labs/graphify) como motor local de
@@ -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.3"
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."}
@@ -57,6 +57,13 @@ def cmd_agent(args):
57
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}")
58
58
  return 0
59
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
+
60
67
  def main(argv=None):
61
68
  # PowerShell's legacy cp1252 console otherwise raises on the intended status symbols.
62
69
  if hasattr(sys.stdout, "reconfigure"):
@@ -68,6 +75,7 @@ def main(argv=None):
68
75
  subs=parser.add_subparsers(dest="command", required=True)
69
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")):
70
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)
71
79
  args=parser.parse_args(argv); return args.func(args)
72
80
 
73
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
@@ -0,0 +1,17 @@
1
+ """Contracts for framework-specific enrichment of the generic semantic model."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Protocol
6
+
7
+ from ..semantic import SemanticModel
8
+
9
+
10
+ class FrameworkAdapter(Protocol):
11
+ """Detect one framework and emit framework-neutral semantic facts."""
12
+
13
+ name: str
14
+
15
+ def detect(self, root: Path) -> bool: ...
16
+
17
+ def analyze(self, root: Path) -> SemanticModel: ...
@@ -0,0 +1,37 @@
1
+ """FastAPI conventions translated into the framework-neutral semantic model."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ from pathlib import Path
6
+
7
+ from ..evidence import Evidence
8
+ from ..providers.python_intelligence import analyze as analyze_python
9
+ from ..semantic import EntryPoint, ExecutionTransition, SemanticModel
10
+
11
+
12
+ class FastAPIAdapter:
13
+ name = "FastAPI"
14
+
15
+ def detect(self, root: Path) -> bool:
16
+ return any("fastapi" in path.read_text(encoding="utf-8", errors="ignore").casefold() for path in (root / "pyproject.toml", root / "requirements.txt") if path.exists())
17
+
18
+ def analyze(self, root: Path) -> SemanticModel:
19
+ components, transitions = analyze_python(root); entries = []
20
+ for path in root.rglob("*.py"):
21
+ if any(part in {".venv", "venv", ".git", ".klap"} for part in path.relative_to(root).parts): continue
22
+ relative = path.relative_to(root).as_posix()
23
+ try: tree = ast.parse(path.read_text(encoding="utf-8"))
24
+ except (OSError, SyntaxError): continue
25
+ for node in tree.body:
26
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): continue
27
+ for decorator in node.decorator_list:
28
+ if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute): continue
29
+ if decorator.func.attr.lower() not in {"get", "post", "put", "patch", "delete", "options"}: continue
30
+ route = decorator.args[0].value if decorator.args and isinstance(decorator.args[0], ast.Constant) else None
31
+ if not isinstance(route, str): continue
32
+ method = decorator.func.attr.upper(); name = f"{method} {route}"
33
+ evidence = [Evidence("code", relative, "Decorador de ruta FastAPI", "CONFIRMED", 1.0, line=node.lineno, symbol=node.name).as_dict()]
34
+ handler = f"{relative.removesuffix('.py').replace('/', '.')}.{node.name}"
35
+ entries.append(EntryPoint("HTTP", name, handler, "HTTP", method, route, relative, node.lineno, "FastAPI", "CONFIRMED", evidence))
36
+ transitions.append(ExecutionTransition(name, handler, "HTTP_ENTRY", relative, node.lineno, "CONFIRMED", "fastapi", evidence))
37
+ return SemanticModel("Python", "FastAPI", "FRAMEWORK", components, entries, transitions)