@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,56 @@
1
+ """Node entity model for knowledge tree."""
2
+
3
+ from enum import Enum
4
+ from typing import Optional
5
+ from datetime import datetime
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class NodeLayer(str, Enum):
10
+ WHY = "why"
11
+ HOW = "how"
12
+ WHAT = "what"
13
+
14
+
15
+ class NodeType(str, Enum):
16
+ STANDARD = "standard"
17
+ NEGATIVE_PATH = "negative_path"
18
+ DECISION = "decision"
19
+ ACTION = "action"
20
+
21
+
22
+ class Node(BaseModel):
23
+ id: str = Field(..., description="Unique node identifier")
24
+ layer: NodeLayer = Field(..., description="Tree layer (why/how/what)")
25
+ title: str = Field(..., description="Node display title")
26
+ description: Optional[str] = Field(default=None, description="Detailed description")
27
+ node_type: NodeType = Field(default=NodeType.STANDARD, description="Node type")
28
+
29
+ parent_id: Optional[str] = Field(default=None, description="Parent node ID")
30
+ children_ids: list[str] = Field(default_factory=list, description="Child node IDs")
31
+
32
+ action: Optional[str] = Field(default=None, description="Executable action (URL, command, etc.)")
33
+ action_type: Optional[str] = Field(default=None, description="Action type: url, shell, app")
34
+
35
+ metadata: dict = Field(default_factory=dict, description="Additional metadata")
36
+ tokens_saved: int = Field(default=0, description="Estimated tokens saved by compression")
37
+
38
+ created_at: datetime = Field(default_factory=datetime.now)
39
+ updated_at: datetime = Field(default_factory=datetime.now)
40
+
41
+ class Config:
42
+ use_enum_values = True
43
+
44
+ def is_root(self) -> bool:
45
+ return self.parent_id is None
46
+
47
+ def is_negative_path(self) -> bool:
48
+ return self.node_type == NodeType.NEGATIVE_PATH
49
+
50
+ def add_child(self, child_id: str) -> None:
51
+ if child_id not in self.children_ids:
52
+ self.children_ids.append(child_id)
53
+
54
+ def remove_child(self, child_id: str) -> None:
55
+ if child_id in self.children_ids:
56
+ self.children_ids.remove(child_id)
@@ -0,0 +1,68 @@
1
+ """Knowledge tree structure with Why-How-What hierarchy."""
2
+
3
+ from typing import Optional
4
+ from datetime import datetime
5
+ from pydantic import BaseModel, Field
6
+ from .node import Node, NodeLayer
7
+
8
+
9
+ class KnowledgeTree(BaseModel):
10
+ id: str = Field(..., description="Tree unique identifier")
11
+ name: str = Field(..., description="Tree name")
12
+ description: Optional[str] = Field(default=None, description="Tree description")
13
+
14
+ root_ids: list[str] = Field(default_factory=list, description="Root node IDs (WHY layer)")
15
+ nodes: dict[str, Node] = Field(default_factory=dict, description="All nodes by ID")
16
+
17
+ created_at: datetime = Field(default_factory=datetime.now)
18
+ updated_at: datetime = Field(default_factory=datetime.now)
19
+
20
+ def add_node(self, node: Node) -> None:
21
+ self.nodes[node.id] = node
22
+ if node.is_root() and node.id not in self.root_ids:
23
+ self.root_ids.append(node.id)
24
+ elif node.parent_id and node.parent_id in self.nodes:
25
+ self.nodes[node.parent_id].add_child(node.id)
26
+
27
+ def remove_node(self, node_id: str) -> Optional[Node]:
28
+ if node_id not in self.nodes:
29
+ return None
30
+
31
+ node = self.nodes.pop(node_id)
32
+
33
+ if node_id in self.root_ids:
34
+ self.root_ids.remove(node_id)
35
+
36
+ if node.parent_id and node.parent_id in self.nodes:
37
+ self.nodes[node.parent_id].remove_child(node_id)
38
+
39
+ for child_id in node.children_ids[:]:
40
+ self.remove_node(child_id)
41
+
42
+ return node
43
+
44
+ def get_node(self, node_id: str) -> Optional[Node]:
45
+ return self.nodes.get(node_id)
46
+
47
+ def get_children(self, node_id: str) -> list[Node]:
48
+ node = self.get_node(node_id)
49
+ if not node:
50
+ return []
51
+ return [self.nodes[cid] for cid in node.children_ids if cid in self.nodes]
52
+
53
+ def get_path(self, node_id: str) -> list[Node]:
54
+ path = []
55
+ current = self.get_node(node_id)
56
+ while current:
57
+ path.insert(0, current)
58
+ current = self.get_node(current.parent_id) if current.parent_id else None
59
+ return path
60
+
61
+ def get_nodes_by_layer(self, layer: NodeLayer) -> list[Node]:
62
+ return [n for n in self.nodes.values() if n.layer == layer]
63
+
64
+ def get_negative_paths(self) -> list[Node]:
65
+ return [n for n in self.nodes.values() if n.is_negative_path()]
66
+
67
+ def total_tokens_saved(self) -> int:
68
+ return sum(n.tokens_saved for n in self.nodes.values())
@@ -0,0 +1,64 @@
1
+ """Work log model for recording user activities."""
2
+
3
+ from enum import Enum
4
+ from typing import Optional
5
+ from datetime import datetime
6
+ from pydantic import BaseModel, Field, ConfigDict
7
+
8
+
9
+ class WorkLogSource(str, Enum):
10
+ GIT = "git"
11
+ SHELL = "shell"
12
+ LINK_CLICK = "link_click"
13
+ SHELL_EXEC = "shell_exec"
14
+ AI_SESSION = "ai_session"
15
+ MANUAL = "manual"
16
+ BROWSER_HISTORY = "browser_history"
17
+
18
+
19
+ class WorkLogResultType(str, Enum):
20
+ COMMIT = "commit"
21
+ PR = "pr"
22
+ FILE = "file"
23
+ URL = "url"
24
+ COMMAND = "command"
25
+ NONE = "none"
26
+ AI_RESPONSE = "ai_response"
27
+
28
+
29
+ class WorkLog(BaseModel):
30
+ model_config = ConfigDict(use_enum_values=True)
31
+
32
+ id: str = Field(..., description="Unique log identifier")
33
+
34
+ source: WorkLogSource = Field(..., description="Where this log came from")
35
+
36
+ request_text: Optional[str] = Field(default=None, description="Original request/prompt")
37
+ request_by: Optional[str] = Field(default=None, description="Who made the request")
38
+
39
+ performed_by: str = Field(..., description="Who/what performed the action")
40
+ performed_at: datetime = Field(default_factory=datetime.now)
41
+
42
+ result_type: WorkLogResultType = Field(default=WorkLogResultType.NONE)
43
+ result_ref: Optional[str] = Field(default=None, description="Reference to result (commit hash, URL, etc)")
44
+ result_data: Optional[dict] = Field(default=None, description="Additional result metadata")
45
+
46
+ context: Optional[str] = Field(default=None, description="Why this action was taken")
47
+ tags: list[str] = Field(default_factory=list, description="Tags for search")
48
+
49
+ project_path: Optional[str] = Field(default=None, description="Project directory if applicable")
50
+
51
+ node_id: Optional[str] = Field(default=None, description="Linked knowledge node ID")
52
+ category: Optional[str] = Field(default=None, description="Category name from HITS UI")
53
+
54
+ created_at: datetime = Field(default_factory=datetime.now)
55
+
56
+ def has_result(self) -> bool:
57
+ return self.result_type != WorkLogResultType.NONE and self.result_ref is not None
58
+
59
+ def get_summary(self) -> str:
60
+ if self.request_text:
61
+ return self.request_text[:100]
62
+ if self.result_ref:
63
+ return f"[{self.result_type}] {self.result_ref[:50]}"
64
+ return f"[{self.source}] {self.performed_by}"
@@ -0,0 +1,92 @@
1
+ """Workflow model for causal relationships between nodes."""
2
+
3
+ from enum import Enum
4
+ from typing import Optional
5
+ from datetime import datetime
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class StepType(str, Enum):
10
+ TRIGGER = "trigger"
11
+ DECISION = "decision"
12
+ ACTION = "action"
13
+ OUTCOME = "outcome"
14
+ FAILURE = "failure"
15
+
16
+
17
+ class WorkflowStep(BaseModel):
18
+ id: str = Field(..., description="Step unique identifier")
19
+ name: str = Field(..., description="Step name")
20
+ step_type: StepType = Field(default=StepType.ACTION, description="Step type")
21
+ node_id: Optional[str] = Field(default=None, description="Linked knowledge node ID")
22
+ description: Optional[str] = Field(default=None, description="Step description")
23
+
24
+ next_steps: list[str] = Field(default_factory=list, description="Next step IDs")
25
+ condition: Optional[str] = Field(default=None, description="Condition for branching")
26
+
27
+ estimated_tokens: int = Field(default=0, description="Estimated tokens for this step")
28
+
29
+ class Config:
30
+ use_enum_values = True
31
+
32
+
33
+ class Workflow(BaseModel):
34
+ id: str = Field(..., description="Workflow unique identifier")
35
+ name: str = Field(..., description="Workflow name")
36
+ description: Optional[str] = Field(default=None, description="Workflow description")
37
+
38
+ entry_step_id: Optional[str] = Field(default=None, description="Entry point step ID")
39
+ steps: dict[str, WorkflowStep] = Field(default_factory=dict, description="All steps by ID")
40
+
41
+ created_at: datetime = Field(default_factory=datetime.now)
42
+ updated_at: datetime = Field(default_factory=datetime.now)
43
+
44
+ def add_step(self, step: WorkflowStep, is_entry: bool = False) -> None:
45
+ self.steps[step.id] = step
46
+ if is_entry or not self.entry_step_id:
47
+ self.entry_step_id = step.id
48
+
49
+ def remove_step(self, step_id: str) -> Optional[WorkflowStep]:
50
+ if step_id not in self.steps:
51
+ return None
52
+
53
+ step = self.steps.pop(step_id)
54
+
55
+ if self.entry_step_id == step_id:
56
+ self.entry_step_id = None
57
+
58
+ for s in self.steps.values():
59
+ if step_id in s.next_steps:
60
+ s.next_steps.remove(step_id)
61
+
62
+ return step
63
+
64
+ def get_step(self, step_id: str) -> Optional[WorkflowStep]:
65
+ return self.steps.get(step_id)
66
+
67
+ def get_next_steps(self, step_id: str) -> list[WorkflowStep]:
68
+ step = self.get_step(step_id)
69
+ if not step:
70
+ return []
71
+ return [self.steps[sid] for sid in step.next_steps if sid in self.steps]
72
+
73
+ def get_execution_order(self) -> list[WorkflowStep]:
74
+ if not self.entry_step_id:
75
+ return []
76
+
77
+ visited = set()
78
+ order = []
79
+
80
+ def dfs(step_id: str):
81
+ if step_id in visited or step_id not in self.steps:
82
+ return
83
+ visited.add(step_id)
84
+ order.append(self.steps[step_id])
85
+ for next_id in self.steps[step_id].next_steps:
86
+ dfs(next_id)
87
+
88
+ dfs(self.entry_step_id)
89
+ return order
90
+
91
+ def total_estimated_tokens(self) -> int:
92
+ return sum(s.estimated_tokens for s in self.steps.values())
@@ -0,0 +1,5 @@
1
+ """Platform utilities package."""
2
+
3
+ from .actions import PlatformAction, is_wsl, get_platform_info
4
+
5
+ __all__ = ["PlatformAction", "is_wsl", "get_platform_info"]
@@ -0,0 +1,225 @@
1
+ """Cross-platform utilities without GUI dependencies."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import webbrowser
7
+ from typing import Optional, Tuple
8
+
9
+
10
+ def is_wsl() -> bool:
11
+ """Detect if running under Windows Subsystem for Linux."""
12
+ if sys.platform != "linux":
13
+ return False
14
+
15
+ try:
16
+ with open("/proc/version", "r") as f:
17
+ version = f.read().lower()
18
+ return "microsoft" in version or "wsl" in version
19
+ except Exception:
20
+ return False
21
+
22
+
23
+ def detect_terminal_emulator() -> Optional[str]:
24
+ """Detect available terminal emulator on Linux."""
25
+ terminals = [
26
+ ("wt", "wt.exe"), # Windows Terminal via WSL
27
+ ("gnome-terminal", "gnome-terminal"),
28
+ ("konsole", "konsole"),
29
+ ("xfce4-terminal", "xfce4-terminal"),
30
+ ("xterm", "xterm"),
31
+ ("alacritty", "alacritty"),
32
+ ("kitty", "kitty"),
33
+ ("terminator", "terminator"),
34
+ ("urxvt", "urxvt"),
35
+ ]
36
+
37
+ for name, cmd in terminals:
38
+ if cmd == "wt.exe" and is_wsl():
39
+ try:
40
+ subprocess.run(["which", "wt.exe"], capture_output=True, check=True)
41
+ return "wt"
42
+ except Exception:
43
+ continue
44
+
45
+ try:
46
+ subprocess.run(["which", cmd], capture_output=True, check=True)
47
+ return name
48
+ except Exception:
49
+ continue
50
+
51
+ return None
52
+
53
+
54
+ def get_wsl_windows_path(linux_path: str) -> str:
55
+ """Convert Linux path to Windows path for WSL."""
56
+ if not is_wsl():
57
+ return linux_path
58
+
59
+ try:
60
+ result = subprocess.run(
61
+ ["wslpath", "-w", linux_path],
62
+ capture_output=True,
63
+ text=True,
64
+ check=True
65
+ )
66
+ return result.stdout.strip()
67
+ except Exception:
68
+ return linux_path
69
+
70
+
71
+ class PlatformAction:
72
+ _is_wsl = is_wsl()
73
+ _terminal_cache: Optional[str] = None
74
+
75
+ @classmethod
76
+ def _get_terminal(cls) -> Optional[str]:
77
+ if cls._terminal_cache is None:
78
+ cls._terminal_cache = detect_terminal_emulator()
79
+ return cls._terminal_cache
80
+
81
+ @staticmethod
82
+ def open_url(url: str) -> bool:
83
+ """Open URL in default browser - works on all platforms."""
84
+ try:
85
+ if is_wsl():
86
+ try:
87
+ win_url = get_wsl_windows_path(url) if url.startswith("/") else url
88
+ subprocess.run(["cmd.exe", "/c", "start", win_url], check=True)
89
+ return True
90
+ except Exception:
91
+ pass
92
+
93
+ webbrowser.open(url)
94
+ return True
95
+ except Exception:
96
+ return False
97
+
98
+ @classmethod
99
+ def run_shell(cls, command: str, terminal: bool = True) -> bool:
100
+ """Execute shell command, optionally in a new terminal."""
101
+ try:
102
+ if not terminal:
103
+ subprocess.Popen(command, shell=True)
104
+ return True
105
+
106
+ if sys.platform == "win32":
107
+ return cls._run_shell_windows(command)
108
+ elif sys.platform == "darwin":
109
+ return cls._run_shell_macos(command)
110
+ else:
111
+ return cls._run_shell_linux(command)
112
+ except Exception:
113
+ return False
114
+
115
+ @classmethod
116
+ def _run_shell_windows(cls, command: str) -> bool:
117
+ """Run shell command on Windows."""
118
+ term = os.environ.get("TERM_PROGRAM", "")
119
+
120
+ if term == "vscode" or "WT_SESSION" in os.environ:
121
+ subprocess.Popen(["wt", "cmd", "/k", command])
122
+ else:
123
+ subprocess.Popen(["cmd", "/c", "start", "cmd", "/k", command])
124
+ return True
125
+
126
+ @classmethod
127
+ def _run_shell_macos(cls, command: str) -> bool:
128
+ """Run shell command on macOS."""
129
+ script = f'tell application "Terminal" to do script "{command}"'
130
+ subprocess.Popen(["osascript", "-e", script])
131
+ return True
132
+
133
+ @classmethod
134
+ def _run_shell_linux(cls, command: str) -> bool:
135
+ """Run shell command on Linux (including WSL)."""
136
+ if cls._is_wsl:
137
+ try:
138
+ subprocess.run(["which", "wt.exe"], capture_output=True, check=True)
139
+ subprocess.Popen([
140
+ "wt.exe", "-d", ".",
141
+ "bash", "-c", f"{command}; echo; read -p 'Press Enter to close...'"
142
+ ])
143
+ return True
144
+ except Exception:
145
+ pass
146
+
147
+ terminal = cls._get_terminal()
148
+
149
+ if terminal == "gnome-terminal":
150
+ subprocess.Popen([
151
+ "gnome-terminal", "--", "bash", "-c",
152
+ f"{command}; echo; read -p 'Press Enter to close...'"
153
+ ])
154
+ elif terminal == "konsole":
155
+ subprocess.Popen([
156
+ "konsole", "-e", "bash", "-c",
157
+ f"{command}; echo; read -p 'Press Enter to close...'"
158
+ ])
159
+ elif terminal == "xfce4-terminal":
160
+ subprocess.Popen([
161
+ "xfce4-terminal", "-e", f"bash -c '{command}; echo; read -p \"Press Enter to close...\"'"
162
+ ])
163
+ elif terminal == "alacritty":
164
+ subprocess.Popen([
165
+ "alacritty", "-e", "bash", "-c",
166
+ f"{command}; echo; read -p 'Press Enter to close...'"
167
+ ])
168
+ elif terminal == "kitty":
169
+ subprocess.Popen([
170
+ "kitty", "bash", "-c",
171
+ f"{command}; echo; read -p 'Press Enter to close...'"
172
+ ])
173
+ elif terminal in ("xterm", "urxvt"):
174
+ subprocess.Popen([
175
+ terminal, "-e", "bash", "-c",
176
+ f"{command}; echo; read -p 'Press Enter to close...'"
177
+ ])
178
+ else:
179
+ subprocess.Popen([
180
+ "x-terminal-emulator", "-e", "bash", "-c",
181
+ f"{command}; echo; read -p 'Press Enter to close...'"
182
+ ])
183
+
184
+ return True
185
+
186
+ @classmethod
187
+ def launch_app(cls, app_path: str) -> bool:
188
+ """Launch an application."""
189
+ try:
190
+ if sys.platform == "win32":
191
+ subprocess.Popen(["cmd", "/c", "start", "", app_path], shell=True)
192
+ elif sys.platform == "darwin":
193
+ subprocess.Popen(["open", app_path])
194
+ elif cls._is_wsl:
195
+ win_path = get_wsl_windows_path(app_path)
196
+ subprocess.Popen(["cmd.exe", "/c", "start", "", win_path])
197
+ else:
198
+ subprocess.Popen([app_path])
199
+ return True
200
+ except Exception:
201
+ return False
202
+
203
+ @staticmethod
204
+ def execute(action_type: str, action: str) -> bool:
205
+ """Execute action based on type."""
206
+ action_type = action_type.lower().strip()
207
+
208
+ if action_type == "url":
209
+ return PlatformAction.open_url(action)
210
+ elif action_type == "shell":
211
+ return PlatformAction.run_shell(action)
212
+ elif action_type == "app":
213
+ return PlatformAction.launch_app(action)
214
+ else:
215
+ return False
216
+
217
+
218
+ def get_platform_info() -> dict:
219
+ """Get current platform information."""
220
+ return {
221
+ "system": sys.platform,
222
+ "is_wsl": is_wsl(),
223
+ "terminal": detect_terminal_emulator(),
224
+ "python": sys.version,
225
+ }
@@ -0,0 +1,6 @@
1
+ """Business services for tree and workflow management."""
2
+
3
+ from .tree_service import TreeService
4
+ from .knowledge_service import KnowledgeService, KnowledgeNode, KnowledgeCategory
5
+
6
+ __all__ = ["TreeService", "KnowledgeService", "KnowledgeNode", "KnowledgeCategory"]