@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,178 @@
1
+ """Shell history collector."""
2
+
3
+ import asyncio
4
+ import os
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Optional, Callable
8
+ import getpass
9
+
10
+ from .base import BaseCollector, CollectorEvent
11
+ from ..models.work_log import WorkLogSource, WorkLogResultType
12
+
13
+
14
+ class ShellCollector(BaseCollector):
15
+ HISTORY_FILES = {
16
+ "bash": ".bash_history",
17
+ "zsh": ".zhistfile",
18
+ "fish": ".local/share/fish/fish_history",
19
+ }
20
+
21
+ DEFAULT_IGNORE_PATTERNS = [
22
+ "ls", "cd", "pwd", "echo", "cat", "clear",
23
+ "exit", "history", "which", "type",
24
+ ]
25
+
26
+ def __init__(
27
+ self,
28
+ callback: Optional[Callable[[CollectorEvent], None]] = None,
29
+ poll_interval: int = 60,
30
+ shell: str = "bash",
31
+ ignore_patterns: Optional[list[str]] = None,
32
+ ):
33
+ super().__init__(callback)
34
+ self.poll_interval = poll_interval
35
+ self.shell = shell
36
+ self.ignore_patterns = ignore_patterns or self.DEFAULT_IGNORE_PATTERNS
37
+ self._last_position: int = 0
38
+ self._task: Optional[asyncio.Task] = None
39
+ self._username = getpass.getuser()
40
+
41
+ @property
42
+ def source(self) -> WorkLogSource:
43
+ return WorkLogSource.SHELL
44
+
45
+ def _get_history_path(self) -> Optional[Path]:
46
+ home = Path.home()
47
+
48
+ if self.shell == "bash":
49
+ histfile = os.environ.get("HISTFILE", str(home / ".bash_history"))
50
+ elif self.shell == "zsh":
51
+ histfile = os.environ.get("HISTFILE", str(home / ".zsh_history"))
52
+ elif self.shell == "fish":
53
+ histfile = str(home / ".local/share/fish/fish_history")
54
+ else:
55
+ return None
56
+
57
+ path = Path(histfile)
58
+ return path if path.exists() else None
59
+
60
+ def _should_ignore(self, command: str) -> bool:
61
+ cmd_first = command.strip().split()[0] if command.strip() else ""
62
+ return cmd_first in self.ignore_patterns
63
+
64
+ def _parse_command(self, line: str) -> Optional[dict]:
65
+ line = line.strip()
66
+ if not line or line.startswith("#"):
67
+ return None
68
+
69
+ if self.shell == "zsh" and line.startswith(":"):
70
+ parts = line.split(";", 1)
71
+ if len(parts) == 2:
72
+ return {"command": parts[1].strip(), "raw": line}
73
+
74
+ return {"command": line, "raw": line}
75
+
76
+ async def collect(self) -> list[CollectorEvent]:
77
+ events = []
78
+
79
+ history_path = self._get_history_path()
80
+ if not history_path:
81
+ return events
82
+
83
+ try:
84
+ with open(history_path, "r", encoding="utf-8", errors="ignore") as f:
85
+ f.seek(self._last_position)
86
+ new_lines = f.readlines()
87
+ self._last_position = f.tell()
88
+ except Exception:
89
+ return events
90
+
91
+ now = datetime.now()
92
+
93
+ for line in new_lines:
94
+ parsed = self._parse_command(line)
95
+ if not parsed:
96
+ continue
97
+
98
+ command = parsed["command"]
99
+ if self._should_ignore(command):
100
+ continue
101
+
102
+ event = CollectorEvent(
103
+ source=self.source,
104
+ performed_by=self._username,
105
+ performed_at=now,
106
+ request_text=command[:200],
107
+ result_type=WorkLogResultType.COMMAND,
108
+ result_ref=command[:50],
109
+ result_data={"raw": parsed["raw"]},
110
+ tags=self._extract_tags(command),
111
+ )
112
+ events.append(event)
113
+ self._emit(event)
114
+
115
+ return events
116
+
117
+ def _extract_tags(self, command: str) -> list[str]:
118
+ tags = []
119
+
120
+ tool_tags = {
121
+ "git": "git",
122
+ "docker": "docker",
123
+ "kubectl": "kubernetes",
124
+ "npm": "npm",
125
+ "yarn": "npm",
126
+ "pip": "python",
127
+ "python": "python",
128
+ "pytest": "test",
129
+ "mvn": "java",
130
+ "gradle": "java",
131
+ "cargo": "rust",
132
+ "go": "go",
133
+ "make": "build",
134
+ "ssh": "ssh",
135
+ "scp": "ssh",
136
+ "rsync": "sync",
137
+ "curl": "http",
138
+ "wget": "http",
139
+ }
140
+
141
+ cmd_lower = command.lower()
142
+ for tool, tag in tool_tags.items():
143
+ if cmd_lower.startswith(tool + " ") or cmd_lower.startswith(tool + "\t"):
144
+ tags.append(tag)
145
+ break
146
+
147
+ return tags
148
+
149
+ async def start(self) -> None:
150
+ self._running = True
151
+ history_path = self._get_history_path()
152
+ if history_path:
153
+ try:
154
+ with open(history_path, "r") as f:
155
+ f.seek(0, 2)
156
+ self._last_position = f.tell()
157
+ except Exception:
158
+ pass
159
+
160
+ self._task = asyncio.create_task(self._poll_loop())
161
+
162
+ async def stop(self) -> None:
163
+ self._running = False
164
+ if self._task:
165
+ self._task.cancel()
166
+ try:
167
+ await self._task
168
+ except asyncio.CancelledError:
169
+ pass
170
+ self._task = None
171
+
172
+ async def _poll_loop(self) -> None:
173
+ while self._running:
174
+ try:
175
+ await self.collect()
176
+ except Exception:
177
+ pass
178
+ await asyncio.sleep(self.poll_interval)
@@ -0,0 +1,36 @@
1
+ """HITS web server entry point.
2
+
3
+ Usage:
4
+ python -m hits_core.main [--port PORT] [--dev]
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(description="HITS Web Server")
13
+ parser.add_argument("--port", type=int, default=8765, help="Server port (default: 8765)")
14
+ parser.add_argument("--dev", action="store_true", help="Development mode")
15
+ args = parser.parse_args()
16
+
17
+ import uvicorn
18
+ from hits_core.api.server import APIServer
19
+
20
+ server = APIServer(port=args.port, dev_mode=args.dev)
21
+ app = server.create_app()
22
+
23
+ print(f"HITS Web Server starting on http://127.0.0.1:{args.port}")
24
+ if args.dev:
25
+ print("Development mode: CSP relaxed, CORS enabled for Vite")
26
+
27
+ uvicorn.run(
28
+ app,
29
+ host="127.0.0.1",
30
+ port=args.port,
31
+ log_level="info" if args.dev else "warning",
32
+ )
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -0,0 +1,20 @@
1
+ """HITS MCP Server - Model Context Protocol interface.
2
+
3
+ Enables AI tools (Claude, OpenCode, Cursor, etc.) to interact with HITS
4
+ directly through MCP, without needing the HTTP API server running.
5
+
6
+ Usage in opencode.json or Claude MCP config:
7
+ {
8
+ "hits": {
9
+ "type": "local",
10
+ "command": ["python", "-m", "hits_core.mcp.server"]
11
+ }
12
+ }
13
+
14
+ Tools provided:
15
+ - hits_record_work: Record a work log for the current project
16
+ - hits_get_handover: Get handover summary for a project
17
+ - hits_search_works: Search previous work logs
18
+ - hits_list_projects: List all projects with activity
19
+ - hits_get_recent: Get recent work logs for a project
20
+ """
@@ -0,0 +1,429 @@
1
+ """HITS MCP Server implementation using stdio transport.
2
+
3
+ This module provides a standalone MCP server that exposes HITS functionality
4
+ as MCP tools. It auto-detects the current project from CWD.
5
+
6
+ Run with: python -m hits_core.mcp.server
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import sys
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+ from uuid import uuid4
17
+
18
+ # Ensure project root is in path
19
+ _project_root = str(Path(__file__).parent.parent.parent)
20
+ if _project_root not in sys.path:
21
+ sys.path.insert(0, _project_root)
22
+
23
+ from hits_core.storage.file_store import FileStorage
24
+ from hits_core.models.work_log import WorkLog, WorkLogSource, WorkLogResultType
25
+ from hits_core.service.handover_service import HandoverService
26
+
27
+
28
+ def _detect_project_path() -> str:
29
+ """Auto-detect project path from CWD, walking up to find git root."""
30
+ cwd = Path.cwd().resolve()
31
+ current = cwd
32
+
33
+ for _ in range(10): # Max 10 levels up
34
+ if (current / ".git").exists():
35
+ return str(current)
36
+ parent = current.parent
37
+ if parent == current:
38
+ break
39
+ current = parent
40
+
41
+ return str(cwd)
42
+
43
+
44
+ def _json_rpc_response(id_val: Any, result: dict = None, error: dict = None) -> str:
45
+ """Build a JSON-RPC 2.0 response."""
46
+ resp = {"jsonrpc": "2.0", "id": id_val}
47
+ if error:
48
+ resp["error"] = error
49
+ else:
50
+ resp["result"] = result or {}
51
+ return json.dumps(resp, ensure_ascii=False)
52
+
53
+
54
+ def _tool_result(text: str) -> list[dict]:
55
+ """Build MCP tool result content."""
56
+ return [{"type": "text", "text": text}]
57
+
58
+
59
+ class HITSMCPServer:
60
+ """MCP Server for HITS - runs over stdio."""
61
+
62
+ TOOLS = [
63
+ {
64
+ "name": "hits_record_work",
65
+ "description": (
66
+ "Record a work log for the current project. "
67
+ "Call this when ending a session or completing a task. "
68
+ "The project_path is auto-detected from CWD but can be overridden."
69
+ ),
70
+ "inputSchema": {
71
+ "type": "object",
72
+ "properties": {
73
+ "request_text": {
74
+ "type": "string",
75
+ "description": "Summary of what was done (1-2 sentences)",
76
+ },
77
+ "context": {
78
+ "type": "string",
79
+ "description": "Detailed context, decisions made, important notes",
80
+ },
81
+ "performed_by": {
82
+ "type": "string",
83
+ "description": "AI tool name: 'claude', 'opencode', 'cursor', etc.",
84
+ },
85
+ "tags": {
86
+ "type": "array",
87
+ "items": {"type": "string"},
88
+ "description": "Tags: ['feature', 'bugfix', 'refactor', etc.]",
89
+ },
90
+ "files_modified": {
91
+ "type": "array",
92
+ "items": {"type": "string"},
93
+ "description": "List of files that were modified",
94
+ },
95
+ "commands_run": {
96
+ "type": "array",
97
+ "items": {"type": "string"},
98
+ "description": "Commands that were run",
99
+ },
100
+ "project_path": {
101
+ "type": "string",
102
+ "description": "Override auto-detected project path",
103
+ },
104
+ },
105
+ "required": ["request_text", "performed_by"],
106
+ },
107
+ },
108
+ {
109
+ "name": "hits_get_handover",
110
+ "description": (
111
+ "Get a handover summary for a project. "
112
+ "Call this when starting a new session to understand what "
113
+ "previous AI sessions (Claude, OpenCode, etc.) have done. "
114
+ "Returns project-scoped context including recent work, key decisions, "
115
+ "pending items, files modified, and session history."
116
+ ),
117
+ "inputSchema": {
118
+ "type": "object",
119
+ "properties": {
120
+ "project_path": {
121
+ "type": "string",
122
+ "description": "Project path (default: auto-detect from CWD)",
123
+ },
124
+ "format": {
125
+ "type": "string",
126
+ "enum": ["text", "dict"],
127
+ "description": "Output format (default: 'text')",
128
+ },
129
+ },
130
+ },
131
+ },
132
+ {
133
+ "name": "hits_search_works",
134
+ "description": (
135
+ "Search previous work logs by keyword, scoped to a project. "
136
+ "Use this to find specific past work or decisions."
137
+ ),
138
+ "inputSchema": {
139
+ "type": "object",
140
+ "properties": {
141
+ "query": {
142
+ "type": "string",
143
+ "description": "Search keyword",
144
+ },
145
+ "project_path": {
146
+ "type": "string",
147
+ "description": "Scope to project (default: auto-detect from CWD)",
148
+ },
149
+ "limit": {
150
+ "type": "integer",
151
+ "description": "Max results (default: 10)",
152
+ },
153
+ },
154
+ "required": ["query"],
155
+ },
156
+ },
157
+ {
158
+ "name": "hits_list_projects",
159
+ "description": (
160
+ "List all projects that have recorded work logs. "
161
+ "Use this to discover which projects have accumulated context."
162
+ ),
163
+ "inputSchema": {
164
+ "type": "object",
165
+ "properties": {},
166
+ },
167
+ },
168
+ {
169
+ "name": "hits_get_recent",
170
+ "description": (
171
+ "Get recent work logs for a project. "
172
+ "Lighter than full handover - returns raw log entries."
173
+ ),
174
+ "inputSchema": {
175
+ "type": "object",
176
+ "properties": {
177
+ "project_path": {
178
+ "type": "string",
179
+ "description": "Project path (default: auto-detect from CWD)",
180
+ },
181
+ "limit": {
182
+ "type": "integer",
183
+ "description": "Number of recent logs (default: 10)",
184
+ },
185
+ "performed_by": {
186
+ "type": "string",
187
+ "description": "Filter by AI tool name",
188
+ },
189
+ },
190
+ },
191
+ },
192
+ ]
193
+
194
+ SERVER_INFO = {
195
+ "name": "hits-mcp",
196
+ "version": "0.1.0",
197
+ }
198
+
199
+ CAPABILITIES = {
200
+ "tools": {"listChanged": False},
201
+ }
202
+
203
+ def __init__(self, data_path: Optional[str] = None):
204
+ # Use centralized ~/.hits/data/ by default (same as FileStorage)
205
+ self.storage = FileStorage(base_path=data_path)
206
+ self.handover_service = HandoverService(storage=self.storage)
207
+
208
+ async def handle_initialize(self, params: dict, id_val: Any) -> str:
209
+ result = {
210
+ "protocolVersion": "2024-11-05",
211
+ "capabilities": self.CAPABILITIES,
212
+ "serverInfo": self.SERVER_INFO,
213
+ }
214
+ return _json_rpc_response(id_val, result=result)
215
+
216
+ async def handle_tools_list(self, params: dict, id_val: Any) -> str:
217
+ return _json_rpc_response(id_val, result={"tools": self.TOOLS})
218
+
219
+ async def handle_tools_call(self, params: dict, id_val: Any) -> str:
220
+ tool_name = params.get("name", "")
221
+ arguments = params.get("arguments", {})
222
+
223
+ try:
224
+ if tool_name == "hits_record_work":
225
+ result = await self._tool_record_work(arguments)
226
+ elif tool_name == "hits_get_handover":
227
+ result = await self._tool_get_handover(arguments)
228
+ elif tool_name == "hits_search_works":
229
+ result = await self._tool_search_works(arguments)
230
+ elif tool_name == "hits_list_projects":
231
+ result = await self._tool_list_projects(arguments)
232
+ elif tool_name == "hits_get_recent":
233
+ result = await self._tool_get_recent(arguments)
234
+ else:
235
+ return _json_rpc_response(
236
+ id_val,
237
+ error={"code": -32601, "message": f"Unknown tool: {tool_name}"},
238
+ )
239
+
240
+ return _json_rpc_response(
241
+ id_val,
242
+ result={"content": result},
243
+ )
244
+
245
+ except Exception as e:
246
+ return _json_rpc_response(
247
+ id_val,
248
+ result={
249
+ "content": _tool_result(f"Error: {str(e)}"),
250
+ "isError": True,
251
+ },
252
+ )
253
+
254
+ async def _tool_record_work(self, args: dict) -> list[dict]:
255
+ project_path = args.get("project_path") or _detect_project_path()
256
+ performed_by = args.get("performed_by", "unknown")
257
+
258
+ log = WorkLog(
259
+ id=str(uuid4())[:8],
260
+ source=WorkLogSource.AI_SESSION,
261
+ performed_by=performed_by,
262
+ request_text=args.get("request_text"),
263
+ context=args.get("context"),
264
+ tags=args.get("tags", []),
265
+ project_path=str(Path(project_path).resolve()),
266
+ result_type=WorkLogResultType.AI_RESPONSE,
267
+ result_data={
268
+ "files_modified": args.get("files_modified", []),
269
+ "commands_run": args.get("commands_run", []),
270
+ },
271
+ )
272
+
273
+ success = await self.storage.save_work_log(log)
274
+
275
+ if success:
276
+ return _tool_result(
277
+ f"✅ 작업 기록 완료\n"
278
+ f" ID: {log.id}\n"
279
+ f" 프로젝트: {project_path}\n"
280
+ f" 수행자: {performed_by}\n"
281
+ f" 요약: {log.request_text}"
282
+ )
283
+ else:
284
+ return _tool_result("❌ 작업 기록 실패")
285
+
286
+ async def _tool_get_handover(self, args: dict) -> list[dict]:
287
+ project_path = args.get("project_path") or _detect_project_path()
288
+ project_path = str(Path(project_path).resolve())
289
+ fmt = args.get("format", "text")
290
+
291
+ summary = await self.handover_service.get_handover(project_path)
292
+
293
+ if fmt == "text":
294
+ return _tool_result(summary.to_text())
295
+ else:
296
+ return _tool_result(json.dumps(summary.to_dict(), indent=2, ensure_ascii=False))
297
+
298
+ async def _tool_search_works(self, args: dict) -> list[dict]:
299
+ query = args.get("query", "")
300
+ project_path = args.get("project_path") or _detect_project_path()
301
+ project_path = str(Path(project_path).resolve())
302
+ limit = args.get("limit", 10)
303
+
304
+ logs = await self.storage.search_work_logs(
305
+ query=query,
306
+ project_path=project_path,
307
+ limit=limit,
308
+ )
309
+
310
+ if not logs:
311
+ return _tool_result(f"검색 결과 없음: '{query}' (프로젝트: {project_path})")
312
+
313
+ lines = [f"검색 결과: '{query}' ({len(logs)}건)\n"]
314
+ for log in logs:
315
+ ts = log.performed_at.strftime("%Y-%m-%d %H:%M")
316
+ lines.append(f"[{ts}] ({log.performed_by}) {log.request_text or log.context}")
317
+ if log.tags:
318
+ lines.append(f" tags: {', '.join(log.tags)}")
319
+
320
+ return _tool_result("\n".join(lines))
321
+
322
+ async def _tool_list_projects(self, args: dict) -> list[dict]:
323
+ projects = await self.handover_service.list_projects()
324
+
325
+ if not projects:
326
+ return _tool_result("기록된 프로젝트가 없습니다.")
327
+
328
+ lines = [f"프로젝트 목록 ({len(projects)}개)\n"]
329
+ for p in projects:
330
+ name = Path(p["project_path"]).name
331
+ logs = p.get("total_logs", 0)
332
+ last = p.get("last_activity", "N/A")
333
+ performers = ", ".join(p.get("performers", {}).keys())
334
+ lines.append(f" {name}: {logs}건 (마지막: {last}) [{performers}]")
335
+
336
+ return _tool_result("\n".join(lines))
337
+
338
+ async def _tool_get_recent(self, args: dict) -> list[dict]:
339
+ project_path = args.get("project_path") or _detect_project_path()
340
+ project_path = str(Path(project_path).resolve())
341
+ limit = args.get("limit", 10)
342
+ performed_by = args.get("performed_by")
343
+
344
+ logs = await self.storage.list_work_logs(
345
+ project_path=project_path,
346
+ performed_by=performed_by,
347
+ limit=limit,
348
+ )
349
+
350
+ if not logs:
351
+ return _tool_result(f"최근 작업 없음 (프로젝트: {project_path})")
352
+
353
+ lines = [f"최근 작업 ({len(logs)}건)\n"]
354
+ for log in logs:
355
+ ts = log.performed_at.strftime("%m/%d %H:%M")
356
+ lines.append(f"[{ts}] ({log.performed_by}) {log.request_text or log.context}")
357
+
358
+ return _tool_result("\n".join(lines))
359
+
360
+ async def handle_request(self, request: dict) -> Optional[str]:
361
+ """Handle a single JSON-RPC request."""
362
+ method = request.get("method", "")
363
+ id_val = request.get("id")
364
+ params = request.get("params", {})
365
+
366
+ if method == "initialize":
367
+ return await self.handle_initialize(params, id_val)
368
+ elif method == "notifications/initialized":
369
+ # Client acknowledges initialization - no response needed
370
+ return None
371
+ elif method == "tools/list":
372
+ return await self.handle_tools_list(params, id_val)
373
+ elif method == "tools/call":
374
+ return await self.handle_tools_call(params, id_val)
375
+ elif method == "ping":
376
+ return _json_rpc_response(id_val, result={})
377
+ else:
378
+ return _json_rpc_response(
379
+ id_val,
380
+ error={"code": -32601, "message": f"Method not found: {method}"},
381
+ )
382
+
383
+ async def run(self):
384
+ """Run the MCP server over stdio."""
385
+ reader = asyncio.StreamReader()
386
+ protocol = asyncio.StreamReaderProtocol(reader)
387
+ await asyncio.get_event_loop().connect_read_pipe(
388
+ lambda: protocol, sys.stdin
389
+ )
390
+
391
+ writer_transport, writer_protocol = await asyncio.get_event_loop().connect_write_pipe(
392
+ asyncio.streams.FlowControlMixin, sys.stdout
393
+ )
394
+ writer = asyncio.StreamWriter(writer_transport, writer_protocol, reader, asyncio.get_event_loop())
395
+
396
+ while True:
397
+ try:
398
+ line = await reader.readline()
399
+ if not line:
400
+ break
401
+
402
+ line = line.decode("utf-8").strip()
403
+ if not line:
404
+ continue
405
+
406
+ try:
407
+ request = json.loads(line)
408
+ except json.JSONDecodeError:
409
+ continue
410
+
411
+ response = await self.handle_request(request)
412
+ if response is not None:
413
+ writer.write((response + "\n").encode("utf-8"))
414
+ await writer.drain()
415
+
416
+ except Exception:
417
+ break
418
+
419
+
420
+ def main():
421
+ """Entry point for running as MCP server."""
422
+ # HITS_DATA_PATH env var can override, otherwise uses ~/.hits/data/
423
+ data_path = os.environ.get("HITS_DATA_PATH")
424
+ server = HITSMCPServer(data_path=data_path)
425
+ asyncio.run(server.run())
426
+
427
+
428
+ if __name__ == "__main__":
429
+ main()
@@ -0,0 +1,18 @@
1
+ """Data models for knowledge tree structure."""
2
+
3
+ from .tree import KnowledgeTree
4
+ from .node import Node, NodeType, NodeLayer
5
+ from .workflow import Workflow, WorkflowStep
6
+ from .work_log import WorkLog, WorkLogSource, WorkLogResultType
7
+
8
+ __all__ = [
9
+ "KnowledgeTree",
10
+ "Node",
11
+ "NodeType",
12
+ "NodeLayer",
13
+ "Workflow",
14
+ "WorkflowStep",
15
+ "WorkLog",
16
+ "WorkLogSource",
17
+ "WorkLogResultType",
18
+ ]