taemin1980 commited on
Commit
de2eefc
·
verified ·
1 Parent(s): 3ae522d

🔱 Imperial Deployment: Shadow Brain Core ignition (part 2)

Browse files
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
+