flash-context 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flash_context-0.3.0/CHANGELOG.md +65 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/PKG-INFO +15 -2
- {flash_context-0.2.0 → flash_context-0.3.0}/README.md +14 -1
- {flash_context-0.2.0 → flash_context-0.3.0}/pyproject.toml +3 -2
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/agent.py +74 -52
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/cli.py +15 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/logging.py +13 -10
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/mcp_server.py +7 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/safety.py +25 -1
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/types.py +0 -2
- {flash_context-0.2.0 → flash_context-0.3.0}/.env.example +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/.gitignore +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/LICENSE +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/config.yaml.example +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/__init__.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/config.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/llm.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/__init__.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/glob.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/grep.py +0 -0
- {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/read.py +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
User-visible changes and migration instructions for each release.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.3.0] - 2026-11-01
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Resolve bare file-path queries locally with bounded previews and find unique
|
|
12
|
+
Python definitions even when many references precede them.
|
|
13
|
+
- Keep literal and scoped searches from scanning unrelated directory trees while
|
|
14
|
+
preserving safety checks and existing resource limits.
|
|
15
|
+
- Surface scan failures from the initial file sample and CLI result warnings.
|
|
16
|
+
- Describe the MCP exploration tool during discovery and use tool-specific
|
|
17
|
+
trajectory counters instead of misleading empty read-content counts.
|
|
18
|
+
|
|
19
|
+
### Migration
|
|
20
|
+
|
|
21
|
+
- Citation objects no longer include the unused `reason` field. Consumers should
|
|
22
|
+
read `path`, `start_line`, and `end_line` without expecting `reason: null`.
|
|
23
|
+
- The CLI now exits 6 for empty results after scan-budget exhaustion or a tool
|
|
24
|
+
processing timeout, retaining text/JSON on stdout and warnings on stderr.
|
|
25
|
+
Partial cited results and complete empty searches still exit 0.
|
|
26
|
+
- Trajectory tool entries now include only their relevant counter:
|
|
27
|
+
`content_length` (read), `hit_count` (grep), or `match_count` (glob).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Prompt the explorer to prefer implementation source for coding questions and
|
|
32
|
+
the smallest supporting citation ranges. Validation checks safe line coverage;
|
|
33
|
+
precision and relevance remain model-dependent.
|
|
34
|
+
- Publish GitHub releases automatically after PyPI installation verification,
|
|
35
|
+
using this changelog and the same distribution artifacts uploaded to PyPI.
|
|
36
|
+
- Refresh package-index metadata during release installation retries.
|
|
37
|
+
|
|
38
|
+
## [0.2.0] - 2026-11-01
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Enforce deny/ignore rules on requested and resolved paths, including environment
|
|
43
|
+
files, private-key names, and runtime histories. Prune denied directories.
|
|
44
|
+
- Bound scan bytes, directory entries, processing time, regex execution, tool
|
|
45
|
+
batches, and model observations; report incomplete coverage explicitly.
|
|
46
|
+
- Improve exact-lookup routing, read ranges, truncation metadata, and structured
|
|
47
|
+
endpoint/filesystem failures across CLI and MCP.
|
|
48
|
+
- Keep optional trajectory logs metadata-only and outside the inspected root.
|
|
49
|
+
|
|
50
|
+
### Migration
|
|
51
|
+
|
|
52
|
+
- Move any trajectory directory inside the inspected repository outside it, or
|
|
53
|
+
disable logging with `FLASH_CONTEXT_TRAJ_DIR=`. Empty/null settings disable
|
|
54
|
+
inherited logging. Existing logs remain untouched.
|
|
55
|
+
|
|
56
|
+
## [0.1.0] - 2026-11-01
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- Initial Python CLI and optional MCP adapter sharing a read-only explorer core.
|
|
61
|
+
- OpenAI-compatible tool-call loop with bounded Read/Glob/Grep repository tools,
|
|
62
|
+
root-scoped path validation, validated citations, and raw source snippets.
|
|
63
|
+
- Exact path/symbol lookup shortcuts, concurrent local tools, and configurable
|
|
64
|
+
endpoint settings and limits.
|
|
65
|
+
- Wheel/source packaging and tag-driven TestPyPI/PyPI publication and smoke checks.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: flash-context
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Read-only context exploration and retrieval for AI agents, starting with codebases and repositories.
|
|
5
5
|
Project-URL: Repository, https://github.com/zyf0717/flash-context
|
|
6
6
|
Project-URL: Issues, https://github.com/zyf0717/flash-context/issues
|
|
@@ -39,10 +39,14 @@ Codebases and repositories are the current focus; the same tools explore
|
|
|
39
39
|
documentation, configuration, and other local text files. CLI and MCP share one
|
|
40
40
|
exploration core with read-only tools, path safety, and resource limits.
|
|
41
41
|
Scans and regex execution are bounded; partial results report truncation.
|
|
42
|
+
Citation validation checks safe paths and existing line coverage, not semantic
|
|
43
|
+
relevance or minimal ranges. Citation precision depends on the explorer model.
|
|
42
44
|
Optional metadata-only trajectory logs must stay outside the inspected repository.
|
|
43
45
|
|
|
44
46
|
Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
|
|
45
|
-
on
|
|
47
|
+
on separating repository exploration from task solving, using specialized models
|
|
48
|
+
to return focused file-line evidence while reducing the coding agent's context
|
|
49
|
+
and token overhead.
|
|
46
50
|
|
|
47
51
|
## Quickstart
|
|
48
52
|
|
|
@@ -73,6 +77,14 @@ lookups can resolve locally without an endpoint. See the
|
|
|
73
77
|
[CLI contract](specs/001-flash-context-explorer/contracts/cli.md) for options,
|
|
74
78
|
output schemas, and exit codes.
|
|
75
79
|
|
|
80
|
+
For example, `--query 'httpx/_client.py'` returns a bounded file preview, while
|
|
81
|
+
`--query 'BaseTransport'` returns its definition if unique within a complete
|
|
82
|
+
Python source/stub scan. Local successes report `turns_used: 0` in JSON.
|
|
83
|
+
|
|
84
|
+
Warnings go to stderr in both output formats. An empty result after scan-budget
|
|
85
|
+
exhaustion exits **6** while preserving stdout; a complete empty search exits
|
|
86
|
+
**0**. For large repositories, see [scan-budget tuning](docs/configuration.md#large-repositories).
|
|
87
|
+
|
|
76
88
|
## MCP
|
|
77
89
|
|
|
78
90
|
```bash
|
|
@@ -87,6 +99,7 @@ See [MCP integration](docs/mcp.md) for editor configuration and source launches.
|
|
|
87
99
|
- [Configuration](docs/configuration.md): endpoint settings, precedence, and limits.
|
|
88
100
|
- [Architecture and scope](docs/architecture.md): design, status, specs, and references.
|
|
89
101
|
- [Contributing](CONTRIBUTING.md): development setup, tests, and pull requests.
|
|
102
|
+
- [Changelog](CHANGELOG.md): release history and migration instructions.
|
|
90
103
|
- [Releasing](docs/releasing.md): package checks and tag-driven TestPyPI/PyPI publishing.
|
|
91
104
|
|
|
92
105
|
## License
|
|
@@ -16,10 +16,14 @@ Codebases and repositories are the current focus; the same tools explore
|
|
|
16
16
|
documentation, configuration, and other local text files. CLI and MCP share one
|
|
17
17
|
exploration core with read-only tools, path safety, and resource limits.
|
|
18
18
|
Scans and regex execution are bounded; partial results report truncation.
|
|
19
|
+
Citation validation checks safe paths and existing line coverage, not semantic
|
|
20
|
+
relevance or minimal ranges. Citation precision depends on the explorer model.
|
|
19
21
|
Optional metadata-only trajectory logs must stay outside the inspected repository.
|
|
20
22
|
|
|
21
23
|
Inspired by [FastContext](https://arxiv.org/abs/2606.14066), a now-withdrawn paper
|
|
22
|
-
on
|
|
24
|
+
on separating repository exploration from task solving, using specialized models
|
|
25
|
+
to return focused file-line evidence while reducing the coding agent's context
|
|
26
|
+
and token overhead.
|
|
23
27
|
|
|
24
28
|
## Quickstart
|
|
25
29
|
|
|
@@ -50,6 +54,14 @@ lookups can resolve locally without an endpoint. See the
|
|
|
50
54
|
[CLI contract](specs/001-flash-context-explorer/contracts/cli.md) for options,
|
|
51
55
|
output schemas, and exit codes.
|
|
52
56
|
|
|
57
|
+
For example, `--query 'httpx/_client.py'` returns a bounded file preview, while
|
|
58
|
+
`--query 'BaseTransport'` returns its definition if unique within a complete
|
|
59
|
+
Python source/stub scan. Local successes report `turns_used: 0` in JSON.
|
|
60
|
+
|
|
61
|
+
Warnings go to stderr in both output formats. An empty result after scan-budget
|
|
62
|
+
exhaustion exits **6** while preserving stdout; a complete empty search exits
|
|
63
|
+
**0**. For large repositories, see [scan-budget tuning](docs/configuration.md#large-repositories).
|
|
64
|
+
|
|
53
65
|
## MCP
|
|
54
66
|
|
|
55
67
|
```bash
|
|
@@ -64,6 +76,7 @@ See [MCP integration](docs/mcp.md) for editor configuration and source launches.
|
|
|
64
76
|
- [Configuration](docs/configuration.md): endpoint settings, precedence, and limits.
|
|
65
77
|
- [Architecture and scope](docs/architecture.md): design, status, specs, and references.
|
|
66
78
|
- [Contributing](CONTRIBUTING.md): development setup, tests, and pull requests.
|
|
79
|
+
- [Changelog](CHANGELOG.md): release history and migration instructions.
|
|
67
80
|
- [Releasing](docs/releasing.md): package checks and tag-driven TestPyPI/PyPI publishing.
|
|
68
81
|
|
|
69
82
|
## License
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "flash-context"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Read-only context exploration and retrieval for AI agents, starting with codebases and repositories."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -52,6 +52,7 @@ include = [
|
|
|
52
52
|
"/src/flash_context",
|
|
53
53
|
"/pyproject.toml",
|
|
54
54
|
"/README.md",
|
|
55
|
+
"/CHANGELOG.md",
|
|
55
56
|
"/LICENSE",
|
|
56
57
|
"/config.yaml.example",
|
|
57
58
|
"/.env.example",
|
|
@@ -74,5 +75,5 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
|
74
75
|
[tool.mypy]
|
|
75
76
|
python_version = "3.13"
|
|
76
77
|
strict = true
|
|
77
|
-
files = ["src/flash_context", "tests/unit", "tests/contract"]
|
|
78
|
+
files = ["src/flash_context", "tests/unit", "tests/contract", "scripts"]
|
|
78
79
|
exclude = ["tests/fixtures"]
|
|
@@ -99,8 +99,9 @@ def explore(
|
|
|
99
99
|
)
|
|
100
100
|
messages = _initial_messages(request, file_sample, settings)
|
|
101
101
|
tools = tool_schemas()
|
|
102
|
-
|
|
103
|
-
warnings
|
|
102
|
+
# A capped file sample is expected; actual scan failures must reach callers.
|
|
103
|
+
warnings = list(file_sample.warnings)
|
|
104
|
+
truncated = bool(warnings)
|
|
104
105
|
last_content = ""
|
|
105
106
|
evidence = EvidenceState()
|
|
106
107
|
|
|
@@ -436,6 +437,7 @@ def _finalize_result(
|
|
|
436
437
|
truncated
|
|
437
438
|
or any(x.truncated for x in raw_locations)
|
|
438
439
|
or "citation verification limit reached" in warnings
|
|
440
|
+
or "read scan budget exhausted" in warnings
|
|
439
441
|
),
|
|
440
442
|
warnings=warnings,
|
|
441
443
|
)
|
|
@@ -448,33 +450,30 @@ def _citation_text(citations: list[Citation]) -> str:
|
|
|
448
450
|
|
|
449
451
|
|
|
450
452
|
def _merge_adjacent_citations(citations: list[Citation]) -> list[Citation]:
|
|
451
|
-
grouped: dict[str, list[
|
|
453
|
+
grouped: dict[str, list[Citation]] = {}
|
|
452
454
|
path_order: list[str] = []
|
|
453
455
|
non_mergeable: list[Citation] = []
|
|
454
|
-
for
|
|
456
|
+
for citation in citations:
|
|
455
457
|
if citation.start_line is None or citation.end_line is None:
|
|
456
458
|
non_mergeable.append(citation)
|
|
457
459
|
continue
|
|
458
460
|
if citation.path not in grouped:
|
|
459
461
|
grouped[citation.path] = []
|
|
460
462
|
path_order.append(citation.path)
|
|
461
|
-
grouped[citation.path].append(
|
|
463
|
+
grouped[citation.path].append(citation)
|
|
462
464
|
|
|
463
465
|
merged: list[Citation] = []
|
|
464
466
|
for path in path_order:
|
|
465
467
|
ranges = sorted(
|
|
466
468
|
grouped[path],
|
|
467
469
|
key=lambda item: (
|
|
468
|
-
item
|
|
469
|
-
item
|
|
470
|
+
item.start_line or 0,
|
|
471
|
+
item.end_line or 0,
|
|
470
472
|
),
|
|
471
473
|
)
|
|
472
474
|
current_start: int | None = None
|
|
473
475
|
current_end: int | None = None
|
|
474
|
-
|
|
475
|
-
current_reason_index: int | None = None
|
|
476
|
-
|
|
477
|
-
for index, citation in ranges:
|
|
476
|
+
for citation in ranges:
|
|
478
477
|
start = citation.start_line
|
|
479
478
|
end = citation.end_line
|
|
480
479
|
if start is None or end is None:
|
|
@@ -482,27 +481,18 @@ def _merge_adjacent_citations(citations: list[Citation]) -> list[Citation]:
|
|
|
482
481
|
if current_start is None or current_end is None:
|
|
483
482
|
current_start = start
|
|
484
483
|
current_end = end
|
|
485
|
-
current_reason = citation.reason
|
|
486
|
-
current_reason_index = index if citation.reason else None
|
|
487
484
|
continue
|
|
488
485
|
|
|
489
486
|
if start <= current_end + 1:
|
|
490
487
|
current_end = max(current_end, end)
|
|
491
|
-
if citation.reason and (
|
|
492
|
-
current_reason_index is None or index < current_reason_index
|
|
493
|
-
):
|
|
494
|
-
current_reason = citation.reason
|
|
495
|
-
current_reason_index = index
|
|
496
488
|
continue
|
|
497
489
|
|
|
498
|
-
merged.append(Citation(path, current_start, current_end
|
|
490
|
+
merged.append(Citation(path, current_start, current_end))
|
|
499
491
|
current_start = start
|
|
500
492
|
current_end = end
|
|
501
|
-
current_reason = citation.reason
|
|
502
|
-
current_reason_index = index if citation.reason else None
|
|
503
493
|
|
|
504
494
|
if current_start is not None and current_end is not None:
|
|
505
|
-
merged.append(Citation(path, current_start, current_end
|
|
495
|
+
merged.append(Citation(path, current_start, current_end))
|
|
506
496
|
|
|
507
497
|
merged.extend(non_mergeable)
|
|
508
498
|
return merged
|
|
@@ -562,6 +552,33 @@ def _try_exact_fast_path(
|
|
|
562
552
|
repo_root: Path,
|
|
563
553
|
settings: Settings,
|
|
564
554
|
) -> ExploreResult | None:
|
|
555
|
+
bare_path = request.query.strip()
|
|
556
|
+
if len(bare_path) >= 2 and bare_path[0] in "`'\"" and bare_path[-1] == bare_path[0]:
|
|
557
|
+
bare_path = bare_path[1:-1]
|
|
558
|
+
if re.fullmatch(r"(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+", bare_path):
|
|
559
|
+
try:
|
|
560
|
+
observation = read_file(
|
|
561
|
+
repo_root=repo_root,
|
|
562
|
+
path=bare_path,
|
|
563
|
+
end_line=settings.max_read_lines,
|
|
564
|
+
max_bytes=settings.max_read_bytes,
|
|
565
|
+
preview=True,
|
|
566
|
+
ignore=settings.ignore,
|
|
567
|
+
limits=settings.limits,
|
|
568
|
+
)
|
|
569
|
+
except ExplorerError:
|
|
570
|
+
pass # Missing, empty, or unsafe paths retain normal model routing.
|
|
571
|
+
else:
|
|
572
|
+
return _finalize_result(
|
|
573
|
+
request=request,
|
|
574
|
+
repo_root=repo_root,
|
|
575
|
+
settings=settings,
|
|
576
|
+
answer="",
|
|
577
|
+
citations=_citations_from_reads([observation]),
|
|
578
|
+
turns_used=0,
|
|
579
|
+
truncated=observation.truncated,
|
|
580
|
+
warnings=list(observation.warnings),
|
|
581
|
+
)
|
|
565
582
|
if not _is_exact_lookup(request.query):
|
|
566
583
|
return None
|
|
567
584
|
evidence = EvidenceState()
|
|
@@ -748,7 +765,6 @@ def _exact_matches_in_file(
|
|
|
748
765
|
grep_result,
|
|
749
766
|
target=target,
|
|
750
767
|
allow_assignment=allow_assignment,
|
|
751
|
-
definitions_only=False,
|
|
752
768
|
)
|
|
753
769
|
return citations if 0 < len(citations) <= MAX_FINAL_CITATIONS else []
|
|
754
770
|
|
|
@@ -759,32 +775,28 @@ def _unique_definition_citation(
|
|
|
759
775
|
settings: Settings,
|
|
760
776
|
symbol: str,
|
|
761
777
|
) -> Citation | None:
|
|
762
|
-
|
|
778
|
+
definition_pattern = rf"^\s*(?:async\s+def|def|class)\s+{re.escape(symbol)}\b"
|
|
779
|
+
grep_result = repo_grep(
|
|
763
780
|
repo_root=repo_root,
|
|
764
|
-
|
|
765
|
-
target=symbol,
|
|
781
|
+
pattern=definition_pattern,
|
|
766
782
|
glob="**/*.py",
|
|
783
|
+
max_results=2,
|
|
784
|
+
ignore=settings.ignore,
|
|
785
|
+
limits=settings.limits,
|
|
767
786
|
)
|
|
768
|
-
pyi_result =
|
|
787
|
+
pyi_result = repo_grep(
|
|
769
788
|
repo_root=repo_root,
|
|
770
|
-
|
|
771
|
-
target=symbol,
|
|
789
|
+
pattern=definition_pattern,
|
|
772
790
|
glob="**/*.pyi",
|
|
791
|
+
max_results=2,
|
|
792
|
+
ignore=settings.ignore,
|
|
793
|
+
limits=settings.limits,
|
|
773
794
|
)
|
|
774
|
-
citations =
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
)
|
|
780
|
-
citations.extend(
|
|
781
|
-
_citations_for_target_hits(
|
|
782
|
-
pyi_result,
|
|
783
|
-
target=symbol,
|
|
784
|
-
allow_assignment=False,
|
|
785
|
-
definitions_only=True,
|
|
786
|
-
)
|
|
787
|
-
)
|
|
795
|
+
citations = [
|
|
796
|
+
Citation(hit.path, hit.line, hit.line)
|
|
797
|
+
for result in (grep_result, pyi_result)
|
|
798
|
+
for hit in result.hits
|
|
799
|
+
]
|
|
788
800
|
if grep_result.truncated or pyi_result.truncated or len(citations) > 1:
|
|
789
801
|
return None
|
|
790
802
|
return citations[0] if len(citations) == 1 else None
|
|
@@ -812,7 +824,6 @@ def _citations_for_target_hits(
|
|
|
812
824
|
*,
|
|
813
825
|
target: str,
|
|
814
826
|
allow_assignment: bool,
|
|
815
|
-
definitions_only: bool,
|
|
816
827
|
) -> list[Citation]:
|
|
817
828
|
citations: list[Citation] = []
|
|
818
829
|
for hit in observation.hits:
|
|
@@ -820,7 +831,6 @@ def _citations_for_target_hits(
|
|
|
820
831
|
hit.text,
|
|
821
832
|
target=target,
|
|
822
833
|
allow_assignment=allow_assignment,
|
|
823
|
-
definitions_only=definitions_only,
|
|
824
834
|
):
|
|
825
835
|
citations.append(Citation(hit.path, hit.line, hit.line))
|
|
826
836
|
return citations
|
|
@@ -831,14 +841,11 @@ def _line_matches_target(
|
|
|
831
841
|
*,
|
|
832
842
|
target: str,
|
|
833
843
|
allow_assignment: bool,
|
|
834
|
-
definitions_only: bool,
|
|
835
844
|
) -> bool:
|
|
836
845
|
escaped = re.escape(target)
|
|
837
846
|
definition_pattern = rf"^\s*(?:async\s+def|def|class)\s+{escaped}\b"
|
|
838
847
|
if re.search(definition_pattern, line):
|
|
839
848
|
return True
|
|
840
|
-
if definitions_only:
|
|
841
|
-
return False
|
|
842
849
|
if allow_assignment and re.search(rf"^\s*{escaped}\s*(?::|=)", line):
|
|
843
850
|
return True
|
|
844
851
|
return bool(re.search(rf"\b{escaped}\b", line))
|
|
@@ -862,6 +869,7 @@ def _validated_citations_from_content(
|
|
|
862
869
|
repo_root=repo_root,
|
|
863
870
|
settings=settings,
|
|
864
871
|
evidence=evidence,
|
|
872
|
+
warnings=warnings,
|
|
865
873
|
)
|
|
866
874
|
if not validated:
|
|
867
875
|
_append_warning_once(warnings, "invalid citation rejected")
|
|
@@ -874,6 +882,7 @@ def _validate_citations(
|
|
|
874
882
|
repo_root: Path,
|
|
875
883
|
settings: Settings,
|
|
876
884
|
evidence: EvidenceState,
|
|
885
|
+
warnings: list[str],
|
|
877
886
|
) -> list[Citation]:
|
|
878
887
|
validated: list[Citation] = []
|
|
879
888
|
for citation in citations[:16]:
|
|
@@ -888,6 +897,7 @@ def _validate_citations(
|
|
|
888
897
|
repo_root=repo_root,
|
|
889
898
|
settings=settings,
|
|
890
899
|
evidence=evidence,
|
|
900
|
+
warnings=warnings,
|
|
891
901
|
)
|
|
892
902
|
if verified is not None:
|
|
893
903
|
validated.append(verified)
|
|
@@ -909,7 +919,6 @@ def _normalize_citation(citation: Citation) -> Citation | None:
|
|
|
909
919
|
path=citation.path,
|
|
910
920
|
start_line=citation.start_line,
|
|
911
921
|
end_line=citation.end_line,
|
|
912
|
-
reason=citation.reason,
|
|
913
922
|
)
|
|
914
923
|
|
|
915
924
|
|
|
@@ -961,6 +970,7 @@ def _verify_citation_by_read(
|
|
|
961
970
|
repo_root: Path,
|
|
962
971
|
settings: Settings,
|
|
963
972
|
evidence: EvidenceState,
|
|
973
|
+
warnings: list[str],
|
|
964
974
|
) -> Citation | None:
|
|
965
975
|
if citation.start_line is None or citation.end_line is None:
|
|
966
976
|
return None
|
|
@@ -974,13 +984,16 @@ def _verify_citation_by_read(
|
|
|
974
984
|
ignore=settings.ignore,
|
|
975
985
|
limits=settings.limits,
|
|
976
986
|
)
|
|
977
|
-
except ExplorerError:
|
|
987
|
+
except ExplorerError as exc:
|
|
988
|
+
if exc.code == "TOOL_LIMIT_EXCEEDED":
|
|
989
|
+
_append_warning_once(warnings, "read scan budget exhausted")
|
|
978
990
|
return None
|
|
991
|
+
for warning in observation.warnings:
|
|
992
|
+
_append_warning_once(warnings, warning)
|
|
979
993
|
verified = Citation(
|
|
980
994
|
path=observation.path or citation.path,
|
|
981
995
|
start_line=citation.start_line,
|
|
982
996
|
end_line=citation.end_line,
|
|
983
|
-
reason=citation.reason,
|
|
984
997
|
)
|
|
985
998
|
if verified.path != citation.path:
|
|
986
999
|
return None
|
|
@@ -1232,6 +1245,11 @@ def _initial_messages(
|
|
|
1232
1245
|
"tool calls in the same turn. "
|
|
1233
1246
|
"For exact symbol, function, or class names, use repo_grep "
|
|
1234
1247
|
"before reading broad file ranges. "
|
|
1248
|
+
"For implementation questions, prefer implementation source over "
|
|
1249
|
+
"documentation or Markdown; use documentation when requested or "
|
|
1250
|
+
"needed to support the answer. Cite the smallest line ranges "
|
|
1251
|
+
"that support the answer, rather than whole files or classes "
|
|
1252
|
+
"when a smaller function or region suffices. "
|
|
1235
1253
|
f"Use read_file line ranges of at most {settings.max_read_lines} "
|
|
1236
1254
|
"lines unless no narrower range is known. "
|
|
1237
1255
|
f"{output_contract}"
|
|
@@ -1464,7 +1482,11 @@ def _execute_tool_call(
|
|
|
1464
1482
|
limits=settings.limits,
|
|
1465
1483
|
)
|
|
1466
1484
|
except ExplorerError as exc:
|
|
1467
|
-
|
|
1485
|
+
observation = ToolObservation.from_error(exc, tool_call_id=tool_call.id)
|
|
1486
|
+
if exc.code == "TOOL_LIMIT_EXCEEDED":
|
|
1487
|
+
observation.truncated = True
|
|
1488
|
+
observation.warnings.append("read scan budget exhausted")
|
|
1489
|
+
return observation
|
|
1468
1490
|
raise AssertionError(f"unhandled tool: {tool_call.name}")
|
|
1469
1491
|
|
|
1470
1492
|
|
|
@@ -11,6 +11,15 @@ from flash_context.config import SettingsOverrides, load_settings
|
|
|
11
11
|
from flash_context.mcp_server import run_mcp
|
|
12
12
|
from flash_context.types import ExploreRequest, ExplorerError
|
|
13
13
|
|
|
14
|
+
_SCAN_LIMIT_WARNINGS = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"glob scan budget exhausted",
|
|
17
|
+
"grep scan budget exhausted",
|
|
18
|
+
"read scan budget exhausted",
|
|
19
|
+
"grep match timed out",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
14
23
|
|
|
15
24
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
16
25
|
parser = _build_parser()
|
|
@@ -60,6 +69,12 @@ def _run_explore(args: argparse.Namespace) -> int:
|
|
|
60
69
|
print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
|
|
61
70
|
else:
|
|
62
71
|
print(format_text_result(result, citation=args.citation))
|
|
72
|
+
for warning in result.warnings:
|
|
73
|
+
print(f"WARNING: {warning}", file=sys.stderr)
|
|
74
|
+
if result.truncated and not result.warnings:
|
|
75
|
+
print("WARNING: Result truncated", file=sys.stderr)
|
|
76
|
+
if not result.citations and _SCAN_LIMIT_WARNINGS.intersection(result.warnings):
|
|
77
|
+
return 6
|
|
63
78
|
return 0
|
|
64
79
|
|
|
65
80
|
|
|
@@ -50,16 +50,19 @@ class TrajectoryRecorder:
|
|
|
50
50
|
self, turn: int, tool_name: str, observation: ToolObservation
|
|
51
51
|
) -> None:
|
|
52
52
|
if self._traj_dir is not None:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
metadata: dict[str, object] = {
|
|
54
|
+
"turn": turn,
|
|
55
|
+
"tool": tool_name,
|
|
56
|
+
"ok": observation.ok,
|
|
57
|
+
"truncated": observation.truncated,
|
|
58
|
+
}
|
|
59
|
+
if tool_name == "read_file":
|
|
60
|
+
metadata["content_length"] = len(observation.content or "")
|
|
61
|
+
elif tool_name == "repo_grep":
|
|
62
|
+
metadata["hit_count"] = len(observation.hits)
|
|
63
|
+
elif tool_name == "repo_glob":
|
|
64
|
+
metadata["match_count"] = len(observation.matches)
|
|
65
|
+
self._turns.append(metadata)
|
|
63
66
|
|
|
64
67
|
def finish(
|
|
65
68
|
self, *, result: ExploreResult | None = None, error: ExplorerError | None = None
|
|
@@ -60,6 +60,13 @@ def create_server() -> Any:
|
|
|
60
60
|
max_turns: int | None = None,
|
|
61
61
|
citation: bool | None = None,
|
|
62
62
|
) -> dict[str, object]:
|
|
63
|
+
"""Find code or local text relevant to a focused repository question.
|
|
64
|
+
|
|
65
|
+
Explore read-only when implementation locations or supporting evidence
|
|
66
|
+
are unknown. Return validated file-line citations and bounded source
|
|
67
|
+
snippets, with warnings for incomplete exploration. repo_root defaults
|
|
68
|
+
to the server's working directory.
|
|
69
|
+
"""
|
|
63
70
|
return explore_repository_handler(
|
|
64
71
|
query=query,
|
|
65
72
|
repo_root=repo_root,
|
|
@@ -173,7 +173,31 @@ class PathSafety:
|
|
|
173
173
|
except OSError:
|
|
174
174
|
budget.warnings.append("search skipped an unreadable directory")
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
directory = self.repo_root
|
|
177
|
+
parts = Path(pattern).parts
|
|
178
|
+
try:
|
|
179
|
+
for index, part in enumerate(parts):
|
|
180
|
+
if any(char in part for char in "*?["):
|
|
181
|
+
break
|
|
182
|
+
candidate = directory / part
|
|
183
|
+
budget.consume(entries=1)
|
|
184
|
+
self.ensure_allowed(candidate)
|
|
185
|
+
if index == len(parts) - 1:
|
|
186
|
+
if candidate.exists() and not candidate.is_dir():
|
|
187
|
+
yield self.resolve_existing_file(candidate)
|
|
188
|
+
return
|
|
189
|
+
# Match the walker's policy: never traverse directory symlinks.
|
|
190
|
+
if candidate.is_symlink() or not candidate.is_dir():
|
|
191
|
+
return
|
|
192
|
+
directory = candidate
|
|
193
|
+
yield from walk(directory)
|
|
194
|
+
except ExplorerError as exc:
|
|
195
|
+
if exc.code == "TOOL_LIMIT_EXCEEDED":
|
|
196
|
+
raise
|
|
197
|
+
if exc.code not in {"PATH_DENIED", "PATH_OUTSIDE_ROOT"}:
|
|
198
|
+
budget.warnings.append("search skipped an unreadable entry")
|
|
199
|
+
except OSError:
|
|
200
|
+
budget.warnings.append("search skipped an unreadable directory")
|
|
177
201
|
|
|
178
202
|
|
|
179
203
|
def _matches_patterns(rel_path: str, patterns: tuple[str, ...]) -> bool:
|
|
@@ -49,14 +49,12 @@ class Citation:
|
|
|
49
49
|
path: str
|
|
50
50
|
start_line: int | None = None
|
|
51
51
|
end_line: int | None = None
|
|
52
|
-
reason: str | None = None
|
|
53
52
|
|
|
54
53
|
def to_dict(self) -> dict[str, object]:
|
|
55
54
|
return {
|
|
56
55
|
"path": self.path,
|
|
57
56
|
"start_line": self.start_line,
|
|
58
57
|
"end_line": self.end_line,
|
|
59
|
-
"reason": self.reason,
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
def label(self) -> str:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|