@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,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
|