codebuff-agent 2.1.74__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 (217) hide show
  1. codebuff_agent-2.1.74.data/data/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
  2. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/package.json +157 -0
  3. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
  4. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/150.39fa98117cc8ca590465.js +1 -0
  5. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/166.386ff9b2c37ac865fdee.js +1 -0
  6. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/216.25cf81131bf7274dabf4.js +1 -0
  7. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/353.581bd1cb00746399b62e.js +1 -0
  8. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/367.575f9621b63c9a641aa9.js +1 -0
  9. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/421.11dac223a2c2da1b09f5.js +1 -0
  10. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js +2 -0
  11. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
  12. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js +2 -0
  13. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
  14. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/481.cc2dec9fa869d66f1407.js +1 -0
  15. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/487.0796e931d6e651482c14.js +1 -0
  16. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/49.d326f554b6f9ccde6580.js +1 -0
  17. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/541.26897ea32845659e36be.js +1 -0
  18. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/579.e569d3edd8391fd8fbb3.js +1 -0
  19. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js +2 -0
  20. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
  21. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/666.d9bfdb23f98489d0a188.js +1 -0
  22. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js +2 -0
  23. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
  24. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/68.26b6f59c8454adb17f20.js +204 -0
  25. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/694.4ac443814260057e68f7.js +1 -0
  26. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/791.61affb8d3480d3d60e0d.js +1 -0
  27. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js +2 -0
  28. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
  29. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
  30. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/97.ea559a5b8a9a09f17c6b.js +1 -0
  31. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
  32. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/style.js +4 -0
  33. codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/third-party-licenses.json +724 -0
  34. codebuff_agent-2.1.74.dist-info/METADATA +142 -0
  35. codebuff_agent-2.1.74.dist-info/RECORD +217 -0
  36. codebuff_agent-2.1.74.dist-info/WHEEL +4 -0
  37. codebuff_agent-2.1.74.dist-info/licenses/LICENSE +21 -0
  38. codebuff_core/__init__.py +153 -0
  39. codebuff_core/codebuff_config_defaults.json +64 -0
  40. codebuff_core/core/__init__.py +35 -0
  41. codebuff_core/core/code_validator.py +1238 -0
  42. codebuff_core/core/error_classifier.py +579 -0
  43. codebuff_core/core/exceptions.py +49 -0
  44. codebuff_core/data/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
  45. codebuff_core/directory_policy.py +32 -0
  46. codebuff_core/factory.py +155 -0
  47. codebuff_core/interfaces.py +197 -0
  48. codebuff_core/knowledge/__init__.py +31 -0
  49. codebuff_core/knowledge/chunking.py +375 -0
  50. codebuff_core/knowledge/loader.py +339 -0
  51. codebuff_core/knowledge/watchdog_service.py +354 -0
  52. codebuff_core/legacy_paths.py +69 -0
  53. codebuff_core/llm/__init__.py +13 -0
  54. codebuff_core/llm/service.py +407 -0
  55. codebuff_core/managers/__init__.py +22 -0
  56. codebuff_core/managers/config_manager.py +690 -0
  57. codebuff_core/managers/remote_config.py +673 -0
  58. codebuff_core/managers/remote_skills.py +539 -0
  59. codebuff_core/managers/session_manager.py +255 -0
  60. codebuff_core/middleware/__init__.py +3 -0
  61. codebuff_core/middleware/error_recovery.py +394 -0
  62. codebuff_core/middleware/logging_utils.py +33 -0
  63. codebuff_core/models/__init__.py +111 -0
  64. codebuff_core/models/agent.py +306 -0
  65. codebuff_core/models/chat.py +57 -0
  66. codebuff_core/models/common.py +82 -0
  67. codebuff_core/models/rag.py +300 -0
  68. codebuff_core/platform_rules.py +127 -0
  69. codebuff_core/prompts/__init__.py +44 -0
  70. codebuff_core/prompts/auto_agent_prompts.py +1278 -0
  71. codebuff_core/services/__init__.py +30 -0
  72. codebuff_core/services/agent_service.py +345 -0
  73. codebuff_core/services/analytics_aggregate.py +837 -0
  74. codebuff_core/services/chat_service.py +898 -0
  75. codebuff_core/services/context_condenser.py +306 -0
  76. codebuff_core/services/pii_guardrail.py +393 -0
  77. codebuff_core/services/rag_chat_agent.py +2993 -0
  78. codebuff_core/services/rag_collections.py +2740 -0
  79. codebuff_core/services/rag_manager.py +890 -0
  80. codebuff_core/services/rag_service.py +145 -0
  81. codebuff_core/services/retriever.py +260 -0
  82. codebuff_core/services/vllm_embedding_service.py +290 -0
  83. codebuff_core/tests/__init__.py +1 -0
  84. codebuff_core/tests/conftest.py +81 -0
  85. codebuff_core/tests/test_error_recovery.py +647 -0
  86. codebuff_core/tests/test_factory.py +135 -0
  87. codebuff_core/tests/test_services.py +150 -0
  88. jupyter_ext/__init__.py +266 -0
  89. jupyter_ext/_version.py +4 -0
  90. jupyter_ext/agent/__init__.py +49 -0
  91. jupyter_ext/agent/agent_factory.py +2270 -0
  92. jupyter_ext/agent/ast_tools.py +451 -0
  93. jupyter_ext/agent/autofix_middleware.py +481 -0
  94. jupyter_ext/agent/background_procs.py +372 -0
  95. jupyter_ext/agent/code_history_middleware.py +211 -0
  96. jupyter_ext/agent/codex_autofix.py +160 -0
  97. jupyter_ext/agent/codex_bridge.py +3339 -0
  98. jupyter_ext/agent/codex_hooks.py +320 -0
  99. jupyter_ext/agent/codex_slash.py +685 -0
  100. jupyter_ext/agent/codex_threads.py +114 -0
  101. jupyter_ext/agent/content_injection.py +377 -0
  102. jupyter_ext/agent/continuation_middleware.py +548 -0
  103. jupyter_ext/agent/custom_skills.py +335 -0
  104. jupyter_ext/agent/file_diffs.py +99 -0
  105. jupyter_ext/agent/git_history.py +188 -0
  106. jupyter_ext/agent/guardrail_middleware.py +421 -0
  107. jupyter_ext/agent/json_repair_utils.py +54 -0
  108. jupyter_ext/agent/langchain_tools.py +2673 -0
  109. jupyter_ext/agent/mermaid_fixer.py +289 -0
  110. jupyter_ext/agent/mermaid_middleware.py +135 -0
  111. jupyter_ext/agent/middleware.py +359 -0
  112. jupyter_ext/agent/middleware_prompts.py +53 -0
  113. jupyter_ext/agent/observability.py +334 -0
  114. jupyter_ext/agent/patch_tool_calls.py +57 -0
  115. jupyter_ext/agent/prompts.py +112 -0
  116. jupyter_ext/agent/skill_middleware.py +257 -0
  117. jupyter_ext/agent/skills/airflow_dag_development.md +36 -0
  118. jupyter_ext/agent/skills/anomaly_detection.md +12 -0
  119. jupyter_ext/agent/skills/athena_sql.md +156 -0
  120. jupyter_ext/agent/skills/bdp_table_search.md +33 -0
  121. jupyter_ext/agent/skills/bigquery.md +65 -0
  122. jupyter_ext/agent/skills/bigquery_oracle.md +231 -0
  123. jupyter_ext/agent/skills/bigquery_tuning.md +307 -0
  124. jupyter_ext/agent/skills/classification_evaluation.md +11 -0
  125. jupyter_ext/agent/skills/clustering_analysis.md +12 -0
  126. jupyter_ext/agent/skills/comprehensive_eda.md +11 -0
  127. jupyter_ext/agent/skills/data_analysis.md +236 -0
  128. jupyter_ext/agent/skills/data_cleaning_pipeline.md +12 -0
  129. jupyter_ext/agent/skills/data_loading.md +158 -0
  130. jupyter_ext/agent/skills/data_quality_report.md +13 -0
  131. jupyter_ext/agent/skills/dimensionality_reduction.md +12 -0
  132. jupyter_ext/agent/skills/emr_script_development.md +17 -0
  133. jupyter_ext/agent/skills/experiment_tracking.md +13 -0
  134. jupyter_ext/agent/skills/feature_importance_analysis.md +10 -0
  135. jupyter_ext/agent/skills/hyperparameter_tuning.md +12 -0
  136. jupyter_ext/agent/skills/hypothesis_testing.md +12 -0
  137. jupyter_ext/agent/skills/imbalanced_data_handling.md +12 -0
  138. jupyter_ext/agent/skills/inference.md +392 -0
  139. jupyter_ext/agent/skills/lambda_function_development.md +19 -0
  140. jupyter_ext/agent/skills/model_comparison.md +11 -0
  141. jupyter_ext/agent/skills/model_explainability.md +12 -0
  142. jupyter_ext/agent/skills/model_training.md +318 -0
  143. jupyter_ext/agent/skills/pyspark.md +352 -0
  144. jupyter_ext/agent/skills/python_code_quality.md +35 -0
  145. jupyter_ext/agent/skills/regression_evaluation.md +12 -0
  146. jupyter_ext/agent/skills/spring_java_development.md +35 -0
  147. jupyter_ext/agent/skills/text_preprocessing.md +12 -0
  148. jupyter_ext/agent/skills/time_series_analysis.md +13 -0
  149. jupyter_ext/agent/skills/unit_testing.md +203 -0
  150. jupyter_ext/agent/todo_prompts.py +57 -0
  151. jupyter_ext/agent/tool_report.py +45 -0
  152. jupyter_ext/analytics_ingest.py +599 -0
  153. jupyter_ext/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
  154. jupyter_ext/handlers/__init__.py +8 -0
  155. jupyter_ext/handlers/analytics_handler.py +170 -0
  156. jupyter_ext/handlers/benchmark_handler.py +794 -0
  157. jupyter_ext/handlers/eda_benchmark_handler.py +1328 -0
  158. jupyter_ext/handlers/rag_admin_handler.py +689 -0
  159. jupyter_ext/handlers/websocket.py +497 -0
  160. jupyter_ext/http_handlers.py +8744 -0
  161. jupyter_ext/idle_monitor.py +346 -0
  162. jupyter_ext/labextension/package.json +157 -0
  163. jupyter_ext/labextension/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
  164. jupyter_ext/labextension/static/150.39fa98117cc8ca590465.js +1 -0
  165. jupyter_ext/labextension/static/166.386ff9b2c37ac865fdee.js +1 -0
  166. jupyter_ext/labextension/static/216.25cf81131bf7274dabf4.js +1 -0
  167. jupyter_ext/labextension/static/353.581bd1cb00746399b62e.js +1 -0
  168. jupyter_ext/labextension/static/367.575f9621b63c9a641aa9.js +1 -0
  169. jupyter_ext/labextension/static/421.11dac223a2c2da1b09f5.js +1 -0
  170. jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js +2 -0
  171. jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
  172. jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js +2 -0
  173. jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
  174. jupyter_ext/labextension/static/481.cc2dec9fa869d66f1407.js +1 -0
  175. jupyter_ext/labextension/static/487.0796e931d6e651482c14.js +1 -0
  176. jupyter_ext/labextension/static/49.d326f554b6f9ccde6580.js +1 -0
  177. jupyter_ext/labextension/static/541.26897ea32845659e36be.js +1 -0
  178. jupyter_ext/labextension/static/579.e569d3edd8391fd8fbb3.js +1 -0
  179. jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js +2 -0
  180. jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
  181. jupyter_ext/labextension/static/666.d9bfdb23f98489d0a188.js +1 -0
  182. jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js +2 -0
  183. jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
  184. jupyter_ext/labextension/static/68.26b6f59c8454adb17f20.js +204 -0
  185. jupyter_ext/labextension/static/694.4ac443814260057e68f7.js +1 -0
  186. jupyter_ext/labextension/static/791.61affb8d3480d3d60e0d.js +1 -0
  187. jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js +2 -0
  188. jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
  189. jupyter_ext/labextension/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
  190. jupyter_ext/labextension/static/97.ea559a5b8a9a09f17c6b.js +1 -0
  191. jupyter_ext/labextension/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
  192. jupyter_ext/labextension/static/style.js +4 -0
  193. jupyter_ext/labextension/static/third-party-licenses.json +724 -0
  194. jupyter_ext/log_paths.py +113 -0
  195. jupyter_ext/resource_usage.py +519 -0
  196. jupyter_ext/secrets_bootstrap.py +116 -0
  197. jupyter_ext/team_access.py +105 -0
  198. jupyter_ext/tests/__init__.py +1 -0
  199. jupyter_ext/tests/test_context.py +234 -0
  200. jupyter_ext/tests/test_factory.py +563 -0
  201. jupyter_ext/tests/test_file_finder.py +139 -0
  202. jupyter_ext/tests/test_handlers.py +58 -0
  203. jupyter_ext/tests/test_mcp_selftest.py +204 -0
  204. jupyter_ext/tests/test_mermaid_fixer.py +272 -0
  205. jupyter_ext/tests/test_resource_tiers.py +298 -0
  206. jupyter_ext/tools/__init__.py +15 -0
  207. jupyter_ext/tools/base.py +90 -0
  208. jupyter_ext/tools/context.py +268 -0
  209. jupyter_ext/tools/dir_complete.py +86 -0
  210. jupyter_ext/tools/file_finder.py +303 -0
  211. jupyter_ext/tools/file_tools.py +244 -0
  212. jupyter_ext/tools/jupyter_tools.py +296 -0
  213. jupyter_ext/tools/registry.py +159 -0
  214. jupyter_ext/tools/search_tools.py +230 -0
  215. jupyter_ext/tools/security_tools.py +657 -0
  216. jupyter_ext/tools/shell_tools.py +109 -0
  217. jupyter_ext/tools/user_tools.py +132 -0
@@ -0,0 +1,86 @@
1
+ """프로젝트 디렉터리 자동완성 — 설정 화면의 디렉터리 칸이 부른다 (이슈 #171).
2
+
3
+ 입력한 경로의 부모 디렉터리 **한 단계만** 읽는다. `@` 파일 멘션 자동완성의 `FileIndex` 는
4
+ 요청마다 트리를 통째로 걸어서(최대 10,000 항목) 키 입력마다 부르기에는 무겁다.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Dict, List, Optional
11
+
12
+ from codebuff_core.directory_policy import COMMON_EXCLUDED_DIRS
13
+
14
+ MAX_RESULTS = 50
15
+
16
+
17
+ def _inside(path: str, roots: List[str]) -> bool:
18
+ return any(
19
+ path == root or path.startswith(root.rstrip(os.sep) + os.sep) for root in roots
20
+ )
21
+
22
+
23
+ def _has_agents_md(path: str) -> bool:
24
+ try:
25
+ return any(
26
+ name.lower() == "agents.md" and os.path.isfile(os.path.join(path, name))
27
+ for name in os.listdir(path)
28
+ )
29
+ except OSError:
30
+ return False
31
+
32
+
33
+ def complete_directories(
34
+ prefix: str,
35
+ base: str,
36
+ limit: int = MAX_RESULTS,
37
+ roots: Optional[List[str]] = None,
38
+ ) -> List[Dict]:
39
+ """`prefix` 에 이어질 디렉터리 후보.
40
+
41
+ 상대 경로는 `base` 기준이다(저장값 해석과 같은 기준). `/` 로 끝나면 그 디렉터리의
42
+ 자식, 아니면 부모의 자식 중 마지막 조각으로 시작하는 것(대소문자 무시)을 준다.
43
+ 부모가 허용 루트(`base`·홈) 밖이거나 없으면 빈 목록이다. 링크 너머가 허용 루트 밖인
44
+ 후보도 뺀다 — 골라도 그 아래로는 더 못 내려간다.
45
+
46
+ `~` 는 풀지 않는다. 저장값 해석(`_get_effective_workspace_root`)과 AGENTS.md 상태 API
47
+ 가 안 풀어서, 여기서만 풀면 후보는 뜨는데 저장한 경로는 서버 루트로 폴백된다.
48
+ """
49
+ typed = prefix
50
+ if not os.path.isabs(typed):
51
+ typed = os.path.join(base, typed)
52
+ parent, fragment = os.path.split(typed)
53
+ if roots is None:
54
+ roots = [base, os.path.expanduser("~")]
55
+ real_roots = [os.path.realpath(r) for r in roots]
56
+ if not os.path.isdir(parent) or not _inside(os.path.realpath(parent), real_roots):
57
+ return []
58
+
59
+ show_hidden = fragment.startswith(".")
60
+ needle = fragment.lower()
61
+ try:
62
+ entries = [e for e in os.scandir(parent) if e.is_dir()]
63
+ except OSError:
64
+ return []
65
+ names = sorted(
66
+ (
67
+ e.name
68
+ for e in entries
69
+ if e.name.lower().startswith(needle)
70
+ and e.name not in COMMON_EXCLUDED_DIRS
71
+ and (show_hidden or not e.name.startswith("."))
72
+ and (not e.is_symlink() or _inside(os.path.realpath(e.path), real_roots))
73
+ ),
74
+ key=str.lower,
75
+ )[:limit]
76
+ result = []
77
+ for name in names:
78
+ path = os.path.normpath(os.path.join(parent, name))
79
+ result.append(
80
+ {
81
+ "path": path,
82
+ "git": os.path.exists(os.path.join(path, ".git")),
83
+ "agentsMd": _has_agents_md(path),
84
+ }
85
+ )
86
+ return result
@@ -0,0 +1,303 @@
1
+ """File finder with fzf-style subsequence matching.
2
+
3
+ Provides a cached file index with both prefix and fuzzy matching,
4
+ shared by the @ file-mention autocomplete handler and the file_find agent tool.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from codebuff_core.directory_policy import FILE_INDEX_EXCLUDED_DIRS as _EXCLUDE_DIRS
14
+
15
+
16
+ def _fzf_score(query: str, candidate: str) -> float | None:
17
+ """fzf-style subsequence match. Returns None if no match."""
18
+ q, c = query.lower(), candidate.lower()
19
+ qi = ci = 0
20
+ positions: list[int] = []
21
+ while qi < len(q) and ci < len(c):
22
+ if q[qi] == c[ci]:
23
+ positions.append(ci)
24
+ qi += 1
25
+ ci += 1
26
+ if qi < len(q):
27
+ return None
28
+ score = 100.0
29
+ # Consecutive character bonus
30
+ consecutive = sum(
31
+ 1 for i in range(1, len(positions)) if positions[i] == positions[i - 1] + 1
32
+ )
33
+ score += consecutive * 10
34
+ # Start-of-string bonus
35
+ if positions and positions[0] == 0:
36
+ score += 15
37
+ # Gap penalty
38
+ total_gap = sum(
39
+ positions[i] - positions[i - 1] - 1
40
+ for i in range(1, len(positions))
41
+ if positions[i] > positions[i - 1] + 1
42
+ )
43
+ score -= total_gap * 3
44
+ # Length difference penalty
45
+ score -= (len(candidate) - len(query)) * 1
46
+ return score
47
+
48
+
49
+ class FileIndex:
50
+ """Caches a directory tree in memory and provides fuzzy search."""
51
+
52
+ def __init__(
53
+ self,
54
+ base_dir: str | Path,
55
+ ttl: int = 30,
56
+ max_files: int = 10_000,
57
+ ):
58
+ self._base_dir = Path(base_dir).resolve()
59
+ self._ttl = ttl
60
+ self._max_files = max_files
61
+ self._entries: list[str] = [] # relative paths
62
+ self._built_at: float = 0.0
63
+
64
+ # ------------------------------------------------------------------
65
+ # Index management
66
+ # ------------------------------------------------------------------
67
+
68
+ def _maybe_rebuild(self) -> None:
69
+ if time.monotonic() - self._built_at < self._ttl and self._entries:
70
+ return
71
+ self._build_index()
72
+
73
+ def _build_index(self) -> None:
74
+ entries: list[str] = []
75
+ base = str(self._base_dir)
76
+ for dirpath, dirnames, filenames in os.walk(base):
77
+ # Prune excluded directories in-place
78
+ dirnames[:] = [
79
+ d for d in dirnames if d not in _EXCLUDE_DIRS and not d.startswith(".")
80
+ ]
81
+
82
+ rel_dir = os.path.relpath(dirpath, base)
83
+ if rel_dir == ".":
84
+ rel_dir = ""
85
+
86
+ # Add directory entry (with trailing /)
87
+ if rel_dir:
88
+ entries.append(rel_dir + "/")
89
+
90
+ for fname in filenames:
91
+ if fname.startswith("."):
92
+ continue
93
+ if rel_dir:
94
+ entries.append(os.path.join(rel_dir, fname))
95
+ else:
96
+ entries.append(fname)
97
+
98
+ if len(entries) >= self._max_files:
99
+ break
100
+ if len(entries) >= self._max_files:
101
+ break
102
+
103
+ self._entries = entries
104
+ self._built_at = time.monotonic()
105
+
106
+ # ------------------------------------------------------------------
107
+ # Search methods
108
+ # ------------------------------------------------------------------
109
+
110
+ def fuzzy_search(
111
+ self,
112
+ query: str,
113
+ max_results: int = 20,
114
+ supported_exts: set[str] | None = None,
115
+ include_dirs: bool = True,
116
+ ) -> list[dict]:
117
+ """Find files by fuzzy matching on the query string.
118
+
119
+ If query contains '/', the part before the last '/' is used as a
120
+ directory filter and only the basename portion is fuzzy-matched.
121
+
122
+ Returns list of dicts: [{path, score, type}, ...]
123
+ """
124
+ self._maybe_rebuild()
125
+ if not query:
126
+ return []
127
+
128
+ # Split directory filter from basename query
129
+ dir_filter = ""
130
+ basename_query = query
131
+ if "/" in query:
132
+ last_slash = query.rfind("/")
133
+ dir_filter = query[:last_slash]
134
+ basename_query = query[last_slash + 1 :]
135
+ if not basename_query:
136
+ # query like "src/" → fall back to prefix search
137
+ return self.prefix_search(query, max_results, supported_exts)
138
+
139
+ # Filter candidates
140
+ candidates: list[str] = []
141
+ for entry in self._entries:
142
+ is_dir = entry.endswith("/")
143
+
144
+ if not include_dirs and is_dir:
145
+ continue
146
+
147
+ if not is_dir and supported_exts is not None:
148
+ ext = os.path.splitext(entry)[1]
149
+ if ext not in supported_exts:
150
+ continue
151
+
152
+ if dir_filter and not entry.startswith(dir_filter):
153
+ continue
154
+
155
+ candidates.append(entry)
156
+
157
+ if not candidates:
158
+ return []
159
+
160
+ # fzf-style subsequence matching on basename
161
+ scored: list[tuple[str, float]] = []
162
+ for entry in candidates:
163
+ basename = os.path.basename(entry.rstrip("/"))
164
+ s = _fzf_score(basename_query, basename)
165
+ if s is None:
166
+ continue
167
+ # Depth penalty: shallower files preferred
168
+ depth = entry.count("/") - (1 if entry.endswith("/") else 0)
169
+ s -= depth * 1.0
170
+ scored.append((entry, s))
171
+
172
+ # Sort by score descending
173
+ scored.sort(key=lambda x: -x[1])
174
+
175
+ results: list[dict] = []
176
+ for entry, score in scored[:max_results]:
177
+ is_dir = entry.endswith("/")
178
+ results.append(
179
+ {
180
+ "path": entry.rstrip("/") if is_dir else entry,
181
+ "score": round(score, 1),
182
+ "type": "directory" if is_dir else "file",
183
+ }
184
+ )
185
+ return results
186
+
187
+ def prefix_search(
188
+ self,
189
+ prefix: str,
190
+ max_results: int = 20,
191
+ supported_exts: set[str] | None = None,
192
+ ) -> list[dict]:
193
+ """Find files/dirs matching a path prefix (existing glob behavior)."""
194
+ self._maybe_rebuild()
195
+
196
+ # Normalize: ensure no trailing slash for matching (unless root)
197
+ norm_prefix = prefix.rstrip("/") if prefix != "/" else ""
198
+ results: list[dict] = []
199
+
200
+ for entry in self._entries:
201
+ is_dir = entry.endswith("/")
202
+ entry_clean = entry.rstrip("/")
203
+
204
+ # Match: entry starts with prefix
205
+ if not entry_clean.startswith(norm_prefix):
206
+ continue
207
+
208
+ # Only direct children if prefix looks like a directory
209
+ if prefix.endswith("/") or not prefix:
210
+ remainder = entry_clean[len(norm_prefix) :].lstrip("/")
211
+ if "/" in remainder:
212
+ continue
213
+
214
+ if not is_dir and supported_exts is not None:
215
+ ext = os.path.splitext(entry)[1]
216
+ if ext not in supported_exts:
217
+ continue
218
+
219
+ results.append(
220
+ {
221
+ "path": entry_clean,
222
+ "score": 100.0,
223
+ "type": "directory" if is_dir else "file",
224
+ }
225
+ )
226
+ if len(results) >= max_results:
227
+ break
228
+
229
+ # Sort: directories first, then alphabetical
230
+ results.sort(key=lambda r: (r["type"] != "directory", r["path"].lower()))
231
+ return results
232
+
233
+
234
+ # ----------------------------------------------------------------------
235
+ # Navigate File (IntelliJ 식 파일 이름 검색)
236
+ # ----------------------------------------------------------------------
237
+
238
+ # 키 입력마다 요청이 오므로 인덱스를 루트별로 한 벌만 두고 TTL 로 갱신한다.
239
+ _SHARED_INDEXES: dict[str, FileIndex] = {}
240
+
241
+
242
+ def shared_file_index(base_dir: str | Path) -> FileIndex:
243
+ key = str(Path(base_dir).resolve())
244
+ index = _SHARED_INDEXES.get(key)
245
+ if index is None:
246
+ index = _SHARED_INDEXES[key] = FileIndex(key, max_files=50_000)
247
+ return index
248
+
249
+
250
+ # 머리글자(IntelliJ 의 camel hump) 매칭 가산점. "ACT" 는 AppConfigTests 의 머리글자라
251
+ # 어디든 부분열로만 맞는 factory.py 보다 위에 와야 하고, 머리글자가 "act" 로 시작하는
252
+ # AppConfigTests 가 머리글자 a·c·s·t 사이에 흩어져 맞는 AccessControlService 보다 위다.
253
+ _INITIALS_PREFIX_BONUS = 80.0
254
+ _INITIALS_BONUS = 50.0
255
+ _SEPARATORS = "._-/ "
256
+
257
+
258
+ def _initials(name: str) -> str:
259
+ """이름 첫 글자, 소문자 뒤 대문자, 구분자 뒤 글자를 모은다 (소문자로)."""
260
+ out = []
261
+ for i, ch in enumerate(name):
262
+ prev = name[i - 1] if i else ""
263
+ if (
264
+ i == 0
265
+ or (ch.isupper() and prev.islower())
266
+ or (prev in _SEPARATORS and ch not in _SEPARATORS)
267
+ ):
268
+ out.append(ch.lower())
269
+ return "".join(out)
270
+
271
+
272
+ def _is_subsequence(query: str, text: str) -> bool:
273
+ it = iter(text)
274
+ return all(ch in it for ch in query)
275
+
276
+
277
+ def navigate_files(
278
+ base_dir: str | Path, query: str, max_results: int = 50
279
+ ) -> list[dict]:
280
+ """파일 이름 퍼지 검색. 확장자를 거르지 않고 디렉터리는 뺀다."""
281
+ query = query.strip()
282
+ if not query:
283
+ return []
284
+ # `폴더/이름`: FileIndex 의 폴더 필터는 루트부터 맞추므로 쓰지 않고, 이름만 검색한 뒤
285
+ # 폴더 부분이 경로 어디에든 있는 것만 남긴다 (IntelliJ 와 같다)
286
+ dir_hint, _, name_query = query.rpartition("/")
287
+ name_query = name_query.lower()
288
+ if not name_query:
289
+ return []
290
+ results = shared_file_index(base_dir).fuzzy_search(
291
+ name_query, max_results=max(500, max_results), include_dirs=False
292
+ )
293
+ if dir_hint:
294
+ hint = dir_hint.strip("/").lower() + "/"
295
+ results = [r for r in results if hint in "/" + r["path"].lower()]
296
+ for r in results:
297
+ initials = _initials(os.path.basename(r["path"]))
298
+ if initials.startswith(name_query):
299
+ r["score"] = round(r["score"] + _INITIALS_PREFIX_BONUS, 1)
300
+ elif _is_subsequence(name_query, initials):
301
+ r["score"] = round(r["score"] + _INITIALS_BONUS, 1)
302
+ results.sort(key=lambda r: -r["score"])
303
+ return results[:max_results]
@@ -0,0 +1,244 @@
1
+ """파일 작업 도구."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .base import BaseTool, ToolResult
9
+
10
+
11
+ class FileReadTool(BaseTool):
12
+ """파일 내용 읽기 도구."""
13
+
14
+ def __init__(self, contents_manager: Any, base_path: str = "."):
15
+ self._contents_manager = contents_manager
16
+ self._base_path = Path(base_path)
17
+
18
+ @property
19
+ def name(self) -> str:
20
+ return "file_read"
21
+
22
+ @property
23
+ def description(self) -> str:
24
+ return """Read the contents of a file.
25
+ Returns the file content as text. For binary files, returns an error."""
26
+
27
+ @property
28
+ def parameters(self) -> dict[str, Any]:
29
+ return {
30
+ "type": "object",
31
+ "properties": {
32
+ "path": {
33
+ "type": "string",
34
+ "description": "The file path to read (relative to workspace)",
35
+ },
36
+ "encoding": {
37
+ "type": "string",
38
+ "description": "File encoding (default: utf-8)",
39
+ "default": "utf-8",
40
+ },
41
+ },
42
+ "required": ["path"],
43
+ }
44
+
45
+ @property
46
+ def risk_level(self) -> str:
47
+ return "low"
48
+
49
+ async def execute(self, **kwargs: Any) -> ToolResult:
50
+ """파일 내용을 읽습니다."""
51
+ path = kwargs.get("path", "")
52
+ encoding = kwargs.get("encoding", "utf-8")
53
+
54
+ if not path:
55
+ return ToolResult(success=False, error="경로가 필요합니다")
56
+
57
+ try:
58
+ file_path = self._base_path / path
59
+ if not file_path.exists():
60
+ return ToolResult(success=False, error=f"파일을 찾을 수 없음: {path}")
61
+
62
+ if not file_path.is_file():
63
+ return ToolResult(success=False, error=f"파일이 아님: {path}")
64
+
65
+ content = file_path.read_text(encoding=encoding)
66
+ return ToolResult(
67
+ success=True,
68
+ data={"content": content, "path": str(path)},
69
+ metadata={"size": len(content), "encoding": encoding},
70
+ )
71
+
72
+ except UnicodeDecodeError:
73
+ return ToolResult(
74
+ success=False, error=f"텍스트로 읽을 수 없는 파일: {path}"
75
+ )
76
+ except Exception as e:
77
+ return ToolResult(success=False, error=f"파일 읽기 오류: {str(e)}")
78
+
79
+
80
+ class FileWriteTool(BaseTool):
81
+ """파일 내용 쓰기 도구."""
82
+
83
+ def __init__(self, contents_manager: Any, base_path: str = "."):
84
+ self._contents_manager = contents_manager
85
+ self._base_path = Path(base_path)
86
+
87
+ @property
88
+ def name(self) -> str:
89
+ return "file_write"
90
+
91
+ @property
92
+ def description(self) -> str:
93
+ return """Write content to a file.
94
+ Creates the file if it doesn't exist. Creates parent directories if needed."""
95
+
96
+ @property
97
+ def parameters(self) -> dict[str, Any]:
98
+ return {
99
+ "type": "object",
100
+ "properties": {
101
+ "path": {
102
+ "type": "string",
103
+ "description": "The file path to write (relative to workspace)",
104
+ },
105
+ "content": {
106
+ "type": "string",
107
+ "description": "The content to write",
108
+ },
109
+ "encoding": {
110
+ "type": "string",
111
+ "description": "File encoding (default: utf-8)",
112
+ "default": "utf-8",
113
+ },
114
+ "append": {
115
+ "type": "boolean",
116
+ "description": "Append to file instead of overwriting",
117
+ "default": False,
118
+ },
119
+ },
120
+ "required": ["path", "content"],
121
+ }
122
+
123
+ @property
124
+ def risk_level(self) -> str:
125
+ return "high"
126
+
127
+ async def execute(self, **kwargs: Any) -> ToolResult:
128
+ """파일 내용을 씁니다."""
129
+ path = kwargs.get("path", "")
130
+ content = kwargs.get("content", "")
131
+ encoding = kwargs.get("encoding", "utf-8")
132
+ append = kwargs.get("append", False)
133
+
134
+ if not path:
135
+ return ToolResult(success=False, error="경로가 필요합니다")
136
+
137
+ try:
138
+ file_path = self._base_path / path
139
+
140
+ # 필요시 상위 디렉토리 생성
141
+ file_path.parent.mkdir(parents=True, exist_ok=True)
142
+
143
+ mode = "a" if append else "w"
144
+ with open(file_path, mode, encoding=encoding) as f:
145
+ f.write(content)
146
+
147
+ return ToolResult(
148
+ success=True,
149
+ data={"path": str(path), "operation": "append" if append else "write"},
150
+ metadata={"size": len(content)},
151
+ )
152
+
153
+ except Exception as e:
154
+ return ToolResult(success=False, error=f"파일 쓰기 오류: {str(e)}")
155
+
156
+
157
+ class FileListTool(BaseTool):
158
+ """디렉토리 내용 목록 도구."""
159
+
160
+ def __init__(self, contents_manager: Any, base_path: str = "."):
161
+ self._contents_manager = contents_manager
162
+ self._base_path = Path(base_path)
163
+
164
+ @property
165
+ def name(self) -> str:
166
+ return "file_list"
167
+
168
+ @property
169
+ def description(self) -> str:
170
+ return """List files and directories in a path.
171
+ Returns a list of file/directory names with their types."""
172
+
173
+ @property
174
+ def parameters(self) -> dict[str, Any]:
175
+ return {
176
+ "type": "object",
177
+ "properties": {
178
+ "path": {
179
+ "type": "string",
180
+ "description": "The directory path to list (relative to workspace)",
181
+ "default": ".",
182
+ },
183
+ "recursive": {
184
+ "type": "boolean",
185
+ "description": "List recursively",
186
+ "default": False,
187
+ },
188
+ "pattern": {
189
+ "type": "string",
190
+ "description": "Glob pattern to filter files (e.g., '*.py')",
191
+ },
192
+ },
193
+ }
194
+
195
+ @property
196
+ def risk_level(self) -> str:
197
+ return "low"
198
+
199
+ async def execute(self, **kwargs: Any) -> ToolResult:
200
+ """디렉토리 내용을 나열합니다."""
201
+ path = kwargs.get("path", ".")
202
+ recursive = kwargs.get("recursive", False)
203
+ pattern = kwargs.get("pattern")
204
+
205
+ try:
206
+ dir_path = self._base_path / path
207
+ if not dir_path.exists():
208
+ return ToolResult(success=False, error=f"경로를 찾을 수 없음: {path}")
209
+
210
+ if not dir_path.is_dir():
211
+ return ToolResult(success=False, error=f"디렉토리가 아님: {path}")
212
+
213
+ entries = []
214
+
215
+ if pattern:
216
+ if recursive:
217
+ files = dir_path.rglob(pattern)
218
+ else:
219
+ files = dir_path.glob(pattern)
220
+ else:
221
+ if recursive:
222
+ files = dir_path.rglob("*")
223
+ else:
224
+ files = dir_path.iterdir()
225
+
226
+ for entry in files:
227
+ rel_path = entry.relative_to(self._base_path)
228
+ entries.append(
229
+ {
230
+ "name": entry.name,
231
+ "path": str(rel_path),
232
+ "type": "directory" if entry.is_dir() else "file",
233
+ "size": entry.stat().st_size if entry.is_file() else None,
234
+ }
235
+ )
236
+
237
+ return ToolResult(
238
+ success=True,
239
+ data={"entries": entries, "path": str(path)},
240
+ metadata={"count": len(entries)},
241
+ )
242
+
243
+ except Exception as e:
244
+ return ToolResult(success=False, error=f"디렉토리 목록 조회 오류: {str(e)}")