velune-cli 0.9.0__py3-none-any.whl
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.
- velune/__init__.py +5 -0
- velune/__main__.py +6 -0
- velune/cli/__init__.py +5 -0
- velune/cli/app.py +208 -0
- velune/cli/autocomplete.py +80 -0
- velune/cli/banner.py +60 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +175 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +228 -0
- velune/cli/commands/config.py +224 -0
- velune/cli/commands/daemon.py +88 -0
- velune/cli/commands/doctor.py +721 -0
- velune/cli/commands/init.py +170 -0
- velune/cli/commands/mcp.py +82 -0
- velune/cli/commands/memory.py +293 -0
- velune/cli/commands/models.py +683 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +270 -0
- velune/cli/commands/setup.py +184 -0
- velune/cli/commands/workspace.py +249 -0
- velune/cli/context.py +36 -0
- velune/cli/councilmodel_ui.py +199 -0
- velune/cli/display/council_view.py +254 -0
- velune/cli/display/memory_view.py +126 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +25 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +51 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +123 -0
- velune/cli/registry.py +80 -0
- velune/cli/rendering/__init__.py +5 -0
- velune/cli/rendering/error_panel.py +79 -0
- velune/cli/rendering/markdown.py +63 -0
- velune/cli/repl.py +1855 -0
- velune/cli/session_manager.py +71 -0
- velune/cli/slash_commands.py +37 -0
- velune/cli/theme.py +8 -0
- velune/cognition/__init__.py +23 -0
- velune/cognition/agents/__init__.py +7 -0
- velune/cognition/agents/coder.py +209 -0
- velune/cognition/agents/planner.py +156 -0
- velune/cognition/agents/reviewer.py +195 -0
- velune/cognition/arbitrator.py +220 -0
- velune/cognition/architecture.py +415 -0
- velune/cognition/budget.py +65 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +217 -0
- velune/cognition/council/challenger.py +74 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +43 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +46 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +56 -0
- velune/cognition/council/planner.py +124 -0
- velune/cognition/council/reviewer.py +74 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +188 -0
- velune/cognition/council_orchestrator.py +282 -0
- velune/cognition/firewall.py +354 -0
- velune/cognition/module.py +46 -0
- velune/cognition/orchestrator.py +1205 -0
- velune/cognition/personality.py +238 -0
- velune/cognition/state.py +104 -0
- velune/cognition/style_resolver.py +64 -0
- velune/cognition/verification.py +205 -0
- velune/context/__init__.py +28 -0
- velune/context/assembler.py +240 -0
- velune/context/budget.py +97 -0
- velune/context/extractive.py +95 -0
- velune/context/prompt_adaptation.py +480 -0
- velune/context/sections.py +99 -0
- velune/context/token_counter.py +134 -0
- velune/context/utilization.py +33 -0
- velune/context/window.py +63 -0
- velune/core/__init__.py +89 -0
- velune/core/background.py +5 -0
- velune/core/config/__init__.py +37 -0
- velune/core/errors/__init__.py +90 -0
- velune/core/errors/catalog.py +188 -0
- velune/core/errors/execution.py +31 -0
- velune/core/errors/memory.py +25 -0
- velune/core/errors/orchestration.py +31 -0
- velune/core/errors/provider.py +37 -0
- velune/core/event_loop.py +35 -0
- velune/core/logging.py +83 -0
- velune/core/paths.py +165 -0
- velune/core/runtime.py +113 -0
- velune/core/startup_profiler.py +56 -0
- velune/core/task_registry.py +117 -0
- velune/core/trace.py +83 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +53 -0
- velune/core/types/context.py +42 -0
- velune/core/types/inference.py +38 -0
- velune/core/types/memory.py +42 -0
- velune/core/types/model.py +70 -0
- velune/core/types/provider.py +62 -0
- velune/core/types/repository.py +38 -0
- velune/core/types/task.py +61 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +127 -0
- velune/daemon/transport.py +179 -0
- velune/events.py +204 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +315 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +130 -0
- velune/execution/command_spec.py +165 -0
- velune/execution/diff_preview.py +197 -0
- velune/execution/executor.py +181 -0
- velune/execution/module.py +18 -0
- velune/execution/multi_diff.py +67 -0
- velune/execution/path_guard.py +74 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +89 -0
- velune/execution/sandbox.py +268 -0
- velune/execution/validator.py +115 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +192 -0
- velune/kernel/__init__.py +55 -0
- velune/kernel/bootstrap.py +125 -0
- velune/kernel/config.py +426 -0
- velune/kernel/entrypoint.py +78 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +143 -0
- velune/kernel/module.py +17 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +96 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +115 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +624 -0
- velune/memory/__init__.py +32 -0
- velune/memory/compaction.py +506 -0
- velune/memory/embedding_pipeline.py +241 -0
- velune/memory/lifecycle.py +680 -0
- velune/memory/module.py +218 -0
- velune/memory/prioritizer.py +67 -0
- velune/memory/storage/episodic_schema.sql +53 -0
- velune/memory/storage/lancedb_store.py +282 -0
- velune/memory/storage/sqlite_manager.py +369 -0
- velune/memory/storage/sqlite_pool.py +149 -0
- velune/memory/tiers/episodic.py +588 -0
- velune/memory/tiers/graph.py +378 -0
- velune/memory/tiers/lineage.py +416 -0
- velune/memory/tiers/semantic.py +475 -0
- velune/memory/tiers/working.py +168 -0
- velune/memory/vitality.py +132 -0
- velune/models/__init__.py +15 -0
- velune/models/family.py +76 -0
- velune/models/module.py +20 -0
- velune/models/probes.py +192 -0
- velune/models/profile_cache.py +84 -0
- velune/models/profiler.py +108 -0
- velune/models/registry.py +251 -0
- velune/models/scorer.py +233 -0
- velune/models/specializations.py +205 -0
- velune/orchestration/__init__.py +19 -0
- velune/orchestration/engine.py +239 -0
- velune/orchestration/module.py +15 -0
- velune/orchestration/role_assignments.py +82 -0
- velune/orchestration/schemas.py +98 -0
- velune/plugins/__init__.py +20 -0
- velune/plugins/hooks.py +50 -0
- velune/plugins/loader.py +161 -0
- velune/plugins/registry.py +56 -0
- velune/plugins/schemas.py +21 -0
- velune/providers/__init__.py +23 -0
- velune/providers/adapters/anthropic.py +257 -0
- velune/providers/adapters/fireworks.py +115 -0
- velune/providers/adapters/google.py +234 -0
- velune/providers/adapters/groq.py +151 -0
- velune/providers/adapters/huggingface.py +210 -0
- velune/providers/adapters/llamacpp.py +208 -0
- velune/providers/adapters/lmstudio.py +175 -0
- velune/providers/adapters/ollama.py +233 -0
- velune/providers/adapters/openai.py +213 -0
- velune/providers/adapters/openrouter.py +81 -0
- velune/providers/adapters/together.py +134 -0
- velune/providers/adapters/xai.py +60 -0
- velune/providers/base.py +86 -0
- velune/providers/benchmarker.py +138 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +79 -0
- velune/providers/discovery/benchmarks.py +44 -0
- velune/providers/discovery/classifier.py +69 -0
- velune/providers/discovery/fireworks.py +95 -0
- velune/providers/discovery/gguf.py +88 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +117 -0
- velune/providers/discovery/groq.py +21 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +162 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +115 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/health.py +67 -0
- velune/providers/health_monitor.py +169 -0
- velune/providers/keystore.py +142 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +229 -0
- velune/providers/module.py +51 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +220 -0
- velune/providers/router.py +255 -0
- velune/providers/task_classifier.py +288 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +33 -0
- velune/repository/analyzer.py +127 -0
- velune/repository/ast_parser.py +822 -0
- velune/repository/blast_radius.py +298 -0
- velune/repository/boundary_classifier.py +295 -0
- velune/repository/cognition.py +316 -0
- velune/repository/grapher.py +179 -0
- velune/repository/import_graph.py +263 -0
- velune/repository/incremental_indexer.py +275 -0
- velune/repository/index_state.py +96 -0
- velune/repository/indexer.py +243 -0
- velune/repository/module.py +17 -0
- velune/repository/parser.py +474 -0
- velune/repository/project_type.py +300 -0
- velune/repository/rename_journal.py +287 -0
- velune/repository/scanner.py +193 -0
- velune/repository/schemas.py +102 -0
- velune/repository/symbol_registry.py +365 -0
- velune/repository/tracker.py +252 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/cache.py +110 -0
- velune/retrieval/fast_path.py +391 -0
- velune/retrieval/graph.py +124 -0
- velune/retrieval/hybrid.py +271 -0
- velune/retrieval/keyword.py +131 -0
- velune/retrieval/module.py +26 -0
- velune/retrieval/pipeline.py +303 -0
- velune/retrieval/reranker.py +102 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/slow_path.py +364 -0
- velune/retrieval/vector.py +203 -0
- velune/telemetry/__init__.py +59 -0
- velune/telemetry/cognition.py +267 -0
- velune/telemetry/cost_estimator.py +92 -0
- velune/telemetry/debug.py +304 -0
- velune/telemetry/doctor.py +244 -0
- velune/telemetry/logging.py +286 -0
- velune/telemetry/spans.py +277 -0
- velune/telemetry/token_tracker.py +140 -0
- velune/telemetry/usage_tracker.py +340 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +87 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +116 -0
- velune/tools/code/search.py +123 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +136 -0
- velune/tools/filesystem/write.py +163 -0
- velune/tools/git/history.py +177 -0
- velune/tools/git/operations.py +122 -0
- velune/tools/git/state.py +121 -0
- velune/tools/module.py +81 -0
- velune/tools/terminal/execute.py +72 -0
- velune/tools/terminal/history.py +47 -0
- velune/tools/web/fetch.py +55 -0
- velune/tools/web/validator.py +122 -0
- velune_cli-0.9.0.dist-info/METADATA +518 -0
- velune_cli-0.9.0.dist-info/RECORD +279 -0
- velune_cli-0.9.0.dist-info/WHEEL +4 -0
- velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
- velune_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Token and cost tracking for sessions.
|
|
2
|
+
|
|
3
|
+
Tracks:
|
|
4
|
+
- Token usage per model
|
|
5
|
+
- Estimated costs per session
|
|
6
|
+
- Cross-session analytics
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from threading import Lock
|
|
16
|
+
|
|
17
|
+
import structlog
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger()
|
|
20
|
+
|
|
21
|
+
# Cost estimates per 1M tokens (approximate)
|
|
22
|
+
DEFAULT_COSTS = {
|
|
23
|
+
"claude-opus": {"input": 15.0, "output": 75.0},
|
|
24
|
+
"claude-sonnet": {"input": 3.0, "output": 15.0},
|
|
25
|
+
"claude-haiku": {"input": 0.80, "output": 4.0},
|
|
26
|
+
"gpt-4": {"input": 30.0, "output": 60.0},
|
|
27
|
+
"gpt-3.5-turbo": {"input": 0.50, "output": 1.50},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class UsageRecord:
|
|
33
|
+
"""Single record of model usage."""
|
|
34
|
+
|
|
35
|
+
session_id: str
|
|
36
|
+
timestamp: str
|
|
37
|
+
model: str
|
|
38
|
+
input_tokens: int
|
|
39
|
+
output_tokens: int
|
|
40
|
+
total_tokens: int
|
|
41
|
+
estimated_cost: float | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class UsageSummary:
|
|
46
|
+
"""Summary of usage for a session."""
|
|
47
|
+
|
|
48
|
+
session_id: str
|
|
49
|
+
total_tokens: int
|
|
50
|
+
total_cost: float | None
|
|
51
|
+
total_input_tokens: int
|
|
52
|
+
total_output_tokens: int
|
|
53
|
+
model_breakdown: dict[str, int] # {model: total_tokens}
|
|
54
|
+
record_count: int
|
|
55
|
+
start_time: str
|
|
56
|
+
end_time: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionUsageTracker:
|
|
60
|
+
"""Tracks token and cost usage per session."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
63
|
+
"""Initialize tracker with SQLite database.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
db_path: Path to SQLite database (default: ~/.velune/telemetry/usage.db)
|
|
67
|
+
"""
|
|
68
|
+
if db_path is None:
|
|
69
|
+
db_path = Path.home() / ".velune" / "telemetry" / "usage.db"
|
|
70
|
+
|
|
71
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
self.db_path = db_path
|
|
73
|
+
self._lock = Lock()
|
|
74
|
+
|
|
75
|
+
# Initialize database
|
|
76
|
+
self._init_db()
|
|
77
|
+
|
|
78
|
+
def _init_db(self) -> None:
|
|
79
|
+
"""Initialize SQLite schema."""
|
|
80
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
81
|
+
conn.execute(
|
|
82
|
+
"""
|
|
83
|
+
CREATE TABLE IF NOT EXISTS usage_records (
|
|
84
|
+
id INTEGER PRIMARY KEY,
|
|
85
|
+
session_id TEXT NOT NULL,
|
|
86
|
+
timestamp TEXT NOT NULL,
|
|
87
|
+
model TEXT NOT NULL,
|
|
88
|
+
input_tokens INTEGER NOT NULL,
|
|
89
|
+
output_tokens INTEGER NOT NULL,
|
|
90
|
+
total_tokens INTEGER NOT NULL,
|
|
91
|
+
estimated_cost REAL,
|
|
92
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
93
|
+
)
|
|
94
|
+
"""
|
|
95
|
+
)
|
|
96
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_session_id ON usage_records(session_id)")
|
|
97
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_timestamp ON usage_records(timestamp)")
|
|
98
|
+
conn.commit()
|
|
99
|
+
|
|
100
|
+
def record_completion(
|
|
101
|
+
self,
|
|
102
|
+
session_id: str,
|
|
103
|
+
model: str,
|
|
104
|
+
input_tokens: int,
|
|
105
|
+
output_tokens: int,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Record a model completion.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
session_id: Session identifier
|
|
111
|
+
model: Model name (e.g., "claude-opus")
|
|
112
|
+
input_tokens: Input tokens used
|
|
113
|
+
output_tokens: Output tokens used
|
|
114
|
+
"""
|
|
115
|
+
total_tokens = input_tokens + output_tokens
|
|
116
|
+
estimated_cost = self._estimate_cost(model, input_tokens, output_tokens)
|
|
117
|
+
|
|
118
|
+
record = UsageRecord(
|
|
119
|
+
session_id=session_id,
|
|
120
|
+
timestamp=datetime.utcnow().isoformat(),
|
|
121
|
+
model=model,
|
|
122
|
+
input_tokens=input_tokens,
|
|
123
|
+
output_tokens=output_tokens,
|
|
124
|
+
total_tokens=total_tokens,
|
|
125
|
+
estimated_cost=estimated_cost,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
with self._lock:
|
|
129
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
130
|
+
conn.execute(
|
|
131
|
+
"""
|
|
132
|
+
INSERT INTO usage_records
|
|
133
|
+
(session_id, timestamp, model, input_tokens, output_tokens, total_tokens, estimated_cost)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
135
|
+
""",
|
|
136
|
+
(
|
|
137
|
+
record.session_id,
|
|
138
|
+
record.timestamp,
|
|
139
|
+
record.model,
|
|
140
|
+
record.input_tokens,
|
|
141
|
+
record.output_tokens,
|
|
142
|
+
record.total_tokens,
|
|
143
|
+
record.estimated_cost,
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
conn.commit()
|
|
147
|
+
|
|
148
|
+
logger.debug(
|
|
149
|
+
"Usage recorded",
|
|
150
|
+
session_id=session_id,
|
|
151
|
+
model=model,
|
|
152
|
+
tokens=total_tokens,
|
|
153
|
+
cost=estimated_cost,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def get_session_total_tokens(self, session_id: str) -> int:
|
|
157
|
+
"""Get total tokens used in a session."""
|
|
158
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
159
|
+
cursor = conn.execute(
|
|
160
|
+
"SELECT SUM(total_tokens) FROM usage_records WHERE session_id = ?",
|
|
161
|
+
(session_id,),
|
|
162
|
+
)
|
|
163
|
+
result = cursor.fetchone()
|
|
164
|
+
return result[0] if result and result[0] else 0
|
|
165
|
+
|
|
166
|
+
def get_session_estimated_cost(self, session_id: str) -> float | None:
|
|
167
|
+
"""Get estimated total cost for a session."""
|
|
168
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
169
|
+
cursor = conn.execute(
|
|
170
|
+
"SELECT SUM(estimated_cost) FROM usage_records WHERE session_id = ?",
|
|
171
|
+
(session_id,),
|
|
172
|
+
)
|
|
173
|
+
result = cursor.fetchone()
|
|
174
|
+
return result[0] if result and result[0] else None
|
|
175
|
+
|
|
176
|
+
def get_session_summary(self, session_id: str) -> UsageSummary | None:
|
|
177
|
+
"""Get complete summary for a session."""
|
|
178
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
179
|
+
cursor = conn.execute(
|
|
180
|
+
"""
|
|
181
|
+
SELECT
|
|
182
|
+
SUM(total_tokens) as total_tokens,
|
|
183
|
+
SUM(estimated_cost) as total_cost,
|
|
184
|
+
SUM(input_tokens) as input_tokens,
|
|
185
|
+
SUM(output_tokens) as output_tokens,
|
|
186
|
+
COUNT(*) as record_count,
|
|
187
|
+
MIN(timestamp) as start_time,
|
|
188
|
+
MAX(timestamp) as end_time
|
|
189
|
+
FROM usage_records
|
|
190
|
+
WHERE session_id = ?
|
|
191
|
+
""",
|
|
192
|
+
(session_id,),
|
|
193
|
+
)
|
|
194
|
+
row = cursor.fetchone()
|
|
195
|
+
|
|
196
|
+
if not row or row[0] is None:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
# Get model breakdown
|
|
200
|
+
cursor = conn.execute(
|
|
201
|
+
"""
|
|
202
|
+
SELECT model, SUM(total_tokens)
|
|
203
|
+
FROM usage_records
|
|
204
|
+
WHERE session_id = ?
|
|
205
|
+
GROUP BY model
|
|
206
|
+
""",
|
|
207
|
+
(session_id,),
|
|
208
|
+
)
|
|
209
|
+
model_breakdown = {row[0]: row[1] for row in cursor.fetchall()}
|
|
210
|
+
|
|
211
|
+
return UsageSummary(
|
|
212
|
+
session_id=session_id,
|
|
213
|
+
total_tokens=row[0],
|
|
214
|
+
total_cost=row[1],
|
|
215
|
+
total_input_tokens=row[2],
|
|
216
|
+
total_output_tokens=row[3],
|
|
217
|
+
model_breakdown=model_breakdown,
|
|
218
|
+
record_count=row[4],
|
|
219
|
+
start_time=row[5],
|
|
220
|
+
end_time=row[6],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def get_recent_sessions(self, days: int = 7) -> list[UsageSummary]:
|
|
224
|
+
"""Get summaries for sessions in the last N days."""
|
|
225
|
+
cutoff = datetime.utcnow() - timedelta(days=days)
|
|
226
|
+
|
|
227
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
228
|
+
cursor = conn.execute(
|
|
229
|
+
"""
|
|
230
|
+
SELECT DISTINCT session_id
|
|
231
|
+
FROM usage_records
|
|
232
|
+
WHERE timestamp > ?
|
|
233
|
+
ORDER BY timestamp DESC
|
|
234
|
+
""",
|
|
235
|
+
(cutoff.isoformat(),),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
sessions = [row[0] for row in cursor.fetchall()]
|
|
239
|
+
|
|
240
|
+
summaries = []
|
|
241
|
+
for session_id in sessions:
|
|
242
|
+
summary = self.get_session_summary(session_id)
|
|
243
|
+
if summary:
|
|
244
|
+
summaries.append(summary)
|
|
245
|
+
|
|
246
|
+
return summaries
|
|
247
|
+
|
|
248
|
+
def get_stats_last_n_days(self, days: int = 7) -> dict[str, any]:
|
|
249
|
+
"""Get aggregated stats for last N days."""
|
|
250
|
+
cutoff = datetime.utcnow() - timedelta(days=days)
|
|
251
|
+
|
|
252
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
253
|
+
cursor = conn.execute(
|
|
254
|
+
"""
|
|
255
|
+
SELECT
|
|
256
|
+
COUNT(DISTINCT session_id) as session_count,
|
|
257
|
+
SUM(total_tokens) as total_tokens,
|
|
258
|
+
SUM(estimated_cost) as total_cost,
|
|
259
|
+
COUNT(*) as completion_count
|
|
260
|
+
FROM usage_records
|
|
261
|
+
WHERE timestamp > ?
|
|
262
|
+
""",
|
|
263
|
+
(cutoff.isoformat(),),
|
|
264
|
+
)
|
|
265
|
+
row = cursor.fetchone()
|
|
266
|
+
|
|
267
|
+
cursor = conn.execute(
|
|
268
|
+
"""
|
|
269
|
+
SELECT model, SUM(total_tokens)
|
|
270
|
+
FROM usage_records
|
|
271
|
+
WHERE timestamp > ?
|
|
272
|
+
GROUP BY model
|
|
273
|
+
ORDER BY SUM(total_tokens) DESC
|
|
274
|
+
LIMIT 1
|
|
275
|
+
""",
|
|
276
|
+
(cutoff.isoformat(),),
|
|
277
|
+
)
|
|
278
|
+
most_used = cursor.fetchone()
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
"days": days,
|
|
282
|
+
"session_count": row[0] if row else 0,
|
|
283
|
+
"total_tokens": row[1] if row and row[1] else 0,
|
|
284
|
+
"total_cost": row[2] if row and row[2] else 0,
|
|
285
|
+
"completion_count": row[3] if row else 0,
|
|
286
|
+
"most_used_model": most_used[0] if most_used else None,
|
|
287
|
+
"most_used_model_tokens": most_used[1] if most_used else 0,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def _estimate_cost(
|
|
291
|
+
self,
|
|
292
|
+
model: str,
|
|
293
|
+
input_tokens: int,
|
|
294
|
+
output_tokens: int,
|
|
295
|
+
) -> float | None:
|
|
296
|
+
"""Estimate cost for a completion.
|
|
297
|
+
|
|
298
|
+
Uses DEFAULT_COSTS table. Returns None if model not found.
|
|
299
|
+
"""
|
|
300
|
+
# Normalize model name
|
|
301
|
+
model_lower = model.lower()
|
|
302
|
+
for key in DEFAULT_COSTS:
|
|
303
|
+
if key.lower() in model_lower or model_lower in key.lower():
|
|
304
|
+
costs = DEFAULT_COSTS[key]
|
|
305
|
+
input_cost = (input_tokens / 1_000_000) * costs["input"]
|
|
306
|
+
output_cost = (output_tokens / 1_000_000) * costs["output"]
|
|
307
|
+
return input_cost + output_cost
|
|
308
|
+
|
|
309
|
+
# Unknown model
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def cleanup_old_records(self, days: int = 90) -> int:
|
|
313
|
+
"""Delete records older than N days. Returns count deleted."""
|
|
314
|
+
cutoff = datetime.utcnow() - timedelta(days=days)
|
|
315
|
+
|
|
316
|
+
with self._lock:
|
|
317
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
318
|
+
cursor = conn.execute(
|
|
319
|
+
"DELETE FROM usage_records WHERE timestamp < ?",
|
|
320
|
+
(cutoff.isoformat(),),
|
|
321
|
+
)
|
|
322
|
+
count = cursor.rowcount
|
|
323
|
+
conn.commit()
|
|
324
|
+
|
|
325
|
+
if count > 0:
|
|
326
|
+
logger.info("Cleaned up old usage records", count=count, days_old=days)
|
|
327
|
+
|
|
328
|
+
return count
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# Global instance
|
|
332
|
+
_tracker: SessionUsageTracker | None = None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def get_tracker() -> SessionUsageTracker:
|
|
336
|
+
"""Get or create global usage tracker instance."""
|
|
337
|
+
global _tracker
|
|
338
|
+
if _tracker is None:
|
|
339
|
+
_tracker = SessionUsageTracker()
|
|
340
|
+
return _tracker
|
velune/tools/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Tool system."""
|
|
2
|
+
|
|
3
|
+
from velune.tools.base.registry import ToolRegistry
|
|
4
|
+
from velune.tools.base.tool import BaseTool
|
|
5
|
+
from velune.tools.code.navigate import FindReferences, GoToDefinition
|
|
6
|
+
from velune.tools.code.search import SemanticCodeSearch, SymbolSearch
|
|
7
|
+
from velune.tools.filesystem.read import ReadDirectory, ReadFile
|
|
8
|
+
from velune.tools.filesystem.search import FindFiles, GrepFiles
|
|
9
|
+
from velune.tools.filesystem.write import CreateFile, DeleteFile, WriteFile
|
|
10
|
+
from velune.tools.git.history import GitBlame, GitDiff, GitLog
|
|
11
|
+
from velune.tools.git.operations import GitCheckout, GitCommit
|
|
12
|
+
from velune.tools.git.state import GitBranch, GitStatus
|
|
13
|
+
from velune.tools.terminal.execute import ExecuteCommand
|
|
14
|
+
from velune.tools.terminal.history import TerminalHistory
|
|
15
|
+
from velune.tools.web.fetch import WebFetch
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BaseTool",
|
|
19
|
+
"ToolRegistry",
|
|
20
|
+
"ReadFile",
|
|
21
|
+
"ReadDirectory",
|
|
22
|
+
"WriteFile",
|
|
23
|
+
"CreateFile",
|
|
24
|
+
"DeleteFile",
|
|
25
|
+
"GrepFiles",
|
|
26
|
+
"FindFiles",
|
|
27
|
+
"GitLog",
|
|
28
|
+
"GitDiff",
|
|
29
|
+
"GitBlame",
|
|
30
|
+
"GitStatus",
|
|
31
|
+
"GitBranch",
|
|
32
|
+
"GitCommit",
|
|
33
|
+
"GitCheckout",
|
|
34
|
+
"ExecuteCommand",
|
|
35
|
+
"TerminalHistory",
|
|
36
|
+
"SemanticCodeSearch",
|
|
37
|
+
"SymbolSearch",
|
|
38
|
+
"GoToDefinition",
|
|
39
|
+
"FindReferences",
|
|
40
|
+
"WebFetch",
|
|
41
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Tool registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from velune.tools.base.tool import BaseTool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolRegistry:
|
|
9
|
+
"""Registry for available tools."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._tools: dict[str, BaseTool] = {}
|
|
13
|
+
self._broken_tools: list[str] = []
|
|
14
|
+
|
|
15
|
+
def validate_tool(self, tool: BaseTool) -> bool:
|
|
16
|
+
"""Validate a tool by calling get_name() and get_schema()."""
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("velune")
|
|
20
|
+
try:
|
|
21
|
+
tool.get_name()
|
|
22
|
+
tool.get_schema()
|
|
23
|
+
return True
|
|
24
|
+
except Exception as e:
|
|
25
|
+
class_name = tool.__class__.__name__
|
|
26
|
+
logger.warning(
|
|
27
|
+
"Tool class %s failed validation: %s",
|
|
28
|
+
class_name,
|
|
29
|
+
str(e),
|
|
30
|
+
exc_info=True,
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
broken_name = tool.get_name()
|
|
34
|
+
except Exception:
|
|
35
|
+
broken_name = class_name
|
|
36
|
+
if broken_name not in self._broken_tools:
|
|
37
|
+
self._broken_tools.append(broken_name)
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def list_broken_tools(self) -> list[str]:
|
|
41
|
+
"""List all tool names that failed validation."""
|
|
42
|
+
return self._broken_tools
|
|
43
|
+
|
|
44
|
+
def register(self, tool: BaseTool, *, replace: bool = True) -> None:
|
|
45
|
+
"""Register a tool."""
|
|
46
|
+
if not self.validate_tool(tool):
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
name = tool.get_name()
|
|
50
|
+
if not replace and name in self._tools:
|
|
51
|
+
raise ValueError(f"Tool already registered: {name}")
|
|
52
|
+
self._tools[name] = tool
|
|
53
|
+
|
|
54
|
+
def get(self, name: str) -> BaseTool | None:
|
|
55
|
+
"""Get a tool by name."""
|
|
56
|
+
return self._tools.get(name)
|
|
57
|
+
|
|
58
|
+
def has(self, name: str) -> bool:
|
|
59
|
+
"""Check if a tool is registered."""
|
|
60
|
+
|
|
61
|
+
return name in self._tools
|
|
62
|
+
|
|
63
|
+
def unregister(self, name: str) -> None:
|
|
64
|
+
"""Remove a tool by name."""
|
|
65
|
+
|
|
66
|
+
self._tools.pop(name, None)
|
|
67
|
+
|
|
68
|
+
def list_tools(self) -> list[str]:
|
|
69
|
+
"""List all registered tool names."""
|
|
70
|
+
return list(self._tools.keys())
|
|
71
|
+
|
|
72
|
+
def list_tool_schemas(self) -> list[dict[str, object]]:
|
|
73
|
+
"""List schemas and capability metadata for all tools."""
|
|
74
|
+
|
|
75
|
+
schemas: list[dict[str, object]] = []
|
|
76
|
+
for tool in self._tools.values():
|
|
77
|
+
schemas.append(
|
|
78
|
+
{
|
|
79
|
+
"name": tool.get_name(),
|
|
80
|
+
"description": tool.get_description(),
|
|
81
|
+
"schema": tool.get_schema(),
|
|
82
|
+
"permissions": sorted(
|
|
83
|
+
permission.value for permission in tool.get_required_permissions()
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return schemas
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Base tool protocol and execution contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ToolPermission(StrEnum):
|
|
13
|
+
"""Permission boundaries enforced at tool execution time."""
|
|
14
|
+
|
|
15
|
+
FILESYSTEM_READ = "filesystem.read"
|
|
16
|
+
FILESYSTEM_WRITE = "filesystem.write"
|
|
17
|
+
GIT_READ = "git.read"
|
|
18
|
+
GIT_WRITE = "git.write"
|
|
19
|
+
TERMINAL_EXECUTE = "terminal.execute"
|
|
20
|
+
NETWORK_ACCESS = "network.access"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class ToolCallContext:
|
|
25
|
+
"""Execution context passed into policy-aware tool calls."""
|
|
26
|
+
|
|
27
|
+
run_id: str
|
|
28
|
+
actor: str
|
|
29
|
+
workspace: Path | None = None
|
|
30
|
+
permissions: set[ToolPermission] = field(default_factory=set)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseTool(ABC):
|
|
34
|
+
"""Abstract base class for tools."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def get_name(self) -> str:
|
|
38
|
+
"""Get the tool name."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def get_description(self) -> str:
|
|
43
|
+
"""Get the tool description."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def execute(self, **kwargs) -> Any:
|
|
48
|
+
"""Execute the tool."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def get_schema(self) -> dict[str, Any]:
|
|
52
|
+
"""Get the tool's parameter schema."""
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
def get_required_permissions(self) -> set[ToolPermission]:
|
|
56
|
+
"""Permissions required to execute this tool."""
|
|
57
|
+
|
|
58
|
+
return set()
|
|
59
|
+
|
|
60
|
+
def validate_input(self, payload: dict[str, Any]) -> None:
|
|
61
|
+
"""Validate tool input before execution."""
|
|
62
|
+
|
|
63
|
+
return None
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from velune.execution.path_guard import PathGuard
|
|
6
|
+
from velune.tools.base.tool import BaseTool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GoToDefinition(BaseTool):
|
|
10
|
+
"""Tool for navigating to symbol definitions."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
13
|
+
self.workspace = Path(workspace).resolve() if workspace else Path.cwd().resolve()
|
|
14
|
+
|
|
15
|
+
def get_name(self) -> str:
|
|
16
|
+
return "go_to_definition"
|
|
17
|
+
|
|
18
|
+
def get_description(self) -> str:
|
|
19
|
+
return "Navigate to symbol definition"
|
|
20
|
+
|
|
21
|
+
async def execute(
|
|
22
|
+
self,
|
|
23
|
+
symbol_name: str,
|
|
24
|
+
file_path: str,
|
|
25
|
+
line: int,
|
|
26
|
+
) -> dict | None:
|
|
27
|
+
"""Go to symbol definition."""
|
|
28
|
+
from velune.repository.parser import ASTParser
|
|
29
|
+
|
|
30
|
+
guard = PathGuard(self.workspace)
|
|
31
|
+
path = guard.validate(file_path)
|
|
32
|
+
|
|
33
|
+
parser = ASTParser()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with open(path, encoding="utf-8", errors="ignore") as f:
|
|
37
|
+
code = f.read()
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
symbols, _ = parser.parse(path, code)
|
|
42
|
+
for symbol in symbols:
|
|
43
|
+
if symbol.name == symbol_name:
|
|
44
|
+
return {
|
|
45
|
+
"file": str(path),
|
|
46
|
+
"line": symbol.line_start,
|
|
47
|
+
"kind": symbol.kind.value if hasattr(symbol.kind, "value") else symbol.kind,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def get_schema(self) -> dict:
|
|
53
|
+
return {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": {
|
|
56
|
+
"symbol_name": {
|
|
57
|
+
"type": "string",
|
|
58
|
+
"description": "Symbol name",
|
|
59
|
+
},
|
|
60
|
+
"file_path": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "File containing the symbol reference",
|
|
63
|
+
},
|
|
64
|
+
"line": {
|
|
65
|
+
"type": "integer",
|
|
66
|
+
"description": "Line number of reference",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
"required": ["symbol_name", "file_path", "line"],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FindReferences(BaseTool):
|
|
74
|
+
"""Tool for finding symbol references."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
77
|
+
self.workspace = Path(workspace).resolve() if workspace else Path.cwd().resolve()
|
|
78
|
+
|
|
79
|
+
def get_name(self) -> str:
|
|
80
|
+
return "find_references"
|
|
81
|
+
|
|
82
|
+
def get_description(self) -> str:
|
|
83
|
+
return "Find all references to a symbol"
|
|
84
|
+
|
|
85
|
+
async def execute(
|
|
86
|
+
self,
|
|
87
|
+
symbol_name: str,
|
|
88
|
+
directory: str = ".",
|
|
89
|
+
) -> list[dict]:
|
|
90
|
+
"""Find references to a symbol."""
|
|
91
|
+
from velune.tools.filesystem.search import GrepFiles
|
|
92
|
+
|
|
93
|
+
grep = GrepFiles(workspace=self.workspace)
|
|
94
|
+
results = await grep.execute(
|
|
95
|
+
pattern=symbol_name,
|
|
96
|
+
directory=directory,
|
|
97
|
+
file_pattern="*.py",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return results
|
|
101
|
+
|
|
102
|
+
def get_schema(self) -> dict:
|
|
103
|
+
return {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"symbol_name": {
|
|
107
|
+
"type": "string",
|
|
108
|
+
"description": "Symbol name to find references for",
|
|
109
|
+
},
|
|
110
|
+
"directory": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"description": "Directory to search in",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
"required": ["symbol_name"],
|
|
116
|
+
}
|