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.
- forgex/__init__.py +3 -0
- forgex/__main__.py +6 -0
- forgex/agents/__init__.py +36 -0
- forgex/agents/architect.py +233 -0
- forgex/agents/base.py +385 -0
- forgex/agents/builder.py +269 -0
- forgex/agents/ensemble.py +334 -0
- forgex/agents/expert_coder.py +283 -0
- forgex/agents/supervisor.py +303 -0
- forgex/cli/__init__.py +1 -0
- forgex/cli/app.py +232 -0
- forgex/cli/build_views.py +94 -0
- forgex/cli/commands/__init__.py +1 -0
- forgex/cli/commands/build.py +63 -0
- forgex/cli/commands/checkpoint.py +41 -0
- forgex/cli/commands/config.py +225 -0
- forgex/cli/commands/context.py +94 -0
- forgex/cli/commands/diff.py +32 -0
- forgex/cli/commands/do_cmd.py +50 -0
- forgex/cli/commands/doctor.py +175 -0
- forgex/cli/commands/fix.py +46 -0
- forgex/cli/commands/init.py +41 -0
- forgex/cli/commands/logs.py +102 -0
- forgex/cli/commands/models.py +144 -0
- forgex/cli/commands/plan.py +103 -0
- forgex/cli/commands/review.py +76 -0
- forgex/cli/commands/rollback.py +65 -0
- forgex/cli/commands/run.py +75 -0
- forgex/cli/commands/status.py +141 -0
- forgex/cli/commands/supervise.py +61 -0
- forgex/cli/commands/task.py +136 -0
- forgex/cli/commands/tools.py +116 -0
- forgex/cli/commands/verify.py +65 -0
- forgex/cli/context_memory.py +62 -0
- forgex/cli/intent.py +209 -0
- forgex/cli/progress.py +101 -0
- forgex/cli/repl.py +377 -0
- forgex/cli/runtime.py +233 -0
- forgex/cli/session.py +95 -0
- forgex/cli/ui.py +113 -0
- forgex/config/__init__.py +53 -0
- forgex/config/loader.py +189 -0
- forgex/config/schema.py +145 -0
- forgex/context/__init__.py +6 -0
- forgex/context/chunker.py +102 -0
- forgex/context/dependency_graph.py +110 -0
- forgex/context/indexer.py +161 -0
- forgex/context/manager.py +174 -0
- forgex/context/retriever.py +99 -0
- forgex/models/__init__.py +25 -0
- forgex/models/base.py +112 -0
- forgex/models/capabilities.py +21 -0
- forgex/models/factory.py +66 -0
- forgex/models/groq.py +13 -0
- forgex/models/local.py +10 -0
- forgex/models/openai_compatible.py +233 -0
- forgex/models/registry.py +81 -0
- forgex/models/router.py +332 -0
- forgex/orchestration/__init__.py +36 -0
- forgex/orchestration/checkpoints.py +117 -0
- forgex/orchestration/engine.py +573 -0
- forgex/orchestration/events.py +101 -0
- forgex/orchestration/lockfile.py +82 -0
- forgex/orchestration/retry.py +65 -0
- forgex/orchestration/state.py +89 -0
- forgex/orchestration/stores.py +90 -0
- forgex/orchestration/workflow.py +103 -0
- forgex/project.py +209 -0
- forgex/prompts/__init__.py +27 -0
- forgex/prompts/architect.md +18 -0
- forgex/prompts/builder.md +20 -0
- forgex/prompts/expert_coder.md +25 -0
- forgex/prompts/reviewer.md +11 -0
- forgex/prompts/supervisor.md +26 -0
- forgex/research/__init__.py +15 -0
- forgex/research/citations.py +16 -0
- forgex/research/engine.py +83 -0
- forgex/research/sources.py +154 -0
- forgex/sandbox/__init__.py +37 -0
- forgex/sandbox/executor.py +418 -0
- forgex/sandbox/permissions.py +67 -0
- forgex/sandbox/policies.py +172 -0
- forgex/schemas/__init__.py +101 -0
- forgex/schemas/agent.py +109 -0
- forgex/schemas/checkpoint.py +19 -0
- forgex/schemas/error.py +125 -0
- forgex/schemas/event.py +76 -0
- forgex/schemas/plan.py +84 -0
- forgex/schemas/project.py +92 -0
- forgex/schemas/research.py +25 -0
- forgex/schemas/result.py +68 -0
- forgex/schemas/task.py +118 -0
- forgex/storage/__init__.py +25 -0
- forgex/storage/database.py +40 -0
- forgex/storage/migrations.py +130 -0
- forgex/storage/repositories.py +492 -0
- forgex/tools/__init__.py +73 -0
- forgex/tools/base.py +212 -0
- forgex/tools/dependencies.py +137 -0
- forgex/tools/filesystem.py +322 -0
- forgex/tools/git.py +239 -0
- forgex/tools/project.py +39 -0
- forgex/tools/search.py +205 -0
- forgex/tools/terminal.py +81 -0
- forgex/tools/testing.py +100 -0
- forgex/tools/verification.py +174 -0
- forgex/utils/__init__.py +15 -0
- forgex/utils/logging.py +28 -0
- forgex/utils/paths.py +48 -0
- forgex/utils/redact.py +44 -0
- forgex_cli-0.1.0.dist-info/METADATA +189 -0
- forgex_cli-0.1.0.dist-info/RECORD +115 -0
- forgex_cli-0.1.0.dist-info/WHEEL +4 -0
- forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|