ltcai 0.1.4 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.py CHANGED
@@ -40,6 +40,7 @@ from pydantic import BaseModel
40
40
  from PIL import Image
41
41
 
42
42
  from llm_router import AsyncOpenAI, LLMRouter, OPENAI_COMPATIBLE_PROVIDERS, parse_model_ref, mx, normalize_branding
43
+ from knowledge_graph import KnowledgeGraphStore
43
44
  from p_reinforce import BRAIN_DIR, PReinforceGardener
44
45
  from setup import get_recommendations, install_stream, open_url, scan_environment
45
46
  from telegram_bot import broadcast_web_chat
@@ -165,6 +166,7 @@ IS_PUBLIC_MODE = APP_MODE == "public"
165
166
  DEFAULT_HOST = env_value("LATTICEAI_HOST", "127.0.0.1")
166
167
  DEFAULT_PORT = int(env_value("LATTICEAI_PORT", "4825"))
167
168
  ENABLE_TELEGRAM = env_bool("LATTICEAI_ENABLE_TELEGRAM", default=not IS_PUBLIC_MODE)
169
+ ENABLE_GRAPH = env_bool("LATTICEAI_ENABLE_GRAPH", default=True)
168
170
  AUTOLOAD_MODELS = env_bool("LATTICEAI_AUTOLOAD_MODELS", default=IS_PUBLIC_MODE)
169
171
  MODEL_IDLE_UNLOAD_SECONDS = int(env_value("LATTICEAI_MODEL_IDLE_UNLOAD_SECONDS", "0"))
170
172
  ALLOW_LOCAL_MODELS = env_bool("LATTICEAI_ALLOW_LOCAL_MODELS", default=not IS_PUBLIC_MODE)
@@ -287,6 +289,12 @@ USERS_FILE = DATA_DIR / "users.json"
287
289
  HISTORY_FILE = DATA_DIR / "chat_history.json"
288
290
  VPC_FILE = DATA_DIR / "vpc_config.json"
289
291
  MCP_FILE = DATA_DIR / "mcp_installs.json"
292
+ AUDIT_FILE = DATA_DIR / "audit_log.json"
293
+ KNOWLEDGE_GRAPH = KnowledgeGraphStore(DATA_DIR / "knowledge_graph.sqlite", DATA_DIR / "knowledge_graph_blobs") if ENABLE_GRAPH else None
294
+
295
+ def _require_graph():
296
+ if not ENABLE_GRAPH or KNOWLEDGE_GRAPH is None:
297
+ raise HTTPException(status_code=404, detail="Data Graph is disabled. Set LATTICEAI_ENABLE_GRAPH=true in .env to enable.")
290
298
 
291
299
  class UserRegister(BaseModel):
292
300
  email: str
@@ -319,6 +327,17 @@ class McpRecommendRequest(BaseModel):
319
327
  class McpInstallRequest(BaseModel):
320
328
  mcp_id: str
321
329
 
330
+ class KnowledgeGraphIngestRequest(BaseModel):
331
+ type: str
332
+ content: str = ""
333
+ role: Optional[str] = None
334
+ title: Optional[str] = None
335
+ source: Optional[str] = None
336
+ conversation_id: Optional[str] = None
337
+ user_email: Optional[str] = None
338
+ user_nickname: Optional[str] = None
339
+ metadata: Optional[Dict] = None
340
+
322
341
  DEFAULT_VPC_CONFIG = {
323
342
  "provider": "AWS",
324
343
  "region": "ap-northeast-2",
@@ -727,6 +746,36 @@ def connector_info(mcp_id: str) -> Dict:
727
746
 
728
747
  _history_lock = threading.Lock()
729
748
 
749
+ def get_audit_log() -> List[Dict]:
750
+ if not os.path.exists(AUDIT_FILE):
751
+ return []
752
+ try:
753
+ with open(AUDIT_FILE, "r", encoding="utf-8") as f:
754
+ data = json.load(f)
755
+ return data if isinstance(data, list) else []
756
+ except Exception as e:
757
+ logging.warning("get_audit_log failed: %s", e)
758
+ return []
759
+
760
+ def append_audit_event(event_type: str, **payload) -> None:
761
+ try:
762
+ event = {
763
+ "event_type": event_type,
764
+ "timestamp": datetime.now().isoformat(),
765
+ **payload,
766
+ }
767
+ with _history_lock:
768
+ events = get_audit_log()
769
+ events.append(event)
770
+ if len(events) > 5000:
771
+ events = events[-5000:]
772
+ tmp_path = str(AUDIT_FILE) + ".tmp"
773
+ with open(tmp_path, "w", encoding="utf-8") as f:
774
+ json.dump(events, f, ensure_ascii=False, indent=2)
775
+ os.replace(tmp_path, AUDIT_FILE)
776
+ except Exception as e:
777
+ logging.warning("append_audit_event failed: %s", e)
778
+
730
779
  def save_to_history(
731
780
  role: str,
732
781
  message: str,
@@ -748,6 +797,19 @@ def save_to_history(
748
797
  item["source"] = source
749
798
  if conversation_id:
750
799
  item["conversation_id"] = conversation_id
800
+ sensitive = classify_sensitive_message(item, -1)
801
+ append_audit_event(
802
+ "chat_message",
803
+ role=role,
804
+ user_email=user_email,
805
+ user_nickname=user_nickname,
806
+ source=source,
807
+ conversation_id=conversation_id,
808
+ content_preview=sensitive.get("preview"),
809
+ content_chars=len(message or ""),
810
+ sensitivity=sensitive.get("sensitivity"),
811
+ sensitive_labels=sensitive.get("labels") or [],
812
+ )
751
813
  with _history_lock:
752
814
  history = []
753
815
  if os.path.exists(HISTORY_FILE):
@@ -760,6 +822,19 @@ def save_to_history(
760
822
  with open(tmp_path, "w", encoding="utf-8") as f:
761
823
  json.dump(history, f, ensure_ascii=False, indent=2)
762
824
  os.replace(tmp_path, HISTORY_FILE)
825
+ try:
826
+ if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
827
+ KNOWLEDGE_GRAPH.ingest_message(
828
+ role,
829
+ message,
830
+ user_email=user_email,
831
+ user_nickname=user_nickname,
832
+ source=source,
833
+ conversation_id=conversation_id,
834
+ raw=item,
835
+ )
836
+ except Exception as graph_error:
837
+ logging.warning("knowledge graph message ingest failed: %s", graph_error)
763
838
  except Exception as e:
764
839
  logging.warning("save_to_history failed: %s", e)
765
840
 
@@ -974,11 +1049,11 @@ def require_user(request: Request) -> str:
974
1049
  return email or ""
975
1050
 
976
1051
  def require_admin(request: Request) -> tuple[str, Dict]:
1052
+ users = load_users()
977
1053
  token = _extract_bearer_token(request)
978
1054
  if token:
979
1055
  email = get_session_email(token)
980
1056
  if email:
981
- users = load_users()
982
1057
  if get_user_role(email, users) == "admin":
983
1058
  return email, users
984
1059
  raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
@@ -1151,6 +1226,136 @@ def build_sensitivity_report(history: List[Dict]) -> Dict:
1151
1226
  "compliance_fields": compliant_items[-30:],
1152
1227
  }
1153
1228
 
1229
+ AUDIT_DELETE_EVENTS = {"conversation_delete", "history_delete", "user_delete"}
1230
+
1231
+ def _audit_user_bucket(email: Optional[str], nickname: Optional[str] = None, users: Optional[Dict] = None) -> Dict:
1232
+ user = (users or {}).get(email or "", {})
1233
+ return {
1234
+ "email": email or "Unknown",
1235
+ "nickname": nickname or user.get("nickname") or user.get("name") or email or "Unknown",
1236
+ "role": get_user_role(email, users or {}) if email else "unknown",
1237
+ "disabled": bool(user.get("disabled")) if user else False,
1238
+ "user_messages": 0,
1239
+ "assistant_messages": 0,
1240
+ "document_uploads": 0,
1241
+ "clear_events": 0,
1242
+ "delete_events": 0,
1243
+ "sensitive_events": 0,
1244
+ "high_sensitive_events": 0,
1245
+ "total_content_chars": 0,
1246
+ "last_activity_at": None,
1247
+ }
1248
+
1249
+ def _public_audit_event(event: Dict) -> Dict:
1250
+ allowed = {
1251
+ "event_type",
1252
+ "timestamp",
1253
+ "role",
1254
+ "user_email",
1255
+ "user_nickname",
1256
+ "source",
1257
+ "conversation_id",
1258
+ "command",
1259
+ "scope",
1260
+ "target_email",
1261
+ "filename",
1262
+ "mime_type",
1263
+ "ext",
1264
+ "bytes",
1265
+ "extracted_chars",
1266
+ "graph_node",
1267
+ "keep_last",
1268
+ "removed",
1269
+ "kept",
1270
+ "started_at",
1271
+ "sensitivity",
1272
+ "sensitive_labels",
1273
+ "content_preview",
1274
+ "content_chars",
1275
+ }
1276
+ return {key: event.get(key) for key in allowed if key in event}
1277
+
1278
+ def build_admin_audit_report(users: Dict) -> Dict:
1279
+ events = get_audit_log()
1280
+ per_user: Dict[str, Dict] = {}
1281
+
1282
+ def ensure_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
1283
+ key = email or nickname or "Unknown"
1284
+ if key not in per_user:
1285
+ per_user[key] = _audit_user_bucket(email, nickname, users)
1286
+ elif nickname and per_user[key].get("nickname") in {"Unknown", email, None}:
1287
+ per_user[key]["nickname"] = nickname
1288
+ return per_user[key]
1289
+
1290
+ for email, user in users.items():
1291
+ ensure_user(email, user.get("nickname") or user.get("name"))
1292
+
1293
+ summary = {
1294
+ "total_events": len(events),
1295
+ "chat_events": 0,
1296
+ "user_messages": 0,
1297
+ "assistant_messages": 0,
1298
+ "document_uploads": 0,
1299
+ "clear_events": 0,
1300
+ "delete_events": 0,
1301
+ "sensitive_events": 0,
1302
+ "high_sensitive_events": 0,
1303
+ }
1304
+
1305
+ sensitive_events = []
1306
+ deletion_events = []
1307
+ for event in events:
1308
+ event_type = event.get("event_type")
1309
+ email = event.get("user_email")
1310
+ user = ensure_user(email, event.get("user_nickname"))
1311
+ timestamp = event.get("timestamp")
1312
+ if timestamp and (not user["last_activity_at"] or timestamp > user["last_activity_at"]):
1313
+ user["last_activity_at"] = timestamp
1314
+
1315
+ user["total_content_chars"] += int(event.get("content_chars") or event.get("extracted_chars") or 0)
1316
+ sensitivity = event.get("sensitivity") or "none"
1317
+ labels = event.get("sensitive_labels") or []
1318
+ is_sensitive = sensitivity != "none" or bool(labels)
1319
+
1320
+ if event_type == "chat_message":
1321
+ summary["chat_events"] += 1
1322
+ if event.get("role") == "user":
1323
+ summary["user_messages"] += 1
1324
+ user["user_messages"] += 1
1325
+ elif event.get("role") == "assistant":
1326
+ summary["assistant_messages"] += 1
1327
+ user["assistant_messages"] += 1
1328
+ elif event_type == "document_upload":
1329
+ summary["document_uploads"] += 1
1330
+ user["document_uploads"] += 1
1331
+ elif event_type == "clear_command":
1332
+ summary["clear_events"] += 1
1333
+ user["clear_events"] += 1
1334
+ elif event_type in AUDIT_DELETE_EVENTS:
1335
+ summary["delete_events"] += 1
1336
+ user["delete_events"] += 1
1337
+ deletion_events.append(_public_audit_event(event))
1338
+
1339
+ if is_sensitive:
1340
+ summary["sensitive_events"] += 1
1341
+ user["sensitive_events"] += 1
1342
+ sensitive_events.append(_public_audit_event(event))
1343
+ if sensitivity == "high":
1344
+ summary["high_sensitive_events"] += 1
1345
+ user["high_sensitive_events"] += 1
1346
+
1347
+ return {
1348
+ "summary": summary,
1349
+ "per_user": sorted(
1350
+ per_user.values(),
1351
+ key=lambda item: (item.get("last_activity_at") or "", item.get("user_messages", 0) + item.get("assistant_messages", 0)),
1352
+ reverse=True,
1353
+ ),
1354
+ "recent_events": [_public_audit_event(event) for event in events[-80:]][::-1],
1355
+ "sensitive_events": sensitive_events[-80:][::-1],
1356
+ "deletion_events": deletion_events[-80:][::-1],
1357
+ }
1358
+
1154
1359
  router = LLMRouter()
1155
1360
  gardener = PReinforceGardener()
1156
1361
 
@@ -1239,11 +1444,16 @@ app.add_middleware(
1239
1444
  allow_origins=CORS_ALLOWED_ORIGINS,
1240
1445
  allow_methods=["*"],
1241
1446
  allow_headers=["*"],
1447
+ allow_credentials=True,
1242
1448
  )
1243
1449
 
1244
1450
  # UI 파일이 담길 static 폴더 연결
1245
1451
  STATIC_DIR.mkdir(parents=True, exist_ok=True)
1246
1452
  app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
1453
+ # PWA icons served at /icons/*
1454
+ _ICONS_DIR = STATIC_DIR / "icons"
1455
+ if _ICONS_DIR.exists():
1456
+ app.mount("/icons", StaticFiles(directory=str(_ICONS_DIR)), name="icons")
1247
1457
  ensure_agent_root()
1248
1458
  app.mount("/agent-files", StaticFiles(directory=str(AGENT_ROOT)), name="agent-files")
1249
1459
 
@@ -1468,6 +1678,17 @@ async def admin_sensitivity(request: Request):
1468
1678
  require_admin(request)
1469
1679
  return build_sensitivity_report(get_history())
1470
1680
 
1681
+ @app.get("/admin/audit")
1682
+ async def admin_audit(request: Request):
1683
+ _, users = require_admin(request)
1684
+ report = build_admin_audit_report(users)
1685
+ try:
1686
+ report["graph"] = KNOWLEDGE_GRAPH.stats() if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else {"disabled": True}
1687
+ except Exception as e:
1688
+ logging.warning("knowledge graph stats for audit failed: %s", e)
1689
+ report["graph"] = {"error": str(e)}
1690
+ return report
1691
+
1471
1692
  @app.get("/vpc/status")
1472
1693
  async def vpc_status(request: Request):
1473
1694
  require_user(request)
@@ -1489,6 +1710,7 @@ async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
1489
1710
  admin_email, users = require_admin(request)
1490
1711
  if email not in users:
1491
1712
  raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1713
+ before = public_user(email, users[email], users)
1492
1714
  if req.role is not None:
1493
1715
  if req.role not in {"admin", "user"}:
1494
1716
  raise HTTPException(status_code=400, detail="role은 admin 또는 user만 가능합니다.")
@@ -1498,7 +1720,9 @@ async def admin_update_user(email: str, req: AdminUserUpdate, request: Request):
1498
1720
  raise HTTPException(status_code=400, detail="자기 자신은 비활성화할 수 없습니다.")
1499
1721
  users[email]["disabled"] = req.disabled
1500
1722
  save_users(users)
1501
- return public_user(email, users[email], users)
1723
+ after = public_user(email, users[email], users)
1724
+ append_audit_event("user_update", user_email=admin_email, target_email=email, before=before, after=after)
1725
+ return after
1502
1726
 
1503
1727
  @app.delete("/admin/users/{email:path}")
1504
1728
  async def admin_delete_user(email: str, request: Request):
@@ -1508,6 +1732,7 @@ async def admin_delete_user(email: str, request: Request):
1508
1732
  if email not in users:
1509
1733
  raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
1510
1734
  deleted = public_user(email, users[email], users)
1735
+ append_audit_event("user_delete", user_email=admin_email, target_email=email, deleted_user=deleted)
1511
1736
  del users[email]
1512
1737
  save_users(users)
1513
1738
  return {"status": "ok", "deleted": deleted}
@@ -1515,7 +1740,7 @@ async def admin_delete_user(email: str, request: Request):
1515
1740
  @app.get("/admin/invite-link")
1516
1741
  async def admin_invite_link(request: Request):
1517
1742
  require_admin(request)
1518
- host = request.headers.get("host", f"localhost:{PORT}")
1743
+ host = request.headers.get("host", f"localhost:{DEFAULT_PORT}")
1519
1744
  scheme = "https" if request.headers.get("x-forwarded-proto") == "https" else "http"
1520
1745
  if INVITE_GATE_ENABLED:
1521
1746
  url = f"{scheme}://{host}/?code={INVITE_CODE}"
@@ -1556,6 +1781,30 @@ async def root(request: Request, code: Optional[str] = None, authorized: Optiona
1556
1781
  """, status_code=403)
1557
1782
 
1558
1783
 
1784
+ @app.get("/account")
1785
+ async def account_page():
1786
+ """Direct login/register page route used by logout and manual navigation."""
1787
+ return FileResponse(STATIC_DIR / "account.html")
1788
+
1789
+
1790
+ @app.get("/manifest.json")
1791
+ async def manifest():
1792
+ p = STATIC_DIR / "manifest.json"
1793
+ if not p.exists():
1794
+ raise HTTPException(status_code=404)
1795
+ return FileResponse(str(p), media_type="application/manifest+json")
1796
+
1797
+
1798
+ @app.get("/sw.js")
1799
+ async def service_worker():
1800
+ p = STATIC_DIR / "sw.js"
1801
+ if not p.exists():
1802
+ raise HTTPException(status_code=404)
1803
+ resp = FileResponse(str(p), media_type="application/javascript")
1804
+ resp.headers["Service-Worker-Allowed"] = "/"
1805
+ return resp
1806
+
1807
+
1559
1808
  @app.get("/chat")
1560
1809
  async def chat_page(request: Request):
1561
1810
  return FileResponse(STATIC_DIR / "chat.html")
@@ -1796,6 +2045,7 @@ ENGINE_MODEL_CATALOG = {
1796
2045
  {"id": "mlx-community/gemma-4-e4b-4bit", "name": "Gemma 4 E4B Base", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
1797
2046
  {"id": "mlx-community/gemma-4-e4b-it-4bit", "name": "Gemma 4 E4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "5.2GB", "pullable": True},
1798
2047
  {"id": "mlx-community/gemma-4-26b-a4b-it-4bit", "name": "Gemma 4 26B A4B Instruct", "family": "Gemma 4", "tag": "local-vlm", "size": "Apple Silicon", "pullable": True},
2048
+ {"id": "Jiunsong/supergemma4-26b-abliterated-multimodal-mlx-4bit", "name": "SuperGemma4 26B Abliterated Multimodal", "family": "Gemma 4", "tag": "local-vlm", "size": "Apple Silicon", "pullable": True},
1799
2049
  {"id": "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit", "name": "Qwen 2.5 Coder 3B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "2.1GB", "pullable": True},
1800
2050
  {"id": "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit", "name": "Qwen 2.5 Coder 7B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "4.3GB", "pullable": True},
1801
2051
  {"id": "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", "name": "Qwen 2.5 Coder 14B", "family": "Qwen 2.5 Coder", "tag": "local-coding", "size": "8.5GB", "pullable": True},
@@ -2045,6 +2295,7 @@ def runtime_features() -> Dict:
2045
2295
  "port": DEFAULT_PORT,
2046
2296
  "data_dir": str(DATA_DIR),
2047
2297
  "telegram_enabled": ENABLE_TELEGRAM,
2298
+ "graph_enabled": ENABLE_GRAPH,
2048
2299
  "autoload_models": AUTOLOAD_MODELS,
2049
2300
  "model_idle_unload_seconds": MODEL_IDLE_UNLOAD_SECONDS,
2050
2301
  "model_memory_policy": router.model_memory_policy(),
@@ -2181,6 +2432,7 @@ async def health(request: Request):
2181
2432
 
2182
2433
 
2183
2434
  @app.get("/mode")
2435
+ @app.get("/runtime_features")
2184
2436
  async def mode():
2185
2437
  return runtime_features()
2186
2438
 
@@ -2400,16 +2652,41 @@ async def chat(req: ChatRequest, request: Request):
2400
2652
 
2401
2653
  if is_clear_command(req.message):
2402
2654
  command = req.message.strip().lower()
2655
+ clear_scope = "all" if command == "/clear_all" else "conversation"
2656
+ if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
2657
+ try:
2658
+ KNOWLEDGE_GRAPH.ingest_event(
2659
+ "ClearEvent",
2660
+ f"{command} requested",
2661
+ user_email=effective_email,
2662
+ user_nickname=req.user_nickname,
2663
+ source=req.source or "web",
2664
+ conversation_id=req.conversation_id,
2665
+ metadata={"command": command, "scope": clear_scope},
2666
+ )
2667
+ except Exception as e:
2668
+ logging.warning("knowledge graph clear event ingest failed: %s", e)
2403
2669
  if command == "/clear_all":
2404
2670
  result = clear_history(0)
2405
- answer = f"전체 대화 기록을 지웠습니다. 삭제 {result.get('removed', 0)}개."
2671
+ answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
2406
2672
  else:
2407
2673
  if req.conversation_id:
2408
2674
  result = clear_conversation(req.conversation_id)
2409
- answer = f"현재 대화방 기록을 지웠습니다. 삭제 {result.get('removed', 0)}개."
2675
+ answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
2410
2676
  else:
2411
2677
  result = clear_history(0)
2412
- answer = f"대화 기록을 지웠습니다. 삭제 {result.get('removed', 0)}개."
2678
+ answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 Data Graph/RAG 데이터는 유지됩니다."
2679
+ append_audit_event(
2680
+ "clear_command",
2681
+ user_email=effective_email,
2682
+ user_nickname=req.user_nickname,
2683
+ source=req.source or "web",
2684
+ conversation_id=req.conversation_id,
2685
+ command=command,
2686
+ scope=clear_scope,
2687
+ removed=result.get("removed", 0),
2688
+ kept=result.get("kept", 0),
2689
+ )
2413
2690
  if req.stream:
2414
2691
  return StreamingResponse(
2415
2692
  single_text_stream(answer),
@@ -2454,11 +2731,33 @@ async def chat(req: ChatRequest, request: Request):
2454
2731
  except Exception as e:
2455
2732
  logging.warning("Knowledge reinforcement skipped: %s", e)
2456
2733
 
2734
+ try:
2735
+ if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
2736
+ graph_context = KNOWLEDGE_GRAPH.context_for_query(req.message)
2737
+ if graph_context:
2738
+ context += f"\n\n[KNOWLEDGE GRAPH]\n{graph_context}"
2739
+ print("🕸️ Context reinforced with knowledge graph.")
2740
+ except Exception as e:
2741
+ logging.warning("Knowledge graph reinforcement skipped: %s", e)
2742
+
2457
2743
  if req.image_data:
2458
2744
  screenshot_context = extract_screenshot_context(req.image_data)
2459
2745
  if screenshot_context:
2460
2746
  context += f"\n\n{screenshot_context}"
2461
2747
 
2748
+ # 메시지 안에 절대 경로나 ~/... 경로가 있으면 자동으로 파일 읽어서 컨텍스트 주입
2749
+ _file_path_re = re.compile(r'(?:^|[\s\'\"(])((~|/[\w.])[^\s\'")\]]*)', re.MULTILINE)
2750
+ for _m in _file_path_re.finditer(req.message or ""):
2751
+ _fpath = _m.group(1).strip()
2752
+ try:
2753
+ _result = local_read(_fpath)
2754
+ _fcontent = _result.get("content", "")
2755
+ if _fcontent:
2756
+ context += f"\n\n[FILE: {_fpath}]\n```\n{_fcontent[:6000]}\n```"
2757
+ print(f"📂 Auto-injected file context: {_fpath}")
2758
+ except Exception:
2759
+ pass
2760
+
2462
2761
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
2463
2762
  save_to_history("user", history_message, source=req.source or "web", conversation_id=req.conversation_id, **history_user)
2464
2763
  if req.source != "telegram":
@@ -2521,14 +2820,31 @@ async def fetch_history_conversation(conversation_id: str, request: Request):
2521
2820
  @app.delete("/history/conversations/{conversation_id:path}")
2522
2821
  async def delete_history_conversation(conversation_id: str, request: Request):
2523
2822
  """선택한 대화방의 메시지만 삭제합니다."""
2524
- require_user(request)
2525
- return clear_conversation(conversation_id, request.query_params.get("started_at"))
2823
+ email = require_user(request)
2824
+ result = clear_conversation(conversation_id, request.query_params.get("started_at"))
2825
+ append_audit_event(
2826
+ "conversation_delete",
2827
+ user_email=email,
2828
+ conversation_id=conversation_id,
2829
+ started_at=request.query_params.get("started_at"),
2830
+ removed=result.get("removed", 0),
2831
+ kept=result.get("kept", 0),
2832
+ )
2833
+ return result
2526
2834
 
2527
2835
 
2528
2836
  @app.delete("/history")
2529
2837
  async def delete_history(request: Request, keep_last: int = 0):
2530
- require_user(request)
2531
- return clear_history(keep_last)
2838
+ email = require_user(request)
2839
+ result = clear_history(keep_last)
2840
+ append_audit_event(
2841
+ "history_delete",
2842
+ user_email=email,
2843
+ keep_last=keep_last,
2844
+ removed=result.get("removed", 0),
2845
+ kept=result.get("kept", 0),
2846
+ )
2847
+ return result
2532
2848
 
2533
2849
  @app.get("/history/search")
2534
2850
  async def search_history(q: str, request: Request):
@@ -2548,6 +2864,85 @@ async def search_history(q: str, request: Request):
2548
2864
  return {"results": list(grouped.values())[-30:], "query": q}
2549
2865
 
2550
2866
 
2867
+ @app.get("/graph")
2868
+ async def knowledge_graph_page(request: Request):
2869
+ """Serve the interactive knowledge graph canvas UI."""
2870
+ _require_graph()
2871
+ require_user(request)
2872
+ return FileResponse(STATIC_DIR / "graph.html")
2873
+
2874
+
2875
+ @app.get("/knowledge-graph")
2876
+ async def knowledge_graph_legacy_page(request: Request):
2877
+ """Backward-compatible route for the graph page."""
2878
+ _require_graph()
2879
+ require_user(request)
2880
+ return FileResponse(STATIC_DIR / "graph.html")
2881
+
2882
+
2883
+ @app.get("/knowledge-graph/stats")
2884
+ async def knowledge_graph_stats(request: Request):
2885
+ _require_graph()
2886
+ require_user(request)
2887
+ return KNOWLEDGE_GRAPH.stats()
2888
+
2889
+
2890
+ @app.get("/knowledge-graph/graph")
2891
+ async def knowledge_graph_data(request: Request, limit: int = 300):
2892
+ _require_graph()
2893
+ require_user(request)
2894
+ return KNOWLEDGE_GRAPH.graph(limit)
2895
+
2896
+
2897
+ @app.get("/knowledge-graph/search")
2898
+ async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
2899
+ _require_graph()
2900
+ require_user(request)
2901
+ if not q or not q.strip():
2902
+ return {"query": q, "matches": []}
2903
+ return KNOWLEDGE_GRAPH.search(q, limit)
2904
+
2905
+
2906
+ @app.get("/knowledge-graph/context")
2907
+ async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
2908
+ _require_graph()
2909
+ require_user(request)
2910
+ return {"query": q, "context": KNOWLEDGE_GRAPH.context_for_query(q, limit)}
2911
+
2912
+
2913
+ @app.get("/knowledge-graph/neighbors/{node_id:path}")
2914
+ async def knowledge_graph_neighbors(node_id: str, request: Request):
2915
+ _require_graph()
2916
+ require_user(request)
2917
+ if not node_id:
2918
+ raise HTTPException(status_code=400, detail="node_id required")
2919
+ return KNOWLEDGE_GRAPH.neighbors(node_id)
2920
+
2921
+
2922
+ @app.post("/knowledge-graph/ingest")
2923
+ async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
2924
+ _require_graph()
2925
+ current_user = require_user(request)
2926
+ event_type = (req.type or "").strip().lower()
2927
+ if event_type not in {"message", "ai_response", "note"}:
2928
+ raise HTTPException(status_code=400, detail="지원하는 type: message, ai_response, note")
2929
+ role = req.role or ("assistant" if event_type == "ai_response" else "user")
2930
+ return KNOWLEDGE_GRAPH.ingest_message(
2931
+ role,
2932
+ req.content,
2933
+ user_email=req.user_email or current_user,
2934
+ user_nickname=req.user_nickname,
2935
+ source=req.source or "mcp",
2936
+ conversation_id=req.conversation_id,
2937
+ raw={
2938
+ "type": req.type,
2939
+ "title": req.title,
2940
+ "content": req.content,
2941
+ "metadata": req.metadata or {},
2942
+ },
2943
+ )
2944
+
2945
+
2551
2946
  async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = None) -> AsyncIterator[str]:
2552
2947
  full_response = ""
2553
2948
  async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
@@ -2572,7 +2967,9 @@ async def _stream_chat(req: ChatRequest, context: str = "", image_data: str = No
2572
2967
  # ── Local Computer Agent ──────────────────────────────────────────────────────
2573
2968
 
2574
2969
  AGENT_SYSTEM_PROMPT = """You are Lattice AI Agent, a local computer-use coding assistant.
2575
- You can work only inside the agent workspace.
2970
+ You have full access to the local filesystem via local_list / local_read / local_write tools.
2971
+ Use read_file / write_file for paths inside the agent workspace (relative paths).
2972
+ Use local_read / local_write for any absolute path on the system (e.g. ~/Downloads, ~/Desktop).
2576
2973
 
2577
2974
  Available actions:
2578
2975
  - list_dir: {"action":"list_dir","args":{"path":"."}}
@@ -2587,6 +2984,7 @@ Available actions:
2587
2984
  - create_xlsx: {"action":"create_xlsx","args":{"rows":[["A","B"],[1,2]],"filename":"spreadsheet.xlsx","sheet_name":"Sheet1"}}
2588
2985
  - create_pptx: {"action":"create_pptx","args":{"title":"title","slides":[{"title":"Slide","bullets":["point"]}],"filename":"presentation.pptx"}}
2589
2986
  - create_pdf: {"action":"create_pdf","args":{"title":"title","body":"paragraphs","filename":"document.pdf"}}
2987
+ - create_web_project: {"action":"create_web_project","args":{"path":"my_app","framework":"react","template":"vite"}} — scaffold a runnable web app project
2590
2988
  - local_list: {"action":"local_list","args":{"path":"/Users/username/Downloads"}} — lists any local folder (UI will request user permission first)
2591
2989
  - local_read: {"action":"local_read","args":{"path":"/Users/username/Documents/note.txt"}} — reads any local file (UI will request user permission first)
2592
2990
  - local_write: {"action":"local_write","args":{"path":"/Users/username/Desktop/output.txt","content":"..."}} — writes any local file (UI will request user permission first)
@@ -2626,10 +3024,14 @@ Rules:
2626
3024
  - Prefer simple, verifiable steps.
2627
3025
  - Use inspect_html and preview_url for generated web UI.
2628
3026
  - Use build_project when the user asks to build, compile, typecheck, or run a package build script.
2629
- - Use deploy_project when the user asks to deploy, preview, or release and package.json defines that script.
3027
+ - Use deploy_project when the user asks to deploy, preview, release, or package installers (pkg/exe) and package.json defines that script (e.g. package, dist, make, build:pkg, build:exe).
3028
+ - If the user asks for app/service/web creation, prefer create_web_project first, then edit files with write_file/read_file and verify with build_project or run_command.
3029
+ - If the user asks for installer outputs (.pkg/.exe), set up packaging config (for example Electron/electron-builder or equivalent), create package scripts in package.json, then run deploy_project for installer scripts.
3030
+ - If .exe cannot be built on current OS/toolchain, still generate the full packaging config and scripts for Windows and report the exact missing prerequisite.
2630
3031
  - Do not claim you cannot build or deploy. If a script, token, or platform config is missing, inspect the workspace and explain the exact missing piece.
2631
3032
  - Use knowledge tools when the user asks to remember, search memory, or organize project context.
2632
3033
  - Use run_command for local inspection, tests, and short development commands after files are written.
3034
+ - For data analysis tasks, read the provided files first (read_document/local_read), compute with run_command when needed, and return concrete findings plus output artifact paths when created.
2633
3035
  - Use clear_history when the user asks to forget, clear, delete, reset, or speed up chat history.
2634
3036
  - Git is read-only: status, diff, log, and show only. Never commit, push, pull, fetch, clone, reset, or checkout.
2635
3037
  - If the user asks for something unsafe or outside the workspace, explain the limitation with final.
@@ -2637,13 +3039,73 @@ Rules:
2637
3039
  """
2638
3040
 
2639
3041
 
2640
- _FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file"}
3042
+ _FILE_CREATE_ACTIONS = {"create_docx", "create_xlsx", "create_pptx", "create_pdf", "write_file", "create_web_project"}
3043
+
3044
+ # Harness risk level per tool action.
3045
+ # low — read-only, no side effects
3046
+ # medium — write/create files or knowledge entries
3047
+ # high — execute commands, control computer, write to arbitrary FS paths
3048
+ _TOOL_RISK: Dict[str, str] = {
3049
+ # read-only workspace tools
3050
+ "list_dir": "low", "workspace_tree": "low", "read_file": "low",
3051
+ "search_files": "low", "inspect_html": "low",
3052
+ # read-only local FS
3053
+ "local_list": "low", "local_read": "low",
3054
+ # read-only git
3055
+ "git_status": "low", "git_log": "low", "git_diff": "low", "git_show": "low",
3056
+ # read-only knowledge / computer
3057
+ "knowledge_search": "low", "knowledge_tree": "low",
3058
+ "obsidian_search": "low", "obsidian_tree": "low",
3059
+ "computer_screenshot": "low", "computer_status": "low",
3060
+ # write workspace
3061
+ "write_file": "medium", "create_web_project": "medium",
3062
+ "create_docx": "medium", "create_xlsx": "medium",
3063
+ "create_pptx": "medium", "create_pdf": "medium",
3064
+ # write knowledge
3065
+ "knowledge_save": "medium", "obsidian_save": "medium",
3066
+ # write local FS (arbitrary path — treated as medium; blocked from system roots below)
3067
+ "local_write": "medium",
3068
+ # preview
3069
+ "preview_url": "medium",
3070
+ # execute commands
3071
+ "run_command": "high",
3072
+ # computer control
3073
+ "computer_click": "high", "computer_type": "high", "computer_key": "high",
3074
+ "computer_scroll": "high", "computer_drag": "high", "computer_move": "high",
3075
+ "computer_open_app": "high", "computer_open_url": "high",
3076
+ }
3077
+
3078
+ # Paths that local_write must never target (system-level protection)
3079
+ _LOCAL_WRITE_BLOCKED_PREFIXES = (
3080
+ "/etc/", "/usr/", "/bin/", "/sbin/", "/System/", "/private/etc/",
3081
+ "/Library/LaunchDaemons/", "/Library/LaunchAgents/",
3082
+ )
3083
+
3084
+
3085
+ def _agent_risk(action_name: str, args: dict) -> str:
3086
+ """Return risk level for an action, upgrading local_write to 'high' for system paths."""
3087
+ risk = _TOOL_RISK.get(action_name, "medium")
3088
+ if action_name == "local_write":
3089
+ path = str(args.get("path", ""))
3090
+ if any(path.startswith(p) for p in _LOCAL_WRITE_BLOCKED_PREFIXES):
3091
+ risk = "high"
3092
+ return risk
3093
+
2641
3094
 
2642
3095
  def _collect_created_files(transcript: list) -> list:
2643
3096
  files = []
2644
3097
  for step in transcript:
2645
3098
  if step.get("action") in _FILE_CREATE_ACTIONS:
2646
3099
  result = step.get("result", {})
3100
+ if isinstance(result.get("created_files"), list):
3101
+ for rel_path in result["created_files"]:
3102
+ files.append({
3103
+ "path": rel_path,
3104
+ "filename": Path(rel_path).name,
3105
+ "bytes": 0,
3106
+ "action": step["action"],
3107
+ })
3108
+ continue
2647
3109
  path = result.get("path")
2648
3110
  if path:
2649
3111
  files.append({
@@ -2679,7 +3141,7 @@ def _extract_agent_action(raw: str) -> Dict:
2679
3141
  @app.post("/agent")
2680
3142
  async def agent(req: AgentRequest, request: Request):
2681
3143
  """Natural-language local agent loop for Telegram and future clients."""
2682
- require_user(request)
3144
+ current_user = require_user(request)
2683
3145
  if not router.current_model_id:
2684
3146
  raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
2685
3147
 
@@ -2710,10 +3172,17 @@ async def agent(req: AgentRequest, request: Request):
2710
3172
  action = _extract_agent_action(str(raw))
2711
3173
  except ValueError as exc:
2712
3174
  transcript.append({"step": step + 1, "action": "parse_error", "raw": str(raw), "error": str(exc)})
2713
- return JSONResponse(
2714
- status_code=500,
2715
- content={"status": "error", "error": str(exc), "raw": str(raw), "steps": transcript},
2716
- )
3175
+ message = "작업 계획을 안정적으로 해석하지 못해 자동 실행을 중단했습니다. 요청을 더 짧고 구체적으로 다시 시도해 주세요."
3176
+ save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
3177
+ save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
3178
+ created_files = _collect_created_files(transcript)
3179
+ return {
3180
+ "status": "ok",
3181
+ "response": message,
3182
+ "workspace": str(AGENT_ROOT),
3183
+ "steps": transcript,
3184
+ "created_files": created_files,
3185
+ }
2717
3186
 
2718
3187
  name = action.get("action")
2719
3188
  if name == "final":
@@ -2723,16 +3192,64 @@ async def agent(req: AgentRequest, request: Request):
2723
3192
  created_files = _collect_created_files(transcript)
2724
3193
  return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
2725
3194
 
3195
+ # Prevent repeated file/project creation loops with identical action+args.
3196
+ last_step = transcript[-1] if transcript else None
3197
+ current_args = action.get("args") or {}
3198
+ if (
3199
+ name in _FILE_CREATE_ACTIONS
3200
+ and last_step
3201
+ and last_step.get("action") == name
3202
+ and (last_step.get("args") or {}) == current_args
3203
+ and "result" in last_step
3204
+ ):
3205
+ message = "요청한 파일 생성을 이미 완료해서 반복 실행을 중단했습니다."
3206
+ save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id)
3207
+ save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id)
3208
+ created_files = _collect_created_files(transcript)
3209
+ return {"status": "ok", "response": message, "workspace": str(AGENT_ROOT), "steps": transcript, "created_files": created_files}
3210
+
2726
3211
  if name == "clear_history":
2727
- result = clear_history((action.get("args") or {}).get("keep_last", 0))
2728
- transcript.append({"step": step + 1, "action": name, "args": action.get("args") or {}, "result": result})
3212
+ result = clear_history(current_args.get("keep_last", 0))
3213
+ append_audit_event(
3214
+ "history_delete",
3215
+ user_email=current_user,
3216
+ source=req.source or "agent",
3217
+ keep_last=current_args.get("keep_last", 0),
3218
+ removed=result.get("removed", 0),
3219
+ kept=result.get("kept", 0),
3220
+ )
3221
+ transcript.append({"step": step + 1, "action": name, "args": current_args, "result": result})
2729
3222
  continue
2730
3223
 
3224
+ risk = _agent_risk(name, current_args)
3225
+
3226
+ # Block system-path local_write even if the LLM tries it
3227
+ if name == "local_write":
3228
+ path = str(current_args.get("path", ""))
3229
+ if any(path.startswith(p) for p in _LOCAL_WRITE_BLOCKED_PREFIXES):
3230
+ transcript.append({
3231
+ "step": step + 1, "action": name, "args": current_args,
3232
+ "risk": "high", "error": f"BLOCKED: writing to system path is not allowed: {path}",
3233
+ })
3234
+ append_audit_event(
3235
+ "agent_blocked", user_email=current_user, source=req.source or "agent",
3236
+ action=name, path=path, reason="system_path",
3237
+ )
3238
+ continue
3239
+
3240
+ # Audit medium/high actions before execution
3241
+ if risk in ("medium", "high"):
3242
+ append_audit_event(
3243
+ "agent_exec", user_email=current_user, source=req.source or "agent",
3244
+ step=step + 1, action=name, risk=risk,
3245
+ args={k: v for k, v in (current_args or {}).items() if k != "content"},
3246
+ )
3247
+
2731
3248
  try:
2732
- result = execute_tool(name, action.get("args") or {})
2733
- transcript.append({"step": step + 1, "action": name, "args": action.get("args") or {}, "result": result})
3249
+ result = execute_tool(name, current_args)
3250
+ transcript.append({"step": step + 1, "action": name, "args": current_args, "risk": risk, "result": result})
2734
3251
  except (ToolError, KeyError, TypeError) as exc:
2735
- transcript.append({"step": step + 1, "action": name, "args": action.get("args") or {}, "error": str(exc)})
3252
+ transcript.append({"step": step + 1, "action": name, "args": current_args, "risk": risk, "error": str(exc)})
2736
3253
 
2737
3254
  summary_context = (
2738
3255
  f"{AGENT_SYSTEM_PROMPT}\n\n"
@@ -2799,8 +3316,17 @@ async def tools_search_files(req: ToolSearchFilesRequest, request: Request):
2799
3316
 
2800
3317
  @app.post("/tools/clear_history")
2801
3318
  async def tools_clear_history(req: ToolClearHistoryRequest, request: Request):
2802
- require_user(request)
2803
- return clear_history(req.keep_last)
3319
+ current_user = require_user(request)
3320
+ result = clear_history(req.keep_last)
3321
+ append_audit_event(
3322
+ "history_delete",
3323
+ user_email=current_user,
3324
+ source="tools",
3325
+ keep_last=req.keep_last,
3326
+ removed=result.get("removed", 0),
3327
+ kept=result.get("kept", 0),
3328
+ )
3329
+ return result
2804
3330
 
2805
3331
 
2806
3332
  @app.post("/tools/inspect_html")
@@ -2839,6 +3365,36 @@ async def tools_create_pdf(req: ToolPdfRequest, request: Request):
2839
3365
  return _tool_response(create_pdf, req.title, req.body, req.filename)
2840
3366
 
2841
3367
 
3368
+ @app.post("/tools/read_document")
3369
+ async def tools_read_document(req: ToolPathRequest, request: Request):
3370
+ require_user(request)
3371
+ return _tool_response(read_document, req.path)
3372
+
3373
+
3374
+ @app.get("/tools/pdf_pages")
3375
+ async def tools_pdf_pages(path: str, request: Request):
3376
+ """Render PDF pages as base64 PNG images using PyMuPDF."""
3377
+ require_user(request)
3378
+ target = Path(path).expanduser().resolve()
3379
+ if not target.exists() or not target.is_file():
3380
+ raise HTTPException(status_code=404, detail="File not found")
3381
+ try:
3382
+ import fitz # PyMuPDF
3383
+ doc = fitz.open(str(target))
3384
+ pages = []
3385
+ for i, page in enumerate(doc):
3386
+ if i >= 20: # 최대 20페이지
3387
+ break
3388
+ mat = fitz.Matrix(1.5, 1.5) # 1.5x 해상도
3389
+ pix = page.get_pixmap(matrix=mat)
3390
+ b64 = base64.b64encode(pix.tobytes("png")).decode()
3391
+ pages.append({"page": i + 1, "b64": b64})
3392
+ doc.close()
3393
+ return {"total": len(doc), "pages": pages}
3394
+ except Exception as e:
3395
+ raise HTTPException(status_code=500, detail=f"PDF 렌더링 실패: {e}")
3396
+
3397
+
2842
3398
  @app.get("/tools/download")
2843
3399
  async def tools_download(path: str, request: Request):
2844
3400
  """Serve a generated file from agent workspace for download."""
@@ -2859,7 +3415,7 @@ async def tools_download(path: str, request: Request):
2859
3415
 
2860
3416
  @app.post("/upload/document")
2861
3417
  async def upload_document(request: Request, file: UploadFile = File(...)):
2862
- require_user(request)
3418
+ current_user = require_user(request)
2863
3419
  """Upload a document and extract text (PDF, DOCX, XLSX, PPTX, TXT, MD, CSV)."""
2864
3420
  suffix = Path(file.filename or "upload").suffix.lower()
2865
3421
  allowed = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
@@ -2873,6 +3429,47 @@ async def upload_document(request: Request, file: UploadFile = File(...)):
2873
3429
  tmp_path = tmp.name
2874
3430
  try:
2875
3431
  result = read_document(tmp_path)
3432
+ sensitive = classify_sensitive_message(
3433
+ {
3434
+ "role": "document",
3435
+ "content": result.get("content") or result.get("preview") or "",
3436
+ "user_email": current_user,
3437
+ "timestamp": datetime.now().isoformat(),
3438
+ },
3439
+ -1,
3440
+ )
3441
+ try:
3442
+ if not (ENABLE_GRAPH and KNOWLEDGE_GRAPH):
3443
+ raise RuntimeError("graph disabled")
3444
+ graph_result = KNOWLEDGE_GRAPH.ingest_document(
3445
+ Path(tmp_path),
3446
+ original_filename=file.filename,
3447
+ mime_type=file.content_type,
3448
+ uploader=current_user,
3449
+ conversation_id=request.query_params.get("conversation_id"),
3450
+ extracted=result,
3451
+ )
3452
+ result["knowledge_graph"] = {
3453
+ "node_id": graph_result["node_id"],
3454
+ "sha256": graph_result["sha256"],
3455
+ }
3456
+ except Exception as graph_error:
3457
+ logging.warning("knowledge graph document ingest failed: %s", graph_error)
3458
+ result["knowledge_graph"] = {"error": str(graph_error)}
3459
+ append_audit_event(
3460
+ "document_upload",
3461
+ user_email=current_user,
3462
+ conversation_id=request.query_params.get("conversation_id"),
3463
+ filename=file.filename,
3464
+ mime_type=file.content_type,
3465
+ ext=suffix,
3466
+ bytes=len(contents),
3467
+ extracted_chars=result.get("chars"),
3468
+ graph_node=(result.get("knowledge_graph") or {}).get("node_id"),
3469
+ content_preview=sensitive.get("preview"),
3470
+ sensitivity=sensitive.get("sensitivity"),
3471
+ sensitive_labels=sensitive.get("labels") or [],
3472
+ )
2876
3473
  except ToolError as exc:
2877
3474
  raise HTTPException(status_code=400, detail=str(exc))
2878
3475
  finally:
@@ -2916,6 +3513,16 @@ async def local_read_endpoint(req: LocalAccessRequest, request: Request):
2916
3513
  return _tool_response(local_read, req.path)
2917
3514
 
2918
3515
 
3516
+ @app.get("/local/serve")
3517
+ async def local_serve_file(path: str, request: Request):
3518
+ """Serve a local file (images etc.) directly for browser preview."""
3519
+ require_user(request)
3520
+ target = Path(path).expanduser().resolve()
3521
+ if not target.exists() or not target.is_file():
3522
+ raise HTTPException(status_code=404, detail="File not found")
3523
+ return FileResponse(str(target))
3524
+
3525
+
2919
3526
  @app.post("/local/write")
2920
3527
  async def local_write_endpoint(req: LocalWriteRequest, request: Request):
2921
3528
  require_user(request)
@@ -3311,6 +3918,10 @@ async def mcp_tools():
3311
3918
  {"name": "knowledge_save", "description": "Save a note into the local knowledge garden."},
3312
3919
  {"name": "knowledge_search", "description": "Search the local knowledge garden."},
3313
3920
  {"name": "knowledge_tree", "description": "List local knowledge garden markdown files."},
3921
+ {"name": "knowledge_graph_ingest", "description": "Ingest a message, AI answer, or connector event into the SQLite knowledge graph."},
3922
+ {"name": "knowledge_graph_search", "description": "Search graph nodes, summaries, and JSON metadata."},
3923
+ {"name": "knowledge_graph_graph", "description": "Return Obsidian-style graph nodes and edges."},
3924
+ {"name": "knowledge_graph_context", "description": "Return compact graph-backed RAG context for a prompt."},
3314
3925
  {"name": "obsidian_save", "description": "Save a note into the Obsidian-compatible memory vault."},
3315
3926
  {"name": "obsidian_search", "description": "Search the Obsidian-compatible memory vault."},
3316
3927
  {"name": "obsidian_tree", "description": "List Obsidian memory vault markdown files."},
@@ -3321,7 +3932,7 @@ async def mcp_tools():
3321
3932
  {"name": "network_status", "description": "Get current local/private IP, public IP, hostname, and Wi-Fi info."},
3322
3933
  {"name": "run_command", "description": "Run an allowlisted local command inside the workspace."},
3323
3934
  {"name": "build_project", "description": "Run an allowlisted package.json build/compile/typecheck/test script."},
3324
- {"name": "deploy_project", "description": "Run an allowlisted package.json deploy/preview/release script."},
3935
+ {"name": "deploy_project", "description": "Run an allowlisted package.json deploy/preview/release/package installer script (pkg/exe)."},
3325
3936
  ],
3326
3937
  }
3327
3938
 
@@ -3353,7 +3964,33 @@ async def mcp_connector(mcp_id: str, request: Request):
3353
3964
 
3354
3965
  @app.post("/mcp/call")
3355
3966
  async def mcp_call(req: McpCallRequest, request: Request):
3356
- require_user(request)
3967
+ current_user = require_user(request)
3968
+ args = req.args or {}
3969
+ if req.action == "knowledge_graph_ingest":
3970
+ _require_graph()
3971
+ return KNOWLEDGE_GRAPH.ingest_message(
3972
+ args.get("role") or ("assistant" if args.get("type") == "ai_response" else "user"),
3973
+ args.get("content") or "",
3974
+ user_email=args.get("user_email") or current_user,
3975
+ user_nickname=args.get("user_nickname"),
3976
+ source=args.get("source") or "mcp",
3977
+ conversation_id=args.get("conversation_id"),
3978
+ raw=args,
3979
+ )
3980
+ if req.action == "knowledge_graph_search":
3981
+ _require_graph()
3982
+ return KNOWLEDGE_GRAPH.search(args.get("query") or args.get("q") or "", args.get("limit", 30))
3983
+ if req.action == "knowledge_graph_graph":
3984
+ _require_graph()
3985
+ return KNOWLEDGE_GRAPH.graph(args.get("limit", 300))
3986
+ if req.action == "knowledge_graph_context":
3987
+ _require_graph()
3988
+ return {
3989
+ "context": KNOWLEDGE_GRAPH.context_for_query(
3990
+ args.get("query") or args.get("q") or "",
3991
+ args.get("limit", 6),
3992
+ )
3993
+ }
3357
3994
  return _tool_response(execute_tool, req.action, req.args or {})
3358
3995
 
3359
3996