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.
- codebuff_agent-2.1.74.data/data/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/package.json +157 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/150.39fa98117cc8ca590465.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/166.386ff9b2c37ac865fdee.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/216.25cf81131bf7274dabf4.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/353.581bd1cb00746399b62e.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/367.575f9621b63c9a641aa9.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/421.11dac223a2c2da1b09f5.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/481.cc2dec9fa869d66f1407.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/487.0796e931d6e651482c14.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/49.d326f554b6f9ccde6580.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/541.26897ea32845659e36be.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/579.e569d3edd8391fd8fbb3.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/666.d9bfdb23f98489d0a188.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/68.26b6f59c8454adb17f20.js +204 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/694.4ac443814260057e68f7.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/791.61affb8d3480d3d60e0d.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/97.ea559a5b8a9a09f17c6b.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/style.js +4 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/third-party-licenses.json +724 -0
- codebuff_agent-2.1.74.dist-info/METADATA +142 -0
- codebuff_agent-2.1.74.dist-info/RECORD +217 -0
- codebuff_agent-2.1.74.dist-info/WHEEL +4 -0
- codebuff_agent-2.1.74.dist-info/licenses/LICENSE +21 -0
- codebuff_core/__init__.py +153 -0
- codebuff_core/codebuff_config_defaults.json +64 -0
- codebuff_core/core/__init__.py +35 -0
- codebuff_core/core/code_validator.py +1238 -0
- codebuff_core/core/error_classifier.py +579 -0
- codebuff_core/core/exceptions.py +49 -0
- codebuff_core/data/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
- codebuff_core/directory_policy.py +32 -0
- codebuff_core/factory.py +155 -0
- codebuff_core/interfaces.py +197 -0
- codebuff_core/knowledge/__init__.py +31 -0
- codebuff_core/knowledge/chunking.py +375 -0
- codebuff_core/knowledge/loader.py +339 -0
- codebuff_core/knowledge/watchdog_service.py +354 -0
- codebuff_core/legacy_paths.py +69 -0
- codebuff_core/llm/__init__.py +13 -0
- codebuff_core/llm/service.py +407 -0
- codebuff_core/managers/__init__.py +22 -0
- codebuff_core/managers/config_manager.py +690 -0
- codebuff_core/managers/remote_config.py +673 -0
- codebuff_core/managers/remote_skills.py +539 -0
- codebuff_core/managers/session_manager.py +255 -0
- codebuff_core/middleware/__init__.py +3 -0
- codebuff_core/middleware/error_recovery.py +394 -0
- codebuff_core/middleware/logging_utils.py +33 -0
- codebuff_core/models/__init__.py +111 -0
- codebuff_core/models/agent.py +306 -0
- codebuff_core/models/chat.py +57 -0
- codebuff_core/models/common.py +82 -0
- codebuff_core/models/rag.py +300 -0
- codebuff_core/platform_rules.py +127 -0
- codebuff_core/prompts/__init__.py +44 -0
- codebuff_core/prompts/auto_agent_prompts.py +1278 -0
- codebuff_core/services/__init__.py +30 -0
- codebuff_core/services/agent_service.py +345 -0
- codebuff_core/services/analytics_aggregate.py +837 -0
- codebuff_core/services/chat_service.py +898 -0
- codebuff_core/services/context_condenser.py +306 -0
- codebuff_core/services/pii_guardrail.py +393 -0
- codebuff_core/services/rag_chat_agent.py +2993 -0
- codebuff_core/services/rag_collections.py +2740 -0
- codebuff_core/services/rag_manager.py +890 -0
- codebuff_core/services/rag_service.py +145 -0
- codebuff_core/services/retriever.py +260 -0
- codebuff_core/services/vllm_embedding_service.py +290 -0
- codebuff_core/tests/__init__.py +1 -0
- codebuff_core/tests/conftest.py +81 -0
- codebuff_core/tests/test_error_recovery.py +647 -0
- codebuff_core/tests/test_factory.py +135 -0
- codebuff_core/tests/test_services.py +150 -0
- jupyter_ext/__init__.py +266 -0
- jupyter_ext/_version.py +4 -0
- jupyter_ext/agent/__init__.py +49 -0
- jupyter_ext/agent/agent_factory.py +2270 -0
- jupyter_ext/agent/ast_tools.py +451 -0
- jupyter_ext/agent/autofix_middleware.py +481 -0
- jupyter_ext/agent/background_procs.py +372 -0
- jupyter_ext/agent/code_history_middleware.py +211 -0
- jupyter_ext/agent/codex_autofix.py +160 -0
- jupyter_ext/agent/codex_bridge.py +3339 -0
- jupyter_ext/agent/codex_hooks.py +320 -0
- jupyter_ext/agent/codex_slash.py +685 -0
- jupyter_ext/agent/codex_threads.py +114 -0
- jupyter_ext/agent/content_injection.py +377 -0
- jupyter_ext/agent/continuation_middleware.py +548 -0
- jupyter_ext/agent/custom_skills.py +335 -0
- jupyter_ext/agent/file_diffs.py +99 -0
- jupyter_ext/agent/git_history.py +188 -0
- jupyter_ext/agent/guardrail_middleware.py +421 -0
- jupyter_ext/agent/json_repair_utils.py +54 -0
- jupyter_ext/agent/langchain_tools.py +2673 -0
- jupyter_ext/agent/mermaid_fixer.py +289 -0
- jupyter_ext/agent/mermaid_middleware.py +135 -0
- jupyter_ext/agent/middleware.py +359 -0
- jupyter_ext/agent/middleware_prompts.py +53 -0
- jupyter_ext/agent/observability.py +334 -0
- jupyter_ext/agent/patch_tool_calls.py +57 -0
- jupyter_ext/agent/prompts.py +112 -0
- jupyter_ext/agent/skill_middleware.py +257 -0
- jupyter_ext/agent/skills/airflow_dag_development.md +36 -0
- jupyter_ext/agent/skills/anomaly_detection.md +12 -0
- jupyter_ext/agent/skills/athena_sql.md +156 -0
- jupyter_ext/agent/skills/bdp_table_search.md +33 -0
- jupyter_ext/agent/skills/bigquery.md +65 -0
- jupyter_ext/agent/skills/bigquery_oracle.md +231 -0
- jupyter_ext/agent/skills/bigquery_tuning.md +307 -0
- jupyter_ext/agent/skills/classification_evaluation.md +11 -0
- jupyter_ext/agent/skills/clustering_analysis.md +12 -0
- jupyter_ext/agent/skills/comprehensive_eda.md +11 -0
- jupyter_ext/agent/skills/data_analysis.md +236 -0
- jupyter_ext/agent/skills/data_cleaning_pipeline.md +12 -0
- jupyter_ext/agent/skills/data_loading.md +158 -0
- jupyter_ext/agent/skills/data_quality_report.md +13 -0
- jupyter_ext/agent/skills/dimensionality_reduction.md +12 -0
- jupyter_ext/agent/skills/emr_script_development.md +17 -0
- jupyter_ext/agent/skills/experiment_tracking.md +13 -0
- jupyter_ext/agent/skills/feature_importance_analysis.md +10 -0
- jupyter_ext/agent/skills/hyperparameter_tuning.md +12 -0
- jupyter_ext/agent/skills/hypothesis_testing.md +12 -0
- jupyter_ext/agent/skills/imbalanced_data_handling.md +12 -0
- jupyter_ext/agent/skills/inference.md +392 -0
- jupyter_ext/agent/skills/lambda_function_development.md +19 -0
- jupyter_ext/agent/skills/model_comparison.md +11 -0
- jupyter_ext/agent/skills/model_explainability.md +12 -0
- jupyter_ext/agent/skills/model_training.md +318 -0
- jupyter_ext/agent/skills/pyspark.md +352 -0
- jupyter_ext/agent/skills/python_code_quality.md +35 -0
- jupyter_ext/agent/skills/regression_evaluation.md +12 -0
- jupyter_ext/agent/skills/spring_java_development.md +35 -0
- jupyter_ext/agent/skills/text_preprocessing.md +12 -0
- jupyter_ext/agent/skills/time_series_analysis.md +13 -0
- jupyter_ext/agent/skills/unit_testing.md +203 -0
- jupyter_ext/agent/todo_prompts.py +57 -0
- jupyter_ext/agent/tool_report.py +45 -0
- jupyter_ext/analytics_ingest.py +599 -0
- jupyter_ext/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
- jupyter_ext/handlers/__init__.py +8 -0
- jupyter_ext/handlers/analytics_handler.py +170 -0
- jupyter_ext/handlers/benchmark_handler.py +794 -0
- jupyter_ext/handlers/eda_benchmark_handler.py +1328 -0
- jupyter_ext/handlers/rag_admin_handler.py +689 -0
- jupyter_ext/handlers/websocket.py +497 -0
- jupyter_ext/http_handlers.py +8744 -0
- jupyter_ext/idle_monitor.py +346 -0
- jupyter_ext/labextension/package.json +157 -0
- jupyter_ext/labextension/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
- jupyter_ext/labextension/static/150.39fa98117cc8ca590465.js +1 -0
- jupyter_ext/labextension/static/166.386ff9b2c37ac865fdee.js +1 -0
- jupyter_ext/labextension/static/216.25cf81131bf7274dabf4.js +1 -0
- jupyter_ext/labextension/static/353.581bd1cb00746399b62e.js +1 -0
- jupyter_ext/labextension/static/367.575f9621b63c9a641aa9.js +1 -0
- jupyter_ext/labextension/static/421.11dac223a2c2da1b09f5.js +1 -0
- jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js +2 -0
- jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
- jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js +2 -0
- jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/481.cc2dec9fa869d66f1407.js +1 -0
- jupyter_ext/labextension/static/487.0796e931d6e651482c14.js +1 -0
- jupyter_ext/labextension/static/49.d326f554b6f9ccde6580.js +1 -0
- jupyter_ext/labextension/static/541.26897ea32845659e36be.js +1 -0
- jupyter_ext/labextension/static/579.e569d3edd8391fd8fbb3.js +1 -0
- jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js +2 -0
- jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/666.d9bfdb23f98489d0a188.js +1 -0
- jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js +2 -0
- jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/68.26b6f59c8454adb17f20.js +204 -0
- jupyter_ext/labextension/static/694.4ac443814260057e68f7.js +1 -0
- jupyter_ext/labextension/static/791.61affb8d3480d3d60e0d.js +1 -0
- jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js +2 -0
- jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
- jupyter_ext/labextension/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
- jupyter_ext/labextension/static/97.ea559a5b8a9a09f17c6b.js +1 -0
- jupyter_ext/labextension/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
- jupyter_ext/labextension/static/style.js +4 -0
- jupyter_ext/labextension/static/third-party-licenses.json +724 -0
- jupyter_ext/log_paths.py +113 -0
- jupyter_ext/resource_usage.py +519 -0
- jupyter_ext/secrets_bootstrap.py +116 -0
- jupyter_ext/team_access.py +105 -0
- jupyter_ext/tests/__init__.py +1 -0
- jupyter_ext/tests/test_context.py +234 -0
- jupyter_ext/tests/test_factory.py +563 -0
- jupyter_ext/tests/test_file_finder.py +139 -0
- jupyter_ext/tests/test_handlers.py +58 -0
- jupyter_ext/tests/test_mcp_selftest.py +204 -0
- jupyter_ext/tests/test_mermaid_fixer.py +272 -0
- jupyter_ext/tests/test_resource_tiers.py +298 -0
- jupyter_ext/tools/__init__.py +15 -0
- jupyter_ext/tools/base.py +90 -0
- jupyter_ext/tools/context.py +268 -0
- jupyter_ext/tools/dir_complete.py +86 -0
- jupyter_ext/tools/file_finder.py +303 -0
- jupyter_ext/tools/file_tools.py +244 -0
- jupyter_ext/tools/jupyter_tools.py +296 -0
- jupyter_ext/tools/registry.py +159 -0
- jupyter_ext/tools/search_tools.py +230 -0
- jupyter_ext/tools/security_tools.py +657 -0
- jupyter_ext/tools/shell_tools.py +109 -0
- 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)
|