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,335 @@
1
+ """커스텀 스킬 — codex 와 같은 규칙으로 찾아 두 엔진에 싣는다 (이슈 #134).
2
+
3
+ 스킬 한 개는 `<이름>/SKILL.md` 파일 하나이고, 머리의 YAML frontmatter 에 `name` 과
4
+ `description` 이 있다. codex 의 스킬 규격과 같다
5
+ (https://developers.openai.com/codex/skills).
6
+
7
+ **codex 네이티브 탐색은 쓸 수 없다.** codex 는 pod 가 아니라 centri 에서 돌아서
8
+ `skills/list` 가 centri 의 파일시스템을 훑는다. 그래서 pod 가 codex 0.154.0 과 같은
9
+ 경로·scope 로 탐색하고, 목록은 프롬프트에 싣고 본문은 도구(`load_skill`)로 되불러
10
+ 준다 — codex 가 원래 하는 progressive disclosure 를 pod 가 대신 한다.
11
+
12
+ 탐색 순서가 곧 우선순위다. 같은 이름이 둘이면 앞의 것이 이긴다. centri 포털 스킬이
13
+ 로컬보다 앞이다(이슈 #182) — 포털이 정한 스킬을 사람이 같은 이름으로 덮지 못한다.
14
+
15
+ | 위치 | scope |
16
+ |---|---|
17
+ | 동기화 디렉터리 `<codebuff 홈>/skills/<scope>/` — `user` `project` `team` `global` 순 | 그 scope |
18
+ | 워크스페이스부터 git 루트까지 `.agents/skills` · `.codex/skills` | `repo` |
19
+ | `~/.agents/skills` · `$CODEX_HOME/skills` (없으면 `~/.codex/skills`) | `user` |
20
+
21
+ 동기화 디렉터리는 `CODEBUFF_SKILLS_URL` 이 있을 때만 본다. 공유 키가 있는 pod 는 시크릿
22
+ 부트스트랩이 이 URL 을 채우고, `codebuff_core.managers.remote_skills` 가 centri 에서 받아
23
+ 그 디렉터리에 쓴다. URL 이 없는 로컬 개발에서는 예전처럼 사람이 만든 스킬만 본다.
24
+ 로컬 스킬 목록은 같은 클라이언트가 centri 에 보고한다(`local_report`).
25
+
26
+ 내장 스킬(패키지 옆 `skills/*.md`, scope `builtin`)은 위 탐색에 들지 않는다. codex 두 모드는
27
+ `with_builtin` 으로 그 뒤에 붙여 쓴다 — 이름이 `_`/`-`·대소문자만 다르면 같은 스킬이고, 앞의
28
+ 커스텀(포털) 스킬이 이긴다 (이슈 #202, #219).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import hashlib
34
+ import logging
35
+ import os
36
+ import re
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Callable, Dict, List, Optional, Tuple
40
+
41
+ import yaml
42
+
43
+ from codebuff_core.legacy_paths import resolve_home
44
+
45
+ from .._version import __version__
46
+
47
+ # 이름 규칙·본문 상한·scope 는 동기화 클라이언트가 정본이다 — 받는 쪽과 읽는 쪽이 갈라지면
48
+ # centri 에서 받은 스킬이 조용히 빠진다
49
+ from codebuff_core.managers.remote_skills import ( # noqa: F401 — 여기서 다시 내보낸다
50
+ BODY_MAX_CHARS,
51
+ NAME_RE,
52
+ SKILL_FILE,
53
+ SYNC_SCOPES,
54
+ )
55
+ from codebuff_core.managers.remote_skills import (
56
+ DESCRIPTION_MAX_CHARS as _DESCRIPTION_MAX_CHARS,
57
+ )
58
+ from codebuff_core.managers.remote_skills import URL_ENV as SKILLS_URL_ENV
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ #: codex 의 스킬 목록 예산과 같다
63
+ LIST_BUDGET_CHARS = 8_000
64
+ #: 예산을 넘을 때 설명을 이 길이로 줄인다
65
+ _SHORT_DESCRIPTION = 120
66
+ _FRONTMATTER = re.compile(r"---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)(.*)", re.S)
67
+ #: 패키지 옆 내장 스킬. 파일 이름이 `<stem>.md` 라 codex 규격(`<이름>/SKILL.md`)과 다르다
68
+ BUILTIN_DIR = Path(__file__).parent / "skills"
69
+ BUILTIN_SCOPE = "builtin"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Skill:
74
+ name: str
75
+ description: str
76
+ scope: str
77
+ path: Path
78
+
79
+
80
+ def sync_root() -> Path:
81
+ """centri 에서 받은 스킬을 두는 곳. 사람이 만든 스킬 디렉터리와 섞지 않는다."""
82
+ return resolve_home("skills")
83
+
84
+
85
+ def _repo_dirs(workspace_root: str) -> List[Path]:
86
+ """워크스페이스부터 git 루트까지. git 저장소가 아니면 워크스페이스 하나다."""
87
+ start = Path(workspace_root).resolve()
88
+ chain = [start, *start.parents]
89
+ for i, d in enumerate(chain):
90
+ if (d / ".git").exists():
91
+ return chain[: i + 1]
92
+ return [start]
93
+
94
+
95
+ def _sync_roots() -> List[Tuple[Path, str]]:
96
+ if not os.environ.get(SKILLS_URL_ENV):
97
+ return []
98
+ return [(sync_root() / scope, scope) for scope in SYNC_SCOPES]
99
+
100
+
101
+ def _local_roots(workspace_root: str) -> List[Tuple[Path, str]]:
102
+ roots: List[Tuple[Path, str]] = []
103
+ for d in _repo_dirs(workspace_root):
104
+ roots.append((d / ".agents" / "skills", "repo"))
105
+ roots.append((d / ".codex" / "skills", "repo"))
106
+ home = Path(os.path.expanduser("~"))
107
+ codex_home = Path(os.environ.get("CODEX_HOME") or home / ".codex")
108
+ roots.append((home / ".agents" / "skills", "user"))
109
+ roots.append((codex_home / "skills", "user"))
110
+ return roots
111
+
112
+
113
+ def _split(text: str) -> Tuple[Optional[dict], str]:
114
+ """frontmatter 와 본문을 가른다. frontmatter 가 없거나 깨졌으면 `(None, text)`.
115
+
116
+ 경계는 `---` 만 있는 줄이다. 줄 안의 `---`(이름 `a---b`, 설명 속 구분선)는 경계가 아니다.
117
+ """
118
+ m = _FRONTMATTER.match(text)
119
+ if not m:
120
+ return None, text
121
+ try:
122
+ meta = yaml.safe_load(m.group(1))
123
+ except yaml.YAMLError:
124
+ return None, text
125
+ return (meta if isinstance(meta, dict) else None), m.group(2).strip()
126
+
127
+
128
+ def _parse(path: Path, scope: str) -> Optional[Skill]:
129
+ try:
130
+ meta, _ = _split(path.read_text(encoding="utf-8"))
131
+ except (OSError, UnicodeDecodeError) as e:
132
+ logger.warning("스킬을 읽지 못함 %s: %s", path, e)
133
+ return None
134
+ if not meta:
135
+ logger.warning("스킬 frontmatter 가 없거나 읽을 수 없어 뺌: %s", path)
136
+ return None
137
+ name = str(meta.get("name") or "").strip()
138
+ description = " ".join(str(meta.get("description") or "").split())
139
+ if not NAME_RE.match(name) or not description:
140
+ logger.warning("스킬 frontmatter 가 규격 밖이라 뺌: %s", path)
141
+ return None
142
+ return Skill(name, description[:_DESCRIPTION_MAX_CHARS], scope, path)
143
+
144
+
145
+ def discover(workspace_root: str) -> List[Skill]:
146
+ """우선순위 순서의 스킬 목록. 같은 이름은 먼저 찾은 하나만 남긴다."""
147
+ return _scan(_sync_roots() + _local_roots(workspace_root))
148
+
149
+
150
+ def skill_key(name: str) -> str:
151
+ """파일 stem(`data_loading`)과 frontmatter name(`data-loading`)을 같은 스킬로 본다."""
152
+ return name.strip().lower().replace("-", "").replace("_", "")
153
+
154
+
155
+ def builtin() -> List[Skill]:
156
+ """내장 스킬 — frontmatter 규격(이름·설명)을 지킨 것만, 파일 이름 순서다."""
157
+ found = (_parse(p, BUILTIN_SCOPE) for p in sorted(BUILTIN_DIR.glob("*.md")))
158
+ return [s for s in found if s]
159
+
160
+
161
+ def with_builtin(skills: List[Skill]) -> List[Skill]:
162
+ """커스텀 스킬 뒤에 그것에 재정의되지 않은 내장 스킬을 붙인다."""
163
+ keys = {skill_key(s.name) for s in skills}
164
+ return [*skills, *(b for b in builtin() if skill_key(b.name) not in keys)]
165
+
166
+
167
+ def local_report(workspace_root: str) -> List[dict]:
168
+ """centri 에 보고하는 로컬 스킬 (이슈 #182). 본문은 싣지 않는다.
169
+
170
+ `shadowed` 는 같은 이름의 포털 스킬에 덮여 에이전트에 안 보인다는 뜻이다.
171
+ """
172
+ discovered = discover(workspace_root)
173
+ winners = {s.name: s.path for s in discovered}
174
+ local = [
175
+ {
176
+ "name": s.name,
177
+ "scope": s.scope,
178
+ "dir": str(s.path.parent.parent),
179
+ "description": s.description,
180
+ "sha256": hashlib.sha256(s.path.read_bytes()).hexdigest(),
181
+ "shadowed": winners.get(s.name) != s.path,
182
+ }
183
+ for s in _scan(_local_roots(workspace_root))
184
+ ]
185
+ return local + _builtin_report({skill_key(s.name) for s in discovered})
186
+
187
+
188
+ def _builtin_report(custom_keys: set) -> List[dict]:
189
+ """내장 스킬은 본문(동적 치환 전 원문)과 codebuff 버전까지 싣는다 (이슈 #219).
190
+
191
+ centri 가 "같은 이름으로 재정의" 를 내장 본문으로 채워 시작하고, 버전이 섞인 pod 중
192
+ 최신 버전 본문을 고르는 데 쓴다. `shadowed` 는 같은 이름의 커스텀 스킬이 이긴다는 뜻이다.
193
+ """
194
+ out = []
195
+ for s in builtin():
196
+ raw = s.path.read_bytes()
197
+ out.append(
198
+ {
199
+ "name": s.name,
200
+ "scope": BUILTIN_SCOPE,
201
+ "description": s.description,
202
+ "body": _split(raw.decode("utf-8"))[1],
203
+ "sha256": hashlib.sha256(raw).hexdigest(),
204
+ "shadowed": skill_key(s.name) in custom_keys,
205
+ "codebuff_version": __version__,
206
+ }
207
+ )
208
+ return out
209
+
210
+
211
+ def _scan(roots: List[Tuple[Path, str]]) -> List[Skill]:
212
+ found: Dict[str, Skill] = {}
213
+ seen_dirs = set()
214
+ for root, scope in roots:
215
+ # 권한 없는 상위 디렉터리는 `is_dir()` 도 예외를 던진다. 스킬 하나가 안 보이는
216
+ # 것으로 끝나야지 에이전트 생성 전체가 죽으면 안 된다
217
+ try:
218
+ key = root.resolve()
219
+ if key in seen_dirs or not root.is_dir():
220
+ continue
221
+ seen_dirs.add(key)
222
+ paths = sorted(root.glob(f"*/{SKILL_FILE}"))
223
+ except OSError as e:
224
+ logger.warning("스킬 디렉터리를 읽지 못함 %s: %s", root, e)
225
+ continue
226
+ for path in paths:
227
+ skill = _parse(path, scope)
228
+ if skill and skill.name not in found:
229
+ found[skill.name] = skill
230
+ return list(found.values())
231
+
232
+
233
+ def fingerprint(skills: List[Skill]) -> str:
234
+ """프롬프트에 굳는 목록이 바뀌었는지 가리는 값. 에이전트 그래프 캐시 키에 쓴다."""
235
+ return "\n".join(f"{s.scope}|{s.name}|{s.path}|{s.description}" for s in skills)
236
+
237
+
238
+ def prompt_section(skills: List[Skill], tool_name: str) -> str:
239
+ """프롬프트에 싣는 목록. 스킬이 없으면 빈 문자열이다.
240
+
241
+ 본문은 싣지 않는다. 목록이 `LIST_BUDGET_CHARS` 를 넘으면 설명을 줄이고, 그래도
242
+ 넘치면 뒤쪽(우선순위가 낮은 것)을 뺀다.
243
+ """
244
+ return _section(skills, tool_name)[0]
245
+
246
+
247
+ def omitted_count(skills: List[Skill]) -> int:
248
+ """목록 예산에 밀려 프롬프트에서 빠지는 스킬 수 — 설정 화면이 보여 준다."""
249
+ return _section(skills, "load_skill_tool")[1]
250
+
251
+
252
+ def builtin_section(custom: List[Skill], tool_name: str) -> str:
253
+ """codex agent 모드 지시문의 내장 스킬 목록. `custom` 에 재정의된 것은 뺀다 (이슈 #219)."""
254
+ shown = with_builtin(custom)[len(custom) :]
255
+ if not shown:
256
+ return ""
257
+ header = (
258
+ "# Built-in Skills\n\n"
259
+ "codebuff 에 내장된 스킬이다. 작업이 아래 설명과 맞으면 "
260
+ f"`{tool_name}` 로 그 스킬 본문을 먼저 읽고 따른다.\n\n"
261
+ )
262
+ return _budgeted(header, shown, lambda s, d: f"- **{s.name}**: {d}")[0]
263
+
264
+
265
+ def catalog(skills: List[Skill]) -> str:
266
+ """도구 설명에 싣는 `- 이름: 설명` 목록 — codex qa 모드 `load_skills` (이슈 #219)."""
267
+ return _budgeted("", skills, lambda s, d: f"- {s.name}: {d}")[0]
268
+
269
+
270
+ def _section(skills: List[Skill], tool_name: str) -> Tuple[str, int]:
271
+ if not skills:
272
+ return "", 0
273
+ header = (
274
+ "# Custom Skills\n\n"
275
+ "사용자·팀이 추가한 스킬이다. 작업이 아래 설명과 맞으면 "
276
+ f"`{tool_name}` 로 그 스킬 본문을 먼저 읽고 따른다.\n\n"
277
+ )
278
+ return _budgeted(header, skills, lambda s, d: f"- **{s.name}** ({s.scope}): {d}")
279
+
280
+
281
+ def _budgeted(
282
+ header: str, skills: List[Skill], fmt: Callable[[Skill, str], str]
283
+ ) -> Tuple[str, int]:
284
+ def line(s: Skill, limit: int) -> str:
285
+ desc = s.description
286
+ if len(desc) > limit:
287
+ desc = desc[: limit - 1] + "…"
288
+ return fmt(s, desc)
289
+
290
+ for limit in (_DESCRIPTION_MAX_CHARS, _SHORT_DESCRIPTION):
291
+ lines = [line(s, limit) for s in skills]
292
+ full = header + "\n".join(lines)
293
+ if len(full) <= LIST_BUDGET_CHARS:
294
+ return full, 0
295
+ reserve = len(_omitted_line(len(lines)))
296
+ kept: List[str] = []
297
+ for text in lines:
298
+ if len(header) + len("\n".join([*kept, text])) + reserve > LIST_BUDGET_CHARS:
299
+ break
300
+ kept.append(text)
301
+ omitted = len(lines) - len(kept)
302
+ return header + "\n".join(kept) + _omitted_line(omitted), omitted
303
+
304
+
305
+ def _omitted_line(count: int) -> str:
306
+ return f"\n- … 목록 예산을 넘어 {count}개를 뺐다"
307
+
308
+
309
+ def read_body(path: Path) -> str:
310
+ """frontmatter 를 뺀 본문. 상한을 넘으면 싣지 않고 오류 문장을 돌려준다."""
311
+ try:
312
+ _, body = _split(path.read_text(encoding="utf-8"))
313
+ except (OSError, UnicodeDecodeError) as e:
314
+ return f"Error: 스킬 파일을 읽지 못했다 ({e})"
315
+ if len(body) > BODY_MAX_CHARS:
316
+ return (
317
+ f"Error: 스킬 본문이 {len(body)}자라 상한 {BODY_MAX_CHARS}자를 넘어 "
318
+ "싣지 않았다"
319
+ )
320
+ return body
321
+
322
+
323
+ def load_body(name: str, skills: List[Skill]) -> str:
324
+ """이름으로 본문을 찾는다. 모르는 이름이면 가능한 이름을 알려 준다.
325
+
326
+ `_`/`-` 는 가리지 않는다 — 내장 스킬은 파일 stem 과 frontmatter name 이 다르다.
327
+ """
328
+ name = (name or "").strip()
329
+ if not NAME_RE.match(name):
330
+ return f"Error: 스킬 이름이 규격 밖이다: {name!r}"
331
+ for s in skills:
332
+ if skill_key(s.name) == skill_key(name):
333
+ return read_body(s.path)
334
+ available = ", ".join(s.name for s in skills) or "없음"
335
+ return f"Error: Unknown skill '{name}'. Available skills: {available}"
@@ -0,0 +1,99 @@
1
+ """승인 없이 실행된 내장 파일 쓰기의 diff (이슈 #147).
2
+
3
+ deepagents 의 `write_file`·`edit_file` 은 HITL 없이 디스크에 쓴다. 도구 시작 때 파일을
4
+ 읽어 두고 끝날 때 다시 읽어, 바뀐 것을 unified diff 로 돌려준다. 스트림 루프 두 곳
5
+ (`AgentRunner.run` 과 resume 핸들러)이 같은 추적기를 쓴다.
6
+
7
+ 한계: 같은 파일을 병렬로 편집하면 먼저 끝난 쪽의 after 에 나중 것이 섞일 수 있다.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import os
14
+ from typing import Any, Dict, Optional
15
+
16
+ # 승인 카드(file_write)는 자기 diff 를 보이므로 여기서 다루지 않는다
17
+ TRACKED_TOOLS = frozenset({"write_file", "edit_file"})
18
+ MAX_BYTES = 1_000_000
19
+ MAX_LINES = 400
20
+
21
+
22
+ class FileDiffTracker:
23
+ def __init__(self, workspace_root: str):
24
+ self._root = os.path.realpath(workspace_root) if workspace_root else ""
25
+ self._pending: Dict[str, tuple] = {}
26
+
27
+ def _resolve(self, raw: Any) -> Optional[tuple]:
28
+ if not self._root or not isinstance(raw, str) or not raw:
29
+ return None
30
+ # virtual_mode: 앞의 "/" 는 작업공간 루트다
31
+ full = os.path.realpath(os.path.join(self._root, raw.lstrip("/")))
32
+ if full != self._root and not full.startswith(self._root + os.sep):
33
+ return None
34
+ return full, os.path.relpath(full, self._root)
35
+
36
+ @staticmethod
37
+ def _read(full: str) -> Optional[str]:
38
+ """없으면 "", 너무 크거나 바이너리거나 못 읽으면 None.
39
+
40
+ 예외를 올리면 스트림 루프가 턴을 끊는다 — 디렉터리·권한 없는 파일은 도구가 오류를
41
+ 돌려주고 모델이 이어 가야 할 상황이다.
42
+ """
43
+ if not os.path.lexists(full):
44
+ return ""
45
+ try:
46
+ if os.path.getsize(full) > MAX_BYTES:
47
+ return None
48
+ with open(full, encoding="utf-8", errors="replace") as f:
49
+ text = f.read()
50
+ except OSError:
51
+ return None
52
+ return None if "\x00" in text else text
53
+
54
+ def start(self, run_id: str, name: str, tool_input: Any) -> None:
55
+ if name not in TRACKED_TOOLS or not isinstance(tool_input, dict):
56
+ return
57
+ target = self._resolve(tool_input.get("file_path"))
58
+ # 디렉터리 등 일반 파일이 아니면 도구가 오류를 돌려준다 — 보일 diff 가 없다
59
+ if target is None or (
60
+ os.path.lexists(target[0]) and not os.path.isfile(target[0])
61
+ ):
62
+ return
63
+ self._pending[run_id] = (target[0], target[1], self._read(target[0]))
64
+
65
+ def end(self, run_id: str) -> Optional[Dict[str, Any]]:
66
+ entry = self._pending.pop(run_id, None)
67
+ if entry is None:
68
+ return None
69
+ full, rel, before = entry
70
+ after = self._read(full)
71
+ if before is None or after is None:
72
+ return {"path": rel, "skipped": True}
73
+ if before == after:
74
+ return None
75
+ lines = list(
76
+ difflib.unified_diff(
77
+ before.splitlines(), after.splitlines(), n=3, lineterm=""
78
+ )
79
+ )[2:] # ---/+++ 헤더는 path 가 대신한다
80
+ if not lines:
81
+ # 끝 개행·줄끝(CRLF)만 바뀌면 줄 단위 diff 가 비는데, 빈 diff 는 "크거나
82
+ # 바이너리" 로 읽힌다
83
+ return None
84
+ added = sum(1 for ln in lines if ln.startswith("+"))
85
+ removed = sum(1 for ln in lines if ln.startswith("-"))
86
+ if len(lines) > MAX_LINES:
87
+ lines = lines[:MAX_LINES] + [f"… ({len(lines) - MAX_LINES}줄 더)"]
88
+ return {
89
+ "path": rel,
90
+ "diff": "\n".join(lines),
91
+ "added": added,
92
+ "removed": removed,
93
+ }
94
+
95
+ def discard(self, run_id: str) -> None:
96
+ self._pending.pop(run_id, None)
97
+
98
+ def clear(self) -> None:
99
+ self._pending.clear()
@@ -0,0 +1,188 @@
1
+ """파일 하나의 git 이력과 두 시점 사이 diff (이슈 #153).
2
+
3
+ 파일 브라우저에서 우클릭한 파일의 커밋 목록을 주고, 커밋 두 개(`from`/`to`) 또는
4
+ 날짜 범위(`since`/`until`)로 고른 두 시점을 비교한다. git 호출은 `/diff`(이슈 #147) 와
5
+ 같은 `codex_slash._git` 이다 — argv 실행이라 셸을 거치지 않고, ref 는 `_commit_of` 가
6
+ `--end-of-options` 로 받아 `--output=…` 같은 옵션 주입이 커밋 이름으로만 읽힌다.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ import json
13
+ import os
14
+ import re
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ from . import codex_slash
18
+ from .codex_slash import _commit_of, _cut, _git, _GitError
19
+
20
+ #: git 기본 `core.quotePath=true` 면 한글 경로가 "\355\225\234" 8진수로 나온다
21
+ _QUOTE = ("-c", "core.quotePath=false")
22
+
23
+ #: 커밋 목록 상한 — 오래된 파일이 탭을 멈추지 않게 한다
24
+ LOG_LIMIT = 200
25
+
26
+ #: 빈 트리 — `from` 이 없을 때(파일이 생긴 커밋) 전체를 추가 줄로 보인다
27
+ _EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
28
+
29
+ #: 브라우저가 시간대 오프셋을 붙여 보낸다. 오프셋이 없으면 서버 시간대로 해석돼 경계일이 흔들린다
30
+ _ISO_WITH_OFFSET = re.compile(
31
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$"
32
+ )
33
+
34
+ #: 날짜는 committer 날짜(`%cI`) — 기간 비교의 `--before` 가 committer 기준이라 목록도 맞춘다
35
+ _FORMAT = "%H%x00%cI%x00%an%x00%s"
36
+
37
+
38
+ async def repo_of(abs_path: str) -> Tuple[str, str]:
39
+ """파일이 든 저장소의 최상위와, 그 기준 상대 경로(`/` 구분)."""
40
+ try:
41
+ top = (
42
+ await _git(os.path.dirname(abs_path), "rev-parse", "--show-toplevel")
43
+ ).strip()
44
+ except _GitError as e:
45
+ raise _GitError(f"git 저장소가 아닙니다. ({e})")
46
+ rel = os.path.relpath(os.path.realpath(abs_path), os.path.realpath(top))
47
+ return top, rel.replace(os.sep, "/")
48
+
49
+
50
+ def _commit_row(line: str) -> Dict[str, str]:
51
+ sha, date, author, subject = (line.split("\0") + ["", "", ""])[:4]
52
+ return {"sha": sha, "date": date, "author": author, "subject": subject}
53
+
54
+
55
+ async def _commit_info(top: str, sha: str) -> Dict[str, str]:
56
+ out = await _git(top, "log", "-1", f"--format={_FORMAT}", sha)
57
+ return _commit_row(out.rstrip("\n"))
58
+
59
+
60
+ async def file_log(abs_path: str) -> Dict[str, Any]:
61
+ """파일을 바꾼 커밋, 최신 먼저. 추적하지 않는 파일이면 빈 목록이다."""
62
+ top, rel = await repo_of(abs_path)
63
+ out = await _git(
64
+ top, *_QUOTE, "log", f"-n{LOG_LIMIT}", f"--format={_FORMAT}", "--", rel
65
+ )
66
+ # splitlines 는 제목 안의 U+2028 같은 문자에서도 쪼갠다 — 레코드 구분은 \n 뿐이다
67
+ commits = [_commit_row(x) for x in out.split("\n") if x]
68
+ return {"path": rel, "commits": commits}
69
+
70
+
71
+ def _project_notebook(text: str) -> Optional[str]:
72
+ """노트북을 셀 소스만 남긴 텍스트로. outputs·실행 카운트·metadata 는 뺀다.
73
+
74
+ 빈 문자열(그 커밋에 파일이 없다)은 셀 0개다. 노트북 꼴이 아니면 None — 부르는 쪽이
75
+ 원문으로 비교한다.
76
+ """
77
+ if not text:
78
+ return ""
79
+ parts: List[str] = []
80
+ try:
81
+ for cell in json.loads(text)["cells"]:
82
+ source = cell.get("source", "")
83
+ if isinstance(source, list):
84
+ source = "".join(source)
85
+ parts.append(f"# %% [{cell.get('cell_type', 'code')}]\n")
86
+ parts.append(
87
+ source if source.endswith("\n") or not source else source + "\n"
88
+ )
89
+ except (ValueError, KeyError, TypeError, AttributeError):
90
+ return None
91
+ return "".join(parts)
92
+
93
+
94
+ async def _blob(top: str, sha: Optional[str], rel: str) -> str:
95
+ if not sha:
96
+ return ""
97
+ try:
98
+ return await _git(top, "show", f"{sha}:{rel}")
99
+ except _GitError as e:
100
+ # 그 커밋에 파일이 없을 때만 빈 파일이다. 타임아웃·손상은 그대로 올린다 —
101
+ # 삼키면 "전부 삭제됨" 가짜 diff 가 된다
102
+ if "does not exist in" in str(e) or "exists on disk, but not in" in str(e):
103
+ return ""
104
+ raise
105
+
106
+
107
+ async def _notebook_diff(top: str, rel: str, old: Optional[str], new: str) -> str:
108
+ before, after = await _blob(top, old, rel), await _blob(top, new, rel)
109
+ pb, pa = _project_notebook(before), _project_notebook(after)
110
+ if pb is not None and pa is not None:
111
+ before, after = pb, pa
112
+ lines = difflib.unified_diff(
113
+ before.splitlines(keepends=True),
114
+ after.splitlines(keepends=True),
115
+ fromfile=f"a/{rel}" if old else "/dev/null",
116
+ tofile=f"b/{rel}",
117
+ n=3,
118
+ )
119
+ return "".join(x if x.endswith("\n") else x + "\n" for x in lines)
120
+
121
+
122
+ async def file_diff(
123
+ abs_path: str, from_ref: Optional[str], to_ref: str
124
+ ) -> Dict[str, Any]:
125
+ """두 커밋 사이 파일 diff. `from_ref` 가 없으면 빈 파일과 비교한다."""
126
+ top, rel = await repo_of(abs_path)
127
+ new = await _commit_of(top, to_ref)
128
+ old = await _commit_of(top, from_ref) if from_ref else None
129
+ if rel.endswith(".ipynb"):
130
+ body = await _notebook_diff(top, rel, old, new)
131
+ else:
132
+ body = await _git(
133
+ top,
134
+ *_QUOTE,
135
+ "diff",
136
+ "--no-color",
137
+ "--no-ext-diff",
138
+ old or _EMPTY_TREE,
139
+ new,
140
+ "--",
141
+ rel,
142
+ )
143
+ body, cut = _cut(body.rstrip("\n"), codex_slash._DIFF_MAX_CHARS)
144
+ message = ""
145
+ if body and "\n@@ " not in body and "Binary files" in body:
146
+ body, message = "", "바이너리 파일이라 내용을 비교하지 않습니다."
147
+ elif not body:
148
+ message = "두 시점의 내용이 같습니다."
149
+ return {
150
+ "path": rel,
151
+ "diff": body,
152
+ "cut": cut,
153
+ "message": message,
154
+ "from": await _commit_info(top, old) if old else None,
155
+ "to": await _commit_info(top, new),
156
+ }
157
+
158
+
159
+ async def _last_commit_before(top: str, rel: str, when: str) -> Optional[str]:
160
+ out = await _git(top, "log", "-1", "--format=%H", f"--before={when}", "--", rel)
161
+ return out.strip() or None
162
+
163
+
164
+ async def file_diff_between_dates(
165
+ abs_path: str, since: str, until: str
166
+ ) -> Dict[str, Any]:
167
+ """`since` 직전 마지막 커밋과 `until` 까지 마지막 커밋을 비교한다 (committer 날짜)."""
168
+ for value in (since, until):
169
+ if not _ISO_WITH_OFFSET.match(value or ""):
170
+ raise _GitError(
171
+ f"날짜 형식이 아닙니다: {value} (YYYY-MM-DDTHH:MM:SS+09:00 꼴)"
172
+ )
173
+ top, rel = await repo_of(abs_path)
174
+ new = await _last_commit_before(top, rel, until)
175
+ if not new:
176
+ raise _GitError("종료일까지 이 파일의 커밋이 없습니다.")
177
+ old = await _last_commit_before(top, rel, since)
178
+ if old == new:
179
+ info = await _commit_info(top, new)
180
+ return {
181
+ "path": rel,
182
+ "diff": "",
183
+ "cut": False,
184
+ "message": "기간 안에 변경이 없습니다.",
185
+ "from": info,
186
+ "to": info,
187
+ }
188
+ return await file_diff(abs_path, old, new)