flash-context 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.
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from importlib import import_module
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from flash_context.agent import explore
9
+ from flash_context.config import Settings, SettingsOverrides, load_settings
10
+ from flash_context.types import ExploreRequest, ExplorerError, ExploreResult
11
+
12
+ CoreFn = Callable[[ExploreRequest, Settings], ExploreResult]
13
+
14
+
15
+ def explore_repository_handler(
16
+ *,
17
+ query: str,
18
+ repo_root: str | None = None,
19
+ max_turns: int | None = None,
20
+ citation: bool | None = None,
21
+ settings: Settings | None = None,
22
+ core: CoreFn = explore,
23
+ ) -> dict[str, object]:
24
+ root = Path(repo_root) if repo_root is not None else Path.cwd()
25
+ try:
26
+ effective_settings = settings or load_settings(
27
+ repo_root=root,
28
+ overrides=SettingsOverrides(max_turns=max_turns),
29
+ )
30
+ if max_turns is not None and settings is not None:
31
+ effective_settings = Settings(
32
+ base_url=settings.base_url,
33
+ model=settings.model,
34
+ api_key=settings.api_key,
35
+ max_turns=max_turns,
36
+ max_read_bytes=settings.max_read_bytes,
37
+ max_grep_results=settings.max_grep_results,
38
+ traj_dir=settings.traj_dir,
39
+ ignore=settings.ignore,
40
+ timeout_seconds=settings.timeout_seconds,
41
+ max_observation_chars=settings.max_observation_chars,
42
+ max_read_lines=settings.max_read_lines,
43
+ max_completion_tokens=settings.max_completion_tokens,
44
+ temperature=settings.temperature,
45
+ max_parallel_tools=settings.max_parallel_tools,
46
+ )
47
+ request = ExploreRequest(
48
+ query=query,
49
+ repo_root=root,
50
+ max_turns=effective_settings.max_turns,
51
+ citation=True if citation is None else citation,
52
+ format="json",
53
+ )
54
+ return core(request, effective_settings).to_dict()
55
+ except ExplorerError as exc:
56
+ return {"error": exc.to_dict()}
57
+
58
+
59
+ def create_server() -> Any:
60
+ try:
61
+ fastmcp_module = import_module("mcp.server.fastmcp")
62
+ except ImportError as exc:
63
+ raise ExplorerError(
64
+ "CONFIG_INVALID",
65
+ "MCP SDK is not installed; install flash-context[mcp]",
66
+ ) from exc
67
+ fast_mcp = fastmcp_module.FastMCP
68
+ server = fast_mcp("flash-context")
69
+
70
+ @server.tool() # type: ignore[untyped-decorator]
71
+ def explore_repository(
72
+ query: str,
73
+ repo_root: str | None = None,
74
+ max_turns: int | None = None,
75
+ citation: bool | None = None,
76
+ ) -> dict[str, object]:
77
+ return explore_repository_handler(
78
+ query=query,
79
+ repo_root=repo_root,
80
+ max_turns=max_turns,
81
+ citation=citation,
82
+ )
83
+
84
+ return server
85
+
86
+
87
+ def run_mcp(*, transport: str = "stdio") -> None:
88
+ server = create_server()
89
+ server.run(transport=transport)
@@ -0,0 +1,5 @@
1
+ from flash_context.tools.glob import repo_glob
2
+ from flash_context.tools.grep import repo_grep
3
+ from flash_context.tools.read import read_file
4
+
5
+ __all__ = ["read_file", "repo_glob", "repo_grep"]
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from flash_context.tools.safety import PathSafety
6
+ from flash_context.types import ExplorerError, ToolObservation
7
+
8
+
9
+ def repo_glob(
10
+ *,
11
+ repo_root: Path,
12
+ pattern: str,
13
+ max_results: int,
14
+ ignore: list[str] | None = None,
15
+ ) -> ToolObservation:
16
+ if not pattern:
17
+ raise ExplorerError("INVALID_TOOL_ARGUMENTS", "pattern is required")
18
+ if max_results <= 0:
19
+ raise ExplorerError("CONFIG_INVALID", "max_results must be positive")
20
+ safety = PathSafety(repo_root, tuple(ignore or ()))
21
+ matches: list[str] = []
22
+ truncated = False
23
+ for path in safety.iter_safe_files(pattern):
24
+ if len(matches) >= max_results:
25
+ truncated = True
26
+ break
27
+ matches.append(safety.relative_or_raw(path))
28
+ return ToolObservation(matches=matches, truncated=truncated)
29
+
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+
7
+ from flash_context.tools.safety import PathSafety
8
+ from flash_context.types import ExplorerError, SearchHit, ToolObservation
9
+
10
+ MAX_HIT_TEXT = 240
11
+
12
+
13
+ def repo_grep(
14
+ *,
15
+ repo_root: Path,
16
+ pattern: str,
17
+ max_results: int,
18
+ glob: str = "**/*",
19
+ configured_max_results: int | None = None,
20
+ ignore: list[str] | None = None,
21
+ ) -> ToolObservation:
22
+ if not pattern:
23
+ raise ExplorerError("INVALID_TOOL_ARGUMENTS", "pattern is required")
24
+ if max_results <= 0:
25
+ raise ExplorerError("CONFIG_INVALID", "max_results must be positive")
26
+ limit = min(max_results, configured_max_results or max_results)
27
+ try:
28
+ regex = re.compile(pattern)
29
+ except re.error as exc:
30
+ raise ExplorerError(
31
+ "INVALID_GREP_PATTERN",
32
+ "Grep pattern is not a valid regular expression",
33
+ details={"error": str(exc)},
34
+ ) from exc
35
+
36
+ safety = PathSafety(repo_root, tuple(ignore or ()))
37
+ hits: list[SearchHit] = []
38
+ truncated = False
39
+ for path in safety.iter_safe_files(glob or "**/*"):
40
+ for line_number, line in _iter_text_lines(path):
41
+ if not regex.search(line):
42
+ continue
43
+ if len(hits) >= limit:
44
+ truncated = True
45
+ return ToolObservation(hits=hits, truncated=truncated)
46
+ hits.append(
47
+ SearchHit(
48
+ path=safety.relative_or_raw(path),
49
+ line=line_number,
50
+ text=_cap_text(line.rstrip("\n")),
51
+ )
52
+ )
53
+ return ToolObservation(hits=hits, truncated=truncated)
54
+
55
+
56
+ def _iter_text_lines(path: Path) -> Iterator[tuple[int, str]]:
57
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
58
+ for line_number, line in enumerate(handle, start=1):
59
+ if "\x00" in line:
60
+ continue
61
+ yield line_number, line
62
+
63
+
64
+ def _cap_text(text: str) -> str:
65
+ if len(text) <= MAX_HIT_TEXT:
66
+ return text
67
+ return f"{text[: MAX_HIT_TEXT - 1]}..."
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from flash_context.tools.safety import PathSafety
6
+ from flash_context.types import ExplorerError, ToolObservation
7
+
8
+
9
+ def read_file(
10
+ *,
11
+ repo_root: Path,
12
+ path: str,
13
+ max_bytes: int,
14
+ start_line: int | None = None,
15
+ end_line: int | None = None,
16
+ ignore: list[str] | None = None,
17
+ ) -> ToolObservation:
18
+ if not path:
19
+ raise ExplorerError("INVALID_TOOL_ARGUMENTS", "path is required")
20
+ if start_line is not None and start_line <= 0:
21
+ raise ExplorerError("INVALID_TOOL_ARGUMENTS", "start_line must be positive")
22
+ if end_line is not None and end_line <= 0:
23
+ raise ExplorerError("INVALID_TOOL_ARGUMENTS", "end_line must be positive")
24
+ if start_line is not None and end_line is not None and end_line < start_line:
25
+ raise ExplorerError(
26
+ "INVALID_TOOL_ARGUMENTS",
27
+ "end_line must be greater than or equal to start_line",
28
+ )
29
+ if max_bytes <= 0:
30
+ raise ExplorerError("CONFIG_INVALID", "max_bytes must be positive")
31
+
32
+ safety = PathSafety(repo_root, tuple(ignore or ()))
33
+ resolved = safety.resolve_existing_file(path)
34
+ rel_path = safety.relative_or_raw(resolved)
35
+ first_line = start_line or 1
36
+ last_line = end_line
37
+ content_bytes = bytearray()
38
+ actual_last: int | None = None
39
+ total_lines = 0
40
+ truncated = False
41
+
42
+ with resolved.open("rb") as handle:
43
+ for line_number, raw_line in enumerate(handle, start=1):
44
+ total_lines = line_number
45
+ if line_number < first_line:
46
+ continue
47
+ if last_line is not None and line_number > last_line:
48
+ break
49
+ if len(content_bytes) + len(raw_line) > max_bytes:
50
+ remaining = max_bytes - len(content_bytes)
51
+ if remaining > 0:
52
+ content_bytes.extend(raw_line[:remaining])
53
+ actual_last = line_number
54
+ truncated = True
55
+ break
56
+ content_bytes.extend(raw_line)
57
+ actual_last = line_number
58
+
59
+ if actual_last is None:
60
+ raise ExplorerError(
61
+ "INVALID_TOOL_ARGUMENTS",
62
+ "Requested start_line is beyond the end of file",
63
+ details={
64
+ "path": rel_path,
65
+ "start_line": first_line,
66
+ "total_lines": total_lines,
67
+ },
68
+ )
69
+
70
+ content = content_bytes.decode("utf-8", errors="replace")
71
+ line_range = f"{first_line}-{actual_last}"
72
+ return ToolObservation(
73
+ path=rel_path,
74
+ line_range=line_range,
75
+ content=content,
76
+ truncated=truncated,
77
+ )
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from flash_context.types import ExplorerError
8
+
9
+ DEFAULT_DENY_PATTERNS = (
10
+ ".git/**",
11
+ ".env",
12
+ ".env.*",
13
+ "*.pem",
14
+ "*.key",
15
+ "id_rsa",
16
+ "id_ed25519",
17
+ "**/secrets/**",
18
+ "**/.aws/**",
19
+ "**/.ssh/**",
20
+ ".venv/**",
21
+ "venv/**",
22
+ "node_modules/**",
23
+ "dist/**",
24
+ "build/**",
25
+ )
26
+
27
+ DENY_DIR_NAMES = {
28
+ ".git",
29
+ ".aws",
30
+ ".ssh",
31
+ ".venv",
32
+ "venv",
33
+ "node_modules",
34
+ "dist",
35
+ "build",
36
+ "secrets",
37
+ }
38
+
39
+ DENY_FILE_NAMES = {"id_rsa", "id_ed25519", ".env"}
40
+ DENY_FILE_SUFFIXES = (".pem", ".key")
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class PathSafety:
45
+ repo_root: Path
46
+ ignore: tuple[str, ...] = field(default_factory=tuple)
47
+
48
+ def __post_init__(self) -> None:
49
+ root = self.repo_root.resolve()
50
+ object.__setattr__(self, "repo_root", root)
51
+ if not root.exists() or not root.is_dir():
52
+ raise ExplorerError(
53
+ "REPO_NOT_FOUND",
54
+ "Repository root must exist and be a directory",
55
+ details={"repo_root": str(root)},
56
+ )
57
+
58
+ def resolve_existing_file(self, requested: str | Path) -> Path:
59
+ raw = Path(requested)
60
+ if ".." in raw.parts:
61
+ raise ExplorerError(
62
+ "PATH_OUTSIDE_ROOT",
63
+ "Requested path is outside the repository root",
64
+ details={"path": str(requested)},
65
+ )
66
+ candidate = raw if raw.is_absolute() else self.repo_root / raw
67
+ resolved = candidate.resolve(strict=False)
68
+ if not _is_relative_to(resolved, self.repo_root):
69
+ raise ExplorerError(
70
+ "PATH_OUTSIDE_ROOT",
71
+ "Requested path is outside the repository root",
72
+ details={"path": str(requested)},
73
+ )
74
+ if not resolved.exists():
75
+ raise ExplorerError(
76
+ "PATH_NOT_FOUND",
77
+ "Requested path does not exist",
78
+ details={"path": self.relative_or_raw(resolved)},
79
+ )
80
+ if not resolved.is_file():
81
+ raise ExplorerError(
82
+ "PATH_DENIED",
83
+ "Requested path is not a regular file",
84
+ details={"path": self.relative_or_raw(resolved)},
85
+ )
86
+ self.ensure_allowed(resolved)
87
+ return resolved
88
+
89
+ def ensure_allowed(self, path: Path) -> None:
90
+ rel = self.relative_or_raw(path)
91
+ parts = rel.split("/")
92
+ if any(part in DENY_DIR_NAMES for part in parts[:-1]):
93
+ raise ExplorerError(
94
+ "PATH_DENIED",
95
+ "Requested path is denied by repository safety policy",
96
+ details={"path": rel},
97
+ )
98
+ name = parts[-1] if parts else rel
99
+ if (
100
+ name in DENY_FILE_NAMES
101
+ or name.startswith(".env.")
102
+ or name.endswith(DENY_FILE_SUFFIXES)
103
+ or _matches_patterns(rel, DEFAULT_DENY_PATTERNS)
104
+ or _matches_patterns(rel, self.ignore)
105
+ ):
106
+ raise ExplorerError(
107
+ "PATH_DENIED",
108
+ "Requested path is denied by repository safety policy",
109
+ details={"path": rel},
110
+ )
111
+
112
+ def relative_or_raw(self, path: Path) -> str:
113
+ try:
114
+ return path.resolve(strict=False).relative_to(self.repo_root).as_posix()
115
+ except ValueError:
116
+ return path.as_posix()
117
+
118
+ def validate_glob_pattern(self, pattern: str) -> None:
119
+ raw = Path(pattern)
120
+ if raw.is_absolute() or ".." in raw.parts:
121
+ raise ExplorerError(
122
+ "PATH_OUTSIDE_ROOT",
123
+ "Glob pattern must stay inside the repository root",
124
+ details={"pattern": pattern},
125
+ )
126
+
127
+ def iter_safe_files(self, pattern: str = "**/*") -> list[Path]:
128
+ self.validate_glob_pattern(pattern)
129
+ files: list[Path] = []
130
+ for candidate in sorted(self.repo_root.glob(pattern)):
131
+ try:
132
+ resolved = candidate.resolve(strict=False)
133
+ if not _is_relative_to(resolved, self.repo_root):
134
+ continue
135
+ if not resolved.is_file():
136
+ continue
137
+ self.ensure_allowed(resolved)
138
+ except ExplorerError:
139
+ continue
140
+ files.append(resolved)
141
+ return files
142
+
143
+
144
+ def _matches_patterns(rel_path: str, patterns: tuple[str, ...]) -> bool:
145
+ return any(
146
+ _matches_pattern(rel_path, pattern.strip())
147
+ for pattern in patterns
148
+ if pattern.strip()
149
+ )
150
+
151
+
152
+ def _matches_pattern(rel_path: str, pattern: str) -> bool:
153
+ normalized = pattern.removeprefix("./")
154
+ if normalized.endswith("/**"):
155
+ prefix = normalized[:-3].rstrip("/")
156
+ return rel_path == prefix or rel_path.startswith(f"{prefix}/")
157
+ if "/" not in normalized:
158
+ parts = rel_path.split("/")
159
+ return any(fnmatch.fnmatchcase(part, normalized) for part in parts)
160
+ return fnmatch.fnmatchcase(rel_path, normalized)
161
+
162
+
163
+ def _is_relative_to(path: Path, root: Path) -> bool:
164
+ try:
165
+ path.relative_to(root)
166
+ except ValueError:
167
+ return False
168
+ return True
flash_context/types.py ADDED
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+
7
+ ErrorCode = Literal[
8
+ "CONFIG_MISSING_ENDPOINT",
9
+ "CONFIG_INVALID",
10
+ "REPO_NOT_FOUND",
11
+ "QUERY_EMPTY",
12
+ "PATH_OUTSIDE_ROOT",
13
+ "PATH_DENIED",
14
+ "PATH_NOT_FOUND",
15
+ "TOOL_OUTPUT_TRUNCATED",
16
+ "ENDPOINT_UNREACHABLE",
17
+ "ENDPOINT_TIMEOUT",
18
+ "ENDPOINT_BAD_RESPONSE",
19
+ "UNSUPPORTED_TOOL_CALL",
20
+ "MAX_TURNS_EXCEEDED",
21
+ "INVALID_TOOL_ARGUMENTS",
22
+ "INVALID_GREP_PATTERN",
23
+ ]
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ExplorerError(Exception):
28
+ code: ErrorCode
29
+ message: str
30
+ retryable: bool = False
31
+ details: dict[str, object] = field(default_factory=dict)
32
+
33
+ def __post_init__(self) -> None:
34
+ super().__init__(f"{self.code}: {self.message}")
35
+
36
+ def to_dict(self) -> dict[str, object]:
37
+ return {
38
+ "code": self.code,
39
+ "message": self.message,
40
+ "retryable": self.retryable,
41
+ "details": self.details,
42
+ }
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Citation:
47
+ path: str
48
+ start_line: int | None = None
49
+ end_line: int | None = None
50
+ reason: str | None = None
51
+
52
+ def to_dict(self) -> dict[str, object]:
53
+ return {
54
+ "path": self.path,
55
+ "start_line": self.start_line,
56
+ "end_line": self.end_line,
57
+ "reason": self.reason,
58
+ }
59
+
60
+ def label(self) -> str:
61
+ if self.start_line is None:
62
+ return self.path
63
+ if self.end_line is None or self.end_line == self.start_line:
64
+ return f"{self.path}:{self.start_line}"
65
+ return f"{self.path}:{self.start_line}-{self.end_line}"
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class RawLocation:
70
+ path: str
71
+ start_line: int
72
+ end_line: int
73
+ text: str
74
+ truncated: bool = False
75
+
76
+ def to_dict(self, *, include_text: bool = True) -> dict[str, object]:
77
+ data: dict[str, object] = {
78
+ "path": self.path,
79
+ "start_line": self.start_line,
80
+ "end_line": self.end_line,
81
+ "truncated": self.truncated,
82
+ }
83
+ if include_text:
84
+ data["text"] = self.text
85
+ else:
86
+ data["text_length"] = len(self.text)
87
+ return data
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class SearchHit:
92
+ path: str
93
+ line: int
94
+ text: str
95
+
96
+ def to_dict(self) -> dict[str, object]:
97
+ return {"path": self.path, "line": self.line, "text": self.text}
98
+
99
+
100
+ @dataclass(slots=True)
101
+ class ToolCall:
102
+ id: str
103
+ name: Literal["read_file", "repo_glob", "repo_grep"]
104
+ arguments: dict[str, object]
105
+
106
+
107
+ @dataclass(slots=True)
108
+ class ToolObservation:
109
+ tool_call_id: str | None = None
110
+ ok: bool = True
111
+ path: str | None = None
112
+ line_range: str | None = None
113
+ content: str | None = None
114
+ matches: list[str] = field(default_factory=list)
115
+ hits: list[SearchHit] = field(default_factory=list)
116
+ truncated: bool = False
117
+ error: ExplorerError | None = None
118
+
119
+ def to_dict(self, *, include_content: bool = True) -> dict[str, object]:
120
+ data: dict[str, object] = {
121
+ "ok": self.ok,
122
+ "truncated": self.truncated,
123
+ }
124
+ if self.tool_call_id is not None:
125
+ data["tool_call_id"] = self.tool_call_id
126
+ if self.path is not None:
127
+ data["path"] = self.path
128
+ if self.line_range is not None:
129
+ data["line_range"] = self.line_range
130
+ if include_content and self.content is not None:
131
+ data["content"] = self.content
132
+ elif self.content is not None:
133
+ data["content_length"] = len(self.content)
134
+ if self.matches:
135
+ data["matches"] = self.matches
136
+ if self.hits:
137
+ data["hits"] = [hit.to_dict() for hit in self.hits]
138
+ if self.error is not None:
139
+ data["error"] = self.error.to_dict()
140
+ return data
141
+
142
+ @classmethod
143
+ def from_error(
144
+ cls, error: ExplorerError, *, tool_call_id: str | None = None
145
+ ) -> ToolObservation:
146
+ return cls(tool_call_id=tool_call_id, ok=False, error=error)
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class ExploreRequest:
151
+ query: str
152
+ repo_root: Path
153
+ max_turns: int = 6
154
+ citation: bool = True
155
+ format: Literal["text", "json"] = "text"
156
+
157
+ def validate(self) -> None:
158
+ if not self.query.strip():
159
+ raise ExplorerError("QUERY_EMPTY", "Query must not be blank")
160
+ if self.max_turns <= 0:
161
+ raise ExplorerError(
162
+ "CONFIG_INVALID",
163
+ "max_turns must be positive",
164
+ details={"max_turns": self.max_turns},
165
+ )
166
+ if not self.repo_root.exists() or not self.repo_root.is_dir():
167
+ raise ExplorerError(
168
+ "REPO_NOT_FOUND",
169
+ "Repository root must exist and be a directory",
170
+ details={"repo_root": str(self.repo_root)},
171
+ )
172
+
173
+ def to_dict(self) -> dict[str, object]:
174
+ return {
175
+ "query": self.query,
176
+ "repo_root": str(self.repo_root),
177
+ "max_turns": self.max_turns,
178
+ "citation": self.citation,
179
+ "format": self.format,
180
+ }
181
+
182
+
183
+ @dataclass(slots=True)
184
+ class ExploreResult:
185
+ query: str
186
+ repo_root: str
187
+ answer: str
188
+ citations: list[Citation]
189
+ turns_used: int
190
+ raw_locations: list[RawLocation] = field(default_factory=list)
191
+ truncated: bool = False
192
+ warnings: list[str] = field(default_factory=list)
193
+
194
+ def to_dict(self, *, include_raw_location_text: bool = True) -> dict[str, object]:
195
+ return {
196
+ "query": self.query,
197
+ "repo_root": self.repo_root,
198
+ "answer": self.answer,
199
+ "citations": [citation.to_dict() for citation in self.citations],
200
+ "raw_locations": [
201
+ raw_location.to_dict(include_text=include_raw_location_text)
202
+ for raw_location in self.raw_locations
203
+ ],
204
+ "turns_used": self.turns_used,
205
+ "truncated": self.truncated,
206
+ "warnings": self.warnings,
207
+ }
208
+
209
+
210
+ JsonObject = dict[str, Any]