@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,117 @@
1
+ """Handover API routes - project-scoped session handover endpoints.
2
+
3
+ These endpoints enable AI tools to:
4
+ 1. Get a structured handover summary for a specific project
5
+ 2. List all projects with activity
6
+ 3. Get project statistics
7
+
8
+ Typical flow:
9
+ Session ending → POST /api/work-log (record work)
10
+ Session starting → GET /api/handover?project_path=... (get context)
11
+ """
12
+
13
+ from typing import Any, Optional
14
+ from pathlib import Path
15
+
16
+ from fastapi import APIRouter, Query
17
+ from pydantic import BaseModel
18
+
19
+ from hits_core.service.handover_service import HandoverService
20
+
21
+
22
+ router = APIRouter()
23
+
24
+ _service: Optional[HandoverService] = None
25
+
26
+
27
+ def get_service() -> HandoverService:
28
+ global _service
29
+ if _service is None:
30
+ _service = HandoverService()
31
+ return _service
32
+
33
+
34
+ class APIResponse(BaseModel):
35
+ success: bool
36
+ data: Optional[Any] = None
37
+ error: Optional[str] = None
38
+
39
+
40
+ @router.get("/handover", response_model=APIResponse)
41
+ async def get_handover(
42
+ project_path: str = Query(..., description="Absolute path to the project directory"),
43
+ format: str = Query("dict", description="Output format: 'dict' (JSON) or 'text' (markdown)"),
44
+ recent_count: int = Query(20, ge=1, le=100, description="Number of recent logs to include"),
45
+ ):
46
+ """Get a handover summary for a specific project.
47
+
48
+ This is the primary endpoint for session continuity. When an AI tool
49
+ starts a new session on a project, it calls this endpoint to get
50
+ all relevant context from previous sessions.
51
+
52
+ The project_path is used to scope all data to the specific project,
53
+ so different projects get completely independent handovers.
54
+ """
55
+ service = get_service()
56
+
57
+ # Validate path exists
58
+ if not Path(project_path).exists():
59
+ return APIResponse(
60
+ success=False,
61
+ error=f"Project path does not exist: {project_path}",
62
+ )
63
+
64
+ try:
65
+ summary = await service.get_handover(
66
+ project_path=project_path,
67
+ recent_count=recent_count,
68
+ )
69
+
70
+ if format == "text":
71
+ return APIResponse(
72
+ success=True,
73
+ data={"text": summary.to_text()},
74
+ )
75
+
76
+ return APIResponse(success=True, data=summary.to_dict())
77
+
78
+ except Exception as e:
79
+ return APIResponse(success=False, error=str(e))
80
+
81
+
82
+ @router.get("/handover/projects", response_model=APIResponse)
83
+ async def list_projects():
84
+ """List all projects that have recorded work logs.
85
+
86
+ Returns project paths with aggregated statistics, sorted by last activity.
87
+ Useful for discovering which projects have accumulated context.
88
+ """
89
+ service = get_service()
90
+
91
+ try:
92
+ projects = await service.list_projects()
93
+ return APIResponse(success=True, data=projects)
94
+ except Exception as e:
95
+ return APIResponse(success=False, error=str(e))
96
+
97
+
98
+ @router.get("/handover/project-stats", response_model=APIResponse)
99
+ async def get_project_stats(
100
+ project_path: str = Query(..., description="Absolute path to the project directory"),
101
+ ):
102
+ """Get aggregated statistics for a specific project.
103
+
104
+ Returns counts, tags, performers, files modified, etc.
105
+ Lighter than full handover - use this for quick project overview.
106
+ """
107
+ service = get_service()
108
+
109
+ try:
110
+ from hits_core.storage.file_store import FileStorage
111
+ storage = FileStorage()
112
+ stats = await storage.get_project_summary(
113
+ str(Path(project_path).resolve())
114
+ )
115
+ return APIResponse(success=True, data=stats)
116
+ except Exception as e:
117
+ return APIResponse(success=False, error=str(e))
@@ -0,0 +1,8 @@
1
+ from fastapi import APIRouter
2
+
3
+ router = APIRouter()
4
+
5
+
6
+ @router.get("/health")
7
+ async def health_check():
8
+ return {"success": True, "data": {"status": "ok"}, "error": None}
@@ -0,0 +1,177 @@
1
+ """Knowledge category API routes.
2
+
3
+ These routes expose the KnowledgeService (config-based categories)
4
+ through the API, enabling the web UI to manage knowledge categories
5
+ and their nodes per-project.
6
+
7
+ Endpoints:
8
+ - GET /api/knowledge/categories → List all categories with nodes
9
+ - POST /api/knowledge/category → Create a new category
10
+ - PUT /api/knowledge/category/{name} → Update a category
11
+ - DELETE /api/knowledge/category/{name} → Delete a category
12
+ - POST /api/knowledge/category/{name}/nodes → Add node
13
+ - PUT /api/knowledge/category/{name}/nodes/{idx} → Update node
14
+ - DELETE /api/knowledge/category/{name}/nodes/{idx} → Delete node
15
+ """
16
+
17
+ from typing import Any, Optional
18
+
19
+ from fastapi import APIRouter, HTTPException, Depends
20
+ from pydantic import BaseModel, Field
21
+
22
+ from hits_core.service.knowledge_service import KnowledgeService, KnowledgeNode
23
+ from hits_core.auth.dependencies import require_auth
24
+
25
+
26
+ router = APIRouter()
27
+
28
+ _service: Optional[KnowledgeService] = None
29
+
30
+
31
+ def get_service() -> KnowledgeService:
32
+ global _service
33
+ if _service is None:
34
+ _service = KnowledgeService()
35
+ return _service
36
+
37
+
38
+ class APIResponse(BaseModel):
39
+ success: bool
40
+ data: Optional[Any] = None
41
+ error: Optional[str] = None
42
+
43
+
44
+ # --- Models ---
45
+
46
+ class CategoryCreate(BaseModel):
47
+ name: str = Field(..., min_length=1, max_length=100)
48
+ icon: str = Field("📁", max_length=4)
49
+
50
+
51
+ class CategoryUpdate(BaseModel):
52
+ name: str = Field(..., min_length=1, max_length=100)
53
+ icon: Optional[str] = Field(None, max_length=4)
54
+
55
+
56
+ class NodeCreate(BaseModel):
57
+ name: str = Field(..., min_length=1, max_length=200)
58
+ layer: str = Field("what", pattern=r"^(why|how|what)$")
59
+ type: str = Field("url", pattern=r"^(url|shell)$")
60
+ action: str = Field("")
61
+ negative_path: bool = Field(False)
62
+
63
+
64
+ class NodeUpdate(BaseModel):
65
+ name: Optional[str] = Field(None, min_length=1, max_length=200)
66
+ layer: Optional[str] = Field(None, pattern=r"^(why|how|what)$")
67
+ type: Optional[str] = Field(None, pattern=r"^(url|shell)$")
68
+ action: Optional[str] = None
69
+ negative_path: Optional[bool] = None
70
+
71
+
72
+ # --- Endpoints ---
73
+
74
+ @router.get("/knowledge/categories", response_model=APIResponse)
75
+ async def list_categories(user: dict = Depends(require_auth)):
76
+ """List all knowledge categories with their nodes."""
77
+ service = get_service()
78
+ categories = service.list_categories()
79
+ return APIResponse(
80
+ success=True,
81
+ data=[cat.to_dict() for cat in categories],
82
+ )
83
+
84
+
85
+ @router.post("/knowledge/category", response_model=APIResponse)
86
+ async def create_category(body: CategoryCreate, user: dict = Depends(require_auth)):
87
+ """Create a new knowledge category."""
88
+ service = get_service()
89
+ cat = service.add_category(body.name, body.icon)
90
+ if cat is None:
91
+ return APIResponse(success=False, error="Category already exists")
92
+ return APIResponse(success=True, data=cat.to_dict())
93
+
94
+
95
+ @router.put("/knowledge/category/{category_name}", response_model=APIResponse)
96
+ async def update_category(
97
+ category_name: str,
98
+ body: CategoryUpdate,
99
+ user: dict = Depends(require_auth),
100
+ ):
101
+ """Update a category's name and/or icon."""
102
+ service = get_service()
103
+ success = service.update_category(category_name, body.name, body.icon)
104
+ if not success:
105
+ return APIResponse(success=False, error="Category not found")
106
+ return APIResponse(success=True, data={"name": body.name})
107
+
108
+
109
+ @router.delete("/knowledge/category/{category_name}", response_model=APIResponse)
110
+ async def delete_category(category_name: str, user: dict = Depends(require_auth)):
111
+ """Delete a category and all its nodes."""
112
+ service = get_service()
113
+ success = service.delete_category(category_name)
114
+ if not success:
115
+ return APIResponse(success=False, error="Category not found")
116
+ return APIResponse(success=True, data={"deleted": category_name})
117
+
118
+
119
+ @router.post("/knowledge/category/{category_name}/nodes", response_model=APIResponse)
120
+ async def add_node(
121
+ category_name: str,
122
+ body: NodeCreate,
123
+ user: dict = Depends(require_auth),
124
+ ):
125
+ """Add a node to a category."""
126
+ service = get_service()
127
+ node = KnowledgeNode(
128
+ name=body.name,
129
+ layer=body.layer,
130
+ type=body.type,
131
+ action=body.action,
132
+ negative_path=body.negative_path,
133
+ )
134
+ success = service.add_node(category_name, node)
135
+ if not success:
136
+ return APIResponse(success=False, error="Category not found")
137
+ return APIResponse(success=True, data=node.to_dict())
138
+
139
+
140
+ @router.put("/knowledge/category/{category_name}/nodes/{node_index}", response_model=APIResponse)
141
+ async def update_node(
142
+ category_name: str,
143
+ node_index: int,
144
+ body: NodeUpdate,
145
+ user: dict = Depends(require_auth),
146
+ ):
147
+ """Update a specific node in a category."""
148
+ service = get_service()
149
+ existing = service.get_node(category_name, node_index)
150
+ if existing is None:
151
+ return APIResponse(success=False, error="Node not found")
152
+
153
+ updated = KnowledgeNode(
154
+ name=body.name or existing.name,
155
+ layer=body.layer or existing.layer,
156
+ type=body.type or existing.type,
157
+ action=body.action if body.action is not None else existing.action,
158
+ negative_path=body.negative_path if body.negative_path is not None else existing.negative_path,
159
+ )
160
+ success = service.update_node(category_name, node_index, updated)
161
+ if not success:
162
+ return APIResponse(success=False, error="Failed to update node")
163
+ return APIResponse(success=True, data=updated.to_dict())
164
+
165
+
166
+ @router.delete("/knowledge/category/{category_name}/nodes/{node_index}", response_model=APIResponse)
167
+ async def delete_node(
168
+ category_name: str,
169
+ node_index: int,
170
+ user: dict = Depends(require_auth),
171
+ ):
172
+ """Delete a node from a category."""
173
+ service = get_service()
174
+ success = service.delete_node(category_name, node_index)
175
+ if not success:
176
+ return APIResponse(success=False, error="Node not found")
177
+ return APIResponse(success=True, data={"deleted_index": node_index})
@@ -0,0 +1,121 @@
1
+ from typing import Any, Optional
2
+ from uuid import uuid4
3
+
4
+ from fastapi import APIRouter, Query, HTTPException
5
+ from pydantic import BaseModel
6
+
7
+ from hits_core.service.tree_service import TreeService
8
+ from hits_core.models.node import Node, NodeLayer, NodeType
9
+
10
+
11
+ router = APIRouter()
12
+
13
+ _service: Optional[TreeService] = None
14
+
15
+
16
+ def get_service() -> TreeService:
17
+ global _service
18
+ if _service is None:
19
+ _service = TreeService()
20
+ return _service
21
+
22
+
23
+ class NodeCreate(BaseModel):
24
+ tree_id: str
25
+ layer: str
26
+ title: str
27
+ description: Optional[str] = None
28
+ node_type: Optional[str] = "standard"
29
+ parent_id: Optional[str] = None
30
+ action: Optional[str] = None
31
+ action_type: Optional[str] = None
32
+ metadata: Optional[dict] = None
33
+
34
+
35
+ class NodeUpdate(BaseModel):
36
+ title: Optional[str] = None
37
+ description: Optional[str] = None
38
+ node_type: Optional[str] = None
39
+ action: Optional[str] = None
40
+ action_type: Optional[str] = None
41
+ metadata: Optional[dict] = None
42
+
43
+
44
+ class APIResponse(BaseModel):
45
+ success: bool
46
+ data: Optional[Any] = None
47
+ error: Optional[str] = None
48
+
49
+
50
+ @router.post("/node", response_model=APIResponse)
51
+ async def create_node(body: NodeCreate):
52
+ service = get_service()
53
+
54
+ tree = await service.get_tree(body.tree_id)
55
+ if tree is None:
56
+ return APIResponse(success=False, error="Tree not found")
57
+
58
+ node = Node(
59
+ id=str(uuid4())[:8],
60
+ layer=NodeLayer(body.layer),
61
+ title=body.title,
62
+ description=body.description,
63
+ node_type=NodeType(body.node_type or "standard"),
64
+ parent_id=body.parent_id,
65
+ action=body.action,
66
+ action_type=body.action_type,
67
+ metadata=body.metadata or {},
68
+ )
69
+
70
+ success = await service.add_node(body.tree_id, node)
71
+ if not success:
72
+ return APIResponse(success=False, error="Failed to add node")
73
+
74
+ return APIResponse(success=True, data=node.model_dump())
75
+
76
+
77
+ @router.put("/node/{node_id}", response_model=APIResponse)
78
+ async def update_node(
79
+ node_id: str,
80
+ tree_id: str = Query(...),
81
+ body: NodeUpdate = None,
82
+ ):
83
+ service = get_service()
84
+
85
+ node = await service.get_node(tree_id, node_id)
86
+ if node is None:
87
+ return APIResponse(success=False, error="Node not found")
88
+
89
+ if body.title is not None:
90
+ node.title = body.title
91
+ if body.description is not None:
92
+ node.description = body.description
93
+ if body.node_type is not None:
94
+ node.node_type = NodeType(body.node_type)
95
+ if body.action is not None:
96
+ node.action = body.action
97
+ if body.action_type is not None:
98
+ node.action_type = body.action_type
99
+ if body.metadata is not None:
100
+ node.metadata = body.metadata
101
+
102
+ tree = await service.get_tree(tree_id)
103
+ if tree:
104
+ tree.nodes[node_id] = node
105
+ await service.save_tree(tree)
106
+
107
+ return APIResponse(success=True, data=node.model_dump())
108
+
109
+
110
+ @router.delete("/node/{node_id}", response_model=APIResponse)
111
+ async def delete_node(
112
+ node_id: str,
113
+ tree_id: str = Query(...),
114
+ ):
115
+ service = get_service()
116
+
117
+ node = await service.remove_node(tree_id, node_id)
118
+ if node is None:
119
+ return APIResponse(success=False, error="Node not found or failed to delete")
120
+
121
+ return APIResponse(success=True, data={"id": node_id})
@@ -0,0 +1,174 @@
1
+ from datetime import datetime
2
+ from typing import Optional, Any
3
+ from uuid import uuid4
4
+
5
+ from fastapi import APIRouter, Query, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ from hits_core.storage.file_store import FileStorage
9
+ from hits_core.models.work_log import WorkLog, WorkLogSource, WorkLogResultType
10
+
11
+
12
+ router = APIRouter()
13
+
14
+ _storage: Optional[FileStorage] = None
15
+
16
+
17
+ def get_storage() -> FileStorage:
18
+ global _storage
19
+ if _storage is None:
20
+ _storage = FileStorage()
21
+ return _storage
22
+
23
+
24
+ class WorkLogCreate(BaseModel):
25
+ source: str
26
+ performed_by: str
27
+ request_text: Optional[str] = None
28
+ request_by: Optional[str] = None
29
+ result_type: Optional[str] = "none"
30
+ result_ref: Optional[str] = None
31
+ result_data: Optional[dict] = None
32
+ context: Optional[str] = None
33
+ tags: Optional[list[str]] = None
34
+ project_path: Optional[str] = None
35
+ node_id: Optional[str] = None
36
+ category: Optional[str] = None
37
+
38
+
39
+ class WorkLogUpdate(BaseModel):
40
+ context: Optional[str] = None
41
+ tags: Optional[list[str]] = None
42
+ category: Optional[str] = None
43
+ node_id: Optional[str] = None
44
+
45
+
46
+ class APIResponse(BaseModel):
47
+ success: bool
48
+ data: Optional[Any] = None
49
+ error: Optional[str] = None
50
+
51
+
52
+ @router.post("/work-log", response_model=APIResponse)
53
+ async def create_work_log(body: WorkLogCreate):
54
+ storage = get_storage()
55
+
56
+ log = WorkLog(
57
+ id=str(uuid4())[:8],
58
+ source=WorkLogSource(body.source),
59
+ performed_by=body.performed_by,
60
+ request_text=body.request_text,
61
+ request_by=body.request_by,
62
+ result_type=WorkLogResultType(body.result_type or "none"),
63
+ result_ref=body.result_ref,
64
+ result_data=body.result_data,
65
+ context=body.context,
66
+ tags=body.tags or [],
67
+ project_path=body.project_path,
68
+ node_id=body.node_id,
69
+ category=body.category,
70
+ )
71
+
72
+ success = await storage.save_work_log(log)
73
+ if not success:
74
+ return APIResponse(success=False, error="Failed to save work log")
75
+
76
+ return APIResponse(success=True, data=log.model_dump())
77
+
78
+
79
+ @router.get("/work-log/{log_id}", response_model=APIResponse)
80
+ async def get_work_log(log_id: str):
81
+ storage = get_storage()
82
+ log = await storage.load_work_log(log_id)
83
+
84
+ if log is None:
85
+ return APIResponse(success=False, error="Work log not found")
86
+
87
+ return APIResponse(success=True, data=log.model_dump())
88
+
89
+
90
+ @router.put("/work-log/{log_id}", response_model=APIResponse)
91
+ async def update_work_log(log_id: str, body: WorkLogUpdate):
92
+ storage = get_storage()
93
+ log = await storage.load_work_log(log_id)
94
+
95
+ if log is None:
96
+ return APIResponse(success=False, error="Work log not found")
97
+
98
+ if body.context is not None:
99
+ log.context = body.context
100
+ if body.tags is not None:
101
+ log.tags = body.tags
102
+ if body.category is not None:
103
+ log.category = body.category
104
+ if body.node_id is not None:
105
+ log.node_id = body.node_id
106
+
107
+ success = await storage.save_work_log(log)
108
+ if not success:
109
+ return APIResponse(success=False, error="Failed to update work log")
110
+
111
+ return APIResponse(success=True, data=log.model_dump())
112
+
113
+
114
+ @router.delete("/work-log/{log_id}", response_model=APIResponse)
115
+ async def delete_work_log(log_id: str):
116
+ storage = get_storage()
117
+ success = await storage.delete_work_log(log_id)
118
+
119
+ if not success:
120
+ return APIResponse(success=False, error="Failed to delete work log")
121
+
122
+ return APIResponse(success=True, data={"id": log_id})
123
+
124
+
125
+ @router.get("/work-logs", response_model=APIResponse)
126
+ async def list_work_logs(
127
+ source: Optional[str] = Query(None),
128
+ performed_by: Optional[str] = Query(None),
129
+ since: Optional[str] = Query(None),
130
+ project_path: Optional[str] = Query(None, description="Filter by project path"),
131
+ limit: int = Query(100, ge=1, le=1000),
132
+ ):
133
+ storage = get_storage()
134
+
135
+ since_dt = None
136
+ if since:
137
+ try:
138
+ since_dt = datetime.fromisoformat(since)
139
+ except ValueError:
140
+ return APIResponse(success=False, error="Invalid since format. Use ISO format.")
141
+
142
+ logs = await storage.list_work_logs(
143
+ source=source,
144
+ performed_by=performed_by,
145
+ since=since_dt,
146
+ project_path=project_path,
147
+ limit=limit,
148
+ )
149
+
150
+ return APIResponse(
151
+ success=True,
152
+ data=[log.model_dump() for log in logs]
153
+ )
154
+
155
+
156
+ @router.get("/work-logs/search", response_model=APIResponse)
157
+ async def search_work_logs(
158
+ q: str = Query(..., description="Search query"),
159
+ project_path: Optional[str] = Query(None, description="Filter by project path"),
160
+ limit: int = Query(50, ge=1, le=200),
161
+ ):
162
+ """Search work logs by keyword, optionally scoped to a project."""
163
+ storage = get_storage()
164
+
165
+ logs = await storage.search_work_logs(
166
+ query=q,
167
+ project_path=project_path,
168
+ limit=limit,
169
+ )
170
+
171
+ return APIResponse(
172
+ success=True,
173
+ data=[log.model_dump() for log in logs]
174
+ )