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.
Files changed (21) hide show
  1. flash_context-0.3.0/CHANGELOG.md +65 -0
  2. {flash_context-0.2.0 → flash_context-0.3.0}/PKG-INFO +15 -2
  3. {flash_context-0.2.0 → flash_context-0.3.0}/README.md +14 -1
  4. {flash_context-0.2.0 → flash_context-0.3.0}/pyproject.toml +3 -2
  5. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/agent.py +74 -52
  6. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/cli.py +15 -0
  7. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/logging.py +13 -10
  8. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/mcp_server.py +7 -0
  9. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/safety.py +25 -1
  10. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/types.py +0 -2
  11. {flash_context-0.2.0 → flash_context-0.3.0}/.env.example +0 -0
  12. {flash_context-0.2.0 → flash_context-0.3.0}/.gitignore +0 -0
  13. {flash_context-0.2.0 → flash_context-0.3.0}/LICENSE +0 -0
  14. {flash_context-0.2.0 → flash_context-0.3.0}/config.yaml.example +0 -0
  15. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/__init__.py +0 -0
  16. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/config.py +0 -0
  17. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/llm.py +0 -0
  18. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/__init__.py +0 -0
  19. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/glob.py +0 -0
  20. {flash_context-0.2.0 → flash_context-0.3.0}/src/flash_context/tools/grep.py +0 -0
  21. {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.2.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 delegating repository exploration to a dedicated model.
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 delegating repository exploration to a dedicated model.
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.2.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
- truncated = False
103
- warnings: list[str] = []
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[tuple[int, Citation]]] = {}
453
+ grouped: dict[str, list[Citation]] = {}
452
454
  path_order: list[str] = []
453
455
  non_mergeable: list[Citation] = []
454
- for index, citation in enumerate(citations):
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((index, citation))
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[1].start_line or 0,
469
- item[1].end_line or 0,
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
- current_reason: str | None = None
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, current_reason))
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, current_reason))
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
- grep_result = _grep_exact_target(
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
- settings=settings,
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 = _grep_exact_target(
787
+ pyi_result = repo_grep(
769
788
  repo_root=repo_root,
770
- settings=settings,
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 = _citations_for_target_hits(
775
- grep_result,
776
- target=symbol,
777
- allow_assignment=False,
778
- definitions_only=True,
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
- return ToolObservation.from_error(exc, tool_call_id=tool_call.id)
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
- self._turns.append(
54
- {
55
- "turn": turn,
56
- "tool": tool_name,
57
- "ok": observation.ok,
58
- "truncated": observation.truncated,
59
- "hit_count": len(observation.hits),
60
- "content_length": len(observation.content or ""),
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
- yield from walk(self.repo_root)
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