husk-local 0.1.0__tar.gz
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.
- husk_local-0.1.0/LICENSE +21 -0
- husk_local-0.1.0/PKG-INFO +76 -0
- husk_local-0.1.0/README.md +54 -0
- husk_local-0.1.0/husk/__init__.py +5 -0
- husk_local-0.1.0/husk/ai/__init__.py +1 -0
- husk_local-0.1.0/husk/ai/adapters.py +186 -0
- husk_local-0.1.0/husk/ai/cache.py +106 -0
- husk_local-0.1.0/husk/ai/estimator.py +66 -0
- husk_local-0.1.0/husk/ai/rag.py +276 -0
- husk_local-0.1.0/husk/ai/summarizer.py +137 -0
- husk_local-0.1.0/husk/cli.py +925 -0
- husk_local-0.1.0/husk/config.py +52 -0
- husk_local-0.1.0/husk/crawler.py +82 -0
- husk_local-0.1.0/husk/graph.py +112 -0
- husk_local-0.1.0/husk/history.py +101 -0
- husk_local-0.1.0/husk/parser.py +243 -0
- husk_local-0.1.0/husk_local.egg-info/PKG-INFO +76 -0
- husk_local-0.1.0/husk_local.egg-info/SOURCES.txt +23 -0
- husk_local-0.1.0/husk_local.egg-info/dependency_links.txt +1 -0
- husk_local-0.1.0/husk_local.egg-info/entry_points.txt +2 -0
- husk_local-0.1.0/husk_local.egg-info/requires.txt +10 -0
- husk_local-0.1.0/husk_local.egg-info/top_level.txt +1 -0
- husk_local-0.1.0/pyproject.toml +30 -0
- husk_local-0.1.0/setup.cfg +4 -0
- husk_local-0.1.0/tests/test_husk.py +441 -0
husk_local-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Husk Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: husk-local
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local-first CLI engine for legacy codebases
|
|
5
|
+
Classifier: Programming Language :: Python :: 3
|
|
6
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
7
|
+
Classifier: Operating System :: OS Independent
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: click>=8.0.0
|
|
12
|
+
Requires-Dist: tree-sitter>=0.21.3
|
|
13
|
+
Requires-Dist: tree-sitter-python>=0.21.0
|
|
14
|
+
Requires-Dist: tree-sitter-javascript>=0.21.0
|
|
15
|
+
Requires-Dist: tree-sitter-typescript>=0.21.0
|
|
16
|
+
Requires-Dist: networkx>=3.0
|
|
17
|
+
Requires-Dist: gitpython>=3.1.0
|
|
18
|
+
Requires-Dist: tiktoken>=0.5.0
|
|
19
|
+
Requires-Dist: pyyaml>=6.0
|
|
20
|
+
Requires-Dist: certifi>=2023.7.22
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Husk
|
|
24
|
+
|
|
25
|
+
*Your codebase's past, present, and architecture—laid bare.*
|
|
26
|
+
|
|
27
|
+
Understand legacy codebases quickly and privately with offline static analysis and local LLMs, without uploading code or fighting heavy setups.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## ❓ Why Husk?
|
|
32
|
+
> Because code archaeology should be secure, local, and instant — without SaaS lock-in or platform-specific vector databases.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 🚀 Key Features
|
|
37
|
+
|
|
38
|
+
* **Static Analysis (Tier 1):** Code complexity, git churn, hotspots rankings, dead code detection, and Mermaid dependency graph exports.
|
|
39
|
+
* **Semantic AI (Tier 2):** Hierarchical Map-Reduce documentation generator, token/budget limits, and natural language Q&A index with Git Blame annotations.
|
|
40
|
+
* **Remote Git Scanning:** Provide a Git clone link (`https://github.com/...`) instead of a local directory, and Husk will automatically download and scan it temporarily.
|
|
41
|
+
* **Local Offline AI:** Fully integrates with local Ollama servers (like `llama3`) for offline, zero-cost semantic search and summarization.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 📦 Quick Start
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Set up environment
|
|
49
|
+
python3 -m venv .venv
|
|
50
|
+
source .venv/bin/activate
|
|
51
|
+
pip install -r requirements.txt && pip install -e .
|
|
52
|
+
|
|
53
|
+
# Configure LLM credentials (or select Ollama for local model)
|
|
54
|
+
husk init
|
|
55
|
+
|
|
56
|
+
# Scan a local folder or remote repository link
|
|
57
|
+
husk scan https://github.com/example/project.git --detailed
|
|
58
|
+
|
|
59
|
+
# Generate documentation suite statically or with AI
|
|
60
|
+
husk doc . --with-ai
|
|
61
|
+
|
|
62
|
+
# Query codebase using RAG
|
|
63
|
+
husk ask "how does authentication work?" https://github.com/example/project.git
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 🛠️ CLI Commands
|
|
69
|
+
|
|
70
|
+
* `husk scan [repo_path_or_url] [--detailed]`: Inventory source files and code symbols.
|
|
71
|
+
* `husk graph [repo_path_or_url] [--output path]`: Export visual Mermaid dependency flows.
|
|
72
|
+
* `husk hotspots [repo_path_or_url]`: Rank files by complexity × churn risks.
|
|
73
|
+
* `husk deadcode [repo_path_or_url]`: List unreferenced files (in-degree = 0).
|
|
74
|
+
* `husk init`: Configure LLM credentials (OpenAI, Anthropic, Ollama).
|
|
75
|
+
* `husk doc [repo_path_or_url] [--with-ai]`: Generate structured documentation under `/docs`.
|
|
76
|
+
* `husk ask "query" [repo_path_or_url]`: Ask natural language questions with git blame context.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Husk
|
|
2
|
+
|
|
3
|
+
*Your codebase's past, present, and architecture—laid bare.*
|
|
4
|
+
|
|
5
|
+
Understand legacy codebases quickly and privately with offline static analysis and local LLMs, without uploading code or fighting heavy setups.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## ❓ Why Husk?
|
|
10
|
+
> Because code archaeology should be secure, local, and instant — without SaaS lock-in or platform-specific vector databases.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 🚀 Key Features
|
|
15
|
+
|
|
16
|
+
* **Static Analysis (Tier 1):** Code complexity, git churn, hotspots rankings, dead code detection, and Mermaid dependency graph exports.
|
|
17
|
+
* **Semantic AI (Tier 2):** Hierarchical Map-Reduce documentation generator, token/budget limits, and natural language Q&A index with Git Blame annotations.
|
|
18
|
+
* **Remote Git Scanning:** Provide a Git clone link (`https://github.com/...`) instead of a local directory, and Husk will automatically download and scan it temporarily.
|
|
19
|
+
* **Local Offline AI:** Fully integrates with local Ollama servers (like `llama3`) for offline, zero-cost semantic search and summarization.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 📦 Quick Start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Set up environment
|
|
27
|
+
python3 -m venv .venv
|
|
28
|
+
source .venv/bin/activate
|
|
29
|
+
pip install -r requirements.txt && pip install -e .
|
|
30
|
+
|
|
31
|
+
# Configure LLM credentials (or select Ollama for local model)
|
|
32
|
+
husk init
|
|
33
|
+
|
|
34
|
+
# Scan a local folder or remote repository link
|
|
35
|
+
husk scan https://github.com/example/project.git --detailed
|
|
36
|
+
|
|
37
|
+
# Generate documentation suite statically or with AI
|
|
38
|
+
husk doc . --with-ai
|
|
39
|
+
|
|
40
|
+
# Query codebase using RAG
|
|
41
|
+
husk ask "how does authentication work?" https://github.com/example/project.git
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 🛠️ CLI Commands
|
|
47
|
+
|
|
48
|
+
* `husk scan [repo_path_or_url] [--detailed]`: Inventory source files and code symbols.
|
|
49
|
+
* `husk graph [repo_path_or_url] [--output path]`: Export visual Mermaid dependency flows.
|
|
50
|
+
* `husk hotspots [repo_path_or_url]`: Rank files by complexity × churn risks.
|
|
51
|
+
* `husk deadcode [repo_path_or_url]`: List unreferenced files (in-degree = 0).
|
|
52
|
+
* `husk init`: Configure LLM credentials (OpenAI, Anthropic, Ollama).
|
|
53
|
+
* `husk doc [repo_path_or_url] [--with-ai]`: Generate structured documentation under `/docs`.
|
|
54
|
+
* `husk ask "query" [repo_path_or_url]`: Ask natural language questions with git blame context.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Husk AI Subpackage
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import urllib.request
|
|
4
|
+
import urllib.error
|
|
5
|
+
import ssl
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
|
|
9
|
+
def get_ssl_context():
|
|
10
|
+
"""
|
|
11
|
+
Returns an SSL context. Prefers certifi's bundle if available.
|
|
12
|
+
"""
|
|
13
|
+
try:
|
|
14
|
+
import certifi
|
|
15
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
16
|
+
except Exception:
|
|
17
|
+
try:
|
|
18
|
+
return ssl._create_unverified_context()
|
|
19
|
+
except AttributeError:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
class BaseAdapter(ABC):
|
|
23
|
+
"""
|
|
24
|
+
Abstract Base Class for LLM API Adapters.
|
|
25
|
+
"""
|
|
26
|
+
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
|
27
|
+
self.api_key = api_key
|
|
28
|
+
self.model = model
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
32
|
+
"""
|
|
33
|
+
Sends a request to the LLM provider and returns the text response.
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
class OpenAIAdapter(BaseAdapter):
|
|
38
|
+
"""
|
|
39
|
+
Adapter for OpenAI completions.
|
|
40
|
+
"""
|
|
41
|
+
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
|
42
|
+
# Fallback to environment variable
|
|
43
|
+
key = api_key or os.environ.get("OPENAI_API_KEY")
|
|
44
|
+
mdl = model or "gpt-4o-mini"
|
|
45
|
+
super().__init__(key, mdl)
|
|
46
|
+
|
|
47
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
48
|
+
if not self.api_key:
|
|
49
|
+
raise ValueError("OpenAI API Key is missing. Set OPENAI_API_KEY or configure it in husk.")
|
|
50
|
+
|
|
51
|
+
url = "https://api.openai.com/v1/chat/completions"
|
|
52
|
+
headers = {
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
"Authorization": f"Bearer {self.api_key}"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
messages = []
|
|
58
|
+
if system:
|
|
59
|
+
messages.append({"role": "system", "content": system})
|
|
60
|
+
messages.append({"role": "user", "content": prompt})
|
|
61
|
+
|
|
62
|
+
payload = {
|
|
63
|
+
"model": self.model,
|
|
64
|
+
"messages": messages,
|
|
65
|
+
"temperature": 0.2
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
req = urllib.request.Request(
|
|
69
|
+
url,
|
|
70
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
71
|
+
headers=headers,
|
|
72
|
+
method="POST"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
|
|
77
|
+
res_data = json.loads(response.read().decode("utf-8"))
|
|
78
|
+
return res_data["choices"][0]["message"]["content"]
|
|
79
|
+
except urllib.error.HTTPError as e:
|
|
80
|
+
err_body = e.read().decode("utf-8", errors="ignore")
|
|
81
|
+
raise RuntimeError(f"OpenAI HTTP Error {e.code}: {err_body}")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise RuntimeError(f"Failed to communicate with OpenAI: {e}")
|
|
84
|
+
|
|
85
|
+
class AnthropicAdapter(BaseAdapter):
|
|
86
|
+
"""
|
|
87
|
+
Adapter for Anthropic messages API.
|
|
88
|
+
"""
|
|
89
|
+
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
|
90
|
+
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
91
|
+
mdl = model or "claude-3-5-sonnet-20241022"
|
|
92
|
+
super().__init__(key, mdl)
|
|
93
|
+
|
|
94
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
95
|
+
if not self.api_key:
|
|
96
|
+
raise ValueError("Anthropic API Key is missing. Set ANTHROPIC_API_KEY or configure it in husk.")
|
|
97
|
+
|
|
98
|
+
url = "https://api.anthropic.com/v1/messages"
|
|
99
|
+
headers = {
|
|
100
|
+
"content-type": "application/json",
|
|
101
|
+
"x-api-key": self.api_key,
|
|
102
|
+
"anthropic-version": "2023-06-01"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
payload = {
|
|
106
|
+
"model": self.model,
|
|
107
|
+
"max_tokens": 2048,
|
|
108
|
+
"messages": [{"role": "user", "content": prompt}]
|
|
109
|
+
}
|
|
110
|
+
if system:
|
|
111
|
+
payload["system"] = system
|
|
112
|
+
|
|
113
|
+
req = urllib.request.Request(
|
|
114
|
+
url,
|
|
115
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
116
|
+
headers=headers,
|
|
117
|
+
method="POST"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
|
|
122
|
+
res_data = json.loads(response.read().decode("utf-8"))
|
|
123
|
+
return res_data["content"][0]["text"]
|
|
124
|
+
except urllib.error.HTTPError as e:
|
|
125
|
+
err_body = e.read().decode("utf-8", errors="ignore")
|
|
126
|
+
raise RuntimeError(f"Anthropic HTTP Error {e.code}: {err_body}")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
raise RuntimeError(f"Failed to communicate with Anthropic: {e}")
|
|
129
|
+
|
|
130
|
+
class OllamaAdapter(BaseAdapter):
|
|
131
|
+
"""
|
|
132
|
+
Adapter for local Ollama instances.
|
|
133
|
+
"""
|
|
134
|
+
def __init__(self, host: Optional[str] = None, model: Optional[str] = None):
|
|
135
|
+
# host maps to api_key argument in base
|
|
136
|
+
hst = host or os.environ.get("OLLAMA_HOST") or "http://localhost:11434"
|
|
137
|
+
mdl = model or "llama3"
|
|
138
|
+
super().__init__(hst, mdl)
|
|
139
|
+
|
|
140
|
+
def generate(self, prompt: str, system: str = "") -> str:
|
|
141
|
+
url = f"{self.api_key}/api/generate"
|
|
142
|
+
headers = {
|
|
143
|
+
"Content-Type": "application/json"
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
payload = {
|
|
147
|
+
"model": self.model,
|
|
148
|
+
"prompt": prompt,
|
|
149
|
+
"stream": False
|
|
150
|
+
}
|
|
151
|
+
if system:
|
|
152
|
+
payload["system"] = system
|
|
153
|
+
|
|
154
|
+
req = urllib.request.Request(
|
|
155
|
+
url,
|
|
156
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
157
|
+
headers=headers,
|
|
158
|
+
method="POST"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
with urllib.request.urlopen(req, timeout=300, context=get_ssl_context()) as response:
|
|
163
|
+
res_data = json.loads(response.read().decode("utf-8"))
|
|
164
|
+
return res_data["response"]
|
|
165
|
+
except urllib.error.HTTPError as e:
|
|
166
|
+
err_body = e.read().decode("utf-8", errors="ignore")
|
|
167
|
+
raise RuntimeError(f"Ollama HTTP Error {e.code}: {err_body}")
|
|
168
|
+
except Exception as e:
|
|
169
|
+
import socket
|
|
170
|
+
if isinstance(e, socket.timeout) or "timed out" in str(e).lower():
|
|
171
|
+
raise RuntimeError(f"Ollama request timed out: {e}")
|
|
172
|
+
raise RuntimeError(f"Failed to communicate with Ollama at {self.api_key}: {e}. Ensure Ollama is running.")
|
|
173
|
+
|
|
174
|
+
def get_adapter(provider: str, api_key: Optional[str] = None, model: Optional[str] = None) -> BaseAdapter:
|
|
175
|
+
"""
|
|
176
|
+
Factory function to retrieve LLM adapter based on provider name.
|
|
177
|
+
"""
|
|
178
|
+
prov = provider.lower()
|
|
179
|
+
if prov == "openai":
|
|
180
|
+
return OpenAIAdapter(api_key, model)
|
|
181
|
+
elif prov == "anthropic":
|
|
182
|
+
return AnthropicAdapter(api_key, model)
|
|
183
|
+
elif prov in ("ollama", "local"):
|
|
184
|
+
return OllamaAdapter(api_key, model) # api_key stores host URL for Ollama
|
|
185
|
+
else:
|
|
186
|
+
raise ValueError(f"Unknown LLM provider: {provider}")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Dict, Optional
|
|
5
|
+
|
|
6
|
+
class FileCache:
|
|
7
|
+
"""
|
|
8
|
+
Manages local filesystem caching for file analysis results using content SHA256 hashes.
|
|
9
|
+
Stores data in `.husk/cache.json`.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, repo_path: str):
|
|
12
|
+
self.repo_path = os.path.abspath(repo_path)
|
|
13
|
+
self.husk_dir = os.path.join(self.repo_path, ".husk")
|
|
14
|
+
self.cache_file = os.path.join(self.husk_dir, "cache.json")
|
|
15
|
+
self.cache_data: Dict[str, Dict[str, str]] = {}
|
|
16
|
+
self.load()
|
|
17
|
+
|
|
18
|
+
def load(self):
|
|
19
|
+
"""
|
|
20
|
+
Loads the cache from disk.
|
|
21
|
+
"""
|
|
22
|
+
if os.path.exists(self.cache_file):
|
|
23
|
+
try:
|
|
24
|
+
with open(self.cache_file, "r") as f:
|
|
25
|
+
self.cache_data = json.load(f)
|
|
26
|
+
except Exception:
|
|
27
|
+
self.cache_data = {}
|
|
28
|
+
else:
|
|
29
|
+
self.cache_data = {}
|
|
30
|
+
|
|
31
|
+
def save(self):
|
|
32
|
+
"""
|
|
33
|
+
Saves the cache to disk.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
os.makedirs(self.husk_dir, exist_ok=True)
|
|
37
|
+
with open(self.cache_file, "w") as f:
|
|
38
|
+
json.dump(self.cache_data, f, indent=2)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def compute_sha256(file_path: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Computes the SHA256 hash of a file's content.
|
|
46
|
+
"""
|
|
47
|
+
if not os.path.exists(file_path):
|
|
48
|
+
return ""
|
|
49
|
+
hasher = hashlib.sha256()
|
|
50
|
+
with open(file_path, "rb") as f:
|
|
51
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
52
|
+
hasher.update(chunk)
|
|
53
|
+
return hasher.hexdigest()
|
|
54
|
+
|
|
55
|
+
def get_summary(self, rel_path: str, file_path: str) -> Optional[str]:
|
|
56
|
+
"""
|
|
57
|
+
Retrieves the cached summary for a file if the content hash matches.
|
|
58
|
+
"""
|
|
59
|
+
sha = self.compute_sha256(file_path)
|
|
60
|
+
if not sha:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
entry = self.cache_data.get(rel_path)
|
|
64
|
+
if entry and entry.get("sha256") == sha:
|
|
65
|
+
return entry.get("summary")
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def set_summary(self, rel_path: str, file_path: str, summary: str):
|
|
69
|
+
"""
|
|
70
|
+
Updates the cached summary for a file.
|
|
71
|
+
"""
|
|
72
|
+
sha = self.compute_sha256(file_path)
|
|
73
|
+
if not sha:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
self.cache_data[rel_path] = {
|
|
77
|
+
"sha256": sha,
|
|
78
|
+
"summary": summary
|
|
79
|
+
}
|
|
80
|
+
self.save()
|
|
81
|
+
|
|
82
|
+
def get_dir_summary(self, rel_path: str, combined_hash: str) -> Optional[str]:
|
|
83
|
+
"""
|
|
84
|
+
Retrieves directory summary if the combined hash matches.
|
|
85
|
+
"""
|
|
86
|
+
entry = self.cache_data.get(rel_path)
|
|
87
|
+
if entry and entry.get("sha256") == combined_hash:
|
|
88
|
+
return entry.get("summary")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def set_dir_summary(self, rel_path: str, combined_hash: str, summary: str):
|
|
92
|
+
"""
|
|
93
|
+
Sets directory summary with combined hash.
|
|
94
|
+
"""
|
|
95
|
+
self.cache_data[rel_path] = {
|
|
96
|
+
"sha256": combined_hash,
|
|
97
|
+
"summary": summary
|
|
98
|
+
}
|
|
99
|
+
self.save()
|
|
100
|
+
|
|
101
|
+
def clear(self):
|
|
102
|
+
"""
|
|
103
|
+
Clears the cache data.
|
|
104
|
+
"""
|
|
105
|
+
self.cache_data = {}
|
|
106
|
+
self.save()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import Dict, Tuple
|
|
2
|
+
|
|
3
|
+
# Pricing per 1,000,000 tokens (in USD)
|
|
4
|
+
MODEL_PRICING: Dict[str, Dict[str, float]] = {
|
|
5
|
+
# OpenAI
|
|
6
|
+
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
|
7
|
+
"gpt-4o": {"input": 2.50, "output": 10.00},
|
|
8
|
+
# Anthropic
|
|
9
|
+
"claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00},
|
|
10
|
+
"claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00},
|
|
11
|
+
# Ollama / Local
|
|
12
|
+
"llama3": {"input": 0.0, "output": 0.0},
|
|
13
|
+
"mistral": {"input": 0.0, "output": 0.0},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class TokenEstimator:
|
|
17
|
+
"""
|
|
18
|
+
Handles local token estimation and cost calculation for LLM processing.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, model_name: str):
|
|
21
|
+
self.model_name = model_name
|
|
22
|
+
self.pricing = MODEL_PRICING.get(model_name, {"input": 0.50, "output": 1.50}) # fallback default
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def count_tokens(text: str) -> int:
|
|
26
|
+
"""
|
|
27
|
+
Estimates the token count of a given text.
|
|
28
|
+
Uses tiktoken if installed, otherwise falls back to character approximation.
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
import tiktoken
|
|
32
|
+
# cl100k_base is the standard vocabulary for GPT-4 and Claude tokens are closely aligned
|
|
33
|
+
encoding = tiktoken.get_encoding("cl100k_base")
|
|
34
|
+
return len(encoding.encode(text))
|
|
35
|
+
except ImportError:
|
|
36
|
+
# Fallback approximation: ~4 characters per token
|
|
37
|
+
return len(text) // 4
|
|
38
|
+
|
|
39
|
+
def calculate_cost(self, total_tokens: int, estimated_completion_ratio: float = 0.25) -> Tuple[float, int, int]:
|
|
40
|
+
"""
|
|
41
|
+
Calculates the estimated cost (in USD) for processing total_tokens.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
total_tokens: Number of prompt/input tokens.
|
|
45
|
+
estimated_completion_ratio: Multiplier to estimate completion output tokens.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tuple: (Estimated Cost USD, Input Tokens, Output Tokens)
|
|
49
|
+
"""
|
|
50
|
+
input_tokens = total_tokens
|
|
51
|
+
output_tokens = int(total_tokens * estimated_completion_ratio)
|
|
52
|
+
|
|
53
|
+
input_cost = (input_tokens / 1_000_000) * self.pricing["input"]
|
|
54
|
+
output_cost = (output_tokens / 1_000_000) * self.pricing["output"]
|
|
55
|
+
|
|
56
|
+
total_cost = input_cost + output_cost
|
|
57
|
+
return total_cost, input_tokens, output_tokens
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def format_cost(cost_usd: float) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Formats cost in a user-friendly way.
|
|
63
|
+
"""
|
|
64
|
+
if cost_usd < 0.01:
|
|
65
|
+
return f"${cost_usd:.5f}"
|
|
66
|
+
return f"${cost_usd:.2f}"
|