Spaces:
Running
Running
🔱 Imperial Deployment: Shadow Brain Core ignition (part 2)
Browse files- shadow_brain_core/runtime/grok_sessions.json +12 -0
- shadow_brain_core/scratch/mcp_tool_calls.log +32 -0
- shadow_brain_core/services/__pycache__/brain_service.cpython-313.pyc +0 -0
- shadow_brain_core/services/__pycache__/brain_service.cpython-314.pyc +0 -0
- shadow_brain_core/services/brain_service.py +59 -2
- shadow_brain_core/static/graph/registry_graph.html +0 -0
- shadow_brain_core/taemini_cli.py +0 -0
- shadow_brain_core/taemini_cli_core.py +828 -0
- shadow_brain_core/taemini_cli_media.py +529 -0
- shadow_brain_core/taemini_cli_select.py +192 -0
- shadow_brain_core/utils/__pycache__/emotion.cpython-313.pyc +0 -0
- shadow_brain_core/utils/__pycache__/emotion.cpython-314.pyc +0 -0
- shadow_brain_core/utils/emotion.py +15 -0
- shadow_brain_core/verify_grok_relay.py +86 -0
- shadow_brain_core/verify_taemini_cli.py +9 -0
- shadow_brain_core/web_server.py +0 -0
- shadow_brain_core/web_server_prelude.py +535 -0
shadow_brain_core/runtime/grok_sessions.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cli-grok-ce8cfb4e": {
|
| 3 |
+
"cwd": "/Users/sontaemin80/Git_Work/jarvis_taemin",
|
| 4 |
+
"session_id": "019f4512-86e8-7a93-b6a5-376f878d739c",
|
| 5 |
+
"updated_at": "2026-07-09T04:17:11Z"
|
| 6 |
+
},
|
| 7 |
+
"discord-grok-1518887916103794739": {
|
| 8 |
+
"cwd": "/Users/sontaemin80/Git_Work/jarvis_taemin",
|
| 9 |
+
"session_id": "019f4519-7436-7fe0-a6b1-69b640ef0f4c",
|
| 10 |
+
"updated_at": "2026-07-09T10:51:01Z"
|
| 11 |
+
}
|
| 12 |
+
}
|
shadow_brain_core/scratch/mcp_tool_calls.log
CHANGED
|
@@ -28,3 +28,35 @@
|
|
| 28 |
[2026-07-07T11:52:01.603817] THINK model=gemini-3.1-flash-lite prompt='[최근 로컬 대화 내역]
|
| 29 |
[마왕님]: 안녕
|
| 30 |
[로컬 섀도우]: ❌ [Google API 에러' images=False is_task=True force_ANY=True tools_in_set=33
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
[2026-07-07T11:52:01.603817] THINK model=gemini-3.1-flash-lite prompt='[최근 로컬 대화 내역]
|
| 29 |
[마왕님]: 안녕
|
| 30 |
[로컬 섀도우]: ❌ [Google API 에러' images=False is_task=True force_ANY=True tools_in_set=33
|
| 31 |
+
[2026-07-08T16:14:49.565712] THINK model=gemini-3.1-flash-lite prompt='안녕' images=False is_task=False force_ANY=False tools_in_set=29
|
| 32 |
+
[2026-07-08T16:14:59.376200] THINK model=gemini-3.1-flash-lite prompt='너는 누구지?' images=False is_task=False force_ANY=False tools_in_set=1
|
| 33 |
+
[2026-07-09T00:45:40.234996] THINK model=gemini-3.1-flash-lite prompt='한 문장으로 인사' images=False is_task=False force_ANY=False tools_in_set=137
|
| 34 |
+
[2026-07-09T00:45:40.865311] THINK model=gemini-3.1-flash-lite prompt='너는 대화에서 '오래 기억할 가치가 있는 사실'만 골라내는 추출기다.
|
| 35 |
+
사용자의 발화에서, ' images=False is_task=True force_ANY=True tools_in_set=137
|
| 36 |
+
[2026-07-09T00:52:13.113137] THINK model=gemini-3.1-flash-lite prompt='안녕' images=False is_task=False force_ANY=False tools_in_set=137
|
| 37 |
+
[2026-07-09T01:55:27.345795] THINK model=gemini-3.1-flash-lite prompt='하이' images=False is_task=False force_ANY=False tools_in_set=137
|
| 38 |
+
[2026-07-09T09:14:13.544000] THINK model=gemini-3.1-flash-lite prompt='comfy 로 이미지 생성할수있나요?' images=False is_task=False force_ANY=False tools_in_set=137
|
| 39 |
+
[2026-07-09T09:14:14.030154] THINK model=gemini-3.1-flash-lite prompt='너는 대화에서 '오래 기억할 가치가 있는 사실'만 골라내는 추출기다.
|
| 40 |
+
사용자의 발화에서, ' images=False is_task=True force_ANY=True tools_in_set=137
|
| 41 |
+
[2026-07-09T09:14:14.599607] CALL mcp_comfyui_comfy_status args=[]
|
| 42 |
+
[2026-07-09T09:14:15.327155] CALL mcp_comfyui_comfy_status args=['request']
|
| 43 |
+
[2026-07-09T09:14:17.435326] CALL mcp_comfyui_comfy_status args=['request']
|
| 44 |
+
[2026-07-09T09:16:07.051282] THINK model=gemini-3.1-flash-lite prompt='그럼 수호자 매치라고알아?' images=False is_task=False force_ANY=False tools_in_set=137
|
| 45 |
+
[2026-07-09T09:16:07.363845] THINK model=gemini-3.1-flash-lite prompt='너는 대화에서 '오래 기억할 가치가 있는 사실'만 골라내는 추출기다.
|
| 46 |
+
사용자의 발화에서, ' images=False is_task=True force_ANY=True tools_in_set=137
|
| 47 |
+
[2026-07-09T09:38:06.410448] THINK model=gemini-3.1-flash-lite prompt='"달과 그림자 테마 배경 이미지 하나 생성해줘"' images=False is_task=False force_ANY=False tools_in_set=137
|
| 48 |
+
[2026-07-09T09:38:07.271073] THINK model=gemini-3.1-flash-lite prompt='너는 대화에서 '오래 기억할 가치가 있는 사실'만 골라내는 추출기다.
|
| 49 |
+
사용자의 발화에서, ' images=False is_task=True force_ANY=True tools_in_set=137
|
| 50 |
+
[2026-07-09T09:38:30.775664] THINK model=gemini-3.1-flash-lite prompt='comfy mcp 사용해서만들어줘' images=False is_task=False force_ANY=False tools_in_set=137
|
| 51 |
+
[2026-07-09T09:38:30.935382] THINK model=gemini-3.1-flash-lite prompt='너는 대화에서 '오래 기억할 가치가 있는 사실'만 골라내는 추출기다.
|
| 52 |
+
사용자의 발화에서, ' images=False is_task=True force_ANY=True tools_in_set=137
|
| 53 |
+
[2026-07-10T00:10:29.928912] THINK model=gemini-3.1-flash-lite prompt='안녕' images=False is_task=False force_ANY=False tools_in_set=137
|
| 54 |
+
[2026-07-10T00:10:43.066078] THINK model=gemini-3.1-flash-lite prompt='슬퍼해봐요' images=False is_task=False force_ANY=False tools_in_set=137
|
| 55 |
+
[2026-07-10T00:11:03.705949] THINK model=gemini-3.1-flash-lite prompt='화난 감정표현해주세요' images=False is_task=False force_ANY=False tools_in_set=137
|
| 56 |
+
[2026-07-10T00:11:03.749128] THINK model=gemini-3.1-flash-lite prompt='너는 대화에서 '오래 기억할 가치가 있는 사실'만 골라내는 추출기다.
|
| 57 |
+
사용자의 발화에서, ' images=False is_task=True force_ANY=True tools_in_set=137
|
| 58 |
+
[2026-07-10T01:44:57.201019] THINK model=gemini-3.1-flash-lite prompt='웃어봐요' images=False is_task=False force_ANY=False tools_in_set=137
|
| 59 |
+
[2026-07-10T01:45:51.565376] THINK model=gemini-3.1-flash-lite prompt='웃어봐요' images=False is_task=False force_ANY=False tools_in_set=137
|
| 60 |
+
[2026-07-10T01:46:18.216982] THINK model=gemini-3.1-flash-lite prompt='화내봐요' images=False is_task=False force_ANY=False tools_in_set=137
|
| 61 |
+
[2026-07-10T04:42:34.476147] THINK model=gemini-3.1-flash-lite prompt='안녕' images=False is_task=False force_ANY=False tools_in_set=137
|
| 62 |
+
[2026-07-10T04:42:55.042710] THINK model=gemini-3.1-flash-lite prompt='화내봐요' images=False is_task=False force_ANY=False tools_in_set=137
|
shadow_brain_core/services/__pycache__/brain_service.cpython-313.pyc
CHANGED
|
Binary files a/shadow_brain_core/services/__pycache__/brain_service.cpython-313.pyc and b/shadow_brain_core/services/__pycache__/brain_service.cpython-313.pyc differ
|
|
|
shadow_brain_core/services/__pycache__/brain_service.cpython-314.pyc
CHANGED
|
Binary files a/shadow_brain_core/services/__pycache__/brain_service.cpython-314.pyc and b/shadow_brain_core/services/__pycache__/brain_service.cpython-314.pyc differ
|
|
|
shadow_brain_core/services/brain_service.py
CHANGED
|
@@ -20,7 +20,32 @@ def invalidate_perf_cache(persona_id):
|
|
| 20 |
_PERF_CACHE.pop(str(persona_id), None)
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def get_job_perf_flags(persona_id, aegis_url=None, token=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"""타깃 수호자의 직업 성능 플래그를 반환. 실패 시 전부 켜짐(기존 동작)."""
|
| 25 |
default = {
|
| 26 |
"memory_recall": True, "proactive_context": True, "stage_timing": True,
|
|
@@ -303,9 +328,28 @@ def raw_model_completion(prompt, model_name=None, provider=None, system_instruct
|
|
| 303 |
logger.warning(f"[Brain Service] raw_model_completion 실패: {e}")
|
| 304 |
return ""
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
def proactive_context_block(context_id=None, sender_persona_id=None, aegis_url=None, token=None):
|
| 307 |
"""[🧠 기억 엔진 3] 그날 첫 대화면 이지스 선제 컨텍스트(날씨/생일)를 가져와
|
| 308 |
-
첫인사 주입 블록을 만든다. 첫 대화가 아니거나 실패하면 빈 문자열.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
try:
|
| 310 |
if os.environ.get("PROACTIVE_CONTEXT_DISABLED", "").lower() in ("1", "true"):
|
| 311 |
return ""
|
|
@@ -373,7 +417,7 @@ def think(prompt, history=None, stream=True, model_name=None, system_instruction
|
|
| 373 |
user_id=None, context_id='GLOBAL', sender_persona_id=None, target_persona_id=None,
|
| 374 |
use_grounding=False, images=None, provider=None, tools=None, token=None, aegis_url=None,
|
| 375 |
override_config=None, is_master=False, interactive=True, skip_grounding=False,
|
| 376 |
-
stream_tokens=False):
|
| 377 |
"""
|
| 378 |
[🔱 Imperial Unified Intelligence Wrapper v19 Stateless]
|
| 379 |
JarvisBrain.think를 통합 관리합니다. stream=True 시 제너레이터를 반환합니다.
|
|
@@ -383,6 +427,13 @@ def think(prompt, history=None, stream=True, model_name=None, system_instruction
|
|
| 383 |
# 실시간 노출하기 위한 마커. stream 모드에서만 [STAGE:라벨|밀리초] 형식으로 흘린다.
|
| 384 |
import time as _t
|
| 385 |
_stage_t0 = _t.perf_counter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
|
| 387 |
def _stage(label):
|
| 388 |
"""직전 단계 종료 시점부터의 누적 경과(ms)를 마커로 반환. stream일 때만 사용."""
|
|
@@ -443,6 +494,7 @@ def think(prompt, history=None, stream=True, model_name=None, system_instruction
|
|
| 443 |
logger.error(f"[Brain Service] Aegis lookup failed: {aegis_err}")
|
| 444 |
|
| 445 |
# 🚀 [직업 성능 토글] 타깃 수호자 직업 설정으로 준비 단계 ON/OFF 결정.
|
|
|
|
| 446 |
_perf = get_job_perf_flags(target_persona_id, aegis_url=aegis_url, token=token)
|
| 447 |
_show_stage = stream and _perf.get("stage_timing", True)
|
| 448 |
if _perf.get("job_source") == "vault" and history:
|
|
@@ -462,6 +514,7 @@ def think(prompt, history=None, stream=True, model_name=None, system_instruction
|
|
| 462 |
if _show_stage:
|
| 463 |
yield _stage("🪪 정체성 확인")
|
| 464 |
|
|
|
|
| 465 |
target_model = final_model or getattr(brain, 'primary_model', 'gemini-3.1-flash-lite')
|
| 466 |
|
| 467 |
# [🔱 Claude Code 채널 라우팅] claude_code 프로바이더 or claude_code/ 모델 → localhost:8790
|
|
@@ -502,6 +555,7 @@ def think(prompt, history=None, stream=True, model_name=None, system_instruction
|
|
| 502 |
if _show_stage:
|
| 503 |
yield _stage("🧠 기억 검색")
|
| 504 |
# [🧠 기억 엔진 3] 그날 첫 대화면 선제 컨텍스트(날씨/생일) 주입
|
|
|
|
| 505 |
proactive_block = ""
|
| 506 |
if _perf.get("proactive_context", True):
|
| 507 |
proactive_block = proactive_context_block(
|
|
@@ -510,6 +564,7 @@ def think(prompt, history=None, stream=True, model_name=None, system_instruction
|
|
| 510 |
)
|
| 511 |
if _show_stage:
|
| 512 |
yield _stage("📜 컨텍스트 준비")
|
|
|
|
| 513 |
extra = recall_block + proactive_block
|
| 514 |
effective_instruction = (system_instruction or "") + extra if extra else system_instruction
|
| 515 |
|
|
@@ -531,6 +586,8 @@ def think(prompt, history=None, stream=True, model_name=None, system_instruction
|
|
| 531 |
skip_grounding=skip_grounding,
|
| 532 |
# 🌊 [Token Streaming] send-stream 옵트인 — Gemini 커스텀 도구 경로 토큰 스트림
|
| 533 |
stream_tokens=stream_tokens,
|
|
|
|
|
|
|
| 534 |
# 🙋 [Ask User] 비스트리밍(동기 릴레이) 경로에서는 선택지 UI가 제때
|
| 535 |
# 뜰 수 없으므로 ask_user를 차단 (대기 없이 텍스트 질문으로 우회)
|
| 536 |
interactive=(stream and interactive),
|
|
|
|
| 20 |
_PERF_CACHE.pop(str(persona_id), None)
|
| 21 |
|
| 22 |
|
| 23 |
+
_PERF_FLAGS_CACHE = {} # (user, persona_id) → (ts, flags) — 매 턴 Aegis 왕복(~0.8s) 차단
|
| 24 |
+
_PERF_FLAGS_TTL = 60.0
|
| 25 |
+
|
| 26 |
+
|
| 27 |
def get_job_perf_flags(persona_id, aegis_url=None, token=None):
|
| 28 |
+
"""타깃 수호자의 직업 성능 플래그 (⏱️ 60초 TTL 캐시 — 실패 포함).
|
| 29 |
+
직업 변경은 최대 1분 뒤 반영. 실패를 캐시 안 하면 Aegis 401/다운 동안 매 턴
|
| 30 |
+
클라우드 왕복(~0.8s)이 TTFT에 얹힌다(2026-07-10 실측)."""
|
| 31 |
+
import time as _time
|
| 32 |
+
_uid = 'anonymous'
|
| 33 |
+
try:
|
| 34 |
+
from flask import has_request_context, request
|
| 35 |
+
if has_request_context():
|
| 36 |
+
_uid = request.headers.get('X-Imperial-User-Id', 'anonymous')
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
+
_ck = (_uid, str(persona_id))
|
| 40 |
+
_c = _PERF_FLAGS_CACHE.get(_ck)
|
| 41 |
+
if _c and (_time.time() - _c[0]) < _PERF_FLAGS_TTL:
|
| 42 |
+
return dict(_c[1])
|
| 43 |
+
flags = _get_job_perf_flags_uncached(persona_id, aegis_url=aegis_url, token=token)
|
| 44 |
+
_PERF_FLAGS_CACHE[_ck] = (_time.time(), dict(flags))
|
| 45 |
+
return flags
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _get_job_perf_flags_uncached(persona_id, aegis_url=None, token=None):
|
| 49 |
"""타깃 수호자의 직업 성능 플래그를 반환. 실패 시 전부 켜짐(기존 동작)."""
|
| 50 |
default = {
|
| 51 |
"memory_recall": True, "proactive_context": True, "stage_timing": True,
|
|
|
|
| 328 |
logger.warning(f"[Brain Service] raw_model_completion 실패: {e}")
|
| 329 |
return ""
|
| 330 |
|
| 331 |
+
_PROACTIVE_CACHE = {} # (context_id, persona) → (ts, block)
|
| 332 |
+
_PROACTIVE_TTL = 120.0
|
| 333 |
+
|
| 334 |
+
|
| 335 |
def proactive_context_block(context_id=None, sender_persona_id=None, aegis_url=None, token=None):
|
| 336 |
"""[🧠 기억 엔진 3] 그날 첫 대화면 이지스 선제 컨텍스트(날씨/생일)를 가져와
|
| 337 |
+
첫인사 주입 블록을 만든다. 첫 대화가 아니거나 실패하면 빈 문자열.
|
| 338 |
+
⏱️ 120초 TTL 캐시(실패 포함) — '그날 첫 대화' 판정은 하루 단위라 캐시 무해,
|
| 339 |
+
매 턴 클라우드 왕복(~0.8s) 차단(2026-07-10)."""
|
| 340 |
+
import time as _time
|
| 341 |
+
_ck = (str(context_id or ""), str(sender_persona_id or ""))
|
| 342 |
+
_c = _PROACTIVE_CACHE.get(_ck)
|
| 343 |
+
if _c and (_time.time() - _c[0]) < _PROACTIVE_TTL:
|
| 344 |
+
return _c[1]
|
| 345 |
+
_blk = _proactive_context_block_uncached(
|
| 346 |
+
context_id=context_id, sender_persona_id=sender_persona_id,
|
| 347 |
+
aegis_url=aegis_url, token=token)
|
| 348 |
+
_PROACTIVE_CACHE[_ck] = (_time.time(), _blk)
|
| 349 |
+
return _blk
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _proactive_context_block_uncached(context_id=None, sender_persona_id=None, aegis_url=None, token=None):
|
| 353 |
try:
|
| 354 |
if os.environ.get("PROACTIVE_CONTEXT_DISABLED", "").lower() in ("1", "true"):
|
| 355 |
return ""
|
|
|
|
| 417 |
user_id=None, context_id='GLOBAL', sender_persona_id=None, target_persona_id=None,
|
| 418 |
use_grounding=False, images=None, provider=None, tools=None, token=None, aegis_url=None,
|
| 419 |
override_config=None, is_master=False, interactive=True, skip_grounding=False,
|
| 420 |
+
stream_tokens=False, coding_intent=None):
|
| 421 |
"""
|
| 422 |
[🔱 Imperial Unified Intelligence Wrapper v19 Stateless]
|
| 423 |
JarvisBrain.think를 통합 관리합니다. stream=True 시 제너레이터를 반환합니다.
|
|
|
|
| 427 |
# 실시간 노출하기 위한 마커. stream 모드에서만 [STAGE:라벨|밀리초] 형식으로 흘린다.
|
| 428 |
import time as _t
|
| 429 |
_stage_t0 = _t.perf_counter()
|
| 430 |
+
_svc_lap = [_t.time()]
|
| 431 |
+
|
| 432 |
+
def _svclap(name):
|
| 433 |
+
now = _t.time()
|
| 434 |
+
if now - _svc_lap[0] > 0.15:
|
| 435 |
+
logger.info(f"⏱️ [svc-prep] {name}: {(now - _svc_lap[0])*1000:.0f}ms")
|
| 436 |
+
_svc_lap[0] = now
|
| 437 |
|
| 438 |
def _stage(label):
|
| 439 |
"""직전 단계 종료 시점부터의 누적 경과(ms)를 마커로 반환. stream일 때만 사용."""
|
|
|
|
| 494 |
logger.error(f"[Brain Service] Aegis lookup failed: {aegis_err}")
|
| 495 |
|
| 496 |
# 🚀 [직업 성능 토글] 타깃 수호자 직업 설정으로 준비 단계 ON/OFF 결정.
|
| 497 |
+
_svclap("identity-resolve")
|
| 498 |
_perf = get_job_perf_flags(target_persona_id, aegis_url=aegis_url, token=token)
|
| 499 |
_show_stage = stream and _perf.get("stage_timing", True)
|
| 500 |
if _perf.get("job_source") == "vault" and history:
|
|
|
|
| 514 |
if _show_stage:
|
| 515 |
yield _stage("🪪 정체성 확인")
|
| 516 |
|
| 517 |
+
_svclap("perf-flags")
|
| 518 |
target_model = final_model or getattr(brain, 'primary_model', 'gemini-3.1-flash-lite')
|
| 519 |
|
| 520 |
# [🔱 Claude Code 채널 라우팅] claude_code 프로바이더 or claude_code/ 모델 → localhost:8790
|
|
|
|
| 555 |
if _show_stage:
|
| 556 |
yield _stage("🧠 기억 검색")
|
| 557 |
# [🧠 기억 엔진 3] 그날 첫 대화면 선제 컨텍스트(날씨/생일) 주입
|
| 558 |
+
_svclap("retrieval+recall")
|
| 559 |
proactive_block = ""
|
| 560 |
if _perf.get("proactive_context", True):
|
| 561 |
proactive_block = proactive_context_block(
|
|
|
|
| 564 |
)
|
| 565 |
if _show_stage:
|
| 566 |
yield _stage("📜 컨텍스트 준비")
|
| 567 |
+
_svclap("proactive")
|
| 568 |
extra = recall_block + proactive_block
|
| 569 |
effective_instruction = (system_instruction or "") + extra if extra else system_instruction
|
| 570 |
|
|
|
|
| 586 |
skip_grounding=skip_grounding,
|
| 587 |
# 🌊 [Token Streaming] send-stream 옵트인 — Gemini 커스텀 도구 경로 토큰 스트림
|
| 588 |
stream_tokens=stream_tokens,
|
| 589 |
+
# 🛠️ [Coding Intent Gate] 호출부 명시가 있으면 키워드 추측을 덮는다
|
| 590 |
+
coding_intent=coding_intent,
|
| 591 |
# 🙋 [Ask User] 비스트리밍(동기 릴레이) 경로에서는 선택지 UI가 제때
|
| 592 |
# 뜰 수 없으므로 ask_user를 차단 (대기 없이 텍스트 질문으로 우회)
|
| 593 |
interactive=(stream and interactive),
|
shadow_brain_core/static/graph/registry_graph.html
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
shadow_brain_core/taemini_cli.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
shadow_brain_core/taemini_cli_core.py
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""🔱 태미니 CLI 코어 — 터미널/색/HTTP/설정/소환/수호자/신뢰/세션/전송
|
| 3 |
+
|
| 4 |
+
taemini_cli.py 에서 분할(2026-07-10, god-file 방지). 공개 이름은 taemini_cli 가
|
| 5 |
+
`from taemini_cli_core import *` 로 재수출하므로 기존 사용법/검증 스크립트는 그대로 동작한다.
|
| 6 |
+
"""
|
| 7 |
+
import argparse
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import math
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
import sys
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
import unicodedata
|
| 17 |
+
import urllib.error
|
| 18 |
+
import urllib.request
|
| 19 |
+
|
| 20 |
+
VERSION = "1.5.0"
|
| 21 |
+
DEFAULT_SERVER = os.environ.get("TAEMINI_SERVER") or os.environ.get("SHADOW_BRAIN_URL") or "http://127.0.0.1:18700"
|
| 22 |
+
REQUEST_TIMEOUT = 900 # 코딩 턴은 수 분 걸릴 수 있음
|
| 23 |
+
PROGRESS_POLL_SEC = 1.0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# ─────────────────────────────────────────────────────────────────
|
| 27 |
+
# 터미널 준비 (윈도우 UTF-8 + ANSI VT 활성화)
|
| 28 |
+
# ─────────────────────────────────────────────────────────────────
|
| 29 |
+
def _setup_terminal():
|
| 30 |
+
if os.name == "nt":
|
| 31 |
+
os.system("") # ANSI VT 시퀀스 활성화 (Win10+)
|
| 32 |
+
for stream in (sys.stdout, sys.stderr):
|
| 33 |
+
try:
|
| 34 |
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
| 35 |
+
except Exception:
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
_USE_COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _c(code, text):
|
| 43 |
+
if not _USE_COLOR:
|
| 44 |
+
return text
|
| 45 |
+
return f"\033[{code}m{text}\033[0m"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def dim(t): return _c("2", t)
|
| 49 |
+
def bold(t): return _c("1", t)
|
| 50 |
+
def cyan(t): return _c("36", t)
|
| 51 |
+
def green(t): return _c("32", t)
|
| 52 |
+
def yellow(t): return _c("33", t)
|
| 53 |
+
def red(t): return _c("31", t)
|
| 54 |
+
def magenta(t): return _c("35", t)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ─────────────────────────────────────────────────────────────────
|
| 58 |
+
# HTTP (표준 라이브러리)
|
| 59 |
+
# ─────────────────────────────────────────────────────────────────
|
| 60 |
+
def post_json(server, path, payload, timeout=REQUEST_TIMEOUT):
|
| 61 |
+
url = server.rstrip("/") + path
|
| 62 |
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
| 63 |
+
headers = {"Content-Type": "application/json; charset=utf-8"}
|
| 64 |
+
headers.update(auth_headers(server)) # 원격 서버 = 클라우드 키 자동 첨부
|
| 65 |
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
| 66 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 67 |
+
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def get_json(server, path, timeout=5):
|
| 71 |
+
url = server.rstrip("/") + path
|
| 72 |
+
req = urllib.request.Request(url, headers=auth_headers(server))
|
| 73 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 74 |
+
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ─────────────────────────────────────────────────────────────────
|
| 78 |
+
# 로컬 설정 (온보딩 GUI 대체) — 런처와 동일한 local_settings.json / SQLite를 공유
|
| 79 |
+
# ─────────────────────────────────────────────────────────────────
|
| 80 |
+
def _settings_path():
|
| 81 |
+
# local_launcher.get_settings_path()와 동일 규칙 (tkinter import 회피를 위해 재구현)
|
| 82 |
+
base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
|
| 83 |
+
d = os.path.join(base, "TaeminGames", "ShadowBrain")
|
| 84 |
+
os.makedirs(d, exist_ok=True)
|
| 85 |
+
return os.path.join(d, "local_settings.json")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def load_settings():
|
| 89 |
+
try:
|
| 90 |
+
with open(_settings_path(), "r", encoding="utf-8") as f:
|
| 91 |
+
return json.load(f)
|
| 92 |
+
except Exception:
|
| 93 |
+
return {}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def save_settings(settings):
|
| 97 |
+
with open(_settings_path(), "w", encoding="utf-8") as f:
|
| 98 |
+
json.dump(settings, f, indent=4)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _store_get(key, default=""):
|
| 102 |
+
"""local_taemini 공급자/모델 — 헤임달 '브레인 지능 설정'과 같은 SQLite SSOT."""
|
| 103 |
+
try:
|
| 104 |
+
from local_guardian_store import get_setting
|
| 105 |
+
return get_setting("anonymous", "local_taemini", key, default)
|
| 106 |
+
except Exception:
|
| 107 |
+
return default
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _store_set(key, value):
|
| 111 |
+
from local_guardian_store import set_setting
|
| 112 |
+
set_setting("anonymous", "local_taemini", key, value)
|
| 113 |
+
_MODEL_LABEL_CACHE["t"] = 0.0 # /config set·/model 변경 즉시 상태바 반영
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
_MODEL_LABEL_CACHE = {"t": 0.0, "v": ""}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _model_label():
|
| 120 |
+
"""현재 선택된 AI 공급자/모델 라벨 (배너·상태바 표시용) — 헤임달 '브��인 지능
|
| 121 |
+
설정'과 같은 SQLite SSOT. 상태바가 초당 수회 갱신되므로 5초 캐시로 읽는다."""
|
| 122 |
+
now = time.time()
|
| 123 |
+
if now - _MODEL_LABEL_CACHE["t"] > 5.0:
|
| 124 |
+
prov = (_store_get("ai_provider", "") or "").strip()
|
| 125 |
+
model = (_store_get("ai_model", "") or "").strip()
|
| 126 |
+
if prov == "none":
|
| 127 |
+
prov = ""
|
| 128 |
+
if len(model) > 36:
|
| 129 |
+
model = model[:35] + "…"
|
| 130 |
+
_MODEL_LABEL_CACHE["v"] = f"{prov}/{model}" if prov and model else (model or prov)
|
| 131 |
+
_MODEL_LABEL_CACHE["t"] = now
|
| 132 |
+
return _MODEL_LABEL_CACHE["v"]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# 설정 항목 레지스트리: 이름 → (저장 위치, 키, 비밀 여부, 설명)
|
| 136 |
+
# 저장 위치 "json" = local_settings.json, "store" = local_data_*.sqlite3(local_taemini)
|
| 137 |
+
CONFIG_KEYS = {
|
| 138 |
+
"gemini": ("json", "GEMINI_API_KEY", True, "Gemini API Key"),
|
| 139 |
+
"discord": ("json", "DISCORD_BOT_TOKEN_SHADOW_LOCAL", True, "디스코드 봇 토큰 (로컬 Shadow)"),
|
| 140 |
+
"agent-token": ("json", "SHADOW_AGENT_TOKEN", True, "제국 Agent Token (비우면 부팅 시 자동 발급)"),
|
| 141 |
+
"nvidia": ("json", "NVIDIA_API_KEY", True, "NVIDIA NIM API Key"),
|
| 142 |
+
"openrouter": ("json", "OPENROUTER_API_KEY", True, "OpenRouter API Key"),
|
| 143 |
+
"glm": ("json", "GLM_API_KEY", True, "Z.ai GLM API Key"),
|
| 144 |
+
"provider": ("store", "ai_provider", False, "로컬 태미니 AI 공급자 (none·nvidia·openrouter·glm·ollama)"),
|
| 145 |
+
"model": ("store", "ai_model", False, "로컬 태미니 모델 ID"),
|
| 146 |
+
"ultra": ("json", "TAEMINI_ULTRA_MODE", False, "ULTRA MODE 연출 (on/off — 기본 on)"),
|
| 147 |
+
"cloud-url": ("json", "TAEMINI_CLOUD_URL", False, "클라우드 쉐도우 브레인 URL (/server cloud 대상)"),
|
| 148 |
+
"cloud-key": ("json", "TAEMINI_CLOUD_KEY", True, "클라우드 접속 키 (X-Taemini-Key — HF 게이트)"),
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
DEFAULT_CLOUD_URL = "https://taemin1980-shadowbrain.hf.space"
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def cloud_url():
|
| 155 |
+
return (load_settings().get("TAEMINI_CLOUD_URL") or DEFAULT_CLOUD_URL).rstrip("/")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def auth_headers(server):
|
| 159 |
+
"""원격(비로컬) 서버 호출 시 클라우드 키 자동 첨부 — HF 공개 게이트 통과용."""
|
| 160 |
+
try:
|
| 161 |
+
if _is_local_server(server):
|
| 162 |
+
return {}
|
| 163 |
+
except Exception:
|
| 164 |
+
return {}
|
| 165 |
+
key = (load_settings().get("TAEMINI_CLOUD_KEY") or "").strip()
|
| 166 |
+
return {"X-Taemini-Key": key} if key else {}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ⚡ ULTRA MODE 토글 — 기본 켜짐. /config set ultra off 로 이전(무배지) 룩 복귀.
|
| 170 |
+
# 나중에 '스킨' 시스템으로 확장 예정: 이 값이 'on/off'에서 스킨 id로 진화하고,
|
| 171 |
+
# 유저 구매에 따라 팔레트/문구가 달라지는 구조의 자리를 여기 잡아둔다.
|
| 172 |
+
_ULTRA_CACHE = {"v": None}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _ultra_enabled():
|
| 176 |
+
if _ULTRA_CACHE["v"] is None:
|
| 177 |
+
val = str(load_settings().get("TAEMINI_ULTRA_MODE", "on")).strip().lower()
|
| 178 |
+
_ULTRA_CACHE["v"] = val not in ("off", "0", "false", "no")
|
| 179 |
+
return _ULTRA_CACHE["v"]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _config_get(name):
|
| 183 |
+
where, key, _secret, _desc = CONFIG_KEYS[name]
|
| 184 |
+
return load_settings().get(key, "") if where == "json" else _store_get(key)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _config_set(name, value):
|
| 188 |
+
where, key, _secret, _desc = CONFIG_KEYS[name]
|
| 189 |
+
if where == "json":
|
| 190 |
+
s = load_settings()
|
| 191 |
+
if value:
|
| 192 |
+
s[key] = value
|
| 193 |
+
else:
|
| 194 |
+
s.pop(key, None)
|
| 195 |
+
save_settings(s)
|
| 196 |
+
else:
|
| 197 |
+
_store_set(key, value)
|
| 198 |
+
if name == "ultra":
|
| 199 |
+
_ULTRA_CACHE["v"] = None # 다음 렌더부터 즉시 반영 (재시작 불필요)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _mask(value):
|
| 203 |
+
if not value:
|
| 204 |
+
return dim("(없음)")
|
| 205 |
+
return "•" * 8 + value[-4:] if len(value) > 4 else "•" * 8
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def cmd_config(arg):
|
| 209 |
+
parts = arg.split(None, 2)
|
| 210 |
+
if not parts:
|
| 211 |
+
print(bold("현재 설정") + dim(f" ({_settings_path()})"))
|
| 212 |
+
for name, (_w, _k, secret, desc) in CONFIG_KEYS.items():
|
| 213 |
+
v = _config_get(name)
|
| 214 |
+
shown = _mask(v) if secret else (v or dim("(없음)"))
|
| 215 |
+
print(f" {cyan(name.ljust(12))} {shown} {dim(desc)}")
|
| 216 |
+
print(dim("변경: /config set <이름> <값> · 삭제: /config unset <이름> · 대화식: /setup"))
|
| 217 |
+
return
|
| 218 |
+
action = parts[0].lower()
|
| 219 |
+
name = parts[1].lower() if len(parts) > 1 else ""
|
| 220 |
+
if name not in CONFIG_KEYS:
|
| 221 |
+
print(red(f"알 수 없는 설정 이름: {name}") + dim(f" ({'·'.join(CONFIG_KEYS)})"))
|
| 222 |
+
return
|
| 223 |
+
if action == "set" and len(parts) == 3:
|
| 224 |
+
_config_set(name, parts[2].strip())
|
| 225 |
+
print(green(f"→ {name} 저장 완료.") + dim(" (데몬 재시작 후 반영: 데몬 종료 뒤 taemini 재실행)"))
|
| 226 |
+
elif action == "unset":
|
| 227 |
+
_config_set(name, "")
|
| 228 |
+
print(green(f"→ {name} 삭제 완료."))
|
| 229 |
+
else:
|
| 230 |
+
print(red("사용법: /config set <이름> <값> | /config unset <이름>"))
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def run_setup_wizard():
|
| 234 |
+
"""터미널 온보딩 — GUI 설정 창의 CLI 대체. Enter=기존 값 유지."""
|
| 235 |
+
import getpass
|
| 236 |
+
print(bold(cyan("\n🔱 쉐도우 브레인 터미널 설��")) + dim(" (Enter = 기존 값 유지, '-' 입력 = 값 삭제)"))
|
| 237 |
+
for name, (_w, _k, secret, desc) in CONFIG_KEYS.items():
|
| 238 |
+
current = _config_get(name)
|
| 239 |
+
shown = _mask(current) if secret else (current or dim("(없음)"))
|
| 240 |
+
prompt = f" {desc} [{shown}]: "
|
| 241 |
+
try:
|
| 242 |
+
raw = getpass.getpass(prompt) if secret else input(prompt)
|
| 243 |
+
except (EOFError, KeyboardInterrupt):
|
| 244 |
+
print(dim("\n설정을 중단합니다 (지금까지 입력한 값은 저장됨)."))
|
| 245 |
+
return
|
| 246 |
+
raw = raw.strip()
|
| 247 |
+
if not raw:
|
| 248 |
+
continue
|
| 249 |
+
_config_set(name, "" if raw == "-" else raw)
|
| 250 |
+
print(green(" ✓ 저장"))
|
| 251 |
+
if not _config_get("agent-token"):
|
| 252 |
+
print(dim(" ℹ️ Agent Token이 없으면 데몬 첫 부팅 때 자동 발급되어 저장됩니다."))
|
| 253 |
+
print(green("✅ 설정 완료.") + dim(" 데몬이 이미 떠 있었다면 재시작해야 반영됩니다.\n"))
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# ─────────────────────────────────────────────────────────────────
|
| 257 |
+
# 데몬 자동 소환 — 로컬 데몬이 꺼져 있으면 Shadow Brain을 백그라운드로 띄운다
|
| 258 |
+
# ─────────────────────────────────────────────────────────────────
|
| 259 |
+
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
| 260 |
+
|
| 261 |
+
SUMMON_WAIT_SEC = 120 # 데몬 점화(모델 로드 포함) 대기 상한
|
| 262 |
+
|
| 263 |
+
# 🔮 소환 대기 배너 — Claude Code ULTRA 셀렉터풍 보라 그라데이션 물결.
|
| 264 |
+
# 트루컬러 미지원 터미널은 xterm-256 보라 계열로 폴백.
|
| 265 |
+
_WAVE_256 = [54, 55, 56, 57, 93, 99, 98, 92] # 어두운→밝은 보라 왕복용
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _summon_wave_line(text, phase):
|
| 269 |
+
"""text를 셀 단위 보라 그라데이션 배경 위에 얹은 한 줄 ANSI 문자열.
|
| 270 |
+
phase가 증가하면 물결이 왼쪽→오른쪽으로 흐른다. 한글/이모지는 2셀 폭으로 계산."""
|
| 271 |
+
truecolor = "truecolor" in os.environ.get("COLORTERM", "") or \
|
| 272 |
+
"24bit" in os.environ.get("COLORTERM", "")
|
| 273 |
+
out = []
|
| 274 |
+
col = 0 # 화면 셀 위치 (전각 문자는 2칸)
|
| 275 |
+
for ch in text:
|
| 276 |
+
wide = unicodedata.east_asian_width(ch) in ("W", "F")
|
| 277 |
+
# 0..1 물결 밝기 — 위치·시간 위상으로 사인파
|
| 278 |
+
w = 0.5 + 0.5 * math.sin(col * 0.22 - phase * 0.35)
|
| 279 |
+
if truecolor:
|
| 280 |
+
r = int(52 + 88 * w) # 52~140
|
| 281 |
+
g = int(16 + 44 * w) # 16~60
|
| 282 |
+
b = int(110 + 110 * w) # 110~220
|
| 283 |
+
bg = f"\033[48;2;{r};{g};{b}m"
|
| 284 |
+
else:
|
| 285 |
+
bg = f"\033[48;5;{_WAVE_256[int(w * (len(_WAVE_256) - 1) + 0.5)]}m"
|
| 286 |
+
out.append(f"{bg}\033[97;1m{ch}")
|
| 287 |
+
col += 2 if wide else 1
|
| 288 |
+
out.append("\033[0m")
|
| 289 |
+
return "".join(out)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def daemon_alive(server, timeout=2):
|
| 293 |
+
try:
|
| 294 |
+
get_json(server, "/api/shadow/config", timeout=timeout)
|
| 295 |
+
return True
|
| 296 |
+
except Exception:
|
| 297 |
+
return False
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _is_local_server(server):
|
| 301 |
+
from urllib.parse import urlparse
|
| 302 |
+
host = (urlparse(server).hostname or "").lower()
|
| 303 |
+
return host in ("127.0.0.1", "localhost", "::1")
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def _find_summon_command():
|
| 307 |
+
"""플랫폼별 Shadow Brain 실행 명령 탐색. (문자열=셸 실행, 리스트=직접 실행)
|
| 308 |
+
|
| 309 |
+
우선순위: 환경변수 override → 리포 소스+venv(--autostart 헤드리스 확실) →
|
| 310 |
+
설치된 앱/exe(--autostart 인자 전달 — 구버전 빌드는 무시하고 설정 창을 띄우므로
|
| 311 |
+
그 경우 사용자가 '시작하기'를 눌러야 점화된다).
|
| 312 |
+
"""
|
| 313 |
+
override = (os.environ.get("TAEMINI_SUMMON_CMD") or "").strip()
|
| 314 |
+
if override:
|
| 315 |
+
return override
|
| 316 |
+
# 1) 리포 소스 실행 — CLI가 리포 안에 살고 venv가 있으면 헤드리스 자동 시작 보장
|
| 317 |
+
root = os.path.dirname(os.path.abspath(__file__))
|
| 318 |
+
launcher = os.path.join(root, "local_launcher.py")
|
| 319 |
+
venv_py = os.path.join(root, "venv",
|
| 320 |
+
"Scripts" if os.name == "nt" else "bin",
|
| 321 |
+
"python.exe" if os.name == "nt" else "python3")
|
| 322 |
+
if os.path.isfile(launcher) and os.path.isfile(venv_py):
|
| 323 |
+
return [venv_py, launcher, "--autostart"]
|
| 324 |
+
# 2) 설치된 배포본
|
| 325 |
+
if sys.platform == "darwin":
|
| 326 |
+
for app in ("/Applications/ShadowBrain.app",
|
| 327 |
+
os.path.expanduser("~/Applications/ShadowBrain.app")):
|
| 328 |
+
if os.path.isdir(app):
|
| 329 |
+
return ["open", "-a", app, "--args", "--autostart"]
|
| 330 |
+
elif os.name == "nt":
|
| 331 |
+
for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)"),
|
| 332 |
+
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs")):
|
| 333 |
+
exe = os.path.join(base or "", "TaeminGames", "ShadowBrain", "ShadowBrain.exe")
|
| 334 |
+
if base and os.path.isfile(exe):
|
| 335 |
+
return [exe, "--autostart"]
|
| 336 |
+
# 3) venv 없는 소스 (표준 파이썬 — web_server 의존성 부족 가능성 있으나 최후 시도)
|
| 337 |
+
if os.path.isfile(launcher):
|
| 338 |
+
return [sys.executable, launcher, "--autostart"]
|
| 339 |
+
return None
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def summon_daemon(server):
|
| 343 |
+
"""데몬이 꺼져 있으면 백그라운드로 소환하고 점화 완료까지 대기. 성공 여부 반환."""
|
| 344 |
+
import subprocess
|
| 345 |
+
cmd = _find_summon_command()
|
| 346 |
+
if not cmd:
|
| 347 |
+
print(red("❌ Shadow Brain 실행 파일을 찾지 못했습니다."))
|
| 348 |
+
print(dim(" TAEMINI_SUMMON_CMD 환경변수로 실행 명령을 지정하거나 직접 실행해 주십시오."))
|
| 349 |
+
return False
|
| 350 |
+
shown = cmd if isinstance(cmd, str) else " ".join(cmd)
|
| 351 |
+
print(dim(f"🔱 쉐도우 브레인이 꺼져 있어 소환합니다: {shown}"))
|
| 352 |
+
if not isinstance(cmd, str) and "--autostart" in cmd and (".app" in shown or ".exe" in shown):
|
| 353 |
+
# 구버전 배포본은 --autostart를 몰라 설정 창을 띄운다 — 사용자 클릭 안내
|
| 354 |
+
print(dim(" (설정 창이 뜨면 '시작하기'를 눌러 주십시오 — 최신 빌드는 창 없이 자동 점화됩니다)"))
|
| 355 |
+
try:
|
| 356 |
+
kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
| 357 |
+
stdin=subprocess.DEVNULL)
|
| 358 |
+
if os.name == "nt":
|
| 359 |
+
kwargs["creationflags"] = 0x00000008 | 0x00000200 # DETACHED | NEW_PROCESS_GROUP
|
| 360 |
+
else:
|
| 361 |
+
kwargs["start_new_session"] = True
|
| 362 |
+
if isinstance(cmd, str):
|
| 363 |
+
subprocess.Popen(cmd, shell=True, **kwargs)
|
| 364 |
+
else:
|
| 365 |
+
subprocess.Popen(cmd, **kwargs)
|
| 366 |
+
except Exception as e:
|
| 367 |
+
print(red(f"❌ 소환 실패: {e}"))
|
| 368 |
+
return False
|
| 369 |
+
|
| 370 |
+
started = time.time()
|
| 371 |
+
i = 0
|
| 372 |
+
last_check = 0.0
|
| 373 |
+
tick = 0.08 if _USE_COLOR else 1.0 # 컬러면 물결 애니메이션(12.5fps), 아니면 1초 스피너
|
| 374 |
+
while time.time() - started < SUMMON_WAIT_SEC:
|
| 375 |
+
now = time.time()
|
| 376 |
+
if now - last_check >= 1.0: # 생존 확인은 1초마다 (urlopen 부하 억제)
|
| 377 |
+
last_check = now
|
| 378 |
+
if daemon_alive(server):
|
| 379 |
+
sys.stdout.write("\r\033[K" if _USE_COLOR else "\r")
|
| 380 |
+
print(green(f"✅ 쉐도우 브레인 점화 완료 ({now - started:.0f}s)"))
|
| 381 |
+
return True
|
| 382 |
+
i += 1
|
| 383 |
+
elapsed = now - started
|
| 384 |
+
if _USE_COLOR:
|
| 385 |
+
msg = f" 🔮 ShadowBrain 로딩 중 — 잠시만 기다려주세요… {elapsed:3.0f}s / {SUMMON_WAIT_SEC}s "
|
| 386 |
+
sys.stdout.write("\r\033[K" + _summon_wave_line(msg, i))
|
| 387 |
+
else:
|
| 388 |
+
frame = _SPINNER[i % len(_SPINNER)]
|
| 389 |
+
sys.stdout.write(f"\r{frame} 쉐도우 브레인 점화 대기 중 · {elapsed:4.0f}s / {SUMMON_WAIT_SEC}s")
|
| 390 |
+
sys.stdout.flush()
|
| 391 |
+
time.sleep(tick)
|
| 392 |
+
sys.stdout.write("\r\033[K" if _USE_COLOR else "\r")
|
| 393 |
+
print(red(f"❌ {SUMMON_WAIT_SEC}초 안에 데몬이 응답하지 않았습니다. 직접 실행 상태를 확인해 주십시오."))
|
| 394 |
+
return False
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def ensure_daemon(server, auto_summon=True):
|
| 398 |
+
"""데몬 생존 확인 → 로컬이면 자동 소환까지. True면 사용 가능."""
|
| 399 |
+
if daemon_alive(server):
|
| 400 |
+
return True
|
| 401 |
+
if not _is_local_server(server):
|
| 402 |
+
print(red(f"❌ 원격 쉐도우 브레인({server})에 연결할 수 없습니다."))
|
| 403 |
+
return False
|
| 404 |
+
if not auto_summon:
|
| 405 |
+
print(red(f"❌ 쉐도우 브레인 데몬({server})이 꺼져 있습니다. (--no-summon 지정됨)"))
|
| 406 |
+
return False
|
| 407 |
+
return summon_daemon(server)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
# ─────────────────────────────────────────────────────────────────
|
| 411 |
+
# 수호자(중계) 정의 — shadow_brain_core/blueprints/relays/* 라우트와 1:1
|
| 412 |
+
# ─────────────────────────────────────────────────────────────────
|
| 413 |
+
GUARDIAN_LABELS = {
|
| 414 |
+
"taemini": "로컬 태미니 (Shadow Brain)",
|
| 415 |
+
"codex": "로컬 코덱스 (Codex CLI)",
|
| 416 |
+
"minimax": "로컬 MiniMax Code (Mavis)",
|
| 417 |
+
"hermes": "헤르메스 (Hermes ACP)",
|
| 418 |
+
"claude": "자비스 (Claude Code 채널)",
|
| 419 |
+
"lmstudio": "LM Studio (로컬 LLM)",
|
| 420 |
+
"grok": "로컬 그록 (Grok CLI)",
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
GUARDIAN_ALIASES = {
|
| 424 |
+
"태미니": "taemini", "shadow": "taemini", "타미니": "taemini",
|
| 425 |
+
"코덱스": "codex",
|
| 426 |
+
"미니맥스": "minimax", "mavis": "minimax",
|
| 427 |
+
"헤르메스": "hermes",
|
| 428 |
+
"자비스": "claude", "claude-code": "claude", "jarvis": "claude",
|
| 429 |
+
"lm": "lmstudio", "lms": "lmstudio", "엘엠스튜디오": "lmstudio",
|
| 430 |
+
"그록": "grok", "xai": "grok",
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def resolve_guardian(name):
|
| 435 |
+
n = (name or "").strip().lower()
|
| 436 |
+
n = GUARDIAN_ALIASES.get(n, n)
|
| 437 |
+
return n if n in GUARDIAN_LABELS else None
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
# 🎖️ [직업 강제 안내] 이 수호자가 직업(도구/스킬/앱)을 실제로 100% 따르는가.
|
| 441 |
+
# 기준: "그 수��자의 디스패치 루프를 우리가 통제하는가"(규약: job-enforcement-axis).
|
| 442 |
+
# - taemini(=로컬 태미니, 우리 코딩 에이전트) → 직업 100% 강제
|
| 443 |
+
# - 그 외 릴레이(codex/minimax/hermes/claude/lmstudio) → 남의 에이전트, 직업 무시(자체 도구만)
|
| 444 |
+
JOB_ENFORCED_GUARDIANS = {"taemini"}
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def job_enforce_tag(guardian, verbose=False):
|
| 448 |
+
"""멘션 메뉴/목록에 붙일 짧은 직업 강제 표식. verbose면 설명형 문구."""
|
| 449 |
+
if guardian in JOB_ENFORCED_GUARDIANS:
|
| 450 |
+
return "직업 100% 사용" if verbose else "🎖️직업"
|
| 451 |
+
return "직업 무시 · 자체 도구만" if verbose else "직업✕"
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
# ─────────────────────────────────────────────────────────────────
|
| 455 |
+
# 📁 폴더 신뢰 (Claude Code 동일 철학) — 낯선 폴더의 프롬프트 인젝션 방어.
|
| 456 |
+
# 신뢰한 폴더만 코딩 도구(파일 수정·명령 실행)의 작업 루트로 전달한다.
|
| 457 |
+
# 저장소: SQLite(local_guardian_kv, SSOT) + local_settings.json 이중 기록.
|
| 458 |
+
# 예전 JSON 단독 기록은 첫 조회 때 SQLite로 자동 이관. 하위 폴더 자동 신뢰.
|
| 459 |
+
# ─────────────────────────────────────────────────────────────────
|
| 460 |
+
def _load_trust_state():
|
| 461 |
+
"""(신뢰 폴더 목록, 최근 사용 시각 맵) — SQLite 우선, 없으면 JSON에서 이관."""
|
| 462 |
+
dirs = used = None
|
| 463 |
+
try:
|
| 464 |
+
raw = _store_get("trusted_dirs", "")
|
| 465 |
+
if raw:
|
| 466 |
+
v = json.loads(raw)
|
| 467 |
+
if isinstance(v, list):
|
| 468 |
+
dirs = [str(x) for x in v]
|
| 469 |
+
raw = _store_get("trusted_dirs_used", "")
|
| 470 |
+
if raw:
|
| 471 |
+
v = json.loads(raw)
|
| 472 |
+
if isinstance(v, dict):
|
| 473 |
+
used = {}
|
| 474 |
+
for k, t in v.items():
|
| 475 |
+
try:
|
| 476 |
+
used[str(k)] = float(t)
|
| 477 |
+
except (TypeError, ValueError):
|
| 478 |
+
pass
|
| 479 |
+
except Exception:
|
| 480 |
+
dirs = used = None
|
| 481 |
+
if dirs is None or used is None:
|
| 482 |
+
s = load_settings()
|
| 483 |
+
if dirs is None:
|
| 484 |
+
v = s.get("TAEMINI_TRUSTED_DIRS")
|
| 485 |
+
dirs = [str(x) for x in v] if isinstance(v, list) else []
|
| 486 |
+
if used is None:
|
| 487 |
+
v = s.get("TAEMINI_TRUSTED_DIRS_USED")
|
| 488 |
+
used = {}
|
| 489 |
+
if isinstance(v, dict):
|
| 490 |
+
for k, t in v.items():
|
| 491 |
+
try:
|
| 492 |
+
used[str(k)] = float(t)
|
| 493 |
+
except (TypeError, ValueError):
|
| 494 |
+
pass
|
| 495 |
+
if dirs or used:
|
| 496 |
+
_save_trust_state(dirs, used) # JSON → SQLite 1회 이관
|
| 497 |
+
return dirs, used
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
def _save_trust_state(dirs, used):
|
| 501 |
+
# 신뢰 목록에서 빠진 최근 사용 기록은 정리
|
| 502 |
+
keep = set(os.path.abspath(t) for t in dirs)
|
| 503 |
+
used = {k: v for k, v in used.items() if os.path.abspath(k) in keep}
|
| 504 |
+
try:
|
| 505 |
+
_store_set("trusted_dirs", json.dumps(dirs, ensure_ascii=False))
|
| 506 |
+
_store_set("trusted_dirs_used", json.dumps(used))
|
| 507 |
+
except Exception:
|
| 508 |
+
pass # SQLite 불가 환경 — JSON 폴백만으로 동작
|
| 509 |
+
s = load_settings()
|
| 510 |
+
s["TAEMINI_TRUSTED_DIRS"] = dirs
|
| 511 |
+
s["TAEMINI_TRUSTED_DIRS_USED"] = used
|
| 512 |
+
save_settings(s)
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def _trusted_dirs():
|
| 516 |
+
return _load_trust_state()[0]
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def is_trusted_dir(path):
|
| 520 |
+
p = os.path.abspath(path)
|
| 521 |
+
for t in _trusted_dirs():
|
| 522 |
+
t = os.path.abspath(t)
|
| 523 |
+
if p == t or (p + os.sep).startswith(t + os.sep):
|
| 524 |
+
return True
|
| 525 |
+
return False
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def trust_dir(path):
|
| 529 |
+
p = os.path.abspath(path)
|
| 530 |
+
dirs, used = _load_trust_state()
|
| 531 |
+
if p not in dirs:
|
| 532 |
+
dirs.append(p)
|
| 533 |
+
used[p] = time.time()
|
| 534 |
+
_save_trust_state(dirs, used)
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def touch_trusted_dir(path):
|
| 538 |
+
"""신뢰 폴더 최근 사용 시각 기록 — 시작 선택창의 정렬/`(9h ago)` 표시용."""
|
| 539 |
+
p = os.path.abspath(path)
|
| 540 |
+
dirs, used = _load_trust_state()
|
| 541 |
+
used[p] = time.time()
|
| 542 |
+
_save_trust_state(dirs, used)
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def _fmt_ago(ts):
|
| 546 |
+
if not ts:
|
| 547 |
+
return ""
|
| 548 |
+
sec = max(0, time.time() - float(ts))
|
| 549 |
+
if sec < 3600:
|
| 550 |
+
return f"{int(sec // 60)}m ago"
|
| 551 |
+
if sec < 86400:
|
| 552 |
+
return f"{int(sec // 3600)}h ago"
|
| 553 |
+
return f"{int(sec // 86400)}d ago"
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def _tilde(path):
|
| 557 |
+
home = os.path.expanduser("~")
|
| 558 |
+
return path.replace(home, "~", 1) if path.startswith(home) else path
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def untrust_dir(path):
|
| 562 |
+
"""신뢰 목록에서 제거 — 시작 선택창의 d 키 / `/untrust` 명령용."""
|
| 563 |
+
p = os.path.abspath(path)
|
| 564 |
+
dirs, used = _load_trust_state()
|
| 565 |
+
dirs = [d for d in dirs if os.path.abspath(d) != p]
|
| 566 |
+
used.pop(p, None)
|
| 567 |
+
_save_trust_state(dirs, used)
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def _start_dir_candidates(cur, dirs, used):
|
| 571 |
+
cands = [os.path.abspath(t) for t in dirs
|
| 572 |
+
if os.path.isdir(t) and os.path.abspath(t) != cur]
|
| 573 |
+
cands.sort(key=lambda p: used.get(p, 0), reverse=True)
|
| 574 |
+
return cands[:8]
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def _read_menu_key():
|
| 578 |
+
"""1키 읽기 → 'up'/'down'/'enter'/'esc'/소문자 문자.
|
| 579 |
+
유닉스는 _pick_menu가 메뉴 동안 cbreak 모드를 유지한 상태에서 호출한다.
|
| 580 |
+
⚠️ sys.stdin.read(1) 금지 — 버퍼드 IO가 뒤따르는 이스케이프 바이트를 내부
|
| 581 |
+
버퍼로 삼켜 fd 기준 select()가 항상 비어 보인다(화살표→Esc 오인 실사례)."""
|
| 582 |
+
if os.name == "nt":
|
| 583 |
+
import msvcrt
|
| 584 |
+
ch = msvcrt.getwch()
|
| 585 |
+
if ch in ("\x00", "\xe0"):
|
| 586 |
+
return {"H": "up", "P": "down"}.get(msvcrt.getwch(), "")
|
| 587 |
+
if ch in ("\r", "\n"):
|
| 588 |
+
return "enter"
|
| 589 |
+
if ch == "\x03":
|
| 590 |
+
raise KeyboardInterrupt
|
| 591 |
+
return "esc" if ch == "\x1b" else ch.lower()
|
| 592 |
+
import select as _sel
|
| 593 |
+
fd = sys.stdin.fileno()
|
| 594 |
+
ch = os.read(fd, 1).decode("utf-8", "ignore")
|
| 595 |
+
if ch == "\x1b":
|
| 596 |
+
if not _sel.select([fd], [], [], 0.05)[0]:
|
| 597 |
+
return "esc"
|
| 598 |
+
seq = os.read(fd, 2).decode("ascii", "ignore")
|
| 599 |
+
if seq.startswith("["):
|
| 600 |
+
c = seq[1:] or os.read(fd, 1).decode("ascii", "ignore")
|
| 601 |
+
return {"A": "up", "B": "down"}.get(c, "")
|
| 602 |
+
return "esc"
|
| 603 |
+
if ch in ("\r", "\n"):
|
| 604 |
+
return "enter"
|
| 605 |
+
if ch == "\x03":
|
| 606 |
+
raise KeyboardInterrupt
|
| 607 |
+
return ch.lower()
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
def _menu_rows(cwd, cands, used):
|
| 611 |
+
rows = [("cur", cwd, f"{_tilde(cwd)} (현재)")]
|
| 612 |
+
for p in cands:
|
| 613 |
+
ago = _fmt_ago(used.get(p))
|
| 614 |
+
name = os.path.basename(p) or p
|
| 615 |
+
rows.append(("dir", p, f"{name:<20s} {_tilde(p)}" + (f" ({ago})" if ago else "")))
|
| 616 |
+
rows.append(("z", None, "직접 경로 입력"))
|
| 617 |
+
return rows
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def _pick_menu(cwd):
|
| 621 |
+
"""화살표 선택 메뉴 — ↑↓ 이동, Enter 선택, 숫자 단축, d=항목 제거(신뢰 해제).
|
| 622 |
+
반환: ('cur'|'dir'|'z', 경로)."""
|
| 623 |
+
idx = 0
|
| 624 |
+
drawn = 0
|
| 625 |
+
old_attr = None
|
| 626 |
+
if os.name != "nt":
|
| 627 |
+
import termios
|
| 628 |
+
import tty
|
| 629 |
+
fd = sys.stdin.fileno()
|
| 630 |
+
old_attr = termios.tcgetattr(fd) # 비TTY면 여기서 예외 → 숫자 폴백
|
| 631 |
+
# ⚠️ setraw(기본 TCSAFLUSH) 금지 — 렌더링 중 눌린 키가 통째로 버려져
|
| 632 |
+
# "첫 키만 먹히는" 실사례. cbreak를 메뉴 동안 1회만 유지(TCSANOW, 입력 보존).
|
| 633 |
+
tty.setcbreak(fd, termios.TCSANOW)
|
| 634 |
+
sys.stdout.write("\033[?25l") # 커서 숨김
|
| 635 |
+
try:
|
| 636 |
+
while True:
|
| 637 |
+
dirs, used = _load_trust_state()
|
| 638 |
+
rows = _menu_rows(cwd, _start_dir_candidates(cwd, dirs, used), used)
|
| 639 |
+
idx = max(0, min(idx, len(rows) - 1))
|
| 640 |
+
if drawn:
|
| 641 |
+
sys.stdout.write(f"\033[{drawn}A")
|
| 642 |
+
for i, (_kind, _p, label) in enumerate(rows):
|
| 643 |
+
line = f" {i + 1}) {label}"
|
| 644 |
+
if i == idx:
|
| 645 |
+
line = f"\033[7m ❯ {i + 1}) {label}\033[0m"
|
| 646 |
+
sys.stdout.write("\033[K" + line + "\n")
|
| 647 |
+
sys.stdout.write("\033[J") # 항목 삭제로 줄이 줄었을 때 잔여 줄 정리
|
| 648 |
+
drawn = len(rows)
|
| 649 |
+
sys.stdout.flush()
|
| 650 |
+
key = _read_menu_key()
|
| 651 |
+
if key == "up":
|
| 652 |
+
idx = (idx - 1) % len(rows)
|
| 653 |
+
elif key == "down":
|
| 654 |
+
idx = (idx + 1) % len(rows)
|
| 655 |
+
elif key in ("enter",):
|
| 656 |
+
return rows[idx][0], rows[idx][1]
|
| 657 |
+
elif key == "esc" or key == "q":
|
| 658 |
+
return "cur", cwd
|
| 659 |
+
elif key == "z":
|
| 660 |
+
return "z", None
|
| 661 |
+
elif key == "d" and rows[idx][0] == "dir":
|
| 662 |
+
untrust_dir(rows[idx][1]) # 다음 루프에서 목록 재구성
|
| 663 |
+
elif key and key.isdigit() and 1 <= int(key) <= len(rows):
|
| 664 |
+
return rows[int(key) - 1][0], rows[int(key) - 1][1]
|
| 665 |
+
finally:
|
| 666 |
+
sys.stdout.write("\033[?25h")
|
| 667 |
+
sys.stdout.flush()
|
| 668 |
+
if old_attr is not None:
|
| 669 |
+
import termios
|
| 670 |
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_attr)
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def _pick_numeric(cwd, cands, used):
|
| 675 |
+
"""숫자 입력 폴백 (원시 키 입력 불가 환경)."""
|
| 676 |
+
for i, (_kind, _p, label) in enumerate(_menu_rows(cwd, cands, used), start=1):
|
| 677 |
+
print(f" {i}) {label}")
|
| 678 |
+
try:
|
| 679 |
+
ans = input(" 선택 [1] ").strip().lower()
|
| 680 |
+
except (EOFError, KeyboardInterrupt):
|
| 681 |
+
ans = ""
|
| 682 |
+
if ans == "z" or (ans.isdigit() and int(ans) == len(cands) + 2):
|
| 683 |
+
return "z", None
|
| 684 |
+
if ans.isdigit() and 2 <= int(ans) <= len(cands) + 1:
|
| 685 |
+
return "dir", cands[int(ans) - 2]
|
| 686 |
+
return "cur", cwd
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def pick_start_dir(cwd):
|
| 690 |
+
"""그록 빌드식 시작 폴더 선택창 — 대화식 시작 시 항상,
|
| 691 |
+
과거에 신뢰했던 폴더들을 최근 사용 순으로 보여주고 골라서 시작한다.
|
| 692 |
+
화살표 메뉴(↑↓·Enter·숫자 단축·d=목록 제거), 미지원 환경은 숫자 입력.
|
| 693 |
+
엔터 한 번 = 현재 폴더 그대로. 반환: (선택된 경로, 그 경로가 신뢰 폴더인지)."""
|
| 694 |
+
cur = os.path.abspath(cwd)
|
| 695 |
+
dirs, used = _load_trust_state()
|
| 696 |
+
cands = _start_dir_candidates(cur, dirs, used)
|
| 697 |
+
if not cands:
|
| 698 |
+
return cwd, is_trusted_dir(cwd)
|
| 699 |
+
|
| 700 |
+
print(bold("📁 프로젝트 폴더에서 시작할까요?"))
|
| 701 |
+
print(dim(" ↑↓ 이동 · Enter 선택 · 숫자 바로 선택 · d 목록에서 제거 · z 직접 경로"))
|
| 702 |
+
try:
|
| 703 |
+
kind, path = _pick_menu(cur)
|
| 704 |
+
except KeyboardInterrupt:
|
| 705 |
+
return cwd, is_trusted_dir(cwd)
|
| 706 |
+
except Exception:
|
| 707 |
+
kind, path = _pick_numeric(cur, cands, used)
|
| 708 |
+
if kind == "dir":
|
| 709 |
+
return path, True
|
| 710 |
+
if kind == "z":
|
| 711 |
+
try:
|
| 712 |
+
typed = input(" 경로: ").strip()
|
| 713 |
+
except (EOFError, KeyboardInterrupt):
|
| 714 |
+
typed = ""
|
| 715 |
+
p = os.path.abspath(os.path.expanduser(typed)) if typed else cwd
|
| 716 |
+
if typed and os.path.isdir(p):
|
| 717 |
+
return p, is_trusted_dir(p)
|
| 718 |
+
if typed:
|
| 719 |
+
print(red(f" 폴더가 아닙니다: {typed} — 현재 폴더로 시작합니다."))
|
| 720 |
+
return cwd, is_trusted_dir(cwd)
|
| 721 |
+
|
| 722 |
+
|
| 723 |
+
class Session:
|
| 724 |
+
"""대화 상태 — 수호자별 세션 키(연속성) + 작업 루트."""
|
| 725 |
+
|
| 726 |
+
def __init__(self, server, cwd, guardian="taemini"):
|
| 727 |
+
self.server = server
|
| 728 |
+
self.cwd = os.path.abspath(cwd)
|
| 729 |
+
self.trusted = True # main()의 신뢰 게이트가 결정 (False=안전 모드)
|
| 730 |
+
self.guardian = guardian
|
| 731 |
+
self.mode = "chat" # chat=일반 · plan=플랜(계획만, 수정 금지) — Shift+Tab 전환
|
| 732 |
+
self.epoch = "" # /new 시 바뀌는 세션 소금값
|
| 733 |
+
self.minimax_session = "" # mavis가 돌려준 세션 ID 재사용(세션 폭증 방지)
|
| 734 |
+
# 🔊 Edge TTS (응답 읽어주기) — local_settings.json에 영속
|
| 735 |
+
_s = load_settings()
|
| 736 |
+
self.tts_enabled = bool(_s.get("CLI_TTS_ENABLED"))
|
| 737 |
+
self.tts_voice = _s.get("CLI_TTS_VOICE") or "ko-KR-SunHiNeural"
|
| 738 |
+
|
| 739 |
+
@property
|
| 740 |
+
def work_cwd(self):
|
| 741 |
+
"""코딩 작업 루트 — 신뢰된 폴더만 서버에 전달. 비신뢰면 None(안전 모드)."""
|
| 742 |
+
return self.cwd if self.trusted else None
|
| 743 |
+
|
| 744 |
+
def key(self, prefix):
|
| 745 |
+
h = hashlib.sha1(self.cwd.encode("utf-8")).hexdigest()[:8]
|
| 746 |
+
return f"{prefix}{h}{self.epoch}"
|
| 747 |
+
|
| 748 |
+
def taemini_key(self):
|
| 749 |
+
# 'taemini-code-' 접두사 → 서버가 간결 수칙 + 코딩 에이전트 모드로 처리
|
| 750 |
+
return self.key("taemini-code-cli-")
|
| 751 |
+
|
| 752 |
+
def new_epoch(self):
|
| 753 |
+
self.epoch = "-" + hashlib.sha1(str(time.time()).encode()).hexdigest()[:6]
|
| 754 |
+
self.minimax_session = ""
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
def send_to_guardian(sess, guardian, message, plain=False, session_override=None):
|
| 758 |
+
"""수호자에게 메시지 전송 → (본문, todos, stats) 반환. 오류는 예외로.
|
| 759 |
+
|
| 760 |
+
plain=True: 코딩 콘솔 세션이 아닌 일반 대화 세션으로 보낸다 (/debate 등 —
|
| 761 |
+
코딩 수칙·코딩 모드가 주입되면 토론에 자꾸 code_* 도구 얘기를 하므로 분리).
|
| 762 |
+
session_override: 태미니 세션 키를 직접 지정 (토론은 회차마다 새 세션 —
|
| 763 |
+
이전 토론의 오염된 히스토리를 이어받는 사고 방지).
|
| 764 |
+
"""
|
| 765 |
+
if guardian == "taemini":
|
| 766 |
+
if plain:
|
| 767 |
+
payload = {"message": message,
|
| 768 |
+
"sessionKey": session_override or sess.key("cli-debate-"),
|
| 769 |
+
"skipGrounding": True,
|
| 770 |
+
# 시스템 레벨 토론 지시 — 코딩 페르소나의 도구 강박을 차단
|
| 771 |
+
"extraSystem": ("지금은 '수호자 토론 모드'다. 코딩 작업이 아니다. "
|
| 772 |
+
"어떤 도구도 호출하지 말고, 주어진 토론 주제에 대한 "
|
| 773 |
+
"의견 텍스트만 간결히 출력하라. 경로/파일/도구 이야기를 꺼내지 마라.")}
|
| 774 |
+
else:
|
| 775 |
+
payload = {"message": message, "sessionKey": sess.taemini_key(), "cwd": sess.work_cwd}
|
| 776 |
+
data = post_json(sess.server, "/api/shadow/shadow/send", payload)
|
| 777 |
+
elif guardian == "codex":
|
| 778 |
+
data = post_json(sess.server, "/api/shadow/codex/send", {
|
| 779 |
+
"message": message,
|
| 780 |
+
"cwd": sess.work_cwd,
|
| 781 |
+
"context_id": sess.key("cli-codex-"),
|
| 782 |
+
})
|
| 783 |
+
elif guardian == "minimax":
|
| 784 |
+
data = post_json(sess.server, "/api/shadow/minimax-code/send", {
|
| 785 |
+
"message": message,
|
| 786 |
+
"cwd": sess.work_cwd,
|
| 787 |
+
"sessionKey": sess.minimax_session,
|
| 788 |
+
})
|
| 789 |
+
if isinstance(data, dict) and data.get("sessionKey"):
|
| 790 |
+
sess.minimax_session = str(data["sessionKey"])
|
| 791 |
+
elif guardian == "hermes":
|
| 792 |
+
data = post_json(sess.server, "/api/shadow/hermes/send", {
|
| 793 |
+
"message": message,
|
| 794 |
+
"sessionKey": sess.key("cli-hermes-"),
|
| 795 |
+
"workdir": sess.work_cwd,
|
| 796 |
+
})
|
| 797 |
+
elif guardian == "claude":
|
| 798 |
+
data = post_json(sess.server, "/api/shadow/claude-code/send", {
|
| 799 |
+
"message": message,
|
| 800 |
+
"context_id": sess.key("cli-claude-"),
|
| 801 |
+
})
|
| 802 |
+
elif guardian == "lmstudio":
|
| 803 |
+
data = post_json(sess.server, "/api/shadow/lmstudio/send", {
|
| 804 |
+
"message": message,
|
| 805 |
+
"sessionKey": sess.key("cli-lms-"),
|
| 806 |
+
})
|
| 807 |
+
elif guardian == "grok":
|
| 808 |
+
data = post_json(sess.server, "/api/shadow/grok/send", {
|
| 809 |
+
"message": message,
|
| 810 |
+
"cwd": sess.work_cwd,
|
| 811 |
+
"context_id": sess.key("cli-grok-"),
|
| 812 |
+
})
|
| 813 |
+
else:
|
| 814 |
+
raise ValueError(f"알 수 없는 수호자: {guardian}")
|
| 815 |
+
|
| 816 |
+
if not isinstance(data, dict):
|
| 817 |
+
raise RuntimeError(f"서버 응답 형식 오류: {data!r}")
|
| 818 |
+
if data.get("status") == "ERROR":
|
| 819 |
+
raise RuntimeError(data.get("message") or "알 수 없는 서버 오류")
|
| 820 |
+
content = (data.get("content") or "").strip()
|
| 821 |
+
return content, data.get("todos") or [], data.get("stats") or {}
|
| 822 |
+
|
| 823 |
+
# 📝 마크다운 경량 정규식 (렌더러·TTS 정리 공용)
|
| 824 |
+
_MD_BOLD = re.compile(r"\*\*(.+?)\*\*")
|
| 825 |
+
_MD_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
| 826 |
+
_MD_BULLET = re.compile(r"^(\s*)[-*] (?=\S)")
|
| 827 |
+
|
| 828 |
+
__all__ = [_n for _n in list(globals()) if not _n.startswith("__")]
|
shadow_brain_core/taemini_cli_media.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""🔱 태미니 CLI 미디어 — Edge TTS 낭독 + 음성 입력(/voice)
|
| 3 |
+
|
| 4 |
+
taemini_cli.py 에서 분할(2026-07-10, god-file 방지). 공개 이름은 taemini_cli 가
|
| 5 |
+
`from taemini_cli_media import *` 로 재수출하므로 기존 사용법/검증 스크립트는 그대로 동작한다.
|
| 6 |
+
"""
|
| 7 |
+
import argparse
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import math
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
import sys
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
import unicodedata
|
| 17 |
+
import urllib.error
|
| 18 |
+
import urllib.request
|
| 19 |
+
|
| 20 |
+
VERSION = "1.5.0"
|
| 21 |
+
DEFAULT_SERVER = os.environ.get("TAEMINI_SERVER") or os.environ.get("SHADOW_BRAIN_URL") or "http://127.0.0.1:18700"
|
| 22 |
+
REQUEST_TIMEOUT = 900 # 코딩 턴은 수 분 걸릴 수 있음
|
| 23 |
+
PROGRESS_POLL_SEC = 1.0
|
| 24 |
+
|
| 25 |
+
from taemini_cli_core import * # noqa: F401,F403
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ─────────────────────────────────────────────────────────────────
|
| 29 |
+
# 🔊 Edge TTS — 응답을 데몬 /api/tts 로 합성해 백그라운드 재생
|
| 30 |
+
# ─────────────────────────────────────────────────────────────────
|
| 31 |
+
_TTS_PROC = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# 이모지·픽토그램 — Edge TTS가 "웃는 얼굴"처럼 읽어버리므로 낭독 전 제거
|
| 35 |
+
_EMOJI_RE = re.compile(
|
| 36 |
+
"["
|
| 37 |
+
"\U0001F000-\U0001FAFF" # 이모티콘·픽토그램·교통·보조 기호 전반
|
| 38 |
+
"☀-➿" # 잡기호·딩벳 (☀☔✅✈ 등)
|
| 39 |
+
"⬀-⯿" # 화살표·별 (⭐ 등)
|
| 40 |
+
"←-⇿" # 화살표 (→ 등)
|
| 41 |
+
"⌀-⏿" # 기술 기호 (⌛⏰ 등)
|
| 42 |
+
"■-◿" # 도형 (▶■ 등)
|
| 43 |
+
"\U0001F1E6-\U0001F1FF" # 국기 조합 문자
|
| 44 |
+
"︀-️" # variation selector
|
| 45 |
+
"" # ZWJ (조합 이모지 연결자)
|
| 46 |
+
"⃣" # keycap 조합
|
| 47 |
+
"]+"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _strip_for_speech(text):
|
| 52 |
+
t = re.sub(r"```[\s\S]*?```", " 코드 블록은 생략합니다. ", text or "")
|
| 53 |
+
t = re.sub(r"`([^`\n]+)`", r"\1", t)
|
| 54 |
+
t = _MD_BOLD.sub(r"\1", t)
|
| 55 |
+
t = re.sub(r"^#+\s*", "", t, flags=re.MULTILINE)
|
| 56 |
+
t = re.sub(r"[─│┌└╭╰╮╯]+", " ", t)
|
| 57 |
+
t = _EMOJI_RE.sub(" ", t)
|
| 58 |
+
return re.sub(r"\s+", " ", t).strip()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
_TTS_GEN = 0 # /tts stop 세대 카운터 — 합성 대기 중이던 낭독도 재생 전에 폐기
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def tts_stop():
|
| 65 |
+
"""재생 중인 TTS 낭독 즉시 중단. 중단한 게 있으면 True."""
|
| 66 |
+
global _TTS_GEN, _TTS_PROC
|
| 67 |
+
_TTS_GEN += 1 # 합성(urlopen) 진행 중이던 work()도 재생 직전 검사에서 폐기됨
|
| 68 |
+
p = _TTS_PROC
|
| 69 |
+
if p and p.poll() is None:
|
| 70 |
+
try:
|
| 71 |
+
p.terminate()
|
| 72 |
+
except Exception:
|
| 73 |
+
pass
|
| 74 |
+
return True
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def tts_speak(sess, text):
|
| 79 |
+
"""응답 본문을 Edge TTS로 읽어준다 (비차단, 새 응답이 오면 이전 재생 중단)."""
|
| 80 |
+
if not getattr(sess, "tts_enabled", False):
|
| 81 |
+
return
|
| 82 |
+
body = _strip_for_speech(text)[:800]
|
| 83 |
+
if not body:
|
| 84 |
+
return
|
| 85 |
+
gen = _TTS_GEN
|
| 86 |
+
|
| 87 |
+
def work():
|
| 88 |
+
global _TTS_PROC
|
| 89 |
+
import subprocess
|
| 90 |
+
import tempfile
|
| 91 |
+
try:
|
| 92 |
+
req = urllib.request.Request(
|
| 93 |
+
sess.server.rstrip("/") + "/api/tts",
|
| 94 |
+
data=json.dumps({"text": body, "voice": sess.tts_voice},
|
| 95 |
+
ensure_ascii=False).encode("utf-8"),
|
| 96 |
+
headers={"Content-Type": "application/json"}, method="POST")
|
| 97 |
+
with urllib.request.urlopen(req, timeout=90) as resp:
|
| 98 |
+
audio = resp.read()
|
| 99 |
+
if not audio or audio[:1] == b"{": # 오류 JSON이면 무시
|
| 100 |
+
return
|
| 101 |
+
if gen != _TTS_GEN: # 합성 중에 /tts stop — 재생하지 않고 폐기
|
| 102 |
+
return
|
| 103 |
+
f = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
| 104 |
+
f.write(audio)
|
| 105 |
+
f.close()
|
| 106 |
+
if _TTS_PROC and _TTS_PROC.poll() is None:
|
| 107 |
+
_TTS_PROC.terminate()
|
| 108 |
+
if sys.platform == "darwin":
|
| 109 |
+
player = ["afplay", f.name]
|
| 110 |
+
else:
|
| 111 |
+
import shutil
|
| 112 |
+
player = None
|
| 113 |
+
for cand in (["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet"],
|
| 114 |
+
["mpg123", "-q"]):
|
| 115 |
+
if shutil.which(cand[0]):
|
| 116 |
+
player = cand + [f.name]
|
| 117 |
+
break
|
| 118 |
+
if player is None:
|
| 119 |
+
if os.name == "nt":
|
| 120 |
+
os.startfile(f.name) # 기본 플레이어
|
| 121 |
+
return
|
| 122 |
+
_TTS_PROC = subprocess.Popen(player, stdout=subprocess.DEVNULL,
|
| 123 |
+
stderr=subprocess.DEVNULL)
|
| 124 |
+
if _TTS_PROC:
|
| 125 |
+
_TTS_PROC.wait() # 재생 종료까지 스레드 유지 (--once에서 join 가능)
|
| 126 |
+
except Exception:
|
| 127 |
+
pass # TTS는 부가 기능 — 실패해도 대화를 막지 않는다
|
| 128 |
+
|
| 129 |
+
t = threading.Thread(target=work, daemon=True)
|
| 130 |
+
t.start()
|
| 131 |
+
sess._tts_thread = t # --once 모드가 재생 완료를 기다릴 수 있게
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def cmd_tts(sess, arg):
|
| 135 |
+
parts = arg.split(None, 1)
|
| 136 |
+
action = parts[0].lower() if parts else ""
|
| 137 |
+
if action in ("", "on", "off"):
|
| 138 |
+
if action == "on":
|
| 139 |
+
sess.tts_enabled = True
|
| 140 |
+
elif action == "off":
|
| 141 |
+
sess.tts_enabled = False
|
| 142 |
+
else:
|
| 143 |
+
sess.tts_enabled = not sess.tts_enabled
|
| 144 |
+
s = load_settings()
|
| 145 |
+
s["CLI_TTS_ENABLED"] = sess.tts_enabled
|
| 146 |
+
save_settings(s)
|
| 147 |
+
state = green("🔊 켜짐") if sess.tts_enabled else dim("🔇 꺼짐")
|
| 148 |
+
print(f"→ 응답 음성 읽기(Edge TTS): {state}" + dim(f" (음성: {sess.tts_voice})"))
|
| 149 |
+
elif action == "stop":
|
| 150 |
+
stopped = tts_stop()
|
| 151 |
+
print(dim("→ 낭독을 중단했습니다.") if stopped else dim("→ 재생 중인 낭독이 없습니다."))
|
| 152 |
+
elif action == "voice" and len(parts) > 1:
|
| 153 |
+
sess.tts_voice = parts[1].strip()
|
| 154 |
+
s = load_settings()
|
| 155 |
+
s["CLI_TTS_VOICE"] = sess.tts_voice
|
| 156 |
+
save_settings(s)
|
| 157 |
+
print(green(f"→ TTS 음성: {sess.tts_voice}"))
|
| 158 |
+
else:
|
| 159 |
+
print(red("사용법: /tts [on|off] · /tts stop · /tts voice <음성ID>"))
|
| 160 |
+
print(dim(" 한국어 음성 예: ko-KR-SunHiNeural(여) · ko-KR-InJoonNeural(남) · ko-KR-HyunsuMultilingualNeural(남)"))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ─────────────────────────────────────────────────────────────────
|
| 164 |
+
# 🎤 음성 입력 (/voice) — ffmpeg 마이크 녹음 → 데몬 faster-whisper 전사
|
| 165 |
+
# ─────────────────────────────────────────────────────────────────
|
| 166 |
+
VOICE_MAX_SEC = 60 # 녹음 상한 (안전장치)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def warmup_stt(sess):
|
| 170 |
+
"""small 전사 모델을 백그라운드로 미리 로드 — 첫 /voice의 20초 지연 제거."""
|
| 171 |
+
def work():
|
| 172 |
+
import struct
|
| 173 |
+
import tempfile
|
| 174 |
+
try:
|
| 175 |
+
# 0.2초 무음 wav (16kHz mono s16le) 생성 → quick 라우트 호출로 모델 로드 유도
|
| 176 |
+
sr, n = 16000, 3200
|
| 177 |
+
path = os.path.join(tempfile.gettempdir(), "taemini_stt_warmup.wav")
|
| 178 |
+
data = b"\x00\x00" * n
|
| 179 |
+
with open(path, "wb") as f:
|
| 180 |
+
f.write(b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVEfmt " +
|
| 181 |
+
struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16) +
|
| 182 |
+
b"data" + struct.pack("<I", len(data)) + data)
|
| 183 |
+
for size in ("base", "small"): # base=실시간 자막, small=최종 확정
|
| 184 |
+
post_json(sess.server, "/api/transcribe/quick",
|
| 185 |
+
{"path": path, "language": "ko", "model": size}, timeout=300)
|
| 186 |
+
except Exception:
|
| 187 |
+
pass # 워밍업 실패는 무해 — 첫 /voice 때 로드될 뿐
|
| 188 |
+
|
| 189 |
+
threading.Thread(target=work, daemon=True).start()
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _list_audio_devices():
|
| 193 |
+
"""macOS avfoundation 오디오 입력 장치 목록 [(번호, 이름)]."""
|
| 194 |
+
import shutil
|
| 195 |
+
import subprocess
|
| 196 |
+
ffmpeg = shutil.which("ffmpeg")
|
| 197 |
+
if not ffmpeg or sys.platform != "darwin":
|
| 198 |
+
return []
|
| 199 |
+
out = subprocess.run([ffmpeg, "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
| 200 |
+
capture_output=True, text=True).stderr
|
| 201 |
+
devices = []
|
| 202 |
+
in_audio = False
|
| 203 |
+
for line in out.splitlines():
|
| 204 |
+
if "audio devices" in line:
|
| 205 |
+
in_audio = True
|
| 206 |
+
continue
|
| 207 |
+
if in_audio:
|
| 208 |
+
m = re.search(r"\[(\d+)\]\s+(.+)$", line)
|
| 209 |
+
if m:
|
| 210 |
+
devices.append((m.group(1), m.group(2).strip()))
|
| 211 |
+
elif "]" not in line:
|
| 212 |
+
break
|
| 213 |
+
return devices
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def cmd_voice_mic(sess, arg):
|
| 217 |
+
"""/voice mic [번호|default] — 녹음 마이크 확인/변경 (영구 저장).
|
| 218 |
+
인자 없이 부르면 목록을 보여주고 그 자리에서 번호를 골라 저장한다."""
|
| 219 |
+
current = load_settings().get("CLI_VOICE_MIC", "default")
|
| 220 |
+
|
| 221 |
+
def _save(value):
|
| 222 |
+
s = load_settings()
|
| 223 |
+
s["CLI_VOICE_MIC"] = value
|
| 224 |
+
save_settings(s)
|
| 225 |
+
print(green(f"→ 녹음 마이크: {value}"))
|
| 226 |
+
|
| 227 |
+
if arg:
|
| 228 |
+
_save(arg.strip())
|
| 229 |
+
return
|
| 230 |
+
devices = _list_audio_devices()
|
| 231 |
+
print(bold("🎙️ 오디오 입력 장치") + dim(f" (현재: {current})"))
|
| 232 |
+
for idx, name in devices:
|
| 233 |
+
in_use = current != "default" and (idx == current or name == current or current in name)
|
| 234 |
+
mark = green(" ◀ 사용 중") if in_use else ""
|
| 235 |
+
print(f" [{idx}] {name}{mark}")
|
| 236 |
+
print(f" [d] 시스템 기본 입력 (macOS 사운드 설정을 따름)"
|
| 237 |
+
+ (green(" ◀ 사용 중") if current == "default" else ""))
|
| 238 |
+
try:
|
| 239 |
+
choice = input(bold(cyan(" 번호 선택 (Enter=유지): "))).strip().lower()
|
| 240 |
+
except (EOFError, KeyboardInterrupt):
|
| 241 |
+
print()
|
| 242 |
+
return
|
| 243 |
+
if not choice:
|
| 244 |
+
print(dim(" (유지)"))
|
| 245 |
+
return
|
| 246 |
+
if choice in ("d", "default"):
|
| 247 |
+
_save("default")
|
| 248 |
+
return
|
| 249 |
+
for idx, name in devices:
|
| 250 |
+
if choice == idx:
|
| 251 |
+
# 번호가 아니라 '이름'으로 저장 — 아이폰 연속성 마이크가 붙었다 떨어지면
|
| 252 |
+
# 장치 번호가 밀리는 실사례가 있어, 녹음 시점에 이름→현재 번호로 재해석한다.
|
| 253 |
+
_save(name)
|
| 254 |
+
return
|
| 255 |
+
print(red(f" 잘못된 선택: {choice}"))
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _hear_binary():
|
| 259 |
+
"""애플 SFSpeechRecognizer CLI(taemini-hear) 경로 — 없으면 swiftc로 1회 빌드."""
|
| 260 |
+
import shutil
|
| 261 |
+
import subprocess
|
| 262 |
+
tools = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")
|
| 263 |
+
binary = os.path.join(tools, "taemini-hear")
|
| 264 |
+
if os.path.isfile(binary):
|
| 265 |
+
return binary
|
| 266 |
+
src = os.path.join(tools, "taemini_hear.swift")
|
| 267 |
+
if os.path.isfile(src) and shutil.which("swiftc"):
|
| 268 |
+
print(dim("🔧 애플 음성 인식기 도구를 처음 한 번 빌드합니다 (swiftc)..."))
|
| 269 |
+
r = subprocess.run(["swiftc", "-O", "-o", binary, src], capture_output=True)
|
| 270 |
+
if r.returncode == 0 and os.path.isfile(binary):
|
| 271 |
+
return binary
|
| 272 |
+
print(yellow("⚠️ 빌드 실패 — whisper 인식으로 진행합니다."))
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def _record_apple(sess, binary):
|
| 277 |
+
"""🍎 애플 네이티브 실시간 인식 — 말하는 즉시 글자가 자라고, 침묵(1.6초)이면 자동 확정.
|
| 278 |
+
|
| 279 |
+
반환: 인식 텍스트 / ""(무음) / None(엔진 오류 → 호출부가 whisper 폴백).
|
| 280 |
+
"""
|
| 281 |
+
import select as _select
|
| 282 |
+
import subprocess
|
| 283 |
+
print(bold(cyan("🎤 듣고 있습니다...")) + dim(" (애플 실시간 인식 · 말이 끝나면 자동 전송, Enter=즉시)"))
|
| 284 |
+
try:
|
| 285 |
+
proc = subprocess.Popen([binary, "ko-KR", "1.6", str(VOICE_MAX_SEC)],
|
| 286 |
+
stdout=subprocess.PIPE, stdin=subprocess.PIPE,
|
| 287 |
+
text=True, bufsize=1)
|
| 288 |
+
except Exception as e:
|
| 289 |
+
print(yellow(f"⚠️ 애플 인식기 실행 실패({e}) — whisper로 폴백"))
|
| 290 |
+
return None
|
| 291 |
+
_term = sys.__stdout__ if (sys.__stdout__ and sys.__stdout__.isatty()) else sys.stdout
|
| 292 |
+
|
| 293 |
+
def _tw(t):
|
| 294 |
+
_term.write(t)
|
| 295 |
+
_term.flush()
|
| 296 |
+
|
| 297 |
+
state = {"final": None, "err": None}
|
| 298 |
+
|
| 299 |
+
def reader():
|
| 300 |
+
for line in proc.stdout:
|
| 301 |
+
line = line.rstrip("\n")
|
| 302 |
+
if line.startswith("P:"):
|
| 303 |
+
cap = "🎤 " + line[2:]
|
| 304 |
+
_tw(("\r\033[K" + green(cap)) if _USE_COLOR else ("\r" + cap))
|
| 305 |
+
elif line.startswith("F:"):
|
| 306 |
+
state["final"] = line[2:].strip()
|
| 307 |
+
return
|
| 308 |
+
elif line.startswith("E:"):
|
| 309 |
+
state["err"] = line[2:]
|
| 310 |
+
return
|
| 311 |
+
|
| 312 |
+
rt = threading.Thread(target=reader, daemon=True)
|
| 313 |
+
rt.start()
|
| 314 |
+
manual = False
|
| 315 |
+
while rt.is_alive():
|
| 316 |
+
try:
|
| 317 |
+
r, _, _ = _select.select([sys.stdin], [], [], 0.2)
|
| 318 |
+
except Exception:
|
| 319 |
+
break
|
| 320 |
+
if r:
|
| 321 |
+
sys.stdin.readline()
|
| 322 |
+
manual = True
|
| 323 |
+
try:
|
| 324 |
+
proc.stdin.close() # Enter → hear가 지금까지 텍스트로 확정
|
| 325 |
+
except Exception:
|
| 326 |
+
pass
|
| 327 |
+
rt.join(timeout=5)
|
| 328 |
+
try:
|
| 329 |
+
proc.wait(timeout=5)
|
| 330 |
+
except Exception:
|
| 331 |
+
proc.kill()
|
| 332 |
+
if state["err"] is not None:
|
| 333 |
+
print(red(f"\n❌ 애플 인식기: {state['err']}"))
|
| 334 |
+
print(dim(" whisper 로컬 인식으로 폴백합니다."))
|
| 335 |
+
return None
|
| 336 |
+
text = " ".join((state["final"] or "").split())
|
| 337 |
+
_tw("\r\033[K" if _USE_COLOR else "\r")
|
| 338 |
+
if not text:
|
| 339 |
+
print(yellow("⚠️ 음성을 인식하지 못했습니다."))
|
| 340 |
+
return ""
|
| 341 |
+
tag = "" if manual else dim(" (침묵 감지 → 자동 전송)")
|
| 342 |
+
print(green("🎤 인식: ") + bold(text) + tag)
|
| 343 |
+
return text
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def record_and_transcribe(sess):
|
| 347 |
+
"""마이크 녹음(Enter로 종료) → /api/transcribe 전사 → 인식 텍스트 반환(실패 시 None).
|
| 348 |
+
|
| 349 |
+
macOS에선 헤임달과 같은 애플 SFSpeechRecognizer(진짜 스트리밍)를 우선 사용하고,
|
| 350 |
+
빌드 불가/권한 거부 등일 때만 whisper(2초 조각) 방식으로 폴백한다.
|
| 351 |
+
"""
|
| 352 |
+
import shutil
|
| 353 |
+
import signal
|
| 354 |
+
import subprocess
|
| 355 |
+
import tempfile
|
| 356 |
+
# 🍎 애플 네이티브 우선 (시뮬레이션 훅 TAEMINI_VOICE_INPUT은 whisper 경로로 검증)
|
| 357 |
+
if sys.platform == "darwin" and not os.environ.get("TAEMINI_VOICE_INPUT"):
|
| 358 |
+
binary = _hear_binary()
|
| 359 |
+
if binary:
|
| 360 |
+
result = _record_apple(sess, binary)
|
| 361 |
+
if result is not None:
|
| 362 |
+
return result or None # ""(무음)이면 전송하지 않음
|
| 363 |
+
ffmpeg = shutil.which("ffmpeg")
|
| 364 |
+
if not ffmpeg:
|
| 365 |
+
print(red("❌ ffmpeg가 필요합니다 (brew install ffmpeg)."))
|
| 366 |
+
return None
|
| 367 |
+
sim_input = os.environ.get("TAEMINI_VOICE_INPUT")
|
| 368 |
+
if sim_input:
|
| 369 |
+
# 🧪 검증 훅: 오디오 파일을 실시간 속도(-re)로 마이크처럼 흘린다 (자동 테스트용)
|
| 370 |
+
in_args = ["-re", "-i", sim_input]
|
| 371 |
+
elif sys.platform == "darwin":
|
| 372 |
+
# ⚠️ ':0' 하드코딩 금지 — 아이폰 연속성 마이크가 0번을 차지하는 실사례.
|
| 373 |
+
# 설정값: 'default'(시스템 기본) / 숫자(레거시) / 장치 이름(권장 — 번호가 밀려도 안전).
|
| 374 |
+
mic = (load_settings().get("CLI_VOICE_MIC") or "default").strip()
|
| 375 |
+
if mic != "default" and not mic.isdigit():
|
| 376 |
+
resolved = None
|
| 377 |
+
for idx, name in _list_audio_devices():
|
| 378 |
+
if name == mic or mic in name:
|
| 379 |
+
resolved = idx
|
| 380 |
+
break
|
| 381 |
+
if resolved is None:
|
| 382 |
+
print(yellow(f"⚠️ 저장된 마이크('{mic}')를 찾지 못해 시스템 기본 입력을 사용합니다."))
|
| 383 |
+
mic = "default"
|
| 384 |
+
else:
|
| 385 |
+
mic = resolved
|
| 386 |
+
in_args = ["-f", "avfoundation", "-i", f":{mic}"]
|
| 387 |
+
elif sys.platform.startswith("linux"):
|
| 388 |
+
in_args = ["-f", "alsa", "-i", "default"]
|
| 389 |
+
else:
|
| 390 |
+
print(yellow("⚠️ 윈도우 마이크 녹음은 아직 미지원입니다 (macOS/Linux만)."))
|
| 391 |
+
return None
|
| 392 |
+
work_dir = tempfile.mkdtemp(prefix="taemini_voice_")
|
| 393 |
+
full_wav = os.path.join(work_dir, "full.wav")
|
| 394 |
+
seg_pattern = os.path.join(work_dir, "seg_%03d.wav")
|
| 395 |
+
# 출력 2개: ① 전체 녹음(최종 정확 전사용) ② 2초 조각(실시간 자막용)
|
| 396 |
+
cmd = [ffmpeg, "-y", *in_args,
|
| 397 |
+
"-ac", "1", "-ar", "16000", "-t", str(VOICE_MAX_SEC), full_wav,
|
| 398 |
+
"-f", "segment", "-segment_time", "1.2", "-reset_timestamps", "1",
|
| 399 |
+
"-ac", "1", "-ar", "16000", "-t", str(VOICE_MAX_SEC), seg_pattern]
|
| 400 |
+
mic_label = (load_settings().get("CLI_VOICE_MIC") or "default").strip()
|
| 401 |
+
if mic_label == "default":
|
| 402 |
+
mic_label = "시스템 기본 입력"
|
| 403 |
+
print(bold(cyan("🎤 듣고 있습니다...")) + dim(f" 말이 끝나면 자동 전송 (또는 Enter, 최대 {VOICE_MAX_SEC}초)"))
|
| 404 |
+
print(dim(f" 마이크: {mic_label} · 변경: /mic"))
|
| 405 |
+
try:
|
| 406 |
+
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
| 407 |
+
stdin=subprocess.DEVNULL)
|
| 408 |
+
except Exception as e:
|
| 409 |
+
print(red(f"❌ 녹음 시작 실패: {e}"))
|
| 410 |
+
return None
|
| 411 |
+
|
| 412 |
+
# 🗣️ 실시간 자막 워처 — 완성된 2초 조각을 즉시 small 모델로 받아 적어 표시
|
| 413 |
+
import glob as _glob
|
| 414 |
+
partials = []
|
| 415 |
+
stop_evt = threading.Event()
|
| 416 |
+
silence_evt = threading.Event() # 말 끝(침묵) 감지 → 자동 전송
|
| 417 |
+
silent_chunks = [0]
|
| 418 |
+
first_slow_notice = [False]
|
| 419 |
+
|
| 420 |
+
# ⚠️ 흐름 모드의 patch_stdout은 '줄바꿈 없는 부분 출력'을 줄이 완성될 때까지
|
| 421 |
+
# 버퍼링한다 → \r 자막이 녹음 끝나고 한꺼번에 쏟아지던 실사례.
|
| 422 |
+
# 진짜 터미널(sys.__stdout__)에 직접 써서 말하는 즉시 화면에 나오게 한다.
|
| 423 |
+
_term = sys.__stdout__ if (sys.__stdout__ and sys.__stdout__.isatty()) else sys.stdout
|
| 424 |
+
|
| 425 |
+
def _tw(text):
|
| 426 |
+
_term.write(text)
|
| 427 |
+
_term.flush()
|
| 428 |
+
|
| 429 |
+
def _show_live():
|
| 430 |
+
line = "🎤 " + " ".join(partials)
|
| 431 |
+
_tw(("\r\033[K" + green(line)) if _USE_COLOR else ("\r" + line))
|
| 432 |
+
|
| 433 |
+
def watcher():
|
| 434 |
+
done = set()
|
| 435 |
+
while True:
|
| 436 |
+
segs = sorted(_glob.glob(os.path.join(work_dir, "seg_*.wav")))
|
| 437 |
+
# 마지막 파일은 아직 기록 중일 수 있음 — 녹음 종료 후엔 전부 처리
|
| 438 |
+
ready = segs if stop_evt.is_set() else segs[:-1]
|
| 439 |
+
for f in ready:
|
| 440 |
+
if f in done:
|
| 441 |
+
continue
|
| 442 |
+
done.add(f)
|
| 443 |
+
t0 = time.time()
|
| 444 |
+
try:
|
| 445 |
+
# 조각당 10초 상한 — 한 조각이 늦어도 자막 흐름을 막지 않는다 (건너뜀)
|
| 446 |
+
r = post_json(sess.server, "/api/transcribe/quick",
|
| 447 |
+
{"path": f, "language": "ko", "model": "base"}, timeout=10)
|
| 448 |
+
t = (r.get("text") or "").strip()
|
| 449 |
+
except Exception as _we:
|
| 450 |
+
t = ""
|
| 451 |
+
r = {"err": str(_we)}
|
| 452 |
+
dbg = os.environ.get("TAEMINI_DEBUG_LOG")
|
| 453 |
+
if dbg:
|
| 454 |
+
with open(dbg, "a", encoding="utf-8") as _df:
|
| 455 |
+
_df.write(f"[watcher] {os.path.basename(f)} {time.time()-t0:.2f}s -> {r}\n")
|
| 456 |
+
if time.time() - t0 > 8 and not first_slow_notice[0]:
|
| 457 |
+
first_slow_notice[0] = True
|
| 458 |
+
_tw("\n" + dim(" (최초 1회 small 모델 준비 중 — 다음부터는 즉시 표시됩니다)") + "\n")
|
| 459 |
+
if t:
|
| 460 |
+
partials.append(t)
|
| 461 |
+
silent_chunks[0] = 0
|
| 462 |
+
_show_live()
|
| 463 |
+
elif partials and not stop_evt.is_set():
|
| 464 |
+
# 말을 시작한 뒤 무음 조각 2개(~2.4초) 연속 → 말이 끝난 것으로 보고 자동 전송
|
| 465 |
+
silent_chunks[0] += 1
|
| 466 |
+
if silent_chunks[0] >= 2:
|
| 467 |
+
silence_evt.set()
|
| 468 |
+
if stop_evt.is_set() and all(s in done for s in
|
| 469 |
+
_glob.glob(os.path.join(work_dir, "seg_*.wav"))):
|
| 470 |
+
return
|
| 471 |
+
time.sleep(0.3)
|
| 472 |
+
|
| 473 |
+
wt = threading.Thread(target=watcher, daemon=True)
|
| 474 |
+
wt.start()
|
| 475 |
+
|
| 476 |
+
# Enter(수동 종료) 또는 침묵 감지(자동 종료)를 함께 기다린다
|
| 477 |
+
import select as _select
|
| 478 |
+
auto_sent = False
|
| 479 |
+
try:
|
| 480 |
+
while True:
|
| 481 |
+
if silence_evt.is_set():
|
| 482 |
+
auto_sent = True
|
| 483 |
+
break
|
| 484 |
+
r, _, _ = _select.select([sys.stdin], [], [], 0.2)
|
| 485 |
+
if r:
|
| 486 |
+
sys.stdin.readline()
|
| 487 |
+
break
|
| 488 |
+
except (EOFError, KeyboardInterrupt):
|
| 489 |
+
pass
|
| 490 |
+
# SIGINT → ffmpeg가 파일을 정상 마무리(finalize)하고 종료
|
| 491 |
+
try:
|
| 492 |
+
proc.send_signal(signal.SIGINT)
|
| 493 |
+
proc.wait(timeout=5)
|
| 494 |
+
except Exception:
|
| 495 |
+
proc.terminate()
|
| 496 |
+
stop_evt.set()
|
| 497 |
+
wt.join(timeout=60)
|
| 498 |
+
|
| 499 |
+
try:
|
| 500 |
+
if not os.path.isfile(full_wav) or os.path.getsize(full_wav) < 2000:
|
| 501 |
+
print(red("❌ 녹음된 소리가 없습니다."))
|
| 502 |
+
print(dim(" ① 마이크 권한 확인: 시스템 설정 → 개인정보 보호 및 보안 → 마이크 → 터미널 허용"))
|
| 503 |
+
print(dim(" ② 다른 마이크가 잡혔을 수 있습니다 — 현재 장치 목록:"))
|
| 504 |
+
for idx, name in _list_audio_devices():
|
| 505 |
+
print(dim(f" [{idx}] {name}"))
|
| 506 |
+
print(dim(" /mic 로 올바른 마이크를 선택해 주십시오."))
|
| 507 |
+
return None
|
| 508 |
+
# 최종 전사: 전체 파일을 한 번 더(조각 경계에서 끊긴 단어 보정) — small이라 1~3초
|
| 509 |
+
final_text = ""
|
| 510 |
+
try:
|
| 511 |
+
r = post_json(sess.server, "/api/transcribe/quick",
|
| 512 |
+
{"path": full_wav, "language": "ko", "model": "small"}, timeout=180)
|
| 513 |
+
final_text = " ".join((r.get("text") or "").split())
|
| 514 |
+
except Exception:
|
| 515 |
+
pass
|
| 516 |
+
if not final_text:
|
| 517 |
+
final_text = " ".join(" ".join(partials).split())
|
| 518 |
+
_tw("\r\033[K" if _USE_COLOR else "\r")
|
| 519 |
+
if not final_text:
|
| 520 |
+
print(yellow("⚠️ 음성을 인식하지 못했습니다."))
|
| 521 |
+
return None
|
| 522 |
+
tag = dim(" (침묵 감지 → 자동 전송)") if auto_sent else ""
|
| 523 |
+
print(green("🎤 인식: ") + bold(final_text) + tag)
|
| 524 |
+
return final_text
|
| 525 |
+
finally:
|
| 526 |
+
import shutil as _sh
|
| 527 |
+
_sh.rmtree(work_dir, ignore_errors=True)
|
| 528 |
+
|
| 529 |
+
__all__ = [_n for _n in list(globals()) if not _n.startswith("__")]
|
shadow_brain_core/taemini_cli_select.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""🔱 태미니 CLI — 대화식 선택기·연결 모드 (taemini_cli_core 분할, 2026-07-10)
|
| 2 |
+
|
| 3 |
+
화살표 메뉴(_arrow_choose)와 그 위에 얹힌 /model 2단 선택기, /server 연결 모드
|
| 4 |
+
전환을 담는다. 신뢰 폴더 시작 선택창(_pick_menu)은 시작 게이트와 결합돼 있어
|
| 5 |
+
core에 남겨뒀다. core의 색/키입력/설정 헬퍼를 그대로 쓴다.
|
| 6 |
+
"""
|
| 7 |
+
from taemini_cli_core import * # noqa: F401,F403
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _arrow_choose(options, title="", footer="↑↓ 이동 · Enter 선택 · 숫자 바로 선택 · Esc 취소"):
|
| 11 |
+
"""범용 화살표 선택 메뉴 — options: [(값, 라벨)], 반환: 선택 값 또는 None(취소).
|
| 12 |
+
원시 키 입력 불가 환경(비TTY 등)은 숫자 입력 폴백."""
|
| 13 |
+
if not options:
|
| 14 |
+
return None
|
| 15 |
+
if title:
|
| 16 |
+
print(bold(title))
|
| 17 |
+
if footer:
|
| 18 |
+
print(dim(f" {footer}"))
|
| 19 |
+
try:
|
| 20 |
+
old_attr = None
|
| 21 |
+
if os.name != "nt":
|
| 22 |
+
import termios
|
| 23 |
+
import tty
|
| 24 |
+
fd = sys.stdin.fileno()
|
| 25 |
+
old_attr = termios.tcgetattr(fd) # 비TTY면 예외 → 숫자 폴백
|
| 26 |
+
tty.setcbreak(fd, termios.TCSANOW) # setraw(TCSAFLUSH)는 대기 입력 폐기 — 금지
|
| 27 |
+
idx = 0
|
| 28 |
+
drawn = 0
|
| 29 |
+
sys.stdout.write("\033[?25l")
|
| 30 |
+
try:
|
| 31 |
+
while True:
|
| 32 |
+
if drawn:
|
| 33 |
+
sys.stdout.write(f"\033[{drawn}A")
|
| 34 |
+
for i, (_v, label) in enumerate(options):
|
| 35 |
+
line = f" {i + 1}) {label}"
|
| 36 |
+
if i == idx:
|
| 37 |
+
line = f"\033[7m ❯ {i + 1}) {label}\033[0m"
|
| 38 |
+
sys.stdout.write("\033[K" + line + "\n")
|
| 39 |
+
drawn = len(options)
|
| 40 |
+
sys.stdout.flush()
|
| 41 |
+
key = _read_menu_key()
|
| 42 |
+
if key == "up":
|
| 43 |
+
idx = (idx - 1) % len(options)
|
| 44 |
+
elif key == "down":
|
| 45 |
+
idx = (idx + 1) % len(options)
|
| 46 |
+
elif key == "enter":
|
| 47 |
+
return options[idx][0]
|
| 48 |
+
elif key in ("esc", "q"):
|
| 49 |
+
return None
|
| 50 |
+
elif key and key.isdigit() and 1 <= int(key) <= len(options):
|
| 51 |
+
return options[int(key) - 1][0]
|
| 52 |
+
finally:
|
| 53 |
+
sys.stdout.write("\033[?25h")
|
| 54 |
+
sys.stdout.flush()
|
| 55 |
+
if old_attr is not None:
|
| 56 |
+
import termios
|
| 57 |
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old_attr)
|
| 58 |
+
except KeyboardInterrupt:
|
| 59 |
+
return None
|
| 60 |
+
except Exception:
|
| 61 |
+
pass # 원시 키 불가 → 숫자 폴백
|
| 62 |
+
for i, (_v, label) in enumerate(options, start=1):
|
| 63 |
+
print(f" {i}) {label}")
|
| 64 |
+
try:
|
| 65 |
+
ans = input(" 선택 (엔터=취소) ").strip()
|
| 66 |
+
except (EOFError, KeyboardInterrupt):
|
| 67 |
+
ans = ""
|
| 68 |
+
if ans.isdigit() and 1 <= int(ans) <= len(options):
|
| 69 |
+
return options[int(ans) - 1][0]
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _fetch_providers(server):
|
| 74 |
+
"""공급자/모델 목록 — 데몬 /api/shadow/config (헤임달 드롭다운과 동일 소스)
|
| 75 |
+
+ 로컬 Ollama(11434)가 살아 있으면 실제 설치 모델로 upsert."""
|
| 76 |
+
provs = []
|
| 77 |
+
try:
|
| 78 |
+
with urllib.request.urlopen(f"{server}/api/shadow/config", timeout=6) as r:
|
| 79 |
+
data = json.loads(r.read().decode("utf-8"))
|
| 80 |
+
provs = [p for p in data.get("providers", [])
|
| 81 |
+
if isinstance(p, dict) and p.get("id") and p.get("models")]
|
| 82 |
+
except Exception:
|
| 83 |
+
pass
|
| 84 |
+
try:
|
| 85 |
+
with urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=2) as r:
|
| 86 |
+
tags = [m.get("name") for m in json.loads(r.read().decode("utf-8")).get("models", [])]
|
| 87 |
+
tags = [t for t in tags if t]
|
| 88 |
+
if tags:
|
| 89 |
+
provs = [p for p in provs if p.get("id") != "ollama"]
|
| 90 |
+
provs.append({"id": "ollama", "name": "🦙 Ollama (로컬)", "models": tags})
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
return provs
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def cmd_model_picker(server):
|
| 97 |
+
"""/model (인자 없음) — 공급자 → 모델 2단 화살표 선택."""
|
| 98 |
+
cur_prov = (_config_get("provider") or "").strip()
|
| 99 |
+
cur_model = (_config_get("model") or "").strip()
|
| 100 |
+
provs = _fetch_providers(server)
|
| 101 |
+
if cur_prov and cur_prov not in [p["id"] for p in provs]:
|
| 102 |
+
provs.append({"id": cur_prov, "name": cur_prov,
|
| 103 |
+
"models": [cur_model] if cur_model else []})
|
| 104 |
+
if not provs:
|
| 105 |
+
print(yellow("공급자 목록을 가져오지 못했습니다 — /model <모델ID> 로 직접 지정하세요."))
|
| 106 |
+
return
|
| 107 |
+
opts = [(p["id"], f'{p.get("name", p["id"])} ({p["id"]})'
|
| 108 |
+
+ (" ✓ 현재" if p["id"] == cur_prov else "")) for p in provs]
|
| 109 |
+
pid = _arrow_choose(opts, title="🧠 AI 공급자 선택")
|
| 110 |
+
if pid is None:
|
| 111 |
+
print(dim("변경 없음."))
|
| 112 |
+
return
|
| 113 |
+
models = next((p.get("models") or [] for p in provs if p["id"] == pid), [])
|
| 114 |
+
mopts = [(m, m + (" ✓ 현재" if pid == cur_prov and m == cur_model else "")) for m in models]
|
| 115 |
+
mopts.append(("__manual__", "직접 입력…"))
|
| 116 |
+
model = _arrow_choose(mopts, title=f"📦 모델 선택 — {pid}")
|
| 117 |
+
if model is None:
|
| 118 |
+
print(dim("변경 없음."))
|
| 119 |
+
return
|
| 120 |
+
if model == "__manual__":
|
| 121 |
+
try:
|
| 122 |
+
model = input(" 모델 ID: ").strip()
|
| 123 |
+
except (EOFError, KeyboardInterrupt):
|
| 124 |
+
model = ""
|
| 125 |
+
if not model:
|
| 126 |
+
print(dim("변경 없음."))
|
| 127 |
+
return
|
| 128 |
+
_config_set("provider", pid)
|
| 129 |
+
_config_set("model", model)
|
| 130 |
+
print(green(f"→ 로컬 태미니: {pid}/{model}") + dim(" (데몬 재시작 후 반영)"))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def cmd_server(sess, arg):
|
| 134 |
+
"""/server [local|cloud|URL] — 연결 모드 전환 (헤임달 로컬/클라우드와 동일 개념).
|
| 135 |
+
인자 없으면 화살표 메뉴. 전환은 저장되어 다음 시작에도 유지."""
|
| 136 |
+
arg = (arg or "").strip()
|
| 137 |
+
local_url = "http://127.0.0.1:18700"
|
| 138 |
+
sel = None
|
| 139 |
+
if not arg:
|
| 140 |
+
is_loc = _is_local_server(sess.server)
|
| 141 |
+
cur_cloud = (not is_loc) and sess.server.rstrip("/") == cloud_url()
|
| 142 |
+
sel = _arrow_choose([
|
| 143 |
+
("local", f"🏠 로컬 데몬 {local_url}" + (" ✓ 현재" if is_loc else "")),
|
| 144 |
+
("cloud", f"☁️ 클라우드 {cloud_url()}" + (" ✓ 현재" if cur_cloud else "")),
|
| 145 |
+
("manual", "직접 URL 입력…"),
|
| 146 |
+
], title="🌐 쉐도우 브레인 연결 모드")
|
| 147 |
+
if sel is None:
|
| 148 |
+
print(dim("변경 없음."))
|
| 149 |
+
return
|
| 150 |
+
else:
|
| 151 |
+
sel = arg.lower() if arg.lower() in ("local", "cloud") else "url"
|
| 152 |
+
if sel == "manual":
|
| 153 |
+
try:
|
| 154 |
+
arg = input(" 서버 URL: ").strip()
|
| 155 |
+
except (EOFError, KeyboardInterrupt):
|
| 156 |
+
arg = ""
|
| 157 |
+
if not arg:
|
| 158 |
+
print(dim("변경 없음."))
|
| 159 |
+
return
|
| 160 |
+
sel = "url"
|
| 161 |
+
target = {"local": local_url, "cloud": cloud_url()}.get(sel, arg).rstrip("/")
|
| 162 |
+
if not target.startswith(("http://", "https://")):
|
| 163 |
+
target = "https://" + target
|
| 164 |
+
print(dim(f" 연결 확인 중… {target}"))
|
| 165 |
+
if not daemon_alive(target, timeout=6):
|
| 166 |
+
if _is_local_server(target):
|
| 167 |
+
if not ensure_daemon(target):
|
| 168 |
+
print(red("전환 취소 — 로컬 데몬을 켤 수 없습니다."))
|
| 169 |
+
return
|
| 170 |
+
else:
|
| 171 |
+
print(red(f"전환 취소 — 서버 응답 없음: {target}"))
|
| 172 |
+
return
|
| 173 |
+
sess.server = target
|
| 174 |
+
if _is_local_server(target):
|
| 175 |
+
sess.trusted = is_trusted_dir(sess.cwd)
|
| 176 |
+
print(green(f"🏠 로컬 데몬으로 전환: {target}"))
|
| 177 |
+
else:
|
| 178 |
+
# 원격 컨테이너에는 이 폴더가 없다 — 코딩 도구 OFF(대화 전용)
|
| 179 |
+
sess.trusted = False
|
| 180 |
+
print(green(f"☁️ 클라우드로 전환: {target}"))
|
| 181 |
+
print(dim(" 대화 전용 모드 — 파일 수정·명령 실행(코딩 도구)은 로컬 모드에서만 동작합니다."))
|
| 182 |
+
if not (load_settings().get("TAEMINI_CLOUD_KEY") or "").strip():
|
| 183 |
+
print(yellow(" ⚠️ cloud-key 미설정 — 게이트가 켜진 서버면 401이 납니다: /config set cloud-key <키>"))
|
| 184 |
+
s = load_settings()
|
| 185 |
+
s["TAEMINI_SERVER_MODE"] = "local" if _is_local_server(target) else "cloud"
|
| 186 |
+
if not _is_local_server(target):
|
| 187 |
+
s["TAEMINI_CLOUD_URL"] = target
|
| 188 |
+
save_settings(s)
|
| 189 |
+
print(dim(" 이 모드는 저장되어 다음 taemini 시작에도 유지됩니다."))
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
__all__ = [_n for _n in list(globals()) if not _n.startswith("__")]
|
shadow_brain_core/utils/__pycache__/emotion.cpython-313.pyc
CHANGED
|
Binary files a/shadow_brain_core/utils/__pycache__/emotion.cpython-313.pyc and b/shadow_brain_core/utils/__pycache__/emotion.cpython-313.pyc differ
|
|
|
shadow_brain_core/utils/__pycache__/emotion.cpython-314.pyc
ADDED
|
Binary file (11.5 kB). View file
|
|
|
shadow_brain_core/utils/emotion.py
CHANGED
|
@@ -72,6 +72,9 @@ def invalidate_guidelines_cache() -> None:
|
|
| 72 |
_emotion_logger.info("[Guidelines] 캐시 무효화 — 다음 조회에서 최신 지침을 다시 가져옵니다.")
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
| 75 |
def _fetch_guidelines_from_aegis(aegis_url: str) -> dict | None:
|
| 76 |
"""Aegis REST API에서 공통 지침을 가져옵니다. 실패 시 None 반환.
|
| 77 |
|
|
@@ -79,6 +82,11 @@ def _fetch_guidelines_from_aegis(aegis_url: str) -> dict | None:
|
|
| 79 |
401 → None → 하드코딩 폴백이 쓰여, '공통 지침 관리자' 편집이 브레인에 전혀 반영되지
|
| 80 |
않는다. 반드시 서비스 토큰을 실어 보낸다(저장 경로 get_job_perf_flags와 동일 패턴)."""
|
| 81 |
global _RUNTIME_TTL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
try:
|
| 83 |
import os
|
| 84 |
import requests
|
|
@@ -97,9 +105,12 @@ def _fetch_guidelines_from_aegis(aegis_url: str) -> dict | None:
|
|
| 97 |
_RUNTIME_TTL = float(data['cache_ttl_seconds'])
|
| 98 |
except (TypeError, ValueError):
|
| 99 |
pass
|
|
|
|
| 100 |
return data # {"readability": "...", "emotion": "...", "cache_ttl_seconds": N}
|
|
|
|
| 101 |
except Exception as e:
|
| 102 |
_emotion_logger.debug(f"[Guidelines] Aegis 조회 실패 (폴백 사용): {e}")
|
|
|
|
| 103 |
return None
|
| 104 |
|
| 105 |
|
|
@@ -116,6 +127,10 @@ def get_dynamic_retrieval_policy(aegis_url: str | None = None) -> dict:
|
|
| 116 |
if isinstance(value, dict):
|
| 117 |
_GUIDELINES_CACHE[cache_key] = {'value': value, 'ts': time.time()}
|
| 118 |
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
return {}
|
| 120 |
|
| 121 |
|
|
|
|
| 72 |
_emotion_logger.info("[Guidelines] 캐시 무효화 — 다음 조회에서 최신 지침을 다시 가져옵니다.")
|
| 73 |
|
| 74 |
|
| 75 |
+
_FETCH_FAIL_CACHE: dict = {} # aegis_url → 실패 시각 (네거티브 캐시)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
def _fetch_guidelines_from_aegis(aegis_url: str) -> dict | None:
|
| 79 |
"""Aegis REST API에서 공통 지침을 가져옵니다. 실패 시 None 반환.
|
| 80 |
|
|
|
|
| 82 |
401 → None → 하드코딩 폴백이 쓰여, '공통 지침 관리자' 편집이 브레인에 전혀 반영되지
|
| 83 |
않는다. 반드시 서비스 토큰을 실어 보낸다(저장 경로 get_job_perf_flags와 동일 패턴)."""
|
| 84 |
global _RUNTIME_TTL
|
| 85 |
+
# 🛡️ 실패 네거티브 캐시 — 401/네트워크 실패가 반복되는 동안 모든 소비자
|
| 86 |
+
# (retrieval_policy/emotion/readability)가 매 턴 클라우드 왕복을 반복하지 않게.
|
| 87 |
+
_fail_at = _FETCH_FAIL_CACHE.get(aegis_url)
|
| 88 |
+
if _fail_at and (time.time() - _fail_at) < _eff_ttl():
|
| 89 |
+
return None
|
| 90 |
try:
|
| 91 |
import os
|
| 92 |
import requests
|
|
|
|
| 105 |
_RUNTIME_TTL = float(data['cache_ttl_seconds'])
|
| 106 |
except (TypeError, ValueError):
|
| 107 |
pass
|
| 108 |
+
_FETCH_FAIL_CACHE.pop(aegis_url, None)
|
| 109 |
return data # {"readability": "...", "emotion": "...", "cache_ttl_seconds": N}
|
| 110 |
+
_emotion_logger.debug(f"[Guidelines] Aegis HTTP {resp.status_code} — 네거티브 캐시")
|
| 111 |
except Exception as e:
|
| 112 |
_emotion_logger.debug(f"[Guidelines] Aegis 조회 실패 (폴백 사용): {e}")
|
| 113 |
+
_FETCH_FAIL_CACHE[aegis_url] = time.time()
|
| 114 |
return None
|
| 115 |
|
| 116 |
|
|
|
|
| 127 |
if isinstance(value, dict):
|
| 128 |
_GUIDELINES_CACHE[cache_key] = {'value': value, 'ts': time.time()}
|
| 129 |
return value
|
| 130 |
+
# 🛡️ 네거티브 캐시 — Aegis 실패/누락도 TTL 동안 기억. 안 하면 의도 감지 헬퍼
|
| 131 |
+
# (_tool_policy/_intent_cfg 등)가 매 턴 클라우드 왕복(~840ms)을 반복해 TTFT를
|
| 132 |
+
# 수 초씩 태운다(2026-07-10 실측).
|
| 133 |
+
_GUIDELINES_CACHE[cache_key] = {'value': {}, 'ts': time.time()}
|
| 134 |
return {}
|
| 135 |
|
| 136 |
|
shadow_brain_core/verify_grok_relay.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""로컬 Grok 릴레이 단위·연동 스모크 검증 (데몬 없이도 모듈 경로 검증 가능)."""
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 9 |
+
if ROOT not in sys.path:
|
| 10 |
+
sys.path.insert(0, ROOT)
|
| 11 |
+
|
| 12 |
+
passed = 0
|
| 13 |
+
failed = 0
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def ok(name, cond, detail=""):
|
| 17 |
+
global passed, failed
|
| 18 |
+
if cond:
|
| 19 |
+
passed += 1
|
| 20 |
+
print(f"[OK] {name}" + (f" — {detail}" if detail else ""))
|
| 21 |
+
else:
|
| 22 |
+
failed += 1
|
| 23 |
+
print(f"[FAIL] {name}" + (f" — {detail}" if detail else ""))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
# 1) 모듈 로드
|
| 28 |
+
from blueprints.relays import grok as grok_mod
|
| 29 |
+
ok("import grok relay module", True)
|
| 30 |
+
|
| 31 |
+
# 2) CLI 탐지
|
| 32 |
+
cmd = grok_mod._grok_command()
|
| 33 |
+
ok("grok CLI installed", cmd is not None, cmd or "not found")
|
| 34 |
+
|
| 35 |
+
# 3) JSON 파서
|
| 36 |
+
sample = json.dumps({
|
| 37 |
+
"text": "HELLO",
|
| 38 |
+
"sessionId": "019f4500-aaaa-bbbb-cccc-ddddeeeeffff",
|
| 39 |
+
"stopReason": "EndTurn",
|
| 40 |
+
})
|
| 41 |
+
text, sid = grok_mod._parse_grok_json(sample)
|
| 42 |
+
ok("parse json text", text == "HELLO", text)
|
| 43 |
+
ok("parse json sessionId", sid and sid.startswith("019f"), sid)
|
| 44 |
+
|
| 45 |
+
# 4) 라이브 헤드리스 (CLI 있을 때만)
|
| 46 |
+
if cmd:
|
| 47 |
+
content, session_id = grok_mod._run_grok_single(
|
| 48 |
+
command=cmd,
|
| 49 |
+
prompt="Reply with exactly: GROK_VERIFY_OK",
|
| 50 |
+
cwd="/tmp",
|
| 51 |
+
model="",
|
| 52 |
+
session_id=None,
|
| 53 |
+
)
|
| 54 |
+
ok("headless single-turn", "GROK_VERIFY_OK" in (content or ""), content[:80] if content else "")
|
| 55 |
+
ok("session id returned", bool(session_id), session_id or "")
|
| 56 |
+
if session_id:
|
| 57 |
+
content2, session_id2 = grok_mod._run_grok_single(
|
| 58 |
+
command=cmd,
|
| 59 |
+
prompt="Repeat the exact token from my previous message (GROK_VERIFY_OK).",
|
| 60 |
+
cwd="/tmp",
|
| 61 |
+
model="",
|
| 62 |
+
session_id=session_id,
|
| 63 |
+
)
|
| 64 |
+
ok(
|
| 65 |
+
"resume continuity",
|
| 66 |
+
"GROK_VERIFY_OK" in (content2 or "") or session_id2 == session_id,
|
| 67 |
+
(content2 or "")[:120],
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# 5) VALID_RELAYS
|
| 71 |
+
from local_guardians import VALID_RELAYS
|
| 72 |
+
ok("VALID_RELAYS has grok", "grok" in VALID_RELAYS)
|
| 73 |
+
|
| 74 |
+
# 6) taemini CLI 맵
|
| 75 |
+
import taemini_cli as cli
|
| 76 |
+
ok("CLI GUARDIAN_LABELS grok", "grok" in cli.GUARDIAN_LABELS)
|
| 77 |
+
ok("CLI alias 그록", cli.resolve_guardian("그록") == "grok")
|
| 78 |
+
ok("CLI alias xai", cli.resolve_guardian("xai") == "grok")
|
| 79 |
+
|
| 80 |
+
print()
|
| 81 |
+
print(f"passed={passed} failed={failed}")
|
| 82 |
+
return 0 if failed == 0 else 1
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
sys.exit(main())
|
shadow_brain_core/verify_taemini_cli.py
CHANGED
|
@@ -122,6 +122,15 @@ def main():
|
|
| 122 |
if not cond:
|
| 123 |
failures.append(name)
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
# 1) --once 태미니 경로: 본문 + 진행판 + 통계
|
| 126 |
p = run_cli(["--once", "안녕 태미니", "--cwd", ROOT], server=server)
|
| 127 |
check("태미니 응답 본문 렌더링", "STUB_TAEMINI_REPLY" in p.stdout, p.stdout[-400:])
|
|
|
|
| 122 |
if not cond:
|
| 123 |
failures.append(name)
|
| 124 |
|
| 125 |
+
# 0) 🚧 God-File 상한 래칫 (2026-07-10 분할: cli 2688 / core 789 / media 529)
|
| 126 |
+
# 상한을 넘기면 새 기능은 파일 추가/모듈 이동으로 배치할 것 (AGENTS.md §13-C 정신).
|
| 127 |
+
_CAPS = {"taemini_cli.py": 2800, "taemini_cli_core.py": 950,
|
| 128 |
+
"taemini_cli_media.py": 650, "taemini_cli_select.py": 400}
|
| 129 |
+
for _f, _cap in _CAPS.items():
|
| 130 |
+
_p = os.path.join(ROOT, _f)
|
| 131 |
+
_n = sum(1 for _ in open(_p, encoding="utf-8")) if os.path.exists(_p) else 0
|
| 132 |
+
check(f"파일 상한 {_f} ≤ {_cap}", 0 < _n <= _cap, f"{_n}줄")
|
| 133 |
+
|
| 134 |
# 1) --once 태미니 경로: 본문 + 진행판 + 통계
|
| 135 |
p = run_cli(["--once", "안녕 태미니", "--cwd", ROOT], server=server)
|
| 136 |
check("태미니 응답 본문 렌더링", "STUB_TAEMINI_REPLY" in p.stdout, p.stdout[-400:])
|
shadow_brain_core/web_server.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
shadow_brain_core/web_server_prelude.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""web_server 모듈 수준 헬퍼/로깅 (God-File 분해).
|
| 3 |
+
|
| 4 |
+
create_web_server 및 라우트는 web_server.py 에 유지.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import secrets
|
| 10 |
+
import json
|
| 11 |
+
import uuid
|
| 12 |
+
import base64
|
| 13 |
+
import random
|
| 14 |
+
import logging
|
| 15 |
+
import time
|
| 16 |
+
import subprocess
|
| 17 |
+
import sys
|
| 18 |
+
from collections import deque
|
| 19 |
+
from datetime import datetime, timezone, timedelta
|
| 20 |
+
import threading
|
| 21 |
+
import importlib.util
|
| 22 |
+
import yaml # Added for frontmatter parsing
|
| 23 |
+
try:
|
| 24 |
+
import pyautogui
|
| 25 |
+
HAS_GUI = True
|
| 26 |
+
except Exception:
|
| 27 |
+
# 🔱 Headless Cloud Fallback
|
| 28 |
+
HAS_GUI = False
|
| 29 |
+
pyautogui = None
|
| 30 |
+
import requests
|
| 31 |
+
from flask import Flask, render_template, jsonify, send_from_directory, request, Response, stream_with_context
|
| 32 |
+
from dotenv import load_dotenv
|
| 33 |
+
from google import genai
|
| 34 |
+
from google.genai import types
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _sanitize_a2ui_for_relay(text: str) -> str:
|
| 40 |
+
"""디스코드/텔레그램 같은 비대화형 릴레이용으로 A2UI 카드 블록을 평문화한다.
|
| 41 |
+
|
| 42 |
+
헤임달 Flutter 전용 ```imperial-*``` 블록(미디어 그리드/유튜브/지도/선택카드)은
|
| 43 |
+
외부 채널에서 렌더링되지 않고 날것 JSON으로 노출되므로:
|
| 44 |
+
- imperial-media / imperial-youtube / imperial-3d / imperial-map(s):
|
| 45 |
+
블록 안의 URL만 추출해 평문 줄로 남긴다(디스코드가 자동 임베드).
|
| 46 |
+
- imperial-ask 및 그 외 imperial-* 블록: 그냥 제거.
|
| 47 |
+
"""
|
| 48 |
+
if not text or '```imperial-' not in text:
|
| 49 |
+
return (text or '').strip()
|
| 50 |
+
|
| 51 |
+
url_kinds = ('media', 'youtube', '3d', 'map', 'maps')
|
| 52 |
+
|
| 53 |
+
def _extract_urls(block: str):
|
| 54 |
+
seen = []
|
| 55 |
+
# imperial-media는 JSON이라 item별 대표 URL(url→preview→page)만 골라낸다.
|
| 56 |
+
try:
|
| 57 |
+
inner = re.sub(r'^```imperial-[\w-]+\s*|\s*```$', '', block.strip())
|
| 58 |
+
data = json.loads(inner)
|
| 59 |
+
for it in (data.get('items') or []):
|
| 60 |
+
u = it.get('url') or it.get('preview') or it.get('page') or it.get('download')
|
| 61 |
+
if u and u not in seen:
|
| 62 |
+
seen.append(u)
|
| 63 |
+
except Exception:
|
| 64 |
+
pass
|
| 65 |
+
if not seen:
|
| 66 |
+
for u in re.findall(r'https?://[^\s"\'\\)]+', block):
|
| 67 |
+
if u not in seen:
|
| 68 |
+
seen.append(u)
|
| 69 |
+
return seen[:5]
|
| 70 |
+
|
| 71 |
+
def _repl(m):
|
| 72 |
+
kind = (m.group(1) or '').lower()
|
| 73 |
+
if kind in url_kinds:
|
| 74 |
+
urls = _extract_urls(m.group(0))
|
| 75 |
+
return ('\n' + '\n'.join(urls) + '\n') if urls else ''
|
| 76 |
+
return ''
|
| 77 |
+
|
| 78 |
+
text = re.sub(r'```imperial-([\w-]+)[\s\S]*?```', _repl, text).strip()
|
| 79 |
+
# [TOOL_USE: ...] 패턴 제거 — 릴레이 채널(디스코드/텔레그램)에서 날것 노출 방지
|
| 80 |
+
text = re.sub(r'\[TOOL_USE:[^\]]*\]\s*', '', text).strip()
|
| 81 |
+
return text
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
from utils.emotion import extract_emotion as _extract_emotion_from_text, strip_emotion_tags as _strip_emotion_tags
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# [🔱 Imperial v2.5.2] Standardized Path Resolution (PyInstaller EXE 호환)
|
| 88 |
+
# PyInstaller로 패키징된 EXE 실행 시 __file__은 임시 폴더를 가리킴.
|
| 89 |
+
# sys.frozen이 True이면 실제 EXE 위치(sys.executable)를 기준으로 경로를 잡습니다.
|
| 90 |
+
if getattr(sys, 'frozen', False):
|
| 91 |
+
# EXE 모드: dist/ShadowBrain/ 내부가 core_root
|
| 92 |
+
core_root = os.path.dirname(os.path.abspath(sys.executable))
|
| 93 |
+
else:
|
| 94 |
+
# 일반 Python 실행 모드
|
| 95 |
+
core_root = os.path.dirname(os.path.abspath(__file__))
|
| 96 |
+
project_root = os.path.dirname(core_root) # One level up to d:\Git_Work\jarvis_taemin
|
| 97 |
+
|
| 98 |
+
# [🔱 Imperial Writable Data Root]
|
| 99 |
+
# 배포본(.exe)은 Program Files 등 쓰기가 막힌 보호 폴더에 설치된다. 이 경우 project_root가
|
| 100 |
+
# Program Files를 가리켜 docker_data/로그/설정 생성 시 PermissionError(WinError 5)로 부팅이
|
| 101 |
+
# 멈춘다. frozen(.exe) 또는 LOCAL_AGENT_MODE에서는 project_root를 쓰기 가능한 사용자 폴더
|
| 102 |
+
# (%LOCALAPPDATA%\TaeminGames\ShadowBrain)로 강제 리다이렉트한다. (읽기용 core_root는 유지)
|
| 103 |
+
def _resolve_writable_data_root():
|
| 104 |
+
explicit = os.environ.get("IMPERIAL_DATA_ROOT")
|
| 105 |
+
if explicit:
|
| 106 |
+
base = explicit
|
| 107 |
+
else:
|
| 108 |
+
local_base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
|
| 109 |
+
base = os.path.join(local_base, "TaeminGames", "ShadowBrain")
|
| 110 |
+
try:
|
| 111 |
+
os.makedirs(base, exist_ok=True)
|
| 112 |
+
except Exception:
|
| 113 |
+
return None
|
| 114 |
+
return base
|
| 115 |
+
|
| 116 |
+
if getattr(sys, 'frozen', False) or os.environ.get("LOCAL_AGENT_MODE", "false").lower() == "true":
|
| 117 |
+
_writable_root = _resolve_writable_data_root()
|
| 118 |
+
if _writable_root:
|
| 119 |
+
project_root = _writable_root
|
| 120 |
+
print(f"[SYSTEM] -> Writable data root: {project_root}", flush=True)
|
| 121 |
+
|
| 122 |
+
def _read_shadow_version() -> str:
|
| 123 |
+
"""VERSION 파일을 읽어 버전 문자열을 반환합니다."""
|
| 124 |
+
version_path = os.path.join(core_root, "VERSION")
|
| 125 |
+
if os.path.exists(version_path):
|
| 126 |
+
try:
|
| 127 |
+
with open(version_path, 'r', encoding='utf-8') as f:
|
| 128 |
+
return f.read().strip()
|
| 129 |
+
except: pass
|
| 130 |
+
return "2.6.72" # Fallback
|
| 131 |
+
|
| 132 |
+
# Add core directory and tools to sys.path before any local imports
|
| 133 |
+
sys.path.append(core_root)
|
| 134 |
+
sys.path.append(os.path.join(project_root, "scripts", "tools"))
|
| 135 |
+
|
| 136 |
+
# [🔱 Imperial] Persistent Console Title
|
| 137 |
+
def set_imperial_title(title_name):
|
| 138 |
+
if sys.platform == "win32":
|
| 139 |
+
import ctypes
|
| 140 |
+
try:
|
| 141 |
+
ctypes.windll.kernel32.SetConsoleTitleW(title_name)
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
else:
|
| 145 |
+
sys.stdout.write(f"\033]2;{title_name}\007")
|
| 146 |
+
sys.stdout.flush()
|
| 147 |
+
|
| 148 |
+
print("[SYSTEM] Booting Imperial Shadow Brain Core...", flush=True)
|
| 149 |
+
set_imperial_title("SHADOW BRAIN (Core Intelligence) - Port 18700")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# Load environment variables from .env if it exists
|
| 153 |
+
load_dotenv()
|
| 154 |
+
|
| 155 |
+
# [🔱 Imperial Local Agent Mode Auto-Detection]
|
| 156 |
+
# 1. sys.frozen이 True이면 PyInstaller로 빌드된 .exe 배포본 상태입니다.
|
| 157 |
+
# 2. 배포본은 기본적으로 '로컬 에이전트 모드(Stateless)'로 작동하도록 자동 설정합니다.
|
| 158 |
+
if getattr(sys, 'frozen', False):
|
| 159 |
+
LOCAL_AGENT_MODE = True
|
| 160 |
+
os.environ["LOCAL_AGENT_MODE"] = "true" # [🔱] 하위 모듈(sovereign_memory 등) 인식을 위해 환경변수 강제 주입
|
| 161 |
+
print("[SYSTEM] 🔱 Imperial Standalone Executable (.exe) detected.", flush=True)
|
| 162 |
+
print("[SYSTEM] -> Auto-activating Local Agent Mode (Stateless).", flush=True)
|
| 163 |
+
else:
|
| 164 |
+
# 소스 코드로 실행 중일 때만 .env 설정을 따릅니다.
|
| 165 |
+
LOCAL_AGENT_MODE = os.environ.get("LOCAL_AGENT_MODE", "false").lower() == "true"
|
| 166 |
+
|
| 167 |
+
# Imperial Core Modules
|
| 168 |
+
print("[SYSTEM] -> Loading Imperial Core Modules...", flush=True)
|
| 169 |
+
import gdrive_sync
|
| 170 |
+
import cloud_tool
|
| 171 |
+
import phantom_sandbox
|
| 172 |
+
# psycopg2는 설치 환경에서만 활성화 (Stateless 배포 호환). 미설치 시 DB 엔드포인트만 비활성.
|
| 173 |
+
try:
|
| 174 |
+
import psycopg2
|
| 175 |
+
except ImportError:
|
| 176 |
+
psycopg2 = None
|
| 177 |
+
from werkzeug.security import check_password_hash, generate_password_hash
|
| 178 |
+
from sovereign_task_manager import task_manager
|
| 179 |
+
import quota_manager
|
| 180 |
+
from quota_manager import ImperialQuotaManager, CopilotUsageManager
|
| 181 |
+
from aegis_link import neural_link
|
| 182 |
+
from sovereign_browser_agent import SovereignMissionAgent
|
| 183 |
+
from imperial_r2_uploader import ImperialR2Uploader
|
| 184 |
+
from compaction import compaction_engine
|
| 185 |
+
print("[SYSTEM] -> Core Modules Loaded.", flush=True)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
from guardian_monitor import guardian_monitor
|
| 189 |
+
|
| 190 |
+
from db_util import get_db_connection
|
| 191 |
+
from telegram_service import telegram_bot
|
| 192 |
+
from discord_service import discord_bot
|
| 193 |
+
from tasks_util import execute_shell_command, _post_to_gbb, _analyze_sync_failure
|
| 194 |
+
print("[SYSTEM] -> Loading Blueprints...", flush=True)
|
| 195 |
+
from blueprints.system_routes import system_bp
|
| 196 |
+
from blueprints.auth_gate import auth_gate_bp # 🔐 공개 배포(HF) 인증 게이트
|
| 197 |
+
from blueprints.media_routes import media_bp
|
| 198 |
+
from blueprints.chat_routes import chat_bp, init_chat_logic
|
| 199 |
+
from blueprints.admin_routes import admin_bp, init_admin_logic
|
| 200 |
+
from blueprints.agent_routes import agent_bp, init_agent_logic
|
| 201 |
+
from blueprints.security_routes import security_bp, init_security_logic
|
| 202 |
+
from blueprints.deployment_routes import deploy_bp, init_deploy_logic
|
| 203 |
+
from blueprints.report_routes import report_bp, init_report_logic
|
| 204 |
+
from blueprints.parallel_routes import parallel_bp, init_parallel_logic
|
| 205 |
+
from blueprints.mcp_routes import mcp_bp
|
| 206 |
+
from blueprints.transcribe_routes import transcribe_bp # [🔱 미디어 전사 API] /api/transcribe/*
|
| 207 |
+
from blueprints.obsidian_routes import obsidian_bp # [🗂️ 옵시디언 지식 레이어] /api/obsidian/* — 로컬 전용
|
| 208 |
+
print("[SYSTEM] -> Blueprints Loaded.", flush=True)
|
| 209 |
+
|
| 210 |
+
from services.brain_service import init_service_logic, get_brain_response_sync
|
| 211 |
+
import services.brain_service as brain_service
|
| 212 |
+
from compaction import CompactionEngine
|
| 213 |
+
from sovereign_memory import memory_write_vector
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
# Imperial Remote Vision Service & Sovereign Engine
|
| 217 |
+
try:
|
| 218 |
+
import remote_vision_service
|
| 219 |
+
import sovereign_engine
|
| 220 |
+
import sovereign_memory
|
| 221 |
+
except ImportError as e:
|
| 222 |
+
print(f"[SYSTEM] -> Remote services loading warning: {e}")
|
| 223 |
+
except Exception as e:
|
| 224 |
+
print(f"[SYSTEM] -> Core engine initialization warning: {e}")
|
| 225 |
+
|
| 226 |
+
# --- [🔱 Imperial Log Monitoring] ---
|
| 227 |
+
class ImperialLogHandler(logging.Handler):
|
| 228 |
+
"""[🔱 Imperial] Custom logging handler to pipe all logs into the global buffer."""
|
| 229 |
+
def __init__(self, log_queue):
|
| 230 |
+
super().__init__()
|
| 231 |
+
self.log_queue = log_queue
|
| 232 |
+
# Use a consistent timestamp format same as StreamTee
|
| 233 |
+
self.setFormatter(logging.Formatter('[%(asctime)s] %(name)s: %(message)s', datefmt='%H:%M:%S'))
|
| 234 |
+
|
| 235 |
+
def emit(self, record):
|
| 236 |
+
try:
|
| 237 |
+
msg = self.format(record)
|
| 238 |
+
self.log_queue.append(msg)
|
| 239 |
+
except Exception:
|
| 240 |
+
self.handleError(record)
|
| 241 |
+
|
| 242 |
+
class StreamTee:
|
| 243 |
+
"""[🔱 Imperial] Custom stream wrapper to copy stdout/stderr to the global log buffer."""
|
| 244 |
+
def __init__(self, stream, log_queue):
|
| 245 |
+
self.stream = stream
|
| 246 |
+
self.log_queue = log_queue
|
| 247 |
+
|
| 248 |
+
def write(self, data):
|
| 249 |
+
self.stream.write(data)
|
| 250 |
+
if isinstance(data, bytes):
|
| 251 |
+
try:
|
| 252 |
+
data = data.decode('utf-8', errors='replace')
|
| 253 |
+
except:
|
| 254 |
+
return # Skip if undecodable
|
| 255 |
+
|
| 256 |
+
text = data.strip()
|
| 257 |
+
if text:
|
| 258 |
+
# [🔱 Imperial] Mute periodic polling logs from stdout capture
|
| 259 |
+
# Stricter filtering to prevent polling loop visibility
|
| 260 |
+
poll_keywords = [
|
| 261 |
+
"/api/logs/shadow-brain",
|
| 262 |
+
"/api/system/monitor/r2",
|
| 263 |
+
"/api/status",
|
| 264 |
+
"/health",
|
| 265 |
+
"GET /api/logs",
|
| 266 |
+
"method: get"
|
| 267 |
+
]
|
| 268 |
+
if any(kw in text for kw in poll_keywords):
|
| 269 |
+
return
|
| 270 |
+
|
| 271 |
+
# Format like a log message for consistency
|
| 272 |
+
timestamp = datetime.now().strftime('%H:%M:%S')
|
| 273 |
+
self.log_queue.append(f"[{timestamp}] [STDOUT] {text}")
|
| 274 |
+
|
| 275 |
+
def flush(self):
|
| 276 |
+
self.stream.flush()
|
| 277 |
+
|
| 278 |
+
SHADOW_BRAIN_LOGS = deque(maxlen=30)
|
| 279 |
+
|
| 280 |
+
def capture_logs():
|
| 281 |
+
"""Starts capturing stdout, stderr, and root logger into the global log queue."""
|
| 282 |
+
# 1. Pipe Standard Output
|
| 283 |
+
sys.stdout = StreamTee(sys.stdout, SHADOW_BRAIN_LOGS)
|
| 284 |
+
sys.stderr = StreamTee(sys.stderr, SHADOW_BRAIN_LOGS)
|
| 285 |
+
|
| 286 |
+
# 2. Add Logging Handler to Root
|
| 287 |
+
root_logger = logging.getLogger()
|
| 288 |
+
handler = ImperialLogHandler(SHADOW_BRAIN_LOGS)
|
| 289 |
+
root_logger.addHandler(handler)
|
| 290 |
+
logger.info("🔱 Imperial Neural Log Stream Integrated.")
|
| 291 |
+
|
| 292 |
+
capture_logs()
|
| 293 |
+
|
| 294 |
+
# --- [Imperial Core] Dynamic R2 Resolution Helpers ---
|
| 295 |
+
def get_imperial_r2_account_name():
|
| 296 |
+
"""Returns the R2 account name based on the current environment."""
|
| 297 |
+
app_env = os.environ.get("APP_ENV", "local").lower()
|
| 298 |
+
return "live" if app_env == "live" else "vault"
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# --- [🔱 Imperial Health Check] ---
|
| 302 |
+
# --- [🔱 Imperial Context Caching] ---
|
| 303 |
+
_IMPERIAL_CONTEXT_CACHE = {
|
| 304 |
+
"agents": "",
|
| 305 |
+
"run_info": "",
|
| 306 |
+
"last_mtime_agents": 0,
|
| 307 |
+
"last_mtime_run": 0
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
def _load_imperial_context():
|
| 311 |
+
"""
|
| 312 |
+
[🔱 High-Performance Comprehensive Context Loader - V3 Final]
|
| 313 |
+
제국의 모든 정체성(수호자, 아키텍처, 바하무트 등 시스템 자산)을 AGENTS.md와 JARVISRUN.md에서 정밀하게 추출합니다.
|
| 314 |
+
"""
|
| 315 |
+
global _IMPERIAL_CONTEXT_CACHE
|
| 316 |
+
|
| 317 |
+
agents_path = os.path.join(project_root, "AGENTS.md")
|
| 318 |
+
run_info_path = os.path.join(project_root, "JARVISRUN.md")
|
| 319 |
+
|
| 320 |
+
try:
|
| 321 |
+
# 1. AGENTS.md 파싱 (수호자 명단 ~ 하단 전체)
|
| 322 |
+
# 9번 섹션(수호자 연합) 이후의 모든 규약과 정보를 포함하여 시스템 정체성 확립
|
| 323 |
+
if os.path.exists(agents_path):
|
| 324 |
+
current_mtime = os.path.getmtime(agents_path)
|
| 325 |
+
if current_mtime > _IMPERIAL_CONTEXT_CACHE["last_mtime_agents"]:
|
| 326 |
+
with open(agents_path, 'r', encoding='utf-8') as f:
|
| 327 |
+
content = f.read()
|
| 328 |
+
match = re.search(r'## 9\. 🔱 황실 수호자 연합.*', content, re.DOTALL)
|
| 329 |
+
if match:
|
| 330 |
+
_IMPERIAL_CONTEXT_CACHE["agents"] = match.group(0).strip()
|
| 331 |
+
else:
|
| 332 |
+
_IMPERIAL_CONTEXT_CACHE["agents"] = content[:3000] # 최후의 수단
|
| 333 |
+
_IMPERIAL_CONTEXT_CACHE["last_mtime_agents"] = current_mtime
|
| 334 |
+
|
| 335 |
+
# 2. JARVISRUN.md 파싱 (아키텍처 ~ 끝까지)
|
| 336 |
+
# 3번 섹션(아키텍처) 및 4번(수호자 위계)을 포함하여 바하무트 등 시스템 엔티티 인지
|
| 337 |
+
if os.path.exists(run_info_path):
|
| 338 |
+
current_mtime = os.path.getmtime(run_info_path)
|
| 339 |
+
if current_mtime > _IMPERIAL_CONTEXT_CACHE["last_mtime_run"]:
|
| 340 |
+
with open(run_info_path, 'r', encoding='utf-8') as f:
|
| 341 |
+
content = f.read()
|
| 342 |
+
# '## 3. 아키텍처' 이후의 모든 내용을 가져와서 시스템 구조를 학습시킴
|
| 343 |
+
match = re.search(r'## 3\. 아키텍처.*', content, re.DOTALL)
|
| 344 |
+
if match:
|
| 345 |
+
_IMPERIAL_CONTEXT_CACHE["run_info"] = match.group(0).strip()
|
| 346 |
+
else:
|
| 347 |
+
_IMPERIAL_CONTEXT_CACHE["run_info"] = ""
|
| 348 |
+
_IMPERIAL_CONTEXT_CACHE["last_mtime_run"] = current_mtime
|
| 349 |
+
|
| 350 |
+
except Exception as e:
|
| 351 |
+
print(f"[🔱 Context Loader] Error loading context: {e}")
|
| 352 |
+
|
| 353 |
+
return f"{_IMPERIAL_CONTEXT_CACHE['agents']}\n\n{_IMPERIAL_CONTEXT_CACHE['run_info']}"
|
| 354 |
+
|
| 355 |
+
def get_imperial_r2_root():
|
| 356 |
+
"""Returns the base public URL for the current environment's R2 account."""
|
| 357 |
+
from cloud_tool import R2_ACCOUNTS
|
| 358 |
+
acc_name = get_imperial_r2_account_name()
|
| 359 |
+
return R2_ACCOUNTS.get(acc_name, {}).get("public_url", "")
|
| 360 |
+
|
| 361 |
+
def resolve_imperial_profile_url(p_url):
|
| 362 |
+
"""Resolves a relative profile URL to a full R2 URL based on environment."""
|
| 363 |
+
if not p_url: return p_url
|
| 364 |
+
if p_url.startswith('/images/portraits/'):
|
| 365 |
+
root = get_imperial_r2_root()
|
| 366 |
+
return f"{root.rstrip('/')}/{p_url.lstrip('/')}"
|
| 367 |
+
return p_url
|
| 368 |
+
|
| 369 |
+
# --- [🔱 Imperial Core] Shadow Brain Engine Port 18700 ---
|
| 370 |
+
# Logic and Management have been consolidated to Django (Port 18701).
|
| 371 |
+
# This engine now focuses on High-Performance Chat, Agency, and GPU tasks.
|
| 372 |
+
|
| 373 |
+
# get_db_connection is imported from db_util
|
| 374 |
+
|
| 375 |
+
# [REMOVED] ensure_db_schema_ready is now handled by Django Fortress (18701)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# Global store for GitHub OAuth sessions
|
| 380 |
+
|
| 381 |
+
GITHUB_AUTH_SESSIONS = {}
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
# Load environment variables from .env if it exists
|
| 386 |
+
|
| 387 |
+
load_dotenv()
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
# Disable Flask logging for a cleaner terminal
|
| 392 |
+
|
| 393 |
+
log = logging.getLogger('werkzeug')
|
| 394 |
+
|
| 395 |
+
log.setLevel(logging.ERROR)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# --- GitHub Copilot Logic ---
|
| 400 |
+
|
| 401 |
+
# Caches the session-based Copilot token
|
| 402 |
+
|
| 403 |
+
GITHUB_COPILOT_CACHE = {
|
| 404 |
+
|
| 405 |
+
"token": None,
|
| 406 |
+
|
| 407 |
+
"expires_at": 0,
|
| 408 |
+
|
| 409 |
+
"base_url": "https://api.individual.githubcopilot.com"
|
| 410 |
+
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
# Caches the last few proactive messages to avoid repetition
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# Phase 41: Graceful Reintegration State
|
| 422 |
+
# RAG Management Globals (Debounce & Thread Safety)
|
| 423 |
+
rag_timers = {} # {guardian: Timer}
|
| 424 |
+
rag_pending = {} # {guardian: (content, metadata)}
|
| 425 |
+
rag_lock = threading.Lock()
|
| 426 |
+
|
| 427 |
+
is_syncing = False
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
# Proactive Chat Configuration Default
|
| 432 |
+
|
| 433 |
+
CHAT_CONTEXT_LIMIT = 50 # [IMPERIAL] Default context window size (overridden by JARVIS_SHADOW_SETTINGS.json)
|
| 434 |
+
|
| 435 |
+
def _get_chat_context_limit(project_root_path: str) -> int:
|
| 436 |
+
"""JARVIS_SHADOW_SETTINGS.json에서 context_limit을 읽어 Hot-Reload로 반환합니다."""
|
| 437 |
+
try:
|
| 438 |
+
settings_file = os.path.join(project_root_path, "docker_data", "shared_workspace", "JarvisRun", "JARVIS_SHADOW_SETTINGS.json")
|
| 439 |
+
if os.path.exists(settings_file):
|
| 440 |
+
with open(settings_file, 'r', encoding='utf-8') as f:
|
| 441 |
+
data = json.load(f)
|
| 442 |
+
val = data.get("context_limit")
|
| 443 |
+
if isinstance(val, int) and 5 <= val <= 500:
|
| 444 |
+
return val
|
| 445 |
+
except Exception:
|
| 446 |
+
pass
|
| 447 |
+
return CHAT_CONTEXT_LIMIT
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
PROACTIVE_CONFIG_FILE = None # Will be set in setup_project
|
| 452 |
+
|
| 453 |
+
# Proactive Topic Mapping (Korean to English Key)
|
| 454 |
+
|
| 455 |
+
# --- [🔱 Imperial Media Services] (Moved to media_service.py) ---
|
| 456 |
+
from services.media_service import fetch_global_trends, fetch_youtube_trending, fetch_youtube_by_keyword, fetch_joke
|
| 457 |
+
|
| 458 |
+
def get_imperial_context(project_root, guardian_id="jarvis"):
|
| 459 |
+
"""Gathers situational and hierarchical awareness for the Shadow Brain, supporting OpenClaw-style Soul logic."""
|
| 460 |
+
context = f"\n[IMPERIAL SITUATIONAL AWARENESS - TARGET: {guardian_id.upper()}]\n"
|
| 461 |
+
|
| 462 |
+
# 1. Determine Workspace Path
|
| 463 |
+
# Default: OpenClaw workspace for Aizen/Gilgamesh, or specialized folders for others
|
| 464 |
+
workspace_map = {
|
| 465 |
+
"jarvis": os.path.join(project_root, "docker_data", "shared_workspace", "JarvisRun"),
|
| 466 |
+
"aris": os.path.join(project_root, "docker_data", "shared_workspace", "Aris")
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
workspace_path = workspace_map.get(guardian_id.lower())
|
| 470 |
+
|
| 471 |
+
# If no specific workspace, check if a folder exists in docker_data/shared_workspace
|
| 472 |
+
if not workspace_path:
|
| 473 |
+
potential_path = os.path.join(project_root, "docker_data", "shared_workspace", guardian_id.capitalize())
|
| 474 |
+
if os.path.exists(potential_path):
|
| 475 |
+
workspace_path = potential_path
|
| 476 |
+
|
| 477 |
+
# 2. Inject Soul Bridge (IDENTITY.md, SOUL.md, USER.md)
|
| 478 |
+
if workspace_path and os.path.exists(workspace_path):
|
| 479 |
+
soul_files = ["IDENTITY.md", "SOUL.md", "USER.md", "AGENTS.md", "BOOTSTRAP.md", "TOOLS.md"]
|
| 480 |
+
context += f"--- [{guardian_id.upper()}] SOUL CLUSTER (Workspace: {os.path.basename(workspace_path)}) ---\n"
|
| 481 |
+
for soul_file in soul_files:
|
| 482 |
+
file_path = os.path.join(workspace_path, soul_file)
|
| 483 |
+
if os.path.exists(file_path):
|
| 484 |
+
try:
|
| 485 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 486 |
+
context += f"\n[{soul_file}]\n{f.read()}\n"
|
| 487 |
+
except Exception as e:
|
| 488 |
+
context += f"\n[{soul_file} LOAD ERROR: {e}]\n"
|
| 489 |
+
else:
|
| 490 |
+
# Fallback to legacy SKILL.md if no workspace found
|
| 491 |
+
persona_file = os.path.join(project_root, ".agent", "skills", "jarvis_guardian_personas", "SKILL.md")
|
| 492 |
+
if os.path.exists(persona_file):
|
| 493 |
+
try:
|
| 494 |
+
with open(persona_file, 'r', encoding='utf-8') as f:
|
| 495 |
+
context += "--- 제국 수호자 페르소나 (LEGACY SKILL.md) ---\n" + f.read() + "\n"
|
| 496 |
+
except Exception as e:
|
| 497 |
+
context += f"--- 제국 수호자 페르소나 로드 실패: {e} ---\n"
|
| 498 |
+
|
| 499 |
+
# 3. Inject Imperial Permanent Rules (AGENTS.md from root)
|
| 500 |
+
# This is a global rule file that applies to all
|
| 501 |
+
agents_rules_file = os.path.join(project_root, "AGENTS.md")
|
| 502 |
+
if os.path.exists(agents_rules_file):
|
| 503 |
+
try:
|
| 504 |
+
with open(agents_rules_file, 'r', encoding='utf-8') as f:
|
| 505 |
+
context += "--- 제국 영구 운영 규약 (GLOBAL AGENTS.md) ---\n" + f.read() + "\n"
|
| 506 |
+
except Exception as e:
|
| 507 |
+
context += f"--- 제국 운영 규약 로드 실패: {e} ---\n"
|
| 508 |
+
|
| 509 |
+
context += f"현재 서버 시간: {time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
| 510 |
+
return context
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def load_guardian_emails():
|
| 514 |
+
path = os.path.join(project_root, 'docker_data', 'shared_workspace', 'JarvisRun', 'JARVIS_GUARDIAN_EMAILS.json')
|
| 515 |
+
if not os.path.exists(path):
|
| 516 |
+
return {}
|
| 517 |
+
try:
|
| 518 |
+
with open(path, 'r', encoding='utf-8') as f:
|
| 519 |
+
return json.load(f)
|
| 520 |
+
except Exception as e:
|
| 521 |
+
logger.error(f"[Emails] Load error: {e}")
|
| 522 |
+
return {}
|
| 523 |
+
|
| 524 |
+
def save_guardian_emails(emails):
|
| 525 |
+
path = os.path.join(project_root, 'docker_data', 'shared_workspace', 'JarvisRun', 'JARVIS_GUARDIAN_EMAILS.json')
|
| 526 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 527 |
+
try:
|
| 528 |
+
with open(path, 'w', encoding='utf-8') as f:
|
| 529 |
+
json.dump(emails, f, indent=4, ensure_ascii=False)
|
| 530 |
+
return True
|
| 531 |
+
except Exception as e:
|
| 532 |
+
logger.error(f"[Emails] Save error: {e}")
|
| 533 |
+
return False
|
| 534 |
+
|
| 535 |
+
|