agent-decoder 0.3.2__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.
- agent_decoder-0.3.2.dist-info/METADATA +387 -0
- agent_decoder-0.3.2.dist-info/RECORD +89 -0
- agent_decoder-0.3.2.dist-info/WHEEL +4 -0
- agent_decoder-0.3.2.dist-info/entry_points.txt +2 -0
- agent_decoder-0.3.2.dist-info/licenses/LICENSE +111 -0
- clau_decode/__init__.py +8 -0
- clau_decode/__main__.py +11 -0
- clau_decode/_auth_env.py +118 -0
- clau_decode/_pty_preexec.py +42 -0
- clau_decode/analytics/__init__.py +0 -0
- clau_decode/analytics/aggregator.py +55 -0
- clau_decode/analytics/cost.py +76 -0
- clau_decode/analytics/extractor.py +32 -0
- clau_decode/analytics/fast.py +289 -0
- clau_decode/analytics/models.py +49 -0
- clau_decode/analytics/pricing.py +162 -0
- clau_decode/analytics/prompt.py +34 -0
- clau_decode/analytics/service.py +23 -0
- clau_decode/analytics/stats.py +117 -0
- clau_decode/analytics/tips.py +176 -0
- clau_decode/btw_capture.py +290 -0
- clau_decode/cli.py +504 -0
- clau_decode/config.py +144 -0
- clau_decode/db.py +2054 -0
- clau_decode/driver_manager.py +387 -0
- clau_decode/drivers/__init__.py +27 -0
- clau_decode/drivers/base.py +170 -0
- clau_decode/drivers/registry.py +113 -0
- clau_decode/drivers/tmux_driver.py +488 -0
- clau_decode/editor.py +182 -0
- clau_decode/events_bus.py +69 -0
- clau_decode/locks.py +388 -0
- clau_decode/migrate.py +1313 -0
- clau_decode/models.py +285 -0
- clau_decode/parser.py +466 -0
- clau_decode/providers/__init__.py +30 -0
- clau_decode/providers/base.py +132 -0
- clau_decode/providers/claude.py +81 -0
- clau_decode/providers/codex.py +441 -0
- clau_decode/providers/registry.py +89 -0
- clau_decode/pty_native.py +39 -0
- clau_decode/pty_runner.py +2406 -0
- clau_decode/pty_screen_state.py +65 -0
- clau_decode/recap_runner.py +230 -0
- clau_decode/reporter.py +305 -0
- clau_decode/scanner.py +125 -0
- clau_decode/server.py +2841 -0
- clau_decode/static/android-chrome-192x192.png +0 -0
- clau_decode/static/android-chrome-512x512.png +0 -0
- clau_decode/static/app-icon.png +0 -0
- clau_decode/static/apple-touch-icon.png +0 -0
- clau_decode/static/assets/ChatView-BBVXuITs.js +79 -0
- clau_decode/static/assets/ChatView-BrP-ENHg.css +1 -0
- clau_decode/static/assets/Dashboard-3CYO4HQw.js +1 -0
- clau_decode/static/assets/FileViewer-BwbDc3ky.js +1 -0
- clau_decode/static/assets/HelpPopup-CPtECjYw.js +1 -0
- clau_decode/static/assets/ScrollContainer-BT_Dzxah.js +1 -0
- clau_decode/static/assets/SearchOverlay-DqI7aZaC.js +1 -0
- clau_decode/static/assets/SessionItem-DlyKg80u.js +1 -0
- clau_decode/static/assets/SettingsModal-CZBfMqx7.js +1 -0
- clau_decode/static/assets/ShortcutsPopup-W7CPdCzW.js +1 -0
- clau_decode/static/assets/Sidebar-BBswhEua.js +1 -0
- clau_decode/static/assets/client-DTSXvAb8.js +1 -0
- clau_decode/static/assets/fira-code-DGosTW8U.woff2 +0 -0
- clau_decode/static/assets/formatRelative-j86webkr.js +1 -0
- clau_decode/static/assets/hljs-B3O74O7Z.js +35 -0
- clau_decode/static/assets/index-BHdCOIkq.css +10 -0
- clau_decode/static/assets/index-DfKltZ6z.js +5 -0
- clau_decode/static/assets/ioskeley-mono-C7VR2-sL.woff2 +0 -0
- clau_decode/static/assets/jetbrains-mono-V6pRDFza.woff2 +0 -0
- clau_decode/static/assets/julia-mono-Cbvba6sW.woff2 +0 -0
- clau_decode/static/assets/libertinus-mono-Coz17E-O.woff2 +0 -0
- clau_decode/static/assets/monaspace-argon-RS-2TGNg.woff2 +0 -0
- clau_decode/static/assets/nativePtyFonts-BaXKtUsn.js +1 -0
- clau_decode/static/assets/react-Bi_azaFt.js +9 -0
- clau_decode/static/assets/renderSnippet-CqpQQugH.js +1 -0
- clau_decode/static/assets/rolldown-runtime-S-ySWqyJ.js +1 -0
- clau_decode/static/assets/shared-DdVGprvF.js +1 -0
- clau_decode/static/assets/source-code-pro-DEXWLoHG.woff2 +0 -0
- clau_decode/static/assets/spline-sans-mono-mUpA6Mve.woff2 +0 -0
- clau_decode/static/assets/ui-BcGPjpYn.js +1 -0
- clau_decode/static/assets/xanh-mono-Dsg19vWS.woff2 +0 -0
- clau_decode/static/favicon-16x16.png +0 -0
- clau_decode/static/favicon-32x32.png +0 -0
- clau_decode/static/favicon.ico +0 -0
- clau_decode/static/index.html +24 -0
- clau_decode/static/site.webmanifest +1 -0
- clau_decode/update_check.py +51 -0
- clau_decode/watcher.py +63 -0
clau_decode/cli.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""CLI entry point — `clau-decode [options] <command>`.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
dashboard Launch the web UI (default when no command given)
|
|
5
|
+
scan Rescan all paths and print summary
|
|
6
|
+
today Show today's token usage and cost
|
|
7
|
+
stats Print statistical metrics across all sessions
|
|
8
|
+
tips Print optimization tips
|
|
9
|
+
migrate Merge/relocate Claude history + configs across machines
|
|
10
|
+
|
|
11
|
+
Global options:
|
|
12
|
+
--path PATH Add an extra scan path (repeatable, appended to config paths)
|
|
13
|
+
--port PORT Override port (default: 4242)
|
|
14
|
+
--host HOST Bind host (default: 127.0.0.1)
|
|
15
|
+
--expose Bind to 0.0.0.0 with a security warning
|
|
16
|
+
--no-open Don't open browser on startup
|
|
17
|
+
--version Print version and exit
|
|
18
|
+
--enable-edit Enable message editing and deletion
|
|
19
|
+
--force-refresh Clear mtime cache and force full rescan
|
|
20
|
+
--since YYYYMMDD Only include sessions on or after this date
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import asyncio
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
import webbrowser
|
|
32
|
+
from datetime import date, datetime
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import uvicorn
|
|
36
|
+
|
|
37
|
+
from . import __version__, migrate
|
|
38
|
+
from .config import get_db_path, load_config
|
|
39
|
+
from .server import create_app
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
43
|
+
parser = argparse.ArgumentParser(
|
|
44
|
+
prog="clau-decode",
|
|
45
|
+
description="Local web viewer and analytics for AI coding assistant chat history",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--path",
|
|
49
|
+
action="append",
|
|
50
|
+
dest="paths",
|
|
51
|
+
metavar="PATH",
|
|
52
|
+
help="Add a scan path (repeatable)",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--port",
|
|
56
|
+
type=int,
|
|
57
|
+
default=None,
|
|
58
|
+
help="Override the listening port (default from config, fallback 4242)",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--host",
|
|
62
|
+
default=None,
|
|
63
|
+
help="Bind host (default: 127.0.0.1)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--expose",
|
|
67
|
+
action="store_true",
|
|
68
|
+
default=False,
|
|
69
|
+
help="Bind to 0.0.0.0 (accessible on your local network). "
|
|
70
|
+
"Use with caution — anyone on the same network can view your data.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--no-open",
|
|
74
|
+
action="store_true",
|
|
75
|
+
help="Don't open a browser window on startup",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--version",
|
|
79
|
+
action="version",
|
|
80
|
+
version=f"clau-decode {__version__}",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--enable-edit",
|
|
84
|
+
action="store_true",
|
|
85
|
+
default=False,
|
|
86
|
+
help="Enable message editing and deletion. A backup is created before every write.",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--force-refresh",
|
|
90
|
+
action="store_true",
|
|
91
|
+
default=False,
|
|
92
|
+
help="Clear mtime cache and force a full rescan of all session files.",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--since",
|
|
96
|
+
type=lambda s: datetime.strptime(s, "%Y%m%d").date(),
|
|
97
|
+
metavar="YYYYMMDD",
|
|
98
|
+
help="Only include sessions on or after this date",
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--log-level",
|
|
102
|
+
choices=("debug", "info", "warning", "error"),
|
|
103
|
+
default="info",
|
|
104
|
+
help="Verbosity for clau_decode loggers (independent of uvicorn). "
|
|
105
|
+
"Default 'info' surfaces operational signals (PTY runner warnings, "
|
|
106
|
+
"/btw finalize/timeout, ephemeral persistence). Use 'debug' when "
|
|
107
|
+
"diagnosing a live issue.",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--demo",
|
|
111
|
+
nargs="?",
|
|
112
|
+
const="__AUTO__",
|
|
113
|
+
default=None,
|
|
114
|
+
metavar="PATH",
|
|
115
|
+
help="Launch in isolated demo mode. Ignores your real config and cache; "
|
|
116
|
+
"scans only the given demo dir (defaults to ./demo-data). Useful for "
|
|
117
|
+
"screen recordings.",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
121
|
+
|
|
122
|
+
subparsers.add_parser(
|
|
123
|
+
"dashboard",
|
|
124
|
+
help="Launch the web UI (default)",
|
|
125
|
+
)
|
|
126
|
+
subparsers.add_parser(
|
|
127
|
+
"scan",
|
|
128
|
+
help="Rescan all paths and print summary",
|
|
129
|
+
)
|
|
130
|
+
subparsers.add_parser(
|
|
131
|
+
"today",
|
|
132
|
+
help="Show today's token usage and cost",
|
|
133
|
+
)
|
|
134
|
+
subparsers.add_parser(
|
|
135
|
+
"stats",
|
|
136
|
+
help="Print statistical metrics across all sessions",
|
|
137
|
+
)
|
|
138
|
+
subparsers.add_parser(
|
|
139
|
+
"tips",
|
|
140
|
+
help="Print optimization tips",
|
|
141
|
+
)
|
|
142
|
+
migrate_parser = subparsers.add_parser(
|
|
143
|
+
"migrate",
|
|
144
|
+
help="Merge/relocate Claude chat history + configs across machines",
|
|
145
|
+
description=(
|
|
146
|
+
"Merge one or more Claude config trees into a destination ~/.claude, "
|
|
147
|
+
"rewriting a project-path prefix so chats stay viewable AND resumable. "
|
|
148
|
+
"Dry-run by default; --apply is gated behind --i-have-a-backup."
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
migrate.add_arguments(migrate_parser)
|
|
152
|
+
|
|
153
|
+
return parser
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_host(args: argparse.Namespace, config=None) -> str:
|
|
157
|
+
"""Determine the bind host: CLI flags > saved config > default."""
|
|
158
|
+
if args.expose:
|
|
159
|
+
print(
|
|
160
|
+
"WARNING: --expose binds to 0.0.0.0. "
|
|
161
|
+
"Anyone on your local network can access your chat history.\n"
|
|
162
|
+
)
|
|
163
|
+
return "0.0.0.0"
|
|
164
|
+
if args.host:
|
|
165
|
+
return args.host
|
|
166
|
+
if config is not None:
|
|
167
|
+
return config.host
|
|
168
|
+
return "127.0.0.1"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _configure_clau_decode_logging(level_name: str) -> None:
|
|
172
|
+
"""Attach an explicit stderr handler to the ``clau_decode`` logger.
|
|
173
|
+
|
|
174
|
+
Uvicorn's default ``log_config`` reconfigures Python logging when
|
|
175
|
+
``uvicorn.run`` is invoked, which drops the implicit ``lastResort``
|
|
176
|
+
handler that would otherwise route our ``_log.warning(...)`` /
|
|
177
|
+
``_log.info(...)`` calls to stderr. Without this, every PTY runner
|
|
178
|
+
signal (e.g. ``_finalize_btw_capture``, ``_btw_stuck_timeout``,
|
|
179
|
+
``record_ephemeral_*`` failures) goes nowhere — verified during the
|
|
180
|
+
Phase 2 live smoke when 220 s of polling yielded zero log lines.
|
|
181
|
+
|
|
182
|
+
``propagate=False`` keeps uvicorn's root handlers from emitting a
|
|
183
|
+
second copy of every line.
|
|
184
|
+
"""
|
|
185
|
+
import logging as _logging
|
|
186
|
+
|
|
187
|
+
# When stderr is redirected to a file (e.g. ``nohup ... > /tmp/log 2>&1``)
|
|
188
|
+
# Python defaults to block-buffering, so log lines accumulate in-memory
|
|
189
|
+
# until shutdown. This was the second half of the Phase 2 smoke
|
|
190
|
+
# invisibility: even after we configured the handler, lines never reached
|
|
191
|
+
# the file until the process exited. Switch to line buffering so each
|
|
192
|
+
# ``\n`` flushes the underlying fd.
|
|
193
|
+
try:
|
|
194
|
+
sys.stderr.reconfigure(line_buffering=True) # type: ignore[attr-defined]
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
level = getattr(_logging, level_name.upper(), _logging.INFO)
|
|
198
|
+
logger = _logging.getLogger("clau_decode")
|
|
199
|
+
logger.setLevel(level)
|
|
200
|
+
if not any(
|
|
201
|
+
isinstance(h, _logging.StreamHandler)
|
|
202
|
+
and getattr(h, "_clau_decode_attached", False)
|
|
203
|
+
for h in logger.handlers
|
|
204
|
+
):
|
|
205
|
+
handler = _logging.StreamHandler(sys.stderr)
|
|
206
|
+
handler.setFormatter(
|
|
207
|
+
_logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
208
|
+
)
|
|
209
|
+
handler._clau_decode_attached = True # type: ignore[attr-defined]
|
|
210
|
+
logger.addHandler(handler)
|
|
211
|
+
logger.propagate = False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _run_dashboard(args: argparse.Namespace, config) -> None:
|
|
215
|
+
"""Launch the web UI with uvicorn."""
|
|
216
|
+
_configure_clau_decode_logging(args.log_level)
|
|
217
|
+
import logging as _logging
|
|
218
|
+
|
|
219
|
+
_logging.getLogger("clau_decode").info(
|
|
220
|
+
"logger configured (level=%s)", args.log_level
|
|
221
|
+
)
|
|
222
|
+
host = _resolve_host(args, config)
|
|
223
|
+
|
|
224
|
+
db_path = get_db_path()
|
|
225
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
|
|
227
|
+
if args.force_refresh:
|
|
228
|
+
asyncio.run(_force_refresh(db_path))
|
|
229
|
+
|
|
230
|
+
app = create_app(config, db_path)
|
|
231
|
+
|
|
232
|
+
if not args.no_open and config.auto_open_browser:
|
|
233
|
+
url = f"http://{host}:{config.port}"
|
|
234
|
+
|
|
235
|
+
def _open_browser() -> None:
|
|
236
|
+
import urllib.request
|
|
237
|
+
|
|
238
|
+
for _ in range(30):
|
|
239
|
+
time.sleep(0.5)
|
|
240
|
+
try:
|
|
241
|
+
urllib.request.urlopen(f"{url}/api/health", timeout=1)
|
|
242
|
+
break
|
|
243
|
+
except Exception:
|
|
244
|
+
continue
|
|
245
|
+
webbrowser.open(url)
|
|
246
|
+
|
|
247
|
+
threading.Thread(target=_open_browser, daemon=True).start()
|
|
248
|
+
|
|
249
|
+
print(f"Clau-Decode running at http://{host}:{config.port}", flush=True)
|
|
250
|
+
uvicorn.run(app, host=host, port=config.port, log_level="warning")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _force_refresh(db_path: Path) -> None:
|
|
254
|
+
"""Clear all stored mtimes so the next scan re-parses every file."""
|
|
255
|
+
from .db import Database
|
|
256
|
+
|
|
257
|
+
async with Database(db_path) as db:
|
|
258
|
+
await db.init_schema()
|
|
259
|
+
await db.execute("UPDATE sessions SET file_mtime = NULL")
|
|
260
|
+
await db.commit()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def _do_scan(db_path: Path, config) -> int:
|
|
264
|
+
"""Scan all paths, return number of sessions indexed."""
|
|
265
|
+
from .db import Database
|
|
266
|
+
from .providers import register_builtins, registry
|
|
267
|
+
|
|
268
|
+
register_builtins()
|
|
269
|
+
|
|
270
|
+
count = 0
|
|
271
|
+
async with Database(db_path) as db:
|
|
272
|
+
await db.init_schema()
|
|
273
|
+
for adapter in registry.all_adapters():
|
|
274
|
+
roots = adapter.configured_roots(config)
|
|
275
|
+
async for project, session_path in adapter.discover(roots):
|
|
276
|
+
try:
|
|
277
|
+
session, messages = adapter.parse(session_path)
|
|
278
|
+
session.project_id = project.id
|
|
279
|
+
project.session_count += 1
|
|
280
|
+
current_mtime = session_path.stat().st_mtime
|
|
281
|
+
await db.upsert_project(project)
|
|
282
|
+
await db.upsert_session(session, file_mtime=current_mtime)
|
|
283
|
+
await db.upsert_messages(messages)
|
|
284
|
+
count += 1
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
print(f" Warning: skipping {session_path}: {exc}")
|
|
287
|
+
return count
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _run_scan(args: argparse.Namespace, config) -> None:
|
|
291
|
+
"""Rescan and print summary."""
|
|
292
|
+
db_path = get_db_path()
|
|
293
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
|
|
295
|
+
if args.force_refresh:
|
|
296
|
+
asyncio.run(_force_refresh(db_path))
|
|
297
|
+
|
|
298
|
+
print("Scanning...")
|
|
299
|
+
count = asyncio.run(_do_scan(db_path, config))
|
|
300
|
+
print(f"Indexed {count} sessions.")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _run_today(args: argparse.Namespace, config) -> None:
|
|
304
|
+
"""Show today's token usage and cost."""
|
|
305
|
+
from .db import Database
|
|
306
|
+
from .analytics.service import TokenAnalyticsService
|
|
307
|
+
from .analytics.cost import CostEngine
|
|
308
|
+
from .analytics.pricing import CachedPricingStrategy
|
|
309
|
+
|
|
310
|
+
db_path = get_db_path()
|
|
311
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
|
|
313
|
+
if args.force_refresh:
|
|
314
|
+
asyncio.run(_force_refresh(db_path))
|
|
315
|
+
asyncio.run(_do_scan(db_path, config))
|
|
316
|
+
|
|
317
|
+
async def _query():
|
|
318
|
+
async with Database(db_path) as db:
|
|
319
|
+
await db.init_schema()
|
|
320
|
+
messages = await db.get_all_messages()
|
|
321
|
+
|
|
322
|
+
today = args.since or date.today()
|
|
323
|
+
svc = TokenAnalyticsService()
|
|
324
|
+
buckets = svc.daily_buckets(messages)
|
|
325
|
+
target = next((b for b in buckets if b.day >= today), None)
|
|
326
|
+
if target is None:
|
|
327
|
+
return None
|
|
328
|
+
return target
|
|
329
|
+
|
|
330
|
+
result = asyncio.run(_query())
|
|
331
|
+
if result is None:
|
|
332
|
+
label = args.since.isoformat() if args.since else "today"
|
|
333
|
+
print(f"No usage data for {label}.")
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
bd = result.breakdown
|
|
337
|
+
print(f"Date: {result.day}")
|
|
338
|
+
print(f" Input tokens: {bd.input_tokens:,}")
|
|
339
|
+
print(f" Output tokens: {bd.output_tokens:,}")
|
|
340
|
+
print(f" Cache creation: {bd.cache_creation_tokens:,}")
|
|
341
|
+
print(f" Cache read: {bd.cache_read_tokens:,}")
|
|
342
|
+
print(f" Total tokens: {bd.total:,}")
|
|
343
|
+
print(f" Prompt count: {result.prompt_count}")
|
|
344
|
+
|
|
345
|
+
# Cost estimate
|
|
346
|
+
pricing = CachedPricingStrategy()
|
|
347
|
+
asyncio.run(pricing.refresh())
|
|
348
|
+
engine = CostEngine(pricing)
|
|
349
|
+
cost = engine.compute("claude-sonnet-4-6", bd)
|
|
350
|
+
print(f" Est. cost (Sonnet): ${float(cost.total_usd):.4f}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _run_stats(args: argparse.Namespace, config) -> None:
|
|
354
|
+
"""Print statistical metrics."""
|
|
355
|
+
from .db import Database
|
|
356
|
+
from .analytics.stats import PromptStatsScanner, ModelUsageScanner, ToolUsageScanner
|
|
357
|
+
|
|
358
|
+
db_path = get_db_path()
|
|
359
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
|
|
361
|
+
if args.force_refresh:
|
|
362
|
+
asyncio.run(_force_refresh(db_path))
|
|
363
|
+
asyncio.run(_do_scan(db_path, config))
|
|
364
|
+
|
|
365
|
+
async def _query():
|
|
366
|
+
async with Database(db_path) as db:
|
|
367
|
+
await db.init_schema()
|
|
368
|
+
messages = await db.get_all_messages()
|
|
369
|
+
return messages
|
|
370
|
+
|
|
371
|
+
messages = asyncio.run(_query())
|
|
372
|
+
|
|
373
|
+
stats = PromptStatsScanner().scan(messages)
|
|
374
|
+
print("=== Prompt Stats ===")
|
|
375
|
+
for key, val in sorted(stats.items()):
|
|
376
|
+
if isinstance(val, float):
|
|
377
|
+
print(f" {key}: {val:.2f}")
|
|
378
|
+
else:
|
|
379
|
+
print(f" {key}: {val}")
|
|
380
|
+
|
|
381
|
+
models = ModelUsageScanner().scan(messages)
|
|
382
|
+
print("\n=== Model Usage ===")
|
|
383
|
+
for m in models:
|
|
384
|
+
print(
|
|
385
|
+
f" {m['model']}: {m['message_count']} messages, "
|
|
386
|
+
f"{m['input_tokens']:,} input, {m['output_tokens']:,} output"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
tools = ToolUsageScanner().scan(messages)
|
|
390
|
+
print("\n=== Tool Usage ===")
|
|
391
|
+
for t in tools[:10]:
|
|
392
|
+
print(f" {t['tool']}: {t['count']} calls")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _run_tips(args: argparse.Namespace, config) -> None:
|
|
396
|
+
"""Print optimization tips."""
|
|
397
|
+
from .db import Database
|
|
398
|
+
from .analytics.tips import (
|
|
399
|
+
TipRegistry,
|
|
400
|
+
RepeatedFileReadRule,
|
|
401
|
+
OversizedToolResultRule,
|
|
402
|
+
LowCacheHitRule,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
db_path = get_db_path()
|
|
406
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
|
|
408
|
+
if args.force_refresh:
|
|
409
|
+
asyncio.run(_force_refresh(db_path))
|
|
410
|
+
asyncio.run(_do_scan(db_path, config))
|
|
411
|
+
|
|
412
|
+
async def _query():
|
|
413
|
+
async with Database(db_path) as db:
|
|
414
|
+
await db.init_schema()
|
|
415
|
+
messages = await db.get_all_messages()
|
|
416
|
+
return messages
|
|
417
|
+
|
|
418
|
+
messages = asyncio.run(_query())
|
|
419
|
+
|
|
420
|
+
registry = TipRegistry()
|
|
421
|
+
registry.register(RepeatedFileReadRule())
|
|
422
|
+
registry.register(OversizedToolResultRule())
|
|
423
|
+
registry.register(LowCacheHitRule())
|
|
424
|
+
tips = registry.run(messages)
|
|
425
|
+
|
|
426
|
+
if not tips:
|
|
427
|
+
print("No optimization tips — looking good!")
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
for t in tips:
|
|
431
|
+
icon = {"info": "ℹ", "warning": "⚠", "critical": "✱"}.get(t.severity, "•")
|
|
432
|
+
print(f"{icon} [{t.severity.upper()}] {t.title}")
|
|
433
|
+
if t.detail:
|
|
434
|
+
print(f" {t.detail}")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _apply_demo_mode(args: argparse.Namespace) -> None:
|
|
438
|
+
"""Isolate config + cache + scan path so a demo launch can't pick up real state.
|
|
439
|
+
|
|
440
|
+
Must run before ``load_config`` and before any ``get_db_path``/``get_config_path``
|
|
441
|
+
call — those functions read ``XDG_CONFIG_HOME`` and ``XDG_CACHE_HOME`` at call
|
|
442
|
+
time, so overriding the env vars here is enough to redirect every consumer
|
|
443
|
+
(load_config, save_config, get_db_path) for the lifetime of the process.
|
|
444
|
+
"""
|
|
445
|
+
if args.demo == "__AUTO__":
|
|
446
|
+
demo_path = Path.cwd() / "demo-data"
|
|
447
|
+
else:
|
|
448
|
+
demo_path = Path(args.demo).expanduser().resolve()
|
|
449
|
+
|
|
450
|
+
if not demo_path.exists() or not (demo_path / "projects").is_dir():
|
|
451
|
+
print(
|
|
452
|
+
f"--demo: '{demo_path}' is not a valid demo dir. "
|
|
453
|
+
"Expected a directory containing a 'projects/' subdirectory.",
|
|
454
|
+
file=sys.stderr,
|
|
455
|
+
)
|
|
456
|
+
sys.exit(2)
|
|
457
|
+
|
|
458
|
+
runtime = demo_path / ".runtime"
|
|
459
|
+
os.environ["XDG_CONFIG_HOME"] = str(runtime / "config")
|
|
460
|
+
os.environ["XDG_CACHE_HOME"] = str(runtime / "cache")
|
|
461
|
+
args.paths = [str(demo_path)]
|
|
462
|
+
|
|
463
|
+
print(f"Demo mode: scanning {demo_path} (runtime state at {runtime})")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def main() -> None:
|
|
467
|
+
"""Entry point registered in pyproject.toml [project.scripts]."""
|
|
468
|
+
parser = _build_parser()
|
|
469
|
+
args = parser.parse_args()
|
|
470
|
+
|
|
471
|
+
if args.demo is not None:
|
|
472
|
+
_apply_demo_mode(args)
|
|
473
|
+
|
|
474
|
+
config = load_config(extra_paths=args.paths, port=args.port)
|
|
475
|
+
|
|
476
|
+
if args.demo is not None:
|
|
477
|
+
# Replace (not append) so neither the default '~/.claude' fallback nor
|
|
478
|
+
# any leftover profile entries can leak real session data into the demo.
|
|
479
|
+
config = config.model_copy(
|
|
480
|
+
update={
|
|
481
|
+
"data_paths": args.paths,
|
|
482
|
+
"profiles": [],
|
|
483
|
+
"active_profile_id": None,
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
if args.enable_edit:
|
|
488
|
+
config = config.model_copy(update={"edit_enabled": True})
|
|
489
|
+
|
|
490
|
+
command = args.command or "dashboard"
|
|
491
|
+
dispatch = {
|
|
492
|
+
"dashboard": _run_dashboard,
|
|
493
|
+
"scan": _run_scan,
|
|
494
|
+
"today": _run_today,
|
|
495
|
+
"stats": _run_stats,
|
|
496
|
+
"tips": _run_tips,
|
|
497
|
+
"migrate": _run_migrate,
|
|
498
|
+
}
|
|
499
|
+
dispatch[command](args, config)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _run_migrate(args, config) -> None:
|
|
503
|
+
"""``clau-decode migrate`` -- filesystem merge tool; ignores app config."""
|
|
504
|
+
raise SystemExit(migrate.run(args))
|
clau_decode/config.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""User configuration — persisted to XDG_CONFIG_HOME/clau-decode/config.json.
|
|
2
|
+
|
|
3
|
+
Contract (for Agent 2 to implement):
|
|
4
|
+
load_config() -> AppConfig
|
|
5
|
+
- Read from config file if it exists, else return defaults
|
|
6
|
+
- Merge CLI-provided overrides (extra_paths, port)
|
|
7
|
+
|
|
8
|
+
save_config(config: AppConfig) -> None
|
|
9
|
+
- Atomically write config to the config file (write to .tmp, rename)
|
|
10
|
+
|
|
11
|
+
get_config_path() -> Path
|
|
12
|
+
- Return XDG_CONFIG_HOME/clau-decode/config.json (or ~/.config/clau-decode/config.json)
|
|
13
|
+
|
|
14
|
+
get_db_path() -> Path
|
|
15
|
+
- Return XDG_DATA_HOME/clau-decode/index.db (or ~/.local/share/clau-decode/index.db),
|
|
16
|
+
transparently migrating a legacy ~/.cache DB on first use
|
|
17
|
+
|
|
18
|
+
SOLID notes:
|
|
19
|
+
- No global state — callers hold the config object; this module is pure I/O
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from .models import AppConfig
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_config_path() -> Path:
|
|
33
|
+
"""Return the path to the clau-decode configuration file.
|
|
34
|
+
|
|
35
|
+
Respects the XDG Base Directory specification: uses ``XDG_CONFIG_HOME`` if
|
|
36
|
+
set, otherwise falls back to ``~/.config``.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
``<xdg_config>/clau-decode/config.json``
|
|
40
|
+
"""
|
|
41
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME", "") or str(
|
|
42
|
+
Path("~/.config").expanduser()
|
|
43
|
+
)
|
|
44
|
+
return Path(xdg_config) / "clau-decode" / "config.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _legacy_cache_db_path() -> Path:
|
|
48
|
+
"""The old DB location under the disposable cache dir (XDG_CACHE_HOME /
|
|
49
|
+
~/.cache). Kept only to migrate data out of it — see ``get_db_path``."""
|
|
50
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME", "") or str(
|
|
51
|
+
Path("~/.cache").expanduser()
|
|
52
|
+
)
|
|
53
|
+
return Path(xdg_cache) / "clau-decode" / "index.db"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_db_path() -> Path:
|
|
57
|
+
"""Return the path to the clau-decode SQLite database.
|
|
58
|
+
|
|
59
|
+
Stored under the DURABLE data dir (``XDG_DATA_HOME`` / ``~/.local/share``),
|
|
60
|
+
NOT the cache dir: the DB holds non-regenerable user intent (archived /
|
|
61
|
+
starred / viewed flags + custom titles in ``session_meta``), which an OS or
|
|
62
|
+
cache cleaner could otherwise wipe. The message index is regenerable but
|
|
63
|
+
lives here too so it persists across cache clears (no rescan needed).
|
|
64
|
+
|
|
65
|
+
On first use we transparently migrate a legacy ``~/.cache`` DB to the new
|
|
66
|
+
location (one-time copy, including any WAL/SHM sidecars so un-checkpointed
|
|
67
|
+
writes survive). The legacy file is left in place as a backstop.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
``<xdg_data>/clau-decode/index.db``
|
|
71
|
+
"""
|
|
72
|
+
xdg_data = os.environ.get("XDG_DATA_HOME", "") or str(
|
|
73
|
+
Path("~/.local/share").expanduser()
|
|
74
|
+
)
|
|
75
|
+
db_path = Path(xdg_data) / "clau-decode" / "index.db"
|
|
76
|
+
|
|
77
|
+
if not db_path.exists():
|
|
78
|
+
legacy = _legacy_cache_db_path()
|
|
79
|
+
if legacy.exists():
|
|
80
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
# Copy the main DB plus WAL/SHM so recent (un-checkpointed) writes
|
|
82
|
+
# — e.g. the latest archive/star — aren't lost in the move.
|
|
83
|
+
for suffix in ("", "-wal", "-shm"):
|
|
84
|
+
src = Path(str(legacy) + suffix)
|
|
85
|
+
if src.exists():
|
|
86
|
+
shutil.copy2(src, Path(str(db_path) + suffix))
|
|
87
|
+
return db_path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_config(
|
|
91
|
+
extra_paths: list[str] | None = None, port: int | None = None
|
|
92
|
+
) -> AppConfig:
|
|
93
|
+
"""Load config from disk and apply any CLI overrides.
|
|
94
|
+
|
|
95
|
+
If the config file does not exist, a default ``AppConfig()`` is returned.
|
|
96
|
+
CLI overrides are applied after loading:
|
|
97
|
+
- ``extra_paths``: appended to ``config.data_paths`` (deduplicated, order
|
|
98
|
+
preserved).
|
|
99
|
+
- ``port``: replaces ``config.port`` when provided.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
extra_paths: Additional scan paths supplied via ``--path`` flags.
|
|
103
|
+
port: Port override supplied via ``--port`` flag.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The resolved ``AppConfig``.
|
|
107
|
+
"""
|
|
108
|
+
path = get_config_path()
|
|
109
|
+
|
|
110
|
+
if path.exists():
|
|
111
|
+
raw = path.read_text(encoding="utf-8")
|
|
112
|
+
config = AppConfig.model_validate(json.loads(raw))
|
|
113
|
+
else:
|
|
114
|
+
config = AppConfig()
|
|
115
|
+
|
|
116
|
+
if extra_paths:
|
|
117
|
+
existing = set(config.data_paths)
|
|
118
|
+
for p in extra_paths:
|
|
119
|
+
if p not in existing:
|
|
120
|
+
config.data_paths.append(p)
|
|
121
|
+
existing.add(p)
|
|
122
|
+
|
|
123
|
+
if port is not None:
|
|
124
|
+
config.port = port
|
|
125
|
+
|
|
126
|
+
return config
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def save_config(config: AppConfig) -> None:
|
|
130
|
+
"""Atomically persist config to disk.
|
|
131
|
+
|
|
132
|
+
Writes to a ``.tmp`` file first, then uses ``os.replace`` for an atomic
|
|
133
|
+
rename so readers never see a partial write. Parent directories are
|
|
134
|
+
created automatically.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
config: The ``AppConfig`` to persist.
|
|
138
|
+
"""
|
|
139
|
+
path = get_config_path()
|
|
140
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
tmp_path = path.with_suffix(".tmp")
|
|
143
|
+
tmp_path.write_text(config.model_dump_json(indent=2), encoding="utf-8")
|
|
144
|
+
os.replace(tmp_path, path)
|