forgex-cli 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 (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,102 @@
1
+ """Chunking + selection (spec section 16: context compression)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ _CAMEL_RE = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])")
10
+
11
+
12
+ @dataclass
13
+ class Chunk:
14
+ name: str
15
+ text: str
16
+
17
+
18
+ def chunk_python(source: str) -> list[Chunk]:
19
+ chunks: list[Chunk] = []
20
+ try:
21
+ tree = ast.parse(source)
22
+ except SyntaxError:
23
+ return [Chunk("whole-file", source)]
24
+
25
+ def node_end(node: ast.AST) -> int:
26
+ val = getattr(node, "end_lineno", None) or getattr(node, "lineno", 0)
27
+ return int(val) if val is not None else 0
28
+
29
+ def_nodes = [
30
+ n for n in tree.body
31
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
32
+ ]
33
+ first_def_line = min((n.lineno for n in def_nodes), default=len(source.splitlines()) + 1)
34
+ head_lines = source.splitlines()[: max(first_def_line - 1, 0)]
35
+ if any(line.strip() for line in head_lines):
36
+ chunks.append(Chunk("module-head", "\n".join(head_lines)))
37
+
38
+ for node in tree.body:
39
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
40
+ lines = source.splitlines()
41
+ body = "\n".join(lines[node.lineno - 1 : node_end(node)])
42
+ kind = "class" if isinstance(node, ast.ClassDef) else "def"
43
+ chunks.append(
44
+ Chunk(node.name, f"# --- {kind} {node.name} (line {node.lineno}) ---\n{body}")
45
+ )
46
+
47
+ return chunks or [Chunk("whole-file", source)]
48
+
49
+
50
+ def chunk_generic(source: str, window: int = 80) -> list[Chunk]:
51
+ lines = source.splitlines()
52
+ if len(lines) <= window:
53
+ return [Chunk("whole-file", source)]
54
+ return [
55
+ Chunk(
56
+ f"lines-{i + 1}-{min(i + window, len(lines))}",
57
+ f"# --- lines {i + 1}-{min(i + window, len(lines))} ---\n"
58
+ + "\n".join(lines[i : i + window]),
59
+ )
60
+ for i in range(0, len(lines), window)
61
+ ]
62
+
63
+
64
+ def _tokens(text: str) -> set[str]:
65
+ out: set[str] = set()
66
+ for part in re.split(r"[^A-Za-z0-9]+", text.lower()):
67
+ out.update(p for p in part.split("_") if len(p) >= 3)
68
+ for token in re.findall(r"[A-Za-z]{3,}", text):
69
+ out.update(p.lower() for p in _CAMEL_RE.findall(token))
70
+ return out
71
+
72
+
73
+ def _matches(chunk: Chunk, terms: set[str]) -> bool:
74
+ chunk_terms = _tokens(chunk.name) | _tokens(chunk.text[:2000])
75
+ return bool(chunk_terms & terms)
76
+
77
+
78
+ def select_chunks(chunks: list[Chunk], terms: set[str], max_chars: int) -> tuple[str, bool]:
79
+ total = sum(len(chunk.text) for chunk in chunks)
80
+ if total <= max_chars:
81
+ return "\n\n".join(chunk.text for chunk in chunks), False
82
+
83
+ head = next((c for c in chunks if c.name == "module-head"), None)
84
+ ordered: list[Chunk] = []
85
+ if head is not None:
86
+ ordered.append(head)
87
+ rest = [c for c in chunks if c is not head]
88
+ ordered.extend(c for c in rest if _matches(c, terms))
89
+ ordered.extend(c for c in rest if not _matches(c, terms))
90
+
91
+ parts: list[str] = []
92
+ used = 0
93
+ for chunk in ordered:
94
+ if used >= max_chars:
95
+ break
96
+ text = chunk.text
97
+ if len(text) > max_chars - used:
98
+ text = text[: max_chars - used] + "\n... [chunk trimmed]"
99
+ parts.append(text)
100
+ used += len(text)
101
+
102
+ return "\n... [lines omitted] ...\n".join(parts), True
@@ -0,0 +1,110 @@
1
+ """Import dependency graph (spec section 17)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ from collections import defaultdict, deque
7
+ from typing import Any
8
+
9
+ from forgex.context.indexer import IndexData
10
+
11
+
12
+ def _match(files: dict[str, Any], candidates: list[str]) -> str | None:
13
+ for c in candidates:
14
+ if c in files:
15
+ return c
16
+ # Try searching suffix match across all project files
17
+ for c in candidates:
18
+ matching = [f for f in files if f.endswith("/" + c) or f == c]
19
+ if matching:
20
+ return min(matching, key=len)
21
+ return None
22
+
23
+
24
+ def _resolve_python(imp: str, importer_rel: str, files: dict[str, Any]) -> str | None:
25
+ importer_dir = posixpath.dirname(importer_rel)
26
+ if imp.startswith("."):
27
+ # Relative import
28
+ level = len(imp) - len(imp.lstrip("."))
29
+ sub = imp.lstrip(".").replace(".", "/")
30
+ target_dir = importer_dir
31
+ for _ in range(level - 1):
32
+ target_dir = posixpath.dirname(target_dir)
33
+ base = posixpath.normpath(posixpath.join(target_dir, sub)) if sub else target_dir
34
+ return _match(files, [f"{base}.py", f"{base}/__init__.py"])
35
+
36
+ # Absolute import
37
+ base = imp.replace(".", "/")
38
+ return _match(files, [f"{base}.py", f"{base}/__init__.py"])
39
+
40
+
41
+ def _resolve_js(imp: str, importer_rel: str, files: dict[str, Any]) -> str | None:
42
+ if not imp.startswith("."):
43
+ return None # External package
44
+ importer_dir = posixpath.dirname(importer_rel)
45
+ base = posixpath.normpath(posixpath.join(importer_dir, imp))
46
+ candidates = [
47
+ base,
48
+ f"{base}.js",
49
+ f"{base}.jsx",
50
+ f"{base}.ts",
51
+ f"{base}.tsx",
52
+ f"{base}/index.js",
53
+ f"{base}/index.ts",
54
+ ]
55
+ return _match(files, candidates)
56
+
57
+
58
+ class DependencyGraph:
59
+ def __init__(self, index: IndexData) -> None:
60
+ self._index = index
61
+ self._imports: dict[str, list[str]] = defaultdict(list)
62
+ self._importers: dict[str, list[str]] = defaultdict(list)
63
+ self._build_graph()
64
+
65
+ def _build_graph(self) -> None:
66
+ files = self._index.files
67
+ for rel_path, entry in files.items():
68
+ for raw_import in entry.imports:
69
+ target = None
70
+ if entry.language == "python":
71
+ target = _resolve_python(raw_import, rel_path, files)
72
+ elif entry.language in ("javascript", "typescript"):
73
+ target = _resolve_js(raw_import, rel_path, files)
74
+ if target and target != rel_path:
75
+ if target not in self._imports[rel_path]:
76
+ self._imports[rel_path].append(target)
77
+ if rel_path not in self._importers[target]:
78
+ self._importers[target].append(rel_path)
79
+
80
+ def imports_of(self, file: str, max_depth: int = 1) -> dict[str, int]:
81
+ results: dict[str, int] = {}
82
+ queue: deque[tuple[str, int]] = deque([(file, 0)])
83
+ seen = {file}
84
+
85
+ while queue:
86
+ curr, depth = queue.popleft()
87
+ if depth >= max_depth:
88
+ continue
89
+ for target in self._imports.get(curr, []):
90
+ if target not in seen:
91
+ seen.add(target)
92
+ results[target] = depth + 1
93
+ queue.append((target, depth + 1))
94
+ return results
95
+
96
+ def importers_of(self, file: str, max_depth: int = 1) -> dict[str, int]:
97
+ results: dict[str, int] = {}
98
+ queue: deque[tuple[str, int]] = deque([(file, 0)])
99
+ seen = {file}
100
+
101
+ while queue:
102
+ curr, depth = queue.popleft()
103
+ if depth >= max_depth:
104
+ continue
105
+ for importer in self._importers.get(curr, []):
106
+ if importer not in seen:
107
+ seen.add(importer)
108
+ results[importer] = depth + 1
109
+ queue.append((importer, depth + 1))
110
+ return results
@@ -0,0 +1,161 @@
1
+ """Project indexer: symbols, imports, and file metadata (spec section 17)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import os
7
+ import re
8
+ from collections.abc import Iterator
9
+ from datetime import UTC, datetime
10
+ from pathlib import Path
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ from forgex.tools.filesystem import IGNORED_DIRS, is_secret_file
15
+
16
+ INDEX_VERSION = 1
17
+ MAX_PARSE_BYTES = 500_000
18
+ MAX_SYMBOLS_PER_FILE = 100
19
+ MAX_IMPORTS_PER_FILE = 50
20
+
21
+ LANGUAGE_BY_SUFFIX = {
22
+ ".py": "python",
23
+ ".js": "javascript",
24
+ ".jsx": "javascript",
25
+ ".ts": "typescript",
26
+ ".tsx": "typescript",
27
+ ".rs": "rust",
28
+ ".go": "go",
29
+ ".java": "java",
30
+ ".json": "json",
31
+ ".toml": "toml",
32
+ ".yaml": "yaml",
33
+ ".yml": "yaml",
34
+ ".md": "markdown",
35
+ ".txt": "text",
36
+ }
37
+
38
+ PARSEABLE = {"python", "javascript", "typescript"}
39
+
40
+
41
+ class SymbolInfo(BaseModel):
42
+ name: str
43
+ kind: str # function | class | method | export
44
+ line: int
45
+
46
+
47
+ class FileIndex(BaseModel):
48
+ size: int
49
+ mtime: float
50
+ language: str
51
+ symbols: list[SymbolInfo] = Field(default_factory=list)
52
+ imports: list[str] = Field(default_factory=list)
53
+
54
+
55
+ class IndexData(BaseModel):
56
+ version: int = INDEX_VERSION
57
+ generated_at: str
58
+ files: dict[str, FileIndex] = Field(default_factory=dict)
59
+ stats: dict[str, int] = Field(default_factory=dict)
60
+
61
+
62
+ def iter_project_files(workspace: Path) -> Iterator[Path]:
63
+ for root, dirs, files in os.walk(workspace):
64
+ # Mutate dirs in place to prevent descending into ignored dirs
65
+ dirs[:] = [d for d in dirs if d not in IGNORED_DIRS]
66
+ for f in files:
67
+ if is_secret_file(f):
68
+ continue
69
+ path = Path(root) / f
70
+ yield path
71
+
72
+
73
+ def _python_parse(source: str) -> tuple[list[SymbolInfo], list[str]]:
74
+ symbols: list[SymbolInfo] = []
75
+ imports: list[str] = []
76
+ try:
77
+ tree = ast.parse(source)
78
+ except SyntaxError:
79
+ return [], []
80
+
81
+ for node in tree.body:
82
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
83
+ symbols.append(SymbolInfo(name=node.name, kind="function", line=node.lineno))
84
+ elif isinstance(node, ast.ClassDef):
85
+ symbols.append(SymbolInfo(name=node.name, kind="class", line=node.lineno))
86
+ for child in node.body:
87
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
88
+ symbols.append(
89
+ SymbolInfo(name=f"{node.name}.{child.name}", kind="method", line=child.lineno)
90
+ )
91
+
92
+ for item in ast.walk(tree):
93
+ if isinstance(item, ast.Import):
94
+ imports.extend(alias.name for alias in item.names)
95
+ elif isinstance(item, ast.ImportFrom):
96
+ prefix = "." * item.level
97
+ if item.module:
98
+ imports.append(prefix + item.module)
99
+ else:
100
+ for alias in item.names:
101
+ imports.append(prefix + alias.name)
102
+
103
+ return symbols[:MAX_SYMBOLS_PER_FILE], imports[:MAX_IMPORTS_PER_FILE]
104
+
105
+
106
+ _JS_IMPORT_RE = re.compile(
107
+ r"""(?:(?:import|export)\s+[^;\n]*?\s+from\s+|import\s+|require\s*\(\s*|import\s*\(\s*)['"]([^'"]+)['"]"""
108
+ )
109
+ _JS_SYMBOL_RE = re.compile(
110
+ r"export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let)\s+(\w+)"
111
+ )
112
+
113
+
114
+ def _js_parse(source: str) -> tuple[list[SymbolInfo], list[str]]:
115
+ imports = _JS_IMPORT_RE.findall(source)[:MAX_IMPORTS_PER_FILE]
116
+ symbols = [
117
+ SymbolInfo(name=name, kind="export", line=0)
118
+ for name in _JS_SYMBOL_RE.findall(source)[:MAX_SYMBOLS_PER_FILE]
119
+ ]
120
+ return symbols, imports
121
+
122
+
123
+ def index_file(path: Path, rel: str, size: int, mtime: float) -> FileIndex:
124
+ language = LANGUAGE_BY_SUFFIX.get(path.suffix.lower(), "other")
125
+ entry = FileIndex(size=size, mtime=mtime, language=language)
126
+ if language in PARSEABLE and size <= MAX_PARSE_BYTES:
127
+ try:
128
+ source = path.read_text(encoding="utf-8", errors="replace")
129
+ except OSError:
130
+ return entry
131
+ symbols, imports = _python_parse(source) if language == "python" else _js_parse(source)
132
+ entry.symbols, entry.imports = symbols, imports
133
+ return entry
134
+
135
+
136
+ def build(workspace: Path, cached: IndexData | None = None) -> IndexData:
137
+ workspace = workspace.resolve()
138
+ cached_files = cached.files if cached and cached.version == INDEX_VERSION else {}
139
+ files: dict[str, FileIndex] = {}
140
+ parsed = reused = 0
141
+
142
+ for path in iter_project_files(workspace):
143
+ rel = path.relative_to(workspace).as_posix()
144
+ try:
145
+ stat = path.stat()
146
+ except OSError:
147
+ continue
148
+ old = cached_files.get(rel)
149
+ if old is not None and old.size == stat.st_size and old.mtime == stat.st_mtime:
150
+ files[rel] = old
151
+ reused += 1
152
+ continue
153
+ files[rel] = index_file(path, rel, stat.st_size, stat.st_mtime)
154
+ parsed += 1
155
+
156
+ pruned = max(0, len(cached_files) - reused)
157
+ return IndexData(
158
+ generated_at=datetime.now(UTC).isoformat(),
159
+ files=files,
160
+ stats={"parsed": parsed, "reused": reused, "pruned": pruned},
161
+ )
@@ -0,0 +1,174 @@
1
+ """ContextManager (spec section 17): index → retrieve → compress → assemble.
2
+
3
+ Fail-open by design in callers: context intelligence enhances prompts but
4
+ must never block a build. The index cache lives in .forgex/cache/.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from forgex.context import indexer
16
+ from forgex.context.chunker import chunk_generic, chunk_python, select_chunks
17
+ from forgex.context.dependency_graph import DependencyGraph
18
+ from forgex.context.indexer import IndexData
19
+ from forgex.context.retriever import ScoredFile, query_terms, score_files
20
+ from forgex.utils.paths import PathViolationError, resolve_in_workspace
21
+ from forgex.utils.redact import redact_patterns
22
+
23
+ logger = logging.getLogger("forgex.context")
24
+
25
+
26
+ class ContextFile(BaseModel):
27
+ path: str
28
+ content: str
29
+ truncated: bool = False
30
+ reasons: list[str] = Field(default_factory=list)
31
+
32
+
33
+ class ContextBundle(BaseModel):
34
+ files: list[ContextFile] = Field(default_factory=list)
35
+ stats: dict[str, Any] = Field(default_factory=dict)
36
+
37
+ def render(self) -> str:
38
+ sections = []
39
+ for f in self.files:
40
+ trunc = " (compressed)" if f.truncated else ""
41
+ sections.append(f"--- {f.path}{trunc} ---\n{f.content}")
42
+ return "\n\n".join(sections)
43
+
44
+
45
+ class ContextManager:
46
+ CACHE_FILE = "context-index.json"
47
+
48
+ def __init__(self, workspace: Path) -> None:
49
+ self.workspace = Path(workspace).resolve()
50
+ self._cache_path = self.workspace / ".forgex" / "cache" / self.CACHE_FILE
51
+ self._index: IndexData | None = None
52
+ self._graph: DependencyGraph | None = None
53
+
54
+ def _load_cache(self) -> IndexData | None:
55
+ if not self._cache_path.is_file():
56
+ return None
57
+ try:
58
+ return IndexData.model_validate_json(self._cache_path.read_text(encoding="utf-8"))
59
+ except Exception: # noqa: BLE001
60
+ return None
61
+
62
+ def _save_cache(self, index: IndexData) -> None:
63
+ try:
64
+ self._cache_path.parent.mkdir(parents=True, exist_ok=True)
65
+ self._cache_path.write_text(index.model_dump_json(), encoding="utf-8")
66
+ except OSError:
67
+ pass
68
+
69
+ def ensure_index(self, refresh: bool = False) -> IndexData:
70
+ if self._index is not None and not refresh:
71
+ return self._index
72
+
73
+ cached = None if refresh else self._load_cache()
74
+ index = indexer.build(self.workspace, cached)
75
+ self._save_cache(index)
76
+ self._index = index
77
+ self._graph = DependencyGraph(index)
78
+ return index
79
+
80
+ @property
81
+ def graph(self) -> DependencyGraph:
82
+ if self._graph is None:
83
+ self.ensure_index()
84
+ assert self._graph is not None
85
+ return self._graph
86
+
87
+ def symbol_map(self) -> str:
88
+ index = self.ensure_index()
89
+ lines: list[str] = []
90
+ for path, entry in sorted(index.files.items()):
91
+ if entry.symbols:
92
+ names = [f"{s.kind} {s.name}" if s.kind in ("class", "def") else s.name for s in entry.symbols[:10]]
93
+ lines.append(f"{path}: {', '.join(names)}")
94
+ return "\n".join(lines)
95
+
96
+ def relevant_files(
97
+ self,
98
+ query: str,
99
+ seeds: list[str],
100
+ recent: list[str] | None = None,
101
+ limit: int = 10,
102
+ ) -> list[ScoredFile]:
103
+ index = self.ensure_index()
104
+ return score_files(
105
+ index=index,
106
+ graph=self.graph,
107
+ query=query,
108
+ seeds=seeds,
109
+ recent=recent,
110
+ limit=limit,
111
+ )
112
+
113
+ def importers_of(self, file: str, max_depth: int = 1) -> list[str]:
114
+ self.ensure_index()
115
+ return sorted(self.graph.importers_of(file, max_depth=max_depth).keys())
116
+
117
+ def build_context(
118
+ self,
119
+ query: str,
120
+ seeds: list[str],
121
+ budget: int,
122
+ max_files: int = 4,
123
+ exclude: set[str] | None = None,
124
+ ) -> ContextBundle:
125
+ ranked = self.relevant_files(query=query, seeds=seeds, limit=max_files * 2)
126
+ exclude_set = exclude or set()
127
+
128
+ files: list[ContextFile] = []
129
+ remaining_budget = budget
130
+ terms = set(query_terms(query))
131
+
132
+ for item in ranked:
133
+ if item.path in exclude_set:
134
+ continue
135
+ if len(files) >= max_files or remaining_budget <= 0:
136
+ break
137
+
138
+ try:
139
+ real_path = resolve_in_workspace(self.workspace, item.path)
140
+ except PathViolationError:
141
+ continue
142
+
143
+ if not real_path.is_file():
144
+ continue
145
+
146
+ try:
147
+ source = real_path.read_text(encoding="utf-8", errors="replace")
148
+ except OSError:
149
+ continue
150
+
151
+ source = redact_patterns(source)
152
+
153
+ # Chunk and select
154
+ per_file_budget = min(remaining_budget, budget // 2 if len(ranked) > 1 else budget)
155
+ if real_path.suffix.lower() == ".py":
156
+ chunks = chunk_python(source)
157
+ else:
158
+ chunks = chunk_generic(source)
159
+
160
+ content, truncated = select_chunks(chunks, terms, max_chars=per_file_budget)
161
+ files.append(
162
+ ContextFile(
163
+ path=item.path,
164
+ content=content,
165
+ truncated=truncated,
166
+ reasons=item.reasons,
167
+ )
168
+ )
169
+ remaining_budget -= len(content)
170
+
171
+ return ContextBundle(
172
+ files=files,
173
+ stats={"budget_used": budget - remaining_budget, "index_files": len(self.ensure_index().files)},
174
+ )
@@ -0,0 +1,99 @@
1
+ """Lexical relevance retrieval with auditable reasons (spec section 16)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ from forgex.context.dependency_graph import DependencyGraph
9
+ from forgex.context.indexer import IndexData
10
+
11
+ STOPWORDS = {
12
+ "the", "and", "for", "with", "into", "from", "this", "that", "must",
13
+ "should", "will", "your", "are", "all", "not", "but", "can", "has",
14
+ "have", "when", "which", "was", "you", "its", "our",
15
+ }
16
+ _CAMEL_RE = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])")
17
+
18
+
19
+ def query_terms(text: str, *, limit: int = 12) -> list[str]:
20
+ terms: list[str] = []
21
+ for token in re.findall(r"[A-Za-z][A-Za-z0-9_]{2,}", text):
22
+ for part in _CAMEL_RE.findall(token.replace("_", " ")) or [token]:
23
+ p = part.lower()
24
+ if len(p) >= 3 and p not in STOPWORDS and p not in terms:
25
+ terms.append(p)
26
+ snake = token.lower().replace("_", " ").split()
27
+ for p in snake:
28
+ if len(p) >= 3 and p not in STOPWORDS and p not in terms:
29
+ terms.append(p)
30
+ return terms[:limit]
31
+
32
+
33
+ @dataclass
34
+ class ScoredFile:
35
+ path: str
36
+ score: int = 0
37
+ reasons: list[str] = field(default_factory=list)
38
+
39
+ def bump(self, points: int, reason: str) -> None:
40
+ self.score += points
41
+ self.reasons.append(reason)
42
+
43
+
44
+ def _path_tokens(path: str) -> set[str]:
45
+ return {t for t in re.split(r"[^a-z0-9]+", path.lower()) if len(t) >= 3}
46
+
47
+
48
+ def _is_test_path(path: str) -> bool:
49
+ return "test" in path.lower()
50
+
51
+
52
+ def score_files(
53
+ *,
54
+ index: IndexData,
55
+ graph: DependencyGraph,
56
+ query: str,
57
+ seeds: list[str],
58
+ recent: list[str] | None = None,
59
+ limit: int = 20,
60
+ ) -> list[ScoredFile]:
61
+ scores: dict[str, ScoredFile] = {}
62
+
63
+ def get(path: str) -> ScoredFile:
64
+ return scores.setdefault(path, ScoredFile(path=path))
65
+
66
+ terms = set(query_terms(query))
67
+
68
+ for seed in seeds:
69
+ if seed in index.files:
70
+ get(seed).bump(100, "seed file (planned/changed)")
71
+ for importer, depth in graph.importers_of(seed, max_depth=2).items():
72
+ if importer in index.files:
73
+ points = 50 if depth == 1 else 15
74
+ get(importer).bump(points, f"imports seed ({'direct' if depth == 1 else 'transitive'})")
75
+ for dependency in graph.imports_of(seed, max_depth=1):
76
+ if dependency in index.files:
77
+ get(dependency).bump(30, "imported by seed")
78
+ seed_tokens = _path_tokens(seed)
79
+ if seed_tokens:
80
+ for path in index.files:
81
+ if _is_test_path(path) and _path_tokens(path) & seed_tokens:
82
+ get(path).bump(30, f"test file for {seed}")
83
+
84
+ for path in recent or []:
85
+ if path in index.files:
86
+ get(path).bump(20, "recently changed")
87
+
88
+ for path, entry in index.files.items():
89
+ lowered = path.lower()
90
+ for term in terms:
91
+ if term in lowered:
92
+ get(path).bump(5, f"path matches '{term}'")
93
+ for symbol in entry.symbols:
94
+ if term in symbol.name.lower():
95
+ get(path).bump(10, f"symbol '{symbol.name}' matches '{term}'")
96
+ break
97
+
98
+ ranked = sorted(scores.values(), key=lambda s: (-s.score, s.path))
99
+ return [s for s in ranked if s.score > 0][:limit]
@@ -0,0 +1,25 @@
1
+ """Model layer: provider contract, capabilities, registry."""
2
+
3
+ from forgex.models.base import (
4
+ Message,
5
+ ModelProvider,
6
+ ModelRequest,
7
+ ModelResponse,
8
+ ModelUsage,
9
+ ProviderHealth,
10
+ ToolCall,
11
+ )
12
+ from forgex.models.capabilities import ModelCapabilities
13
+ from forgex.models.registry import ModelRegistry
14
+
15
+ __all__ = [
16
+ "Message",
17
+ "ModelCapabilities",
18
+ "ModelProvider",
19
+ "ModelRegistry",
20
+ "ModelRequest",
21
+ "ModelResponse",
22
+ "ModelUsage",
23
+ "ProviderHealth",
24
+ "ToolCall",
25
+ ]