@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.
- package/AGENTS.md +298 -0
- package/LICENSE +190 -0
- package/README.md +336 -0
- package/bin/hits.js +56 -0
- package/config/schema.json +94 -0
- package/config/settings.yaml +102 -0
- package/data/dev_handover.yaml +143 -0
- package/hits_core/__init__.py +9 -0
- package/hits_core/ai/__init__.py +11 -0
- package/hits_core/ai/compressor.py +86 -0
- package/hits_core/ai/llm_client.py +65 -0
- package/hits_core/ai/slm_filter.py +126 -0
- package/hits_core/api/__init__.py +3 -0
- package/hits_core/api/routes/__init__.py +8 -0
- package/hits_core/api/routes/auth.py +211 -0
- package/hits_core/api/routes/handover.py +117 -0
- package/hits_core/api/routes/health.py +8 -0
- package/hits_core/api/routes/knowledge.py +177 -0
- package/hits_core/api/routes/node.py +121 -0
- package/hits_core/api/routes/work_log.py +174 -0
- package/hits_core/api/server.py +181 -0
- package/hits_core/auth/__init__.py +21 -0
- package/hits_core/auth/dependencies.py +61 -0
- package/hits_core/auth/manager.py +368 -0
- package/hits_core/auth/middleware.py +69 -0
- package/hits_core/collector/__init__.py +18 -0
- package/hits_core/collector/ai_session_collector.py +118 -0
- package/hits_core/collector/base.py +73 -0
- package/hits_core/collector/daemon.py +94 -0
- package/hits_core/collector/git_collector.py +177 -0
- package/hits_core/collector/hits_action_collector.py +110 -0
- package/hits_core/collector/shell_collector.py +178 -0
- package/hits_core/main.py +36 -0
- package/hits_core/mcp/__init__.py +20 -0
- package/hits_core/mcp/server.py +429 -0
- package/hits_core/models/__init__.py +18 -0
- package/hits_core/models/node.py +56 -0
- package/hits_core/models/tree.py +68 -0
- package/hits_core/models/work_log.py +64 -0
- package/hits_core/models/workflow.py +92 -0
- package/hits_core/platform/__init__.py +5 -0
- package/hits_core/platform/actions.py +225 -0
- package/hits_core/service/__init__.py +6 -0
- package/hits_core/service/handover_service.py +382 -0
- package/hits_core/service/knowledge_service.py +172 -0
- package/hits_core/service/tree_service.py +105 -0
- package/hits_core/storage/__init__.py +11 -0
- package/hits_core/storage/base.py +84 -0
- package/hits_core/storage/file_store.py +314 -0
- package/hits_core/storage/redis_store.py +123 -0
- package/hits_web/dist/assets/index-Bgx7F6m6.css +1 -0
- package/hits_web/dist/assets/index-D1B5E67G.js +3 -0
- package/hits_web/dist/index.html +16 -0
- package/package.json +60 -0
- package/requirements-core.txt +7 -0
- package/requirements.txt +1 -0
- package/run.sh +271 -0
- 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
|
+
]
|