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
forgex/cli/session.py ADDED
@@ -0,0 +1,95 @@
1
+ """Interactive session state management for FORGEX REPL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from rich.console import Console
11
+
12
+ from forgex.cli.context_memory import ConversationContext
13
+ from forgex.cli.ui import UI, Output
14
+ from forgex.config import ForgexConfig, load_config
15
+ from forgex.config.loader import load_env_files
16
+ from forgex.orchestration.state import StateStore, TaskStore
17
+ from forgex.project import ProjectDetection, detect_project
18
+ from forgex.schemas.project import ProjectState
19
+ from forgex.schemas.task import Task
20
+ from forgex.utils.paths import find_project_root
21
+
22
+
23
+ @dataclass
24
+ class Session:
25
+ """Encapsulates persistent state during an interactive FORGEX terminal session."""
26
+
27
+ project_root: Path | None = None
28
+ config: ForgexConfig = field(default_factory=ForgexConfig)
29
+ ui: UI = field(default_factory=lambda: UI(mode=Output.RICH))
30
+ console: Console = field(default_factory=Console)
31
+ history: list[str] = field(default_factory=list)
32
+ context: ConversationContext = field(default_factory=ConversationContext)
33
+ _router: Any = None
34
+
35
+ def __post_init__(self) -> None:
36
+ self.refresh()
37
+
38
+ def refresh(self) -> None:
39
+ """Refresh project root, environment, and configuration from disk."""
40
+ self.project_root = find_project_root()
41
+ load_env_files(self.project_root)
42
+ self.config = load_config(self.project_root)
43
+ self._router = None
44
+
45
+ @property
46
+ def is_attached(self) -> bool:
47
+ """True if the session is currently attached to an initialized project."""
48
+ return self.project_root is not None and (self.project_root / ".forgex").is_dir()
49
+
50
+ @property
51
+ def project_name(self) -> str:
52
+ """Name of the attached project directory, or 'unattached'."""
53
+ if self.project_root is not None:
54
+ return self.project_root.name
55
+ return "unattached"
56
+
57
+ def detect(self) -> ProjectDetection | None:
58
+ """Run project detector on current directory or attached project."""
59
+ target = self.project_root or Path.cwd()
60
+ return detect_project(target)
61
+
62
+ def get_state(self) -> ProjectState | None:
63
+ """Load current ProjectState if attached."""
64
+ if not self.is_attached or self.project_root is None:
65
+ return None
66
+ try:
67
+ return StateStore(self.project_root).load()
68
+ except Exception:
69
+ return None
70
+
71
+ def get_tasks(self) -> list[Task]:
72
+ """Load project tasks if attached."""
73
+ if not self.is_attached or self.project_root is None:
74
+ return []
75
+ try:
76
+ return TaskStore(self.project_root).load()
77
+ except Exception:
78
+ return []
79
+
80
+ def get_router(self) -> Any:
81
+ """Lazily instantiate and return the ModelRouter."""
82
+ if self._router is None:
83
+ from forgex.models.router import ModelRouter
84
+
85
+ self._router = ModelRouter(self.config)
86
+ return self._router
87
+
88
+ def change_directory(self, target: Path | str) -> bool:
89
+ """Change working directory and refresh session."""
90
+ dest = Path(target).expanduser().resolve()
91
+ if not dest.is_dir():
92
+ return False
93
+ os.chdir(dest)
94
+ self.refresh()
95
+ return True
forgex/cli/ui.py ADDED
@@ -0,0 +1,113 @@
1
+ """Terminal output adapter (spec section 23).
2
+
3
+ All user-facing output goes through :class:`UI` so the modes required by
4
+ spec section 23 (``--plain`` / ``--json`` / ``--quiet``) behave
5
+ consistently across every command.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from enum import Enum
13
+ from typing import Annotated, Any
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.prompt import Confirm
18
+ from rich.theme import Theme
19
+
20
+ _THEME = Theme(
21
+ {
22
+ "forgex.title": "bold cyan",
23
+ "forgex.ok": "green",
24
+ "forgex.warn": "yellow",
25
+ "forgex.err": "red",
26
+ "forgex.muted": "dim",
27
+ }
28
+ )
29
+
30
+
31
+ class Output(str, Enum):
32
+ RICH = "rich"
33
+ PLAIN = "plain"
34
+ JSON = "json"
35
+ QUIET = "quiet"
36
+
37
+
38
+ JsonFlag = Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")]
39
+ PlainFlag = Annotated[bool, typer.Option("--plain", help="Plain output (CI/SSH safe).")]
40
+
41
+
42
+ class UI:
43
+ def __init__(self, mode: Output, console: Console | None = None) -> None:
44
+ self.mode = mode
45
+ if console is not None:
46
+ self.console = console
47
+ elif mode is Output.JSON:
48
+ self.console = Console(theme=_THEME, soft_wrap=True, highlight=False)
49
+ else:
50
+ self.console = Console(theme=_THEME)
51
+
52
+ def ok(self, message: str) -> None:
53
+ if self.mode is Output.QUIET or self.mode is Output.JSON:
54
+ return
55
+ if self.mode is Output.RICH:
56
+ self.console.print(f"[green]✓[/green] {message}")
57
+ else:
58
+ self.console.print(f"OK: {message}")
59
+
60
+ def warn(self, message: str) -> None:
61
+ if self.mode is Output.QUIET or self.mode is Output.JSON:
62
+ return
63
+ if self.mode is Output.RICH:
64
+ self.console.print(f"[yellow]![/yellow] {message}")
65
+ else:
66
+ self.console.print(f"WARN: {message}")
67
+
68
+ def error(self, message: str) -> None:
69
+ if self.mode is Output.JSON:
70
+ return
71
+ if self.mode is Output.RICH:
72
+ self.console.print(f"[red]✗[/red] {message}")
73
+ else:
74
+ self.console.print(f"ERROR: {message}")
75
+
76
+ def text(self, message: str) -> None:
77
+ if self.mode is Output.QUIET or self.mode is Output.JSON:
78
+ return
79
+ self.console.print(message)
80
+
81
+ def data(self, payload: dict[str, Any]) -> None:
82
+ if self.mode is Output.JSON:
83
+ self.console.print(json.dumps(payload, indent=2, default=str), soft_wrap=True, highlight=False)
84
+
85
+
86
+ def ui_from(
87
+ ctx: typer.Context,
88
+ *,
89
+ json_out: bool = False,
90
+ plain: bool = False,
91
+ ) -> UI:
92
+ obj = ctx.obj or {}
93
+ if json_out or obj.get("json"):
94
+ mode = Output.JSON
95
+ elif plain or obj.get("plain"):
96
+ mode = Output.PLAIN
97
+ elif obj.get("quiet"):
98
+ mode = Output.QUIET
99
+ else:
100
+ mode = Output.RICH
101
+
102
+ return UI(mode=mode)
103
+
104
+
105
+ def make_approval(ui: UI):
106
+ """Interactive approval callback (spec section 33)."""
107
+
108
+ def approve(description: str) -> bool:
109
+ if ui.mode is not Output.RICH or not sys.stdin.isatty():
110
+ return False
111
+ return Confirm.ask(f"FORGEX wants to: {description}\nAllow?", default=False)
112
+
113
+ return approve
@@ -0,0 +1,53 @@
1
+ """Configuration package: layered loading with Pydantic validation."""
2
+
3
+ from forgex.config.loader import (
4
+ CONFIG_FILE_NAME,
5
+ ENV_FILE_NAME,
6
+ FORGEX_DIR_NAME,
7
+ global_config_path,
8
+ load_config,
9
+ load_env_files,
10
+ parse_env_file,
11
+ project_config_path,
12
+ save_config,
13
+ set_config_value,
14
+ store_secret,
15
+ )
16
+ from forgex.config.schema import (
17
+ AgentModelBinding,
18
+ EnsembleConfig,
19
+ FallbackConfig,
20
+ ForgexConfig,
21
+ ModelsConfig,
22
+ PermissionsConfig,
23
+ ProviderConfig,
24
+ ResearchConfig,
25
+ SandboxConfig,
26
+ TerminalConfig,
27
+ WorkflowConfig,
28
+ )
29
+
30
+ __all__ = [
31
+ "CONFIG_FILE_NAME",
32
+ "ENV_FILE_NAME",
33
+ "FORGEX_DIR_NAME",
34
+ "AgentModelBinding",
35
+ "EnsembleConfig",
36
+ "FallbackConfig",
37
+ "ForgexConfig",
38
+ "ModelsConfig",
39
+ "PermissionsConfig",
40
+ "ProviderConfig",
41
+ "ResearchConfig",
42
+ "SandboxConfig",
43
+ "TerminalConfig",
44
+ "WorkflowConfig",
45
+ "global_config_path",
46
+ "load_config",
47
+ "load_env_files",
48
+ "parse_env_file",
49
+ "project_config_path",
50
+ "save_config",
51
+ "set_config_value",
52
+ "store_secret",
53
+ ]
@@ -0,0 +1,189 @@
1
+ """Layered configuration loading, saving, and dotted-key editing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tomllib
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import tomli_w
12
+ from pydantic import ValidationError
13
+
14
+ from forgex.config.schema import ForgexConfig
15
+ from forgex.schemas.error import ConfigError
16
+
17
+ FORGEX_DIR_NAME = ".forgex"
18
+ CONFIG_FILE_NAME = "config.toml"
19
+ ENV_FILE_NAME = ".env"
20
+
21
+
22
+ def global_config_path() -> Path:
23
+ return Path.home() / FORGEX_DIR_NAME / CONFIG_FILE_NAME
24
+
25
+
26
+ def project_config_path(project_root: Path) -> Path:
27
+ return project_root / FORGEX_DIR_NAME / CONFIG_FILE_NAME
28
+
29
+
30
+ def _read_toml(path: Path) -> dict[str, Any]:
31
+ if not path.is_file():
32
+ return {}
33
+ try:
34
+ with path.open("rb") as fh:
35
+ return tomllib.load(fh)
36
+ except tomllib.TOMLDecodeError as exc:
37
+ msg = f"Invalid TOML in {path}: {exc}"
38
+ raise ConfigError(msg) from exc
39
+
40
+
41
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
42
+ merged = dict(base)
43
+ for key, value in override.items():
44
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
45
+ merged[key] = _deep_merge(merged[key], value)
46
+ else:
47
+ merged[key] = value
48
+ return merged
49
+
50
+
51
+ def load_config(
52
+ project_root: Path | None = None,
53
+ overrides: dict[str, Any] | None = None,
54
+ ) -> ForgexConfig:
55
+ """Load merged configuration: defaults < global < project < overrides."""
56
+ data: dict[str, Any] = ForgexConfig().model_dump()
57
+ data = _deep_merge(data, _read_toml(global_config_path()))
58
+ if project_root is not None:
59
+ data = _deep_merge(data, _read_toml(project_config_path(project_root)))
60
+ if overrides:
61
+ data = _deep_merge(data, overrides)
62
+ try:
63
+ config = ForgexConfig.model_validate(data)
64
+ except ValidationError as exc:
65
+ msg = f"Invalid configuration: {exc.error_count()} error(s): {exc}"
66
+ raise ConfigError(msg) from exc
67
+
68
+ if os.environ.get("FORGEX_OFFLINE") == "1":
69
+ # Offline mode (§73): web research off; local providers still allowed.
70
+ config.research.enabled = False
71
+ return config
72
+
73
+
74
+ def save_config(config: ForgexConfig, path: Path) -> None:
75
+ """Write a full config to *path* as TOML (None values omitted — TOML has no null)."""
76
+ path.parent.mkdir(parents=True, exist_ok=True)
77
+ text = tomli_w.dumps(config.model_dump(mode="json", exclude_none=True))
78
+ path.write_text(text, encoding="utf-8")
79
+
80
+
81
+ def _coerce(raw: Any) -> Any:
82
+ """Convert a CLI string into bool/int/float/JSON when unambiguous."""
83
+ if not isinstance(raw, str):
84
+ return raw
85
+ if raw.lower() in ("true", "false"):
86
+ return raw.lower() == "true"
87
+ if raw and raw[0] in "[{":
88
+ try:
89
+ return json.loads(raw)
90
+ except ValueError:
91
+ pass
92
+ for cast in (int, float):
93
+ try:
94
+ return cast(raw)
95
+ except ValueError:
96
+ pass
97
+ return raw
98
+
99
+
100
+ def set_config_value(path: Path, dotted_key: str, value: Any) -> ForgexConfig:
101
+ """Set a dotted key in config at path, validate with Pydantic, and save.
102
+
103
+ Refuses to write if the resulting configuration would be invalid.
104
+ """
105
+ raw_dict = _read_toml(path) if path.is_file() else {}
106
+ coerced_value = _coerce(value)
107
+
108
+ # Insert into nested dict
109
+ parts = dotted_key.split(".")
110
+ curr = raw_dict
111
+ for part in parts[:-1]:
112
+ if part not in curr or not isinstance(curr[part], dict):
113
+ curr[part] = {}
114
+ curr = curr[part]
115
+ curr[parts[-1]] = coerced_value
116
+
117
+ # Merge onto defaults to validate full schema
118
+ data = ForgexConfig().model_dump()
119
+ data = _deep_merge(data, raw_dict)
120
+
121
+ try:
122
+ validated = ForgexConfig.model_validate(data)
123
+ except ValidationError as exc:
124
+ msg = f"Invalid configuration for {dotted_key}={value}: {exc}"
125
+ raise ConfigError(msg) from exc
126
+
127
+ # Write validated TOML
128
+ path.parent.mkdir(parents=True, exist_ok=True)
129
+ # Save the minimal raw_dict or full validated config
130
+ save_config(validated, path)
131
+ return validated
132
+
133
+
134
+ def parse_env_file(path: Path) -> dict[str, str]:
135
+ """Minimal .env parser: comments, optional 'export ', KEY=VALUE, quotes."""
136
+ values: dict[str, str] = {}
137
+ for line in path.read_text(encoding="utf-8").splitlines():
138
+ line = line.strip()
139
+ if not line or line.startswith("#"):
140
+ continue
141
+ line = line.removeprefix("export ")
142
+ if "=" not in line:
143
+ continue
144
+ key, _, value = line.partition("=")
145
+ key, value = key.strip(), value.strip()
146
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
147
+ value = value[1:-1]
148
+ if key:
149
+ values[key] = value
150
+ return values
151
+
152
+
153
+ def load_env_files(project_root: Path | None = None) -> list[Path]:
154
+ """Load ~/.forgex/.env then <project>/.env into os.environ with setdefault.
155
+
156
+ Priority: real environment always wins; project .env beats global .env.
157
+ """
158
+ paths = []
159
+ if project_root is not None:
160
+ paths.append(Path(project_root) / ENV_FILE_NAME)
161
+ paths.append(Path.home() / FORGEX_DIR_NAME / ENV_FILE_NAME)
162
+ loaded: list[Path] = []
163
+ for path in paths:
164
+ if not path.is_file():
165
+ continue
166
+ try:
167
+ values = parse_env_file(path)
168
+ except OSError:
169
+ continue
170
+ for key, value in values.items():
171
+ os.environ.setdefault(key, value)
172
+ loaded.append(path)
173
+ return loaded
174
+
175
+
176
+ def store_secret(env_var: str, value: str, *, global_dir: Path | None = None) -> Path:
177
+ """Persist a secret to ~/.forgex/.env (chmod 600). Never into config files."""
178
+ target = (global_dir or Path.home() / FORGEX_DIR_NAME) / ENV_FILE_NAME
179
+ target.parent.mkdir(parents=True, exist_ok=True)
180
+ existing: dict[str, str] = parse_env_file(target) if target.is_file() else {}
181
+ existing[env_var] = value
182
+ lines = [f"{key}={val}" for key, val in existing.items()]
183
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
184
+ if os.name == "posix":
185
+ try:
186
+ os.chmod(target, 0o600)
187
+ except OSError:
188
+ pass
189
+ return target
@@ -0,0 +1,145 @@
1
+ """Pydantic models describing FORGEX configuration.
2
+
3
+ Priority (highest wins), per spec section 27:
4
+ CLI arguments > environment (secrets) > project .forgex/config.toml
5
+ > global ~/.forgex/config.toml > schema defaults.
6
+
7
+ Secrets NEVER live in these files — providers store only the *name* of
8
+ the environment variable holding the key (``api_key_env``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Literal
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from forgex.models.capabilities import ModelCapabilities
18
+
19
+
20
+ class WorkflowConfig(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+
23
+ max_iterations: int = Field(default=5, ge=1, le=20)
24
+
25
+
26
+ class PermissionsConfig(BaseModel):
27
+ model_config = ConfigDict(extra="forbid")
28
+
29
+ mode: Literal["auto", "ask", "safe"] = "ask"
30
+
31
+
32
+ class ProviderConfig(BaseModel):
33
+ """A provider endpoint description. Contains no secrets."""
34
+
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ type: Literal["openai_compatible", "groq", "local"] = "openai_compatible"
38
+ base_url: str | None = None
39
+ api_key_env: str | None = None
40
+ default_model: str | None = None
41
+ timeout_seconds: float = 60.0
42
+ max_retries: int = 2
43
+
44
+
45
+ class AgentModelBinding(BaseModel):
46
+ model_config = ConfigDict(extra="forbid")
47
+
48
+ provider: str = "groq"
49
+ model: str | None = None
50
+
51
+
52
+ class ModelsConfig(BaseModel):
53
+ model_config = ConfigDict(extra="forbid")
54
+
55
+ architect: AgentModelBinding = Field(default_factory=AgentModelBinding)
56
+ builder: AgentModelBinding = Field(default_factory=AgentModelBinding)
57
+ expert_coder: AgentModelBinding = Field(default_factory=AgentModelBinding)
58
+ supervisor: AgentModelBinding = Field(default_factory=AgentModelBinding)
59
+ capabilities: dict[str, ModelCapabilities] = Field(default_factory=dict)
60
+
61
+
62
+ class FallbackConfig(BaseModel):
63
+ model_config = ConfigDict(extra="forbid")
64
+
65
+ enabled: bool = False
66
+ providers: list[str] = Field(default_factory=list)
67
+
68
+
69
+ class TerminalConfig(BaseModel):
70
+ """Command execution policy knobs (spec sections 19, 45)."""
71
+
72
+ model_config = ConfigDict(extra="forbid")
73
+
74
+ allowed: list[str] = Field(default_factory=list)
75
+ denied: list[str] = Field(default_factory=list)
76
+ timeout_seconds: int = 120
77
+ max_output_bytes: int = 1_000_000
78
+
79
+
80
+ class SandboxConfig(BaseModel):
81
+ """Docker sandbox (spec section 20). Default: restricted."""
82
+
83
+ model_config = ConfigDict(extra="forbid")
84
+
85
+ enabled: bool = False
86
+ network: bool = False
87
+ image: str = "python:3.12-slim"
88
+ cpu_limit: str | None = None
89
+ memory_limit: str | None = None
90
+ read_only: bool = True
91
+ user: str | None = None
92
+ timeout_seconds: int = 120
93
+
94
+
95
+ class ResearchConfig(BaseModel):
96
+ """Web research toggle (spec sections 8, 73)."""
97
+
98
+ model_config = ConfigDict(extra="forbid")
99
+
100
+ enabled: bool = True
101
+
102
+
103
+ class EnsembleConfig(BaseModel):
104
+ """Multi-model ensemble review (spec section 15)."""
105
+
106
+ model_config = ConfigDict(extra="forbid")
107
+
108
+ enabled: bool = False
109
+ reviewers: int = Field(default=2, ge=2, le=4)
110
+ bindings: list[AgentModelBinding] = Field(default_factory=list)
111
+
112
+
113
+ def _default_providers() -> dict[str, ProviderConfig]:
114
+ return {
115
+ "groq": ProviderConfig(
116
+ type="groq",
117
+ base_url="https://api.groq.com/openai/v1",
118
+ api_key_env="FORGEX_GROQ_API_KEY",
119
+ default_model="llama-3.3-70b-versatile",
120
+ ),
121
+ "openai_compatible": ProviderConfig(
122
+ type="openai_compatible",
123
+ base_url="https://api.openai.com/v1",
124
+ api_key_env="FORGEX_OPENAI_API_KEY",
125
+ default_model="gpt-4o-mini",
126
+ ),
127
+ "local": ProviderConfig(
128
+ type="local",
129
+ base_url="http://localhost:11434/v1",
130
+ ),
131
+ }
132
+
133
+
134
+ class ForgexConfig(BaseModel):
135
+ model_config = ConfigDict(extra="forbid")
136
+
137
+ workflow: WorkflowConfig = Field(default_factory=WorkflowConfig)
138
+ permissions: PermissionsConfig = Field(default_factory=PermissionsConfig)
139
+ terminal: TerminalConfig = Field(default_factory=TerminalConfig)
140
+ sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
141
+ research: ResearchConfig = Field(default_factory=ResearchConfig)
142
+ ensemble: EnsembleConfig = Field(default_factory=EnsembleConfig)
143
+ fallback: FallbackConfig = Field(default_factory=FallbackConfig)
144
+ models: ModelsConfig = Field(default_factory=ModelsConfig)
145
+ providers: dict[str, ProviderConfig] = Field(default_factory=_default_providers)
@@ -0,0 +1,6 @@
1
+ """Context intelligence (spec sections 16, 17)."""
2
+
3
+ from forgex.context.manager import ContextBundle, ContextManager
4
+ from forgex.context.retriever import ScoredFile, query_terms
5
+
6
+ __all__ = ["ContextBundle", "ContextManager", "ScoredFile", "query_terms"]