@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,18 @@
1
+ """Work log collectors for various sources."""
2
+
3
+ from .base import BaseCollector, CollectorEvent
4
+ from .git_collector import GitCollector
5
+ from .shell_collector import ShellCollector
6
+ from .hits_action_collector import HitsActionCollector
7
+ from .ai_session_collector import AISessionCollector
8
+ from .daemon import CollectorDaemon
9
+
10
+ __all__ = [
11
+ "BaseCollector",
12
+ "CollectorEvent",
13
+ "GitCollector",
14
+ "ShellCollector",
15
+ "HitsActionCollector",
16
+ "AISessionCollector",
17
+ "CollectorDaemon",
18
+ ]
@@ -0,0 +1,118 @@
1
+ """AI session collector - monitors AI interaction sessions."""
2
+
3
+ import asyncio
4
+ import json
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 AISessionCollector(BaseCollector):
15
+ SESSION_DIR = Path.home() / ".hits" / "sessions"
16
+
17
+ def __init__(
18
+ self,
19
+ callback: Optional[Callable[[CollectorEvent], None]] = None,
20
+ poll_interval: int = 30,
21
+ ):
22
+ super().__init__(callback)
23
+ self.poll_interval = poll_interval
24
+ self._task: Optional[asyncio.Task] = None
25
+ self._username = getpass.getuser()
26
+ self._processed_files: set[str] = set()
27
+
28
+ @property
29
+ def source(self) -> WorkLogSource:
30
+ return WorkLogSource.AI_SESSION
31
+
32
+ @classmethod
33
+ def get_session_file(cls, session_id: str) -> Path:
34
+ cls.SESSION_DIR.mkdir(parents=True, exist_ok=True)
35
+ return cls.SESSION_DIR / f"{session_id}.json"
36
+
37
+ @classmethod
38
+ def write_session_summary(
39
+ cls,
40
+ session_id: str,
41
+ ai_type: str,
42
+ prompt: str,
43
+ summary: str,
44
+ files_modified: Optional[list[str]] = None,
45
+ commands_run: Optional[list[str]] = None,
46
+ ) -> None:
47
+ data = {
48
+ "session_id": session_id,
49
+ "ai_type": ai_type,
50
+ "prompt": prompt,
51
+ "summary": summary,
52
+ "files_modified": files_modified or [],
53
+ "commands_run": commands_run or [],
54
+ "timestamp": datetime.now().isoformat(),
55
+ }
56
+
57
+ path = cls.get_session_file(session_id)
58
+ cls.SESSION_DIR.mkdir(parents=True, exist_ok=True)
59
+ with open(path, "w", encoding="utf-8") as f:
60
+ json.dump(data, f, indent=2, ensure_ascii=False)
61
+
62
+ async def collect(self) -> list[CollectorEvent]:
63
+ events = []
64
+
65
+ if not self.SESSION_DIR.exists():
66
+ return events
67
+
68
+ for session_file in self.SESSION_DIR.glob("*.json"):
69
+ if str(session_file) in self._processed_files:
70
+ continue
71
+
72
+ try:
73
+ with open(session_file, "r", encoding="utf-8") as f:
74
+ data = json.load(f)
75
+
76
+ event = CollectorEvent(
77
+ source=self.source,
78
+ performed_by=f"{data.get('ai_type', 'unknown')} ({self._username})",
79
+ performed_at=datetime.fromisoformat(data["timestamp"]),
80
+ request_text=data.get("prompt", "")[:200],
81
+ context=data.get("summary", "")[:500],
82
+ result_type=WorkLogResultType.AI_RESPONSE,
83
+ result_ref=data.get("session_id", "")[:20],
84
+ result_data={
85
+ "files_modified": data.get("files_modified", []),
86
+ "commands_run": data.get("commands_run", []),
87
+ },
88
+ tags=[data.get("ai_type", "ai")],
89
+ )
90
+ events.append(event)
91
+ self._emit(event)
92
+ self._processed_files.add(str(session_file))
93
+ except Exception:
94
+ pass
95
+
96
+ return events
97
+
98
+ async def start(self) -> None:
99
+ self._running = True
100
+ self._task = asyncio.create_task(self._poll_loop())
101
+
102
+ async def stop(self) -> None:
103
+ self._running = False
104
+ if self._task:
105
+ self._task.cancel()
106
+ try:
107
+ await self._task
108
+ except asyncio.CancelledError:
109
+ pass
110
+ self._task = None
111
+
112
+ async def _poll_loop(self) -> None:
113
+ while self._running:
114
+ try:
115
+ await self.collect()
116
+ except Exception:
117
+ pass
118
+ await asyncio.sleep(self.poll_interval)
@@ -0,0 +1,73 @@
1
+ """Base collector interface and common utilities."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from typing import Optional, Callable, Any
7
+ import uuid
8
+
9
+ from ..models.work_log import WorkLog, WorkLogSource
10
+
11
+
12
+ @dataclass
13
+ class CollectorEvent:
14
+ source: WorkLogSource
15
+ performed_by: str
16
+ performed_at: datetime
17
+ request_text: Optional[str] = None
18
+ result_type: str = "none"
19
+ result_ref: Optional[str] = None
20
+ result_data: Optional[dict] = None
21
+ context: Optional[str] = None
22
+ tags: Optional[list[str]] = None
23
+ project_path: Optional[str] = None
24
+ node_id: Optional[str] = None
25
+ category: Optional[str] = None
26
+
27
+ def to_work_log(self) -> WorkLog:
28
+ return WorkLog(
29
+ id=str(uuid.uuid4()),
30
+ source=self.source,
31
+ request_text=self.request_text,
32
+ request_by=None,
33
+ performed_by=self.performed_by,
34
+ performed_at=self.performed_at,
35
+ result_type=self.result_type,
36
+ result_ref=self.result_ref,
37
+ result_data=self.result_data,
38
+ context=self.context,
39
+ tags=self.tags or [],
40
+ project_path=self.project_path,
41
+ node_id=self.node_id,
42
+ category=self.category,
43
+ )
44
+
45
+
46
+ class BaseCollector(ABC):
47
+ def __init__(self, callback: Optional[Callable[[CollectorEvent], None]] = None):
48
+ self.callback = callback
49
+ self._running = False
50
+
51
+ @property
52
+ @abstractmethod
53
+ def source(self) -> WorkLogSource:
54
+ pass
55
+
56
+ @abstractmethod
57
+ async def collect(self) -> list[CollectorEvent]:
58
+ pass
59
+
60
+ @abstractmethod
61
+ async def start(self) -> None:
62
+ pass
63
+
64
+ @abstractmethod
65
+ async def stop(self) -> None:
66
+ pass
67
+
68
+ def _emit(self, event: CollectorEvent) -> None:
69
+ if self.callback:
70
+ self.callback(event)
71
+
72
+ def is_running(self) -> bool:
73
+ return self._running
@@ -0,0 +1,94 @@
1
+ """Collector daemon - manages all collectors and stores events."""
2
+
3
+ import asyncio
4
+ from typing import Optional, Callable, Any
5
+ from pathlib import Path
6
+
7
+ from .base import BaseCollector, CollectorEvent
8
+ from .git_collector import GitCollector
9
+ from .shell_collector import ShellCollector
10
+ from .hits_action_collector import HitsActionCollector
11
+ from .ai_session_collector import AISessionCollector
12
+ from ..storage.base import BaseStorage
13
+ from ..storage.file_store import FileStorage
14
+
15
+
16
+ class CollectorDaemon:
17
+ def __init__(
18
+ self,
19
+ storage: Optional[BaseStorage] = None,
20
+ project_paths: Optional[list[str]] = None,
21
+ on_event: Optional[Callable[[CollectorEvent], None]] = None,
22
+ ):
23
+ self.storage = storage or FileStorage()
24
+ self.project_paths = project_paths or []
25
+ self.on_event = on_event
26
+
27
+ self._collectors: list[BaseCollector] = []
28
+ self._hits_collector: Optional[HitsActionCollector] = None
29
+ self._running = False
30
+
31
+ def _handle_event(self, event: CollectorEvent) -> None:
32
+ asyncio.create_task(self._save_event(event))
33
+
34
+ if self.on_event:
35
+ self.on_event(event)
36
+
37
+ async def _save_event(self, event: CollectorEvent) -> None:
38
+ try:
39
+ work_log = event.to_work_log()
40
+ await self.storage.save_work_log(work_log)
41
+ except Exception:
42
+ pass
43
+
44
+ def setup(self) -> None:
45
+ for path in self.project_paths:
46
+ if Path(path).exists():
47
+ git_collector = GitCollector(
48
+ project_path=path,
49
+ callback=self._handle_event,
50
+ )
51
+ self._collectors.append(git_collector)
52
+
53
+ shell_collector = ShellCollector(callback=self._handle_event)
54
+ self._collectors.append(shell_collector)
55
+
56
+ ai_collector = AISessionCollector(callback=self._handle_event)
57
+ self._collectors.append(ai_collector)
58
+
59
+ self._hits_collector = HitsActionCollector(callback=self._handle_event)
60
+ self._collectors.append(self._hits_collector)
61
+
62
+ async def start(self) -> None:
63
+ if self._running:
64
+ return
65
+
66
+ self._running = True
67
+
68
+ for collector in self._collectors:
69
+ try:
70
+ await collector.start()
71
+ except Exception:
72
+ pass
73
+
74
+ async def stop(self) -> None:
75
+ self._running = False
76
+
77
+ for collector in self._collectors:
78
+ try:
79
+ await collector.stop()
80
+ except Exception:
81
+ pass
82
+
83
+ def get_hits_collector(self) -> Optional[HitsActionCollector]:
84
+ return self._hits_collector
85
+
86
+ def is_running(self) -> bool:
87
+ return self._running
88
+
89
+ def get_collector_stats(self) -> dict:
90
+ return {
91
+ "total_collectors": len(self._collectors),
92
+ "running_collectors": sum(1 for c in self._collectors if c.is_running()),
93
+ "collector_types": [c.__class__.__name__ for c in self._collectors],
94
+ }
@@ -0,0 +1,177 @@
1
+ """Git commit collector."""
2
+
3
+ import asyncio
4
+ import subprocess
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Optional, Callable
8
+
9
+ from .base import BaseCollector, CollectorEvent
10
+ from ..models.work_log import WorkLogSource, WorkLogResultType
11
+
12
+
13
+ class GitCollector(BaseCollector):
14
+ def __init__(
15
+ self,
16
+ project_path: str,
17
+ callback: Optional[Callable[[CollectorEvent], None]] = None,
18
+ poll_interval: int = 300,
19
+ ):
20
+ super().__init__(callback)
21
+ self.project_path = Path(project_path)
22
+ self.poll_interval = poll_interval
23
+ self._last_commit_hash: Optional[str] = None
24
+ self._task: Optional[asyncio.Task] = None
25
+
26
+ @property
27
+ def source(self) -> WorkLogSource:
28
+ return WorkLogSource.GIT
29
+
30
+ def _run_git(self, *args: str) -> Optional[str]:
31
+ try:
32
+ result = subprocess.run(
33
+ ["git"] + list(args),
34
+ cwd=self.project_path,
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=30,
38
+ )
39
+ if result.returncode == 0:
40
+ return result.stdout.strip()
41
+ except Exception:
42
+ pass
43
+ return None
44
+
45
+ def _get_current_user(self) -> str:
46
+ name = self._run_git("config", "user.name") or "unknown"
47
+ email = self._run_git("config", "user.email") or ""
48
+ if email:
49
+ return f"{name} <{email}>"
50
+ return name
51
+
52
+ def _get_last_commit_hash(self) -> Optional[str]:
53
+ return self._run_git("rev-parse", "HEAD")
54
+
55
+ def _parse_commits(self, since_hash: Optional[str] = None) -> list[dict]:
56
+ format_str = "%H|%an|%ae|%at|%s"
57
+ cmd = ["log", f"--format={format_str}"]
58
+
59
+ if since_hash:
60
+ cmd.append(f"{since_hash}..HEAD")
61
+ else:
62
+ cmd.extend(["-n", "50"])
63
+
64
+ output = self._run_git(*cmd)
65
+ if not output:
66
+ return []
67
+
68
+ commits = []
69
+ for line in output.split("\n"):
70
+ if not line.strip():
71
+ continue
72
+ parts = line.split("|", 4)
73
+ if len(parts) == 5:
74
+ commits.append({
75
+ "hash": parts[0],
76
+ "author": parts[1],
77
+ "email": parts[2],
78
+ "timestamp": int(parts[3]),
79
+ "message": parts[4],
80
+ })
81
+ return commits
82
+
83
+ def _get_changed_files(self, commit_hash: str) -> list[str]:
84
+ output = self._run_git("diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash)
85
+ if output:
86
+ return output.split("\n")
87
+ return []
88
+
89
+ async def collect(self) -> list[CollectorEvent]:
90
+ events = []
91
+
92
+ current_hash = self._get_last_commit_hash()
93
+ if not current_hash:
94
+ return events
95
+
96
+ commits = self._parse_commits(self._last_commit_hash)
97
+
98
+ for commit in commits:
99
+ files = self._get_changed_files(commit["hash"])
100
+ author = f"{commit['author']} <{commit['email']}>"
101
+
102
+ event = CollectorEvent(
103
+ source=self.source,
104
+ performed_by=author,
105
+ performed_at=datetime.fromtimestamp(commit["timestamp"]),
106
+ request_text=commit["message"],
107
+ result_type=WorkLogResultType.COMMIT,
108
+ result_ref=commit["hash"][:8],
109
+ result_data={
110
+ "full_hash": commit["hash"],
111
+ "files": files[:20],
112
+ "file_count": len(files),
113
+ },
114
+ tags=self._extract_tags(commit["message"], files),
115
+ project_path=str(self.project_path),
116
+ )
117
+ events.append(event)
118
+ self._emit(event)
119
+
120
+ if commits:
121
+ self._last_commit_hash = current_hash
122
+
123
+ return events
124
+
125
+ def _extract_tags(self, message: str, files: list[str]) -> list[str]:
126
+ tags = []
127
+
128
+ keywords = {
129
+ "fix": "bug",
130
+ "bug": "bug",
131
+ "feat": "feature",
132
+ "feature": "feature",
133
+ "add": "feature",
134
+ "refactor": "refactor",
135
+ "test": "test",
136
+ "doc": "docs",
137
+ "docs": "docs",
138
+ "style": "style",
139
+ "chore": "chore",
140
+ "perf": "performance",
141
+ "ci": "ci",
142
+ }
143
+
144
+ msg_lower = message.lower()
145
+ for keyword, tag in keywords.items():
146
+ if keyword in msg_lower:
147
+ tags.append(tag)
148
+
149
+ for file in files[:5]:
150
+ parts = file.split("/")
151
+ if len(parts) > 1:
152
+ tags.append(parts[0])
153
+
154
+ return list(set(tags))[:5]
155
+
156
+ async def start(self) -> None:
157
+ self._running = True
158
+ self._last_commit_hash = self._get_last_commit_hash()
159
+ self._task = asyncio.create_task(self._poll_loop())
160
+
161
+ async def stop(self) -> None:
162
+ self._running = False
163
+ if self._task:
164
+ self._task.cancel()
165
+ try:
166
+ await self._task
167
+ except asyncio.CancelledError:
168
+ pass
169
+ self._task = None
170
+
171
+ async def _poll_loop(self) -> None:
172
+ while self._running:
173
+ try:
174
+ await self.collect()
175
+ except Exception:
176
+ pass
177
+ await asyncio.sleep(self.poll_interval)
@@ -0,0 +1,110 @@
1
+ """HITS internal action collector - records actions taken within HITS UI."""
2
+
3
+ from datetime import datetime
4
+ from typing import Optional, Callable, Any
5
+ import getpass
6
+
7
+ from .base import BaseCollector, CollectorEvent
8
+ from ..models.work_log import WorkLogSource, WorkLogResultType
9
+
10
+
11
+ class HitsActionCollector(BaseCollector):
12
+ def __init__(
13
+ self,
14
+ callback: Optional[Callable[[CollectorEvent], None]] = None,
15
+ username: Optional[str] = None,
16
+ ):
17
+ super().__init__(callback)
18
+ self._username = username or getpass.getuser()
19
+
20
+ @property
21
+ def source(self) -> WorkLogSource:
22
+ return WorkLogSource.LINK_CLICK
23
+
24
+ async def collect(self) -> list[CollectorEvent]:
25
+ return []
26
+
27
+ async def start(self) -> None:
28
+ self._running = True
29
+
30
+ async def stop(self) -> None:
31
+ self._running = False
32
+
33
+ def record_link_click(
34
+ self,
35
+ url: str,
36
+ title: Optional[str] = None,
37
+ category: Optional[str] = None,
38
+ node_id: Optional[str] = None,
39
+ tags: Optional[list[str]] = None,
40
+ ) -> CollectorEvent:
41
+ event = CollectorEvent(
42
+ source=WorkLogSource.LINK_CLICK,
43
+ performed_by=self._username,
44
+ performed_at=datetime.now(),
45
+ request_text=title,
46
+ result_type=WorkLogResultType.URL,
47
+ result_ref=url[:100],
48
+ result_data={"url": url, "title": title},
49
+ tags=tags or [],
50
+ category=category,
51
+ node_id=node_id,
52
+ )
53
+ self._emit(event)
54
+ return event
55
+
56
+ def record_shell_exec(
57
+ self,
58
+ command: str,
59
+ category: Optional[str] = None,
60
+ node_id: Optional[str] = None,
61
+ tags: Optional[list[str]] = None,
62
+ ) -> CollectorEvent:
63
+ event = CollectorEvent(
64
+ source=WorkLogSource.SHELL_EXEC,
65
+ performed_by=self._username,
66
+ performed_at=datetime.now(),
67
+ request_text=command[:200],
68
+ result_type=WorkLogResultType.COMMAND,
69
+ result_ref=command[:50],
70
+ result_data={"command": command},
71
+ tags=tags or [],
72
+ category=category,
73
+ node_id=node_id,
74
+ )
75
+ self._emit(event)
76
+ return event
77
+
78
+ def record_manual_entry(
79
+ self,
80
+ text: str,
81
+ context: Optional[str] = None,
82
+ tags: Optional[list[str]] = None,
83
+ ) -> CollectorEvent:
84
+ event = CollectorEvent(
85
+ source=WorkLogSource.MANUAL,
86
+ performed_by=self._username,
87
+ performed_at=datetime.now(),
88
+ request_text=text,
89
+ context=context,
90
+ tags=tags or [],
91
+ )
92
+ self._emit(event)
93
+ return event
94
+
95
+ def record_navigation(
96
+ self,
97
+ from_view: str,
98
+ to_view: str,
99
+ query: Optional[str] = None,
100
+ ) -> CollectorEvent:
101
+ event = CollectorEvent(
102
+ source=WorkLogSource.MANUAL,
103
+ performed_by=self._username,
104
+ performed_at=datetime.now(),
105
+ request_text=f"Navigate: {from_view} → {to_view}",
106
+ result_data={"from": from_view, "to": to_view, "query": query},
107
+ tags=["navigation"],
108
+ )
109
+ self._emit(event)
110
+ return event