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,539 @@
1
+ """centri 커스텀 스킬 동기화 (이슈 #134).
2
+
3
+ 스킬의 정본은 centri RDS(`codebuff.custom_skill`)이고, centri 포털에서 편집한다. pod 는
4
+ agent server 의 `GET /api/v1/codebuff/skills` 로 받아 동기화 디렉터리
5
+ `<codebuff 홈>/skills/<scope>/<name>/SKILL.md` 로 내려쓴다. 에이전트는 그 파일을 로컬
6
+ 스킬과 같은 규칙으로 읽는다(`jupyter_ext/agent/custom_skills.py`). 계약은
7
+ `docs/스킬RDS/계약.md` 가 정본이다.
8
+
9
+ - `CODEBUFF_SKILLS_URL` 이 없으면 완전한 no-op 이다. 공유 키가 있는 pod 는 시크릿
10
+ 부트스트랩이 클러스터 주소로 채운다.
11
+ - 기동 때 한 번, 그 뒤 300초마다 받는다. 기동은 기다리지 않는다 — 스킬은 설정과 달리
12
+ 기동 시점에 한 번만 읽는 값이 없고, 다음 에이전트 생성부터 쓰이면 된다.
13
+ - 200 을 받으면 받은 목록이 곧 디렉터리의 상태다. 빠진 스킬은 지운다 — 단 `SYNC_SCOPES`
14
+ 네 디렉터리 바로 아래의 스킬 디렉터리만 지운다. 사람이 만든 스킬 디렉터리와 섞지 않는다.
15
+ - 받기 실패(연결·타임아웃·4xx/5xx·비JSON·`schema_version` 불일치)는 파일을 그대로 둔다.
16
+ 마지막으로 받은 스킬이 계속 쓰인다. 쓰는 도중의 디스크 오류는 그 회차를 멈춘다 — 쓰기를
17
+ 지우기보다 먼저 하므로 옛 스킬이 남는 쪽이고, 다음 회차가 다시 맞춘다.
18
+ - 항목 단위로 검증한다. 남의 `scope_key`·규격 밖 이름·빈 설명·상한 넘는 본문은 그 항목만
19
+ 버린다.
20
+ - 어떤 경우에도 예외를 밖으로 내지 않는다.
21
+
22
+ 로컬 스킬 보고 (이슈 #182): 같은 스레드가 주기마다 pod 가 찾은 로컬 스킬 목록을
23
+ `POST <CODEBUFF_SKILLS_URL>/local` 로 보낸다. 지난 성공 보고와 같으면 보내지 않는다.
24
+ 본문의 `host` 는 `socket.gethostname()`(k8s 에서는 pod 이름)이다 (이슈 #204).
25
+ centri 수신부가 아직 없을 수 있어(404) 실패는 debug 로그와 `status()["report"]` 로만
26
+ 남기고, 다음 주기에 다시 보낸다.
27
+
28
+ 도구 정의 보고 (이슈 #248): 같은 본문 최상위 `tools[]` 에 에이전트가 모델에 넘기는 도구
29
+ 정의(엔진별 이름·설명·입력 스키마·승인 여부)를 싣는다. centri(lks21c/centri#1846)는 규격
30
+ 밖 항목을 그 항목만 버리므로, 여기서 먼저 계약에 맞춘다(`clean_tools`). 목록을 만들지
31
+ 못하면 `tools` 키를 뺀다 — 빈 배열은 "도구 0개" 로 읽힌다.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import logging
38
+ import os
39
+ import re
40
+ import shutil
41
+ import socket
42
+ import threading
43
+ from datetime import datetime, timedelta, timezone
44
+ from pathlib import Path
45
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
46
+
47
+ from codebuff_core.legacy_paths import resolve_home
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ SCHEMA_VERSION = 1
52
+ REFRESH_INTERVAL_SEC = 300.0
53
+ # 본문이 수십 KB 라 설정(3초)보다 길게 준다. 기동을 기다리게 하지 않으므로 길어도 무해하다
54
+ TIMEOUT_SEC = 10.0
55
+ URL_ENV = "CODEBUFF_SKILLS_URL"
56
+ # 설정 동기화와 같은 공유 키·사용자 토큰 (remote_config 와 같은 값)
57
+ TOKEN_ENV = "CODEX_SERVER_API_KEY"
58
+ USER_TOKEN_ENV = "KEYCLOAK_ID_TOKEN"
59
+ USER_TOKEN_HEADER = "X-Codebuff-User-Token"
60
+ #: 로컬 스킬 보고 경로 — `CODEBUFF_SKILLS_URL` 뒤에 붙인다 (이슈 #182)
61
+ REPORT_SUFFIX = "/local"
62
+
63
+ SKILL_FILE = "SKILL.md"
64
+ #: 디렉터리 이름이 되는 값이라 경로 문자를 막는다. 로컬 파서와 DDL 주석이 이 값을 따른다
65
+ NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
66
+ # YAML 이 읽지 못하는 문자 — 설명에 남으면 로컬 파서가 그 파일을 통째로 버린다
67
+ _YAML_UNPRINTABLE = re.compile(
68
+ "[^\x09\x0a\x0d\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\U00010000-\U0010ffff]"
69
+ )
70
+ #: codex `read_file` 의 상한과 같다. 넘는 본문은 받지 않는다
71
+ BODY_MAX_CHARS = 64_000
72
+ DESCRIPTION_MAX_CHARS = 1_024
73
+ #: 동기화 디렉터리 안의 하위 디렉터리 — 순서가 우선순위다(구체적인 쪽이 앞)
74
+ SYNC_SCOPES = ("user", "project", "team", "global")
75
+ #: 응답 항목에서 pod 가 읽는 필드. DDL 이 모두 담아야 한다
76
+ ENTRY_FIELDS = ("name", "scope", "scope_key", "description", "body", "enabled")
77
+
78
+ # 도구 정의 보고의 centri 계약 (이슈 #248, centri `codebuff_builtin_tools.py`)
79
+ TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
80
+ TOOL_ENGINES = ("codex", "langchain")
81
+ TOOL_VERSION_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$")
82
+ #: 넘으면 centri 가 스키마를 비운다 — 보내기 전에 비워 본문을 줄인다
83
+ TOOL_SCHEMA_MAX_CHARS = 16_000
84
+ TOOL_MAX_ITEMS = 100
85
+
86
+ _KST = timezone(timedelta(hours=9))
87
+
88
+
89
+ def clean_tools(entries: Sequence[Any]) -> List[Dict[str, Any]]:
90
+ """도구 정의 목록을 centri 계약에 맞춘다. 맞출 수 없는 항목은 버린다.
91
+
92
+ 이름·엔진·버전이 규격 밖이거나 같은 엔진에 같은 이름이 또 있으면 버린다. 설명은 1024자에서
93
+ 자르고, 직렬화한 스키마가 16000자를 넘으면 비운다. 승인은 `True` 일 때만 참이다.
94
+ """
95
+ out: List[Dict[str, Any]] = []
96
+ seen = set()
97
+ for e in entries:
98
+ if len(out) >= TOOL_MAX_ITEMS:
99
+ break
100
+ if not isinstance(e, dict):
101
+ continue
102
+ name, engine = e.get("name"), e.get("engine")
103
+ if not isinstance(name, str) or not TOOL_NAME_RE.match(name):
104
+ continue
105
+ if engine not in TOOL_ENGINES or (engine, name) in seen:
106
+ continue
107
+ version = e.get("codebuff_version")
108
+ if not isinstance(version, str) or not TOOL_VERSION_RE.match(version):
109
+ continue
110
+ schema = e.get("input_schema")
111
+ if isinstance(schema, dict):
112
+ text = json.dumps(schema, ensure_ascii=False)
113
+ elif isinstance(schema, str):
114
+ text = schema
115
+ else:
116
+ schema, text = "", ""
117
+ if len(text) > TOOL_SCHEMA_MAX_CHARS:
118
+ schema = ""
119
+ description = e.get("description")
120
+ seen.add((engine, name))
121
+ out.append(
122
+ {
123
+ "name": name,
124
+ "engine": engine,
125
+ "codebuff_version": version,
126
+ "description": (description if isinstance(description, str) else "")[
127
+ :DESCRIPTION_MAX_CHARS
128
+ ],
129
+ "input_schema": schema,
130
+ "approval": e.get("approval") is True,
131
+ }
132
+ )
133
+ return out
134
+
135
+
136
+ def _render(name: str, description: str, body: str) -> str:
137
+ """SKILL.md 한 벌. 설명은 YAML 큰따옴표 문자열로 쓴다.
138
+
139
+ JSON 문자열은 YAML 큰따옴표 문자열로도 읽히고 한 줄이라, 설명 속 `---` 가 frontmatter
140
+ 경계(`---` 만 있는 줄)로 읽힐 일이 없다.
141
+ """
142
+ quoted = json.dumps(description, ensure_ascii=False)
143
+ # 이름도 따옴표로 감싼다 — 맨 이름 `no`·`0x1f` 는 YAML 이 불리언·숫자로 읽는다
144
+ return f"---\nname: {json.dumps(name)}\ndescription: {quoted}\n---\n\n{body}\n"
145
+
146
+
147
+ def _clean_description(text: str) -> str:
148
+ return " ".join(_YAML_UNPRINTABLE.sub("", text).split())[:DESCRIPTION_MAX_CHARS]
149
+
150
+
151
+ class RemoteSkillsClient:
152
+ """centri 에서 스킬 목록을 받아 동기화 디렉터리에 내려쓴다."""
153
+
154
+ def __init__(
155
+ self,
156
+ url: str = "",
157
+ token: str = "",
158
+ transport: Any = None,
159
+ refresh_interval: float = REFRESH_INTERVAL_SEC,
160
+ timeout: float = TIMEOUT_SEC,
161
+ team: str = "",
162
+ project: str = "",
163
+ user_ids: Sequence[str] = (),
164
+ user_token: str = "",
165
+ root: Optional[Path] = None,
166
+ local_source: Optional[Callable[[], List[dict]]] = None,
167
+ tools_source: Optional[Callable[[], List[dict]]] = None,
168
+ ) -> None:
169
+ self._url = (url or "").strip()
170
+ self._token = token or ""
171
+ self._team = team or ""
172
+ self._project = project or ""
173
+ # 사번이 어느 변수로 오는지 확정하지 못했다(이슈 #101). 첫 값을 보내고, 받을 때는
174
+ # 후보 중 하나와 맞으면 이 사용자의 것으로 본다
175
+ self._user_ids = tuple(u for u in user_ids if u)
176
+ self._user_token = user_token or ""
177
+ self._transport = transport
178
+ self._interval = refresh_interval
179
+ self._timeout = timeout
180
+ self._root = root
181
+ self._lock = threading.Lock()
182
+ # 디스크 쓰기는 한 번에 하나 — 임시 파일 이름이 pid 라 겹치면 서로의 것을 옮긴다
183
+ self._apply_lock = threading.Lock()
184
+ self._stop = threading.Event()
185
+ self._thread: Optional[threading.Thread] = None
186
+ self._count = 0
187
+ self._dropped = 0
188
+ self._updated_at: Optional[str] = None
189
+ self._last_ok_at: Optional[str] = None
190
+ self._last_error: Optional[str] = None
191
+ self._attempted = False
192
+ # 로컬 스킬 보고 — 목록을 만드는 함수는 jupyter_ext 가 준다(워크스페이스를 안다)
193
+ self._local_source = local_source
194
+ # 도구 정의 보고 (이슈 #248) — 도구를 만드는 쪽(jupyter_ext)이 목록을 준다
195
+ self._tools_source = tools_source
196
+ self._report_sent: Optional[str] = None
197
+ self._report_count = 0
198
+ self._report_ok_at: Optional[str] = None
199
+ self._report_error: Optional[str] = None
200
+ self._report_attempted = False
201
+
202
+ @property
203
+ def enabled(self) -> bool:
204
+ return bool(self._url)
205
+
206
+ @property
207
+ def started(self) -> bool:
208
+ return self._thread is not None
209
+
210
+ @property
211
+ def root(self) -> Path:
212
+ return self._root if self._root is not None else resolve_home("skills")
213
+
214
+ def start(self) -> None:
215
+ if not self.enabled or self._thread is not None:
216
+ return
217
+ self._thread = threading.Thread(
218
+ target=self._run, name="codebuff-remote-skills", daemon=True
219
+ )
220
+ self._thread.start()
221
+
222
+ @property
223
+ def report_url(self) -> str:
224
+ return self._url.rstrip("/") + REPORT_SUFFIX
225
+
226
+ def _run(self) -> None:
227
+ while not self._stop.is_set():
228
+ self.refresh_now()
229
+ self.report_now()
230
+ self._stop.wait(self._interval)
231
+
232
+ def stop(self) -> None:
233
+ self._stop.set()
234
+
235
+ def refresh_now(self) -> bool:
236
+ """한 번 받아 디렉터리에 반영한다. 성공 여부를 돌려준다. 예외를 내지 않는다."""
237
+ if not self.enabled:
238
+ return False
239
+ try:
240
+ entries, updated_at = self._fetch()
241
+ wanted, dropped = self._accept(entries)
242
+ with self._apply_lock:
243
+ self._apply(wanted)
244
+ with self._lock:
245
+ self._count = len(wanted)
246
+ self._dropped = dropped
247
+ self._updated_at = updated_at
248
+ self._last_ok_at = datetime.now(_KST).isoformat(timespec="seconds")
249
+ self._last_error = None
250
+ self._attempted = True
251
+ return True
252
+ except Exception as e: # noqa: BLE001 — 어떤 실패도 마지막 파일을 유지한다
253
+ with self._lock:
254
+ self._last_error = f"{type(e).__name__}: {e}"[:300]
255
+ self._attempted = True
256
+ logger.warning("custom skills 받기 실패 — 마지막 파일 유지: %s", e)
257
+ return False
258
+
259
+ def report_now(self) -> bool:
260
+ """로컬 스킬 목록을 centri 에 보낸다. 바뀌지 않았으면 보내지 않는다. 예외를 내지 않는다.
261
+
262
+ 수신부 미배포(404)가 정상 상태일 수 있어 실패는 WARNING 이 아니라 debug 로 남긴다.
263
+ """
264
+ if not self.enabled or self._local_source is None:
265
+ return False
266
+ try:
267
+ skills = list(self._local_source())
268
+ payload: Dict[str, Any] = {
269
+ "schema_version": SCHEMA_VERSION,
270
+ "team": self._team,
271
+ "project": self._project,
272
+ "user": self._user_ids[0] if self._user_ids else "",
273
+ # 같은 사람·팀·프로젝트의 기계 둘을 centri 가 가를 수 있게 (#204)
274
+ "host": socket.gethostname(),
275
+ "skills": skills,
276
+ }
277
+ tools = self._report_tools()
278
+ if tools is not None:
279
+ payload["tools"] = tools
280
+ digest = json.dumps(payload, sort_keys=True, ensure_ascii=False)
281
+ with self._lock:
282
+ if digest == self._report_sent:
283
+ return True
284
+ payload["reported_at"] = datetime.now(_KST).isoformat(timespec="seconds")
285
+ self._post_report(payload)
286
+ with self._lock:
287
+ self._report_sent = digest
288
+ self._report_count = len(skills)
289
+ self._report_ok_at = payload["reported_at"]
290
+ self._report_error = None
291
+ self._report_attempted = True
292
+ return True
293
+ except Exception as e: # noqa: BLE001 — 보고 실패가 pod 를 흔들면 안 된다
294
+ with self._lock:
295
+ self._report_error = f"{type(e).__name__}: {e}"[:300]
296
+ self._report_attempted = True
297
+ logger.debug("로컬 스킬 보고 실패 — 다음 주기에 다시 보낸다: %s", e)
298
+ return False
299
+
300
+ def _report_tools(self) -> Optional[List[Dict[str, Any]]]:
301
+ """보고할 도구 정의. 소스가 없거나 실패하면 None — 스킬 보고는 계속 간다."""
302
+ if self._tools_source is None:
303
+ return None
304
+ try:
305
+ return clean_tools(list(self._tools_source()))
306
+ except Exception as e: # noqa: BLE001 — 도구 목록 실패가 스킬 보고를 막지 않는다
307
+ logger.debug("도구 정의 목록 실패 — 이번 보고에서 뺀다: %s", e)
308
+ return None
309
+
310
+ def _post_report(self, payload: Dict[str, Any]) -> None:
311
+ import httpx
312
+
313
+ with httpx.Client(
314
+ transport=self._transport, timeout=httpx.Timeout(self._timeout)
315
+ ) as client:
316
+ resp = client.post(self.report_url, headers=self._headers(), json=payload)
317
+ resp.raise_for_status()
318
+
319
+ def _headers(self) -> Dict[str, str]:
320
+ headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
321
+ if self._user_token:
322
+ headers[USER_TOKEN_HEADER] = self._user_token
323
+ return headers
324
+
325
+ def _fetch(self) -> Tuple[list, Optional[str]]:
326
+ import httpx
327
+
328
+ params = {
329
+ "team": self._team,
330
+ "project": self._project,
331
+ "user": self._user_ids[0] if self._user_ids else "",
332
+ }
333
+ with httpx.Client(
334
+ transport=self._transport, timeout=httpx.Timeout(self._timeout)
335
+ ) as client:
336
+ resp = client.get(self._url, headers=self._headers(), params=params)
337
+ resp.raise_for_status()
338
+ body = resp.json()
339
+ if not isinstance(body, dict):
340
+ raise ValueError("응답이 JSON 객체가 아님")
341
+ version = body.get("schema_version")
342
+ if version != SCHEMA_VERSION:
343
+ raise ValueError(f"schema_version 불일치: {version!r}")
344
+ skills = body.get("skills")
345
+ # 빠진 `skills` 를 빈 목록으로 받으면 받은 스킬이 전부 지워진다
346
+ if not isinstance(skills, list):
347
+ raise ValueError("skills 가 JSON 배열이 아님")
348
+ updated_at = body.get("updated_at")
349
+ return skills, updated_at if isinstance(updated_at, str) else None
350
+
351
+ def _owns(self, scope: str, key: str) -> bool:
352
+ if scope == "global":
353
+ return key == ""
354
+ if scope == "team":
355
+ return bool(self._team) and key == self._team
356
+ if scope == "project":
357
+ return bool(self._project) and key == self._project
358
+ return key in self._user_ids
359
+
360
+ def _accept(self, entries: list) -> Tuple[Dict[Tuple[str, str], str], int]:
361
+ """받을 스킬 `{(scope, name): 파일 내용}` 과 버린 항목 수."""
362
+ wanted: Dict[Tuple[str, str], str] = {}
363
+ dropped = 0
364
+ for e in entries:
365
+ reason = self._reject_reason(e)
366
+ if reason is None and (e["scope"], e["name"]) in wanted:
367
+ reason = "같은 scope 에 같은 이름이 또 있음"
368
+ if reason is not None:
369
+ dropped += 1
370
+ label = e.get("name") if isinstance(e, dict) else type(e).__name__
371
+ logger.warning("custom skill 항목을 버림 (%s): %r", reason, label)
372
+ continue
373
+ wanted[(e["scope"], e["name"])] = _render(
374
+ e["name"], _clean_description(e["description"]), e["body"].strip()
375
+ )
376
+ return wanted, dropped
377
+
378
+ def _reject_reason(self, e: Any) -> Optional[str]:
379
+ if not isinstance(e, dict):
380
+ return "객체가 아님"
381
+ # TINYINT 가 0·"0" 으로 직렬화돼도 꺼진 것으로 본다
382
+ if e.get("enabled", True) in (False, 0, "0", "false", None):
383
+ return "꺼짐"
384
+ name, scope = e.get("name"), e.get("scope")
385
+ # fullmatch — `$` 는 끝 개행 앞에서도 맞아 `sql\n` 이 디렉터리 이름이 된다
386
+ if not isinstance(name, str) or not NAME_RE.fullmatch(name):
387
+ return "이름이 규격 밖"
388
+ if scope not in SYNC_SCOPES:
389
+ return "scope 가 규격 밖"
390
+ if not self._owns(scope, e.get("scope_key") or ""):
391
+ return "이 pod 의 scope_key 가 아님"
392
+ desc, body = e.get("description"), e.get("body")
393
+ if not isinstance(desc, str) or not _clean_description(desc):
394
+ return "설명이 없음"
395
+ if not isinstance(body, str):
396
+ return "본문이 문자열이 아님"
397
+ if len(body) > BODY_MAX_CHARS:
398
+ return f"본문이 {BODY_MAX_CHARS}자를 넘음"
399
+ return None
400
+
401
+ def _apply(self, wanted: Dict[Tuple[str, str], str]) -> None:
402
+ """쓰기를 먼저 하고 지우기를 나중에 한다 — 중간에 실패하면 옛 스킬이 남는 쪽이다."""
403
+ root = self.root
404
+ for (scope, name), text in wanted.items():
405
+ skill_dir = root / scope / name
406
+ # 심볼릭 링크는 따라가지 않는다 — 링크만 떼고 가리키는 곳은 남긴다.
407
+ # 같은 이름의 일반 파일도 떼야 디렉터리를 만들 수 있다
408
+ if skill_dir.is_symlink() or skill_dir.is_file():
409
+ skill_dir.unlink()
410
+ path = skill_dir / SKILL_FILE
411
+ if path.is_symlink():
412
+ path.unlink()
413
+ try:
414
+ if path.read_text(encoding="utf-8") == text:
415
+ continue
416
+ except (OSError, UnicodeDecodeError):
417
+ pass
418
+ skill_dir.mkdir(parents=True, exist_ok=True)
419
+ tmp = path.with_name(f".{SKILL_FILE}.{os.getpid()}.tmp")
420
+ try:
421
+ tmp.write_text(text, encoding="utf-8")
422
+ os.replace(tmp, path)
423
+ finally:
424
+ if tmp.exists():
425
+ tmp.unlink()
426
+ for scope in SYNC_SCOPES:
427
+ scope_dir = root / scope
428
+ scope_dir.mkdir(parents=True, exist_ok=True)
429
+ for child in scope_dir.iterdir():
430
+ if (scope, child.name) in wanted:
431
+ continue
432
+ if child.is_symlink() or not child.is_dir():
433
+ child.unlink()
434
+ else:
435
+ shutil.rmtree(child)
436
+
437
+ def status(self) -> Dict[str, Any]:
438
+ with self._lock:
439
+ if not self.enabled:
440
+ state = "disabled"
441
+ elif not self._attempted:
442
+ state = "pending"
443
+ elif self._last_error is not None:
444
+ state = "error"
445
+ else:
446
+ state = "ok"
447
+ return {
448
+ "state": state,
449
+ "schemaVersion": SCHEMA_VERSION,
450
+ "skills": self._count,
451
+ "dropped": self._dropped,
452
+ "updatedAt": self._updated_at,
453
+ "lastOkAt": self._last_ok_at,
454
+ "lastError": self._last_error,
455
+ "report": self._report_status(),
456
+ }
457
+
458
+ def _report_status(self) -> Dict[str, Any]:
459
+ """로컬 스킬 보고 상태. `_lock` 을 잡은 채로 부른다."""
460
+ if not self.enabled or self._local_source is None:
461
+ state = "disabled"
462
+ elif not self._report_attempted:
463
+ state = "pending"
464
+ elif self._report_error is not None:
465
+ state = "error"
466
+ else:
467
+ state = "ok"
468
+ return {
469
+ "state": state,
470
+ "skills": self._report_count,
471
+ "lastOkAt": self._report_ok_at,
472
+ "lastError": self._report_error,
473
+ }
474
+
475
+
476
+ _client: Optional[RemoteSkillsClient] = None
477
+ _client_lock = threading.Lock()
478
+
479
+
480
+ def get_client() -> RemoteSkillsClient:
481
+ """현재 클라이언트. 기동 전이면 꺼진 클라이언트다 — identity 는 기동이 넘긴다."""
482
+ global _client
483
+ with _client_lock:
484
+ if _client is None:
485
+ _client = RemoteSkillsClient()
486
+ return _client
487
+
488
+
489
+ def _client_from_env(
490
+ team: str,
491
+ project: str,
492
+ user_ids: Sequence[str],
493
+ local_source: Optional[Callable[[], List[dict]]] = None,
494
+ tools_source: Optional[Callable[[], List[dict]]] = None,
495
+ ) -> RemoteSkillsClient:
496
+ return RemoteSkillsClient(
497
+ url=os.environ.get(URL_ENV, ""),
498
+ token=os.environ.get(TOKEN_ENV, ""),
499
+ team=team,
500
+ project=project,
501
+ user_ids=user_ids,
502
+ user_token=os.environ.get(USER_TOKEN_ENV, ""),
503
+ local_source=local_source,
504
+ tools_source=tools_source,
505
+ )
506
+
507
+
508
+ def start_from_env(
509
+ team: str,
510
+ project: str,
511
+ user_ids: Sequence[str],
512
+ local_source: Optional[Callable[[], List[dict]]] = None,
513
+ tools_source: Optional[Callable[[], List[dict]]] = None,
514
+ ) -> RemoteSkillsClient:
515
+ """서버 기동 시 시크릿 주입 뒤에 부른다. 첫 받기를 기다리지 않는다.
516
+
517
+ 팀·프로젝트·사용자는 인자로만 받는다 — 팀은 JWT 클레임 폴백이 있어 env 를 직접
518
+ 읽으면 빠진다(`jupyter_ext.team_access.resolve_team`). `local_source` 는 centri 에
519
+ 보고할 로컬 스킬 목록을, `tools_source` 는 도구 정의 목록을 돌려주는 함수다(이슈 #182·#248).
520
+ """
521
+ global _client
522
+ with _client_lock:
523
+ if _client is None or not _client.started:
524
+ _client = _client_from_env(
525
+ team=team,
526
+ project=project,
527
+ user_ids=user_ids,
528
+ local_source=local_source,
529
+ tools_source=tools_source,
530
+ )
531
+ _client.start()
532
+ return _client
533
+
534
+
535
+ def set_client(client: Optional[RemoteSkillsClient]) -> None:
536
+ """테스트용 — 클라이언트를 갈아 끼운다. None 이면 다음 조회 때 꺼진 클라이언트를 만든다."""
537
+ global _client
538
+ with _client_lock:
539
+ _client = client