@purpleraven/hits 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/AGENTS.md +298 -0
  2. package/LICENSE +190 -0
  3. package/README.md +336 -0
  4. package/bin/hits.js +56 -0
  5. package/config/schema.json +94 -0
  6. package/config/settings.yaml +102 -0
  7. package/data/dev_handover.yaml +143 -0
  8. package/hits_core/__init__.py +9 -0
  9. package/hits_core/ai/__init__.py +11 -0
  10. package/hits_core/ai/compressor.py +86 -0
  11. package/hits_core/ai/llm_client.py +65 -0
  12. package/hits_core/ai/slm_filter.py +126 -0
  13. package/hits_core/api/__init__.py +3 -0
  14. package/hits_core/api/routes/__init__.py +8 -0
  15. package/hits_core/api/routes/auth.py +211 -0
  16. package/hits_core/api/routes/handover.py +117 -0
  17. package/hits_core/api/routes/health.py +8 -0
  18. package/hits_core/api/routes/knowledge.py +177 -0
  19. package/hits_core/api/routes/node.py +121 -0
  20. package/hits_core/api/routes/work_log.py +174 -0
  21. package/hits_core/api/server.py +181 -0
  22. package/hits_core/auth/__init__.py +21 -0
  23. package/hits_core/auth/dependencies.py +61 -0
  24. package/hits_core/auth/manager.py +368 -0
  25. package/hits_core/auth/middleware.py +69 -0
  26. package/hits_core/collector/__init__.py +18 -0
  27. package/hits_core/collector/ai_session_collector.py +118 -0
  28. package/hits_core/collector/base.py +73 -0
  29. package/hits_core/collector/daemon.py +94 -0
  30. package/hits_core/collector/git_collector.py +177 -0
  31. package/hits_core/collector/hits_action_collector.py +110 -0
  32. package/hits_core/collector/shell_collector.py +178 -0
  33. package/hits_core/main.py +36 -0
  34. package/hits_core/mcp/__init__.py +20 -0
  35. package/hits_core/mcp/server.py +429 -0
  36. package/hits_core/models/__init__.py +18 -0
  37. package/hits_core/models/node.py +56 -0
  38. package/hits_core/models/tree.py +68 -0
  39. package/hits_core/models/work_log.py +64 -0
  40. package/hits_core/models/workflow.py +92 -0
  41. package/hits_core/platform/__init__.py +5 -0
  42. package/hits_core/platform/actions.py +225 -0
  43. package/hits_core/service/__init__.py +6 -0
  44. package/hits_core/service/handover_service.py +382 -0
  45. package/hits_core/service/knowledge_service.py +172 -0
  46. package/hits_core/service/tree_service.py +105 -0
  47. package/hits_core/storage/__init__.py +11 -0
  48. package/hits_core/storage/base.py +84 -0
  49. package/hits_core/storage/file_store.py +314 -0
  50. package/hits_core/storage/redis_store.py +123 -0
  51. package/hits_web/dist/assets/index-Bgx7F6m6.css +1 -0
  52. package/hits_web/dist/assets/index-D1B5E67G.js +3 -0
  53. package/hits_web/dist/index.html +16 -0
  54. package/package.json +60 -0
  55. package/requirements-core.txt +7 -0
  56. package/requirements.txt +1 -0
  57. package/run.sh +271 -0
  58. package/server.js +234 -0
@@ -0,0 +1,143 @@
1
+ # HITS 프로젝트 지식 트리 샘플
2
+ # 이 파일은 HITS 지식 트리 구조의 예시입니다.
3
+
4
+ tree:
5
+ id: hits-knowledge-001
6
+ name: "HITS 프로젝트 핵심 지식"
7
+ description: "HITS 시스템 개발에 필요한 핵심 지식 트리"
8
+
9
+ nodes:
10
+ # WHY Layer - 의도/목적
11
+ - id: why-architecture
12
+ layer: why
13
+ title: "시스템 아키텍처 이해"
14
+ description: |
15
+ HITS는 hits_core(순수 Python) + hits_ui(PySide6) 구조
16
+ → 목적: AI 토큰 최적화 + 지식 보존
17
+ → 격리: core는 GUI 의존성 없음
18
+ → 저장: ~/.hits/data/에 중앙 집중
19
+ tokens_saved: 50
20
+
21
+ - id: why-handover
22
+ layer: why
23
+ title: "AI 세션 인수인계"
24
+ description: |
25
+ 토큰 한계 등으로 AI 교체 시 프로젝트 컨텍스트 유지
26
+ → 프로젝트별 project_path로 격리
27
+ → 세션 종료 시 작업 기록, 시작 시 요약 조회
28
+ tokens_saved: 40
29
+
30
+ # HOW Layer - 논리/방법
31
+ - id: how-storage
32
+ layer: how
33
+ title: "중앙 집중 저장소"
34
+ parent_id: why-architecture
35
+ description: |
36
+ FileStorage 기본 경로: ~/.hits/data/
37
+ → work_logs/: 작업 기록 JSON
38
+ → trees/: 지식 트리
39
+ → HITS_DATA_PATH 환경변수로 오버라이드
40
+ tokens_saved: 35
41
+
42
+ - id: how-mcp
43
+ layer: how
44
+ title: "MCP 인터페이스"
45
+ parent_id: why-handover
46
+ description: |
47
+ hits_core/mcp/server.py가 MCP 서버 제공
48
+ → 5개 툴: record_work, get_handover, search_works, list_projects, get_recent
49
+ → stdio transport로 Claude/OpenCode와 직접 통신
50
+ tokens_saved: 30
51
+
52
+ - id: how-api
53
+ layer: how
54
+ title: "HTTP API 서버"
55
+ parent_id: why-handover
56
+ description: |
57
+ FastAPI 기반, 포트 8765
58
+ → /api/handover: 인수인계 요약
59
+ → /api/work-log: 작업 기록 CRUD
60
+ → /api/work-logs/search: 검색 (project_path 스코프)
61
+ tokens_saved: 30
62
+
63
+ - id: how-compression
64
+ layer: how
65
+ title: "시멘틱 압축"
66
+ parent_id: why-architecture
67
+ description: |
68
+ SemanticCompressor가 한국어 키워드를 기호로 치환
69
+ → "따라서" → "→", "중요" → "★", "필수" → "!"
70
+ → SLMFilter가 CRITICAL/IMPORTANT/NOISE 분류
71
+ node_type: "negative_path"
72
+ tokens_saved: 25
73
+
74
+ # WHAT Layer - 실행/작업
75
+ - id: what-run
76
+ layer: what
77
+ title: "실행 방법"
78
+ parent_id: how-storage
79
+ description: |
80
+ ./run.sh # GUI 실행
81
+ ./run.sh --check # pre-flight 체크
82
+ ./run.sh --test # 테스트 실행
83
+ action: "bash ./run.sh"
84
+ action_type: shell
85
+ tokens_saved: 15
86
+
87
+ - id: what-record
88
+ layer: what
89
+ title: "작업 기록"
90
+ parent_id: how-api
91
+ description: |
92
+ curl -X POST http://localhost:8765/api/work-log \
93
+ -d '{"performed_by":"opencode","request_text":"요약","source":"ai_session","project_path":"/절대경로"}'
94
+ action: "curl -X POST http://localhost:8765/api/work-log"
95
+ action_type: shell
96
+ tokens_saved: 20
97
+
98
+ - id: what-handover
99
+ layer: what
100
+ title: "인수인계 조회"
101
+ parent_id: how-api
102
+ description: |
103
+ curl http://localhost:8765/api/handover?project_path=/my/project
104
+ → 프로젝트별 작업 이력, 결정 사항, 미완료 항목 반환
105
+ action: "curl http://localhost:8765/api/handover?project_path=$(pwd)"
106
+ action_type: shell
107
+ tokens_saved: 20
108
+
109
+ # 인과관계 워크플로우
110
+ workflow:
111
+ id: ai-session-handover
112
+ name: "AI 세션 인수인계 워크플로우"
113
+ description: "AI 세션 전환 시 표준 인수인계 절차"
114
+
115
+ steps:
116
+ - id: step-start
117
+ name: "세션 시작"
118
+ step_type: trigger
119
+ node_id: why-handover
120
+ next_steps: ["step-check-handover"]
121
+
122
+ - id: step-check-handover
123
+ name: "이전 작업 확인"
124
+ step_type: action
125
+ node_id: what-handover
126
+ next_steps: ["step-work"]
127
+
128
+ - id: step-work
129
+ name: "작업 수행"
130
+ step_type: action
131
+ node_id: what-run
132
+ next_steps: ["step-record"]
133
+
134
+ - id: step-record
135
+ name: "작업 기록"
136
+ step_type: action
137
+ node_id: what-record
138
+ next_steps: ["step-end"]
139
+
140
+ - id: step-end
141
+ name: "세션 종료"
142
+ step_type: outcome
143
+ node_id: why-handover
@@ -0,0 +1,9 @@
1
+ """
2
+ HITS Core - Hybrid Intel Trace System Core Library
3
+
4
+ Apache 2.0 License
5
+ Pure Python implementation without GUI dependencies.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __license__ = "Apache-2.0"
@@ -0,0 +1,11 @@
1
+ """AI module for token optimization and knowledge compression."""
2
+
3
+ from .compressor import SemanticCompressor
4
+ from .slm_filter import SLMFilter
5
+ from .llm_client import LLMClient
6
+
7
+ __all__ = [
8
+ "SemanticCompressor",
9
+ "SLMFilter",
10
+ "LLMClient",
11
+ ]
@@ -0,0 +1,86 @@
1
+ """Semantic compression for token-efficient knowledge storage."""
2
+
3
+ from typing import Optional
4
+ from ..models.node import Node
5
+
6
+
7
+ class SemanticCompressor:
8
+ KEYWORD_MAP = {
9
+ "따라서": "→",
10
+ "그래서": "→",
11
+ "그러므로": "→",
12
+ "그러나": "↔",
13
+ "하지만": "↔",
14
+ "또한": "+",
15
+ "그리고": "+",
16
+ "또는": "|",
17
+ "만약": "?",
18
+ "만일": "?",
19
+ "그러면": "→",
20
+ "결과": "⊕",
21
+ "원인": "⊙",
22
+ "목적": "◎",
23
+ "방법": "⚙",
24
+ "주의": "⚠",
25
+ "중요": "★",
26
+ "필수": "!",
27
+ "선택": "○",
28
+ "성공": "✓",
29
+ "실패": "✗",
30
+ "버그": "🐛",
31
+ "수정": "🔧",
32
+ "배포": "🚀",
33
+ "테스트": "🧪",
34
+ }
35
+
36
+ COMPRESSION_RULES = [
37
+ ("입니다", ""),
38
+ ("있습니다", "+"),
39
+ ("없습니다", "-"),
40
+ ("합니다", "."),
41
+ ("되어야 합니다", "→!"),
42
+ ("해야 합니다", "→!"),
43
+ ("필요합니다", "!"),
44
+ ("가능합니다", "✓"),
45
+ ("불가능합니다", "✗"),
46
+ ("중요합니다", "★!"),
47
+ ("필수입니다", "!"),
48
+ ]
49
+
50
+ def compress(self, text: str) -> str:
51
+ if not text:
52
+ return text
53
+
54
+ result = text
55
+
56
+ for keyword, symbol in self.KEYWORD_MAP.items():
57
+ result = result.replace(keyword, f" {symbol} ")
58
+
59
+ for full, compressed in self.COMPRESSION_RULES:
60
+ result = result.replace(full, compressed)
61
+
62
+ while " " in result:
63
+ result = result.replace(" ", " ")
64
+
65
+ return result.strip()
66
+
67
+ def compress_node(self, node: Node) -> int:
68
+ original_length = len(node.description or "")
69
+
70
+ if node.description:
71
+ node.description = self.compress(node.description)
72
+
73
+ for key, value in node.metadata.items():
74
+ if isinstance(value, str):
75
+ node.metadata[key] = self.compress(value)
76
+
77
+ compressed_length = len(node.description or "")
78
+ tokens_saved = max(0, (original_length - compressed_length) // 4)
79
+
80
+ node.tokens_saved = tokens_saved
81
+ return tokens_saved
82
+
83
+ def estimate_tokens(self, text: str) -> int:
84
+ korean_chars = sum(1 for c in text if '\uAC00' <= c <= '\uD7A3')
85
+ other_chars = len(text) - korean_chars
86
+ return (korean_chars // 2) + (other_chars // 4)
@@ -0,0 +1,65 @@
1
+ """LLM client for on-demand knowledge analysis."""
2
+
3
+ from typing import Optional
4
+ from abc import ABC, abstractmethod
5
+
6
+
7
+ class LLMProvider(ABC):
8
+ @abstractmethod
9
+ async def generate(self, prompt: str, max_tokens: int = 1000) -> str:
10
+ pass
11
+
12
+
13
+ class MockLLMProvider(LLMProvider):
14
+ async def generate(self, prompt: str, max_tokens: int = 1000) -> str:
15
+ return f"[Mock Response] Analyzed: {prompt[:50]}..."
16
+
17
+
18
+ class LLMClient:
19
+ def __init__(self, provider: Optional[LLMProvider] = None):
20
+ self.provider = provider or MockLLMProvider()
21
+ self.total_tokens_used = 0
22
+
23
+ async def analyze_node(self, node_data: str, max_tokens: int = 500) -> str:
24
+ prompt = f"""다음 지식 노드를 분석하여 핵심 맥락을 요약하세요:
25
+
26
+ {node_data}
27
+
28
+ 요약:"""
29
+
30
+ response = await self.provider.generate(prompt, max_tokens)
31
+ self.total_tokens_used += max_tokens
32
+ return response
33
+
34
+ async def suggest_children(self, node_data: str, max_tokens: int = 300) -> list[str]:
35
+ prompt = f"""다음 지식 노드에 대해 적절한 하위 항목을 3-5개 제안하세요:
36
+
37
+ {node_data}
38
+
39
+ 하위 항목:"""
40
+
41
+ response = await self.provider.generate(prompt, max_tokens)
42
+ self.total_tokens_used += max_tokens
43
+
44
+ suggestions = [
45
+ line.strip().lstrip("0123456789.-) ")
46
+ for line in response.split("\n")
47
+ if line.strip()
48
+ ]
49
+ return suggestions[:5]
50
+
51
+ async def generate_handover_summary(
52
+ self,
53
+ tree_data: str,
54
+ max_tokens: int = 1000
55
+ ) -> str:
56
+ prompt = f"""다음 지식 트리를 기반으로 인수인계 요약을 작성하세요.
57
+ 주요 의사결정, 실패 경험, 필수 지식을 포함하세요:
58
+
59
+ {tree_data}
60
+
61
+ 인수인계 요약:"""
62
+
63
+ response = await self.provider.generate(prompt, max_tokens)
64
+ self.total_tokens_used += max_tokens
65
+ return response
@@ -0,0 +1,126 @@
1
+ """SLM-based pre-filter for token cost optimization."""
2
+
3
+ from typing import Optional
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+
8
+ class ContentImportance(str, Enum):
9
+ CRITICAL = "critical"
10
+ IMPORTANT = "important"
11
+ NOISE = "noise"
12
+
13
+
14
+ @dataclass
15
+ class FilterResult:
16
+ content: str
17
+ importance: ContentImportance
18
+ confidence: float
19
+ keywords: list[str]
20
+
21
+
22
+ CRITICAL_KEYWORDS = [
23
+ "장애", "에러", "버그", "핫픽스", "긴급", "중단", "실패",
24
+ "롤백", "복구", "보안", "취약점", "인증", "권한",
25
+ ]
26
+
27
+ IMPORTANT_KEYWORDS = [
28
+ "배포", "빌드", "테스트", "설정", "구성", "변경",
29
+ "업데이트", "수정", "개선", "최적화", "성능",
30
+ ]
31
+
32
+ NOISE_PATTERNS = [
33
+ "로그:", "debug:", "trace:", "info:",
34
+ "TODO:", "FIXME:", "XXX:",
35
+ ]
36
+
37
+
38
+ class SLMFilter:
39
+ def __init__(
40
+ self,
41
+ critical_threshold: float = 0.7,
42
+ important_threshold: float = 0.5,
43
+ ):
44
+ self.critical_threshold = critical_threshold
45
+ self.important_threshold = important_threshold
46
+
47
+ def classify(self, content: str) -> FilterResult:
48
+ if not content:
49
+ return FilterResult(
50
+ content=content,
51
+ importance=ContentImportance.NOISE,
52
+ confidence=1.0,
53
+ keywords=[],
54
+ )
55
+
56
+ keywords = []
57
+ critical_score = 0.0
58
+ important_score = 0.0
59
+ noise_score = 0.0
60
+
61
+ content_lower = content.lower()
62
+
63
+ for keyword in CRITICAL_KEYWORDS:
64
+ if keyword in content:
65
+ critical_score += 1.0
66
+ keywords.append(keyword)
67
+
68
+ for keyword in IMPORTANT_KEYWORDS:
69
+ if keyword in content:
70
+ important_score += 0.5
71
+ keywords.append(keyword)
72
+
73
+ for pattern in NOISE_PATTERNS:
74
+ if pattern.lower() in content_lower:
75
+ noise_score += 0.5
76
+
77
+ total_chars = max(len(content), 1)
78
+
79
+ # Score normalization: each keyword contributes meaningfully
80
+ # Critical: even 1 keyword should be enough for critical content
81
+ critical_score = min(critical_score / 2.0, 1.0)
82
+ important_score = min(important_score / 3.0, 1.0)
83
+ noise_score = min(noise_score / 1.0, 1.0)
84
+
85
+ # Decision: critical takes priority over noise
86
+ # "긴급 장애 발생" has both critical and noise-like exclamation
87
+ # but the critical keywords should dominate
88
+ if critical_score > 0:
89
+ importance = ContentImportance.CRITICAL
90
+ confidence = critical_score
91
+ elif noise_score > important_score and noise_score >= 0.5:
92
+ importance = ContentImportance.NOISE
93
+ confidence = noise_score
94
+ elif important_score > 0:
95
+ importance = ContentImportance.IMPORTANT
96
+ confidence = important_score
97
+ else:
98
+ importance = ContentImportance.IMPORTANT
99
+ confidence = 0.5
100
+
101
+ return FilterResult(
102
+ content=content,
103
+ importance=importance,
104
+ confidence=confidence,
105
+ keywords=keywords,
106
+ )
107
+
108
+ def filter_batch(self, contents: list[str]) -> tuple[list[str], list[str]]:
109
+ critical_important = []
110
+ noise = []
111
+
112
+ for content in contents:
113
+ result = self.classify(content)
114
+ if result.importance in (ContentImportance.CRITICAL, ContentImportance.IMPORTANT):
115
+ critical_important.append(content)
116
+ else:
117
+ noise.append(content)
118
+
119
+ return critical_important, noise
120
+
121
+ def estimate_filter_ratio(self, contents: list[str]) -> float:
122
+ if not contents:
123
+ return 0.0
124
+
125
+ critical_important, _ = self.filter_batch(contents)
126
+ return len(critical_important) / len(contents)
@@ -0,0 +1,3 @@
1
+ from .server import start_api_server, stop_api_server
2
+
3
+ __all__ = ["start_api_server", "stop_api_server"]
@@ -0,0 +1,8 @@
1
+ from . import health
2
+ from . import work_log
3
+ from . import node
4
+ from . import handover
5
+ from . import auth
6
+ from . import knowledge
7
+
8
+ __all__ = ["health", "work_log", "node", "handover", "auth", "knowledge"]
@@ -0,0 +1,211 @@
1
+ """Authentication API routes.
2
+
3
+ Endpoints:
4
+ - POST /api/auth/register → Create user (first user becomes admin)
5
+ - POST /api/auth/login → Authenticate, set HttpOnly cookies
6
+ - POST /api/auth/logout → Clear auth cookies
7
+ - POST /api/auth/refresh → Refresh access token
8
+ - GET /api/auth/me → Get current user info
9
+ - PUT /api/auth/password → Change password
10
+ """
11
+
12
+ from fastapi import APIRouter, Request, Response, HTTPException, Depends, status
13
+ from pydantic import BaseModel, Field
14
+ from typing import Any, Optional
15
+
16
+ from hits_core.auth.manager import get_auth_manager
17
+ from hits_core.auth.dependencies import get_current_user, require_auth
18
+
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ # --- Request/Response Models ---
24
+
25
+ class RegisterRequest(BaseModel):
26
+ username: str = Field(..., min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$")
27
+ password: str = Field(..., min_length=8, max_length=128)
28
+
29
+
30
+ class LoginRequest(BaseModel):
31
+ username: str = Field(..., min_length=1)
32
+ password: str = Field(..., min_length=1)
33
+
34
+
35
+ class ChangePasswordRequest(BaseModel):
36
+ old_password: str = Field(..., min_length=1)
37
+ new_password: str = Field(..., min_length=8, max_length=128)
38
+
39
+
40
+ class APIResponse(BaseModel):
41
+ success: bool
42
+ data: Optional[Any] = None
43
+ error: Optional[str] = None
44
+
45
+
46
+ # --- Cookie Settings ---
47
+ # Secure cookies: HttpOnly (no JS access), Secure (HTTPS only in prod),
48
+ # SameSite=Lax (CSRF protection), path restricted to /api
49
+ def _cookie_params(secure: bool = False) -> dict:
50
+ return {
51
+ "httponly": True,
52
+ "secure": secure,
53
+ "samesite": "lax",
54
+ "path": "/",
55
+ "max_age": None, # session cookie for access
56
+ }
57
+
58
+
59
+ # --- Endpoints ---
60
+
61
+ @router.post("/auth/register", response_model=APIResponse)
62
+ async def register(body: RegisterRequest, request: Request):
63
+ """Register a new user. First user automatically becomes admin."""
64
+ auth = get_auth_manager()
65
+
66
+ if auth.has_any_user():
67
+ # Registration requires auth after first user
68
+ user = await get_current_user(request)
69
+ if not user or user.get("role") != "admin":
70
+ raise HTTPException(
71
+ status_code=status.HTTP_403_FORBIDDEN,
72
+ detail="Only admins can create new users",
73
+ )
74
+
75
+ if auth.user_exists(body.username):
76
+ return APIResponse(success=False, error="Username already exists")
77
+
78
+ success = auth.create_user(body.username, body.password)
79
+ if not success:
80
+ return APIResponse(success=False, error="Failed to create user")
81
+
82
+ return APIResponse(
83
+ success=True,
84
+ data={
85
+ "username": body.username,
86
+ "role": "admin" if not auth.has_any_user() else "user",
87
+ "message": "User created successfully",
88
+ },
89
+ )
90
+
91
+
92
+ @router.post("/auth/login", response_model=APIResponse)
93
+ async def login(body: LoginRequest, response: Response):
94
+ """Authenticate and set HttpOnly JWT cookies."""
95
+ auth = get_auth_manager()
96
+ tokens = auth.create_tokens(body.username, body.password)
97
+
98
+ if not tokens:
99
+ raise HTTPException(
100
+ status_code=status.HTTP_401_UNAUTHORIZED,
101
+ detail="Invalid username or password",
102
+ )
103
+
104
+ # Set HttpOnly cookies
105
+ # Access token: short-lived (15 min)
106
+ response.set_cookie(
107
+ key="access_token",
108
+ value=tokens["access_token"],
109
+ httponly=True,
110
+ secure=False, # Set True in production with HTTPS
111
+ samesite="lax",
112
+ path="/",
113
+ max_age=900, # 15 minutes
114
+ )
115
+
116
+ # Refresh token: long-lived (7 days)
117
+ response.set_cookie(
118
+ key="refresh_token",
119
+ value=tokens["refresh_token"],
120
+ httponly=True,
121
+ secure=False, # Set True in production with HTTPS
122
+ samesite="lax",
123
+ path="/api/auth/refresh", # Only sent to refresh endpoint
124
+ max_age=604800, # 7 days
125
+ )
126
+
127
+ return APIResponse(
128
+ success=True,
129
+ data={
130
+ "username": tokens["username"],
131
+ "role": tokens["role"],
132
+ },
133
+ )
134
+
135
+
136
+ @router.post("/auth/logout", response_model=APIResponse)
137
+ async def logout(response: Response):
138
+ """Clear authentication cookies."""
139
+ response.delete_cookie(key="access_token", path="/")
140
+ response.delete_cookie(key="refresh_token", path="/api/auth/refresh")
141
+ return APIResponse(success=True, data={"message": "Logged out"})
142
+
143
+
144
+ @router.post("/auth/refresh", response_model=APIResponse)
145
+ async def refresh_token(request: Request, response: Response):
146
+ """Refresh access token using refresh token cookie."""
147
+ refresh_token = request.cookies.get("refresh_token")
148
+ if not refresh_token:
149
+ raise HTTPException(
150
+ status_code=status.HTTP_401_UNAUTHORIZED,
151
+ detail="No refresh token",
152
+ )
153
+
154
+ auth = get_auth_manager()
155
+ new_access = auth.refresh_access_token(refresh_token)
156
+
157
+ if not new_access:
158
+ raise HTTPException(
159
+ status_code=status.HTTP_401_UNAUTHORIZED,
160
+ detail="Invalid or expired refresh token",
161
+ )
162
+
163
+ response.set_cookie(
164
+ key="access_token",
165
+ value=new_access,
166
+ httponly=True,
167
+ secure=False,
168
+ samesite="lax",
169
+ path="/",
170
+ max_age=900,
171
+ )
172
+
173
+ return APIResponse(success=True, data={"message": "Token refreshed"})
174
+
175
+
176
+ @router.get("/auth/me", response_model=APIResponse)
177
+ async def get_me(user: dict = Depends(require_auth)):
178
+ """Get current authenticated user info."""
179
+ return APIResponse(success=True, data=user)
180
+
181
+
182
+ @router.put("/auth/password", response_model=APIResponse)
183
+ async def change_password(
184
+ body: ChangePasswordRequest,
185
+ user: dict = Depends(require_auth),
186
+ ):
187
+ """Change password for the authenticated user."""
188
+ auth = get_auth_manager()
189
+ success = auth.change_password(user["username"], body.old_password, body.new_password)
190
+
191
+ if not success:
192
+ return APIResponse(success=False, error="Invalid current password")
193
+
194
+ return APIResponse(success=True, data={"message": "Password changed"})
195
+
196
+
197
+ @router.get("/auth/status", response_model=APIResponse)
198
+ async def auth_status(request: Request):
199
+ """Check if authentication is initialized (has any users)."""
200
+ auth = get_auth_manager()
201
+ user = await get_current_user(request)
202
+
203
+ return APIResponse(
204
+ success=True,
205
+ data={
206
+ "initialized": auth.has_any_user(),
207
+ "authenticated": user is not None,
208
+ "username": user.get("username") if user else None,
209
+ "role": user.get("role") if user else None,
210
+ },
211
+ )