cctally 1.66.0 → 1.67.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.
- package/CHANGELOG.md +30 -0
- package/README.md +6 -4
- package/bin/_cctally_core.py +76 -0
- package/bin/_cctally_dashboard.py +14 -2
- package/bin/_cctally_dashboard_conversation.py +63 -4
- package/bin/_cctally_db.py +31 -0
- package/bin/_cctally_five_hour.py +17 -2
- package/bin/_cctally_forecast.py +40 -26
- package/bin/_cctally_parser.py +91 -0
- package/bin/_cctally_project.py +10 -37
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_five_hour.py +27 -0
- package/bin/_lib_render.py +8 -1
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +8 -0
- package/bin/cctally-transcript +5 -0
- package/dashboard/static/assets/index-BybNp_Di.js +80 -0
- package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
package/bin/_cctally_project.py
CHANGED
|
@@ -28,7 +28,7 @@ import sys
|
|
|
28
28
|
|
|
29
29
|
from _cctally_core import (
|
|
30
30
|
_command_as_of,
|
|
31
|
-
|
|
31
|
+
_floored_week_max,
|
|
32
32
|
eprint,
|
|
33
33
|
open_db,
|
|
34
34
|
parse_iso_datetime,
|
|
@@ -82,45 +82,18 @@ def _load_week_snapshots(
|
|
|
82
82
|
"AND datetime(week_end_at) > datetime(?)",
|
|
83
83
|
(until.isoformat(), since.isoformat()),
|
|
84
84
|
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
# mid-week credit doesn't re-inflate the per-project Used %. None means
|
|
88
|
-
# "no floor" — every captured row counts.
|
|
89
|
-
floor_epoch: dict[str, int | None] = {}
|
|
90
|
-
|
|
91
|
-
def _floor_for(wsd, ws_iso, we_iso):
|
|
92
|
-
if wsd not in floor_epoch:
|
|
93
|
-
f_iso = _reset_aware_floor(conn, wsd, ws_iso, we_iso)
|
|
94
|
-
floor_epoch[wsd] = (
|
|
95
|
-
int(parse_iso_datetime(f_iso, "project.floor").timestamp())
|
|
96
|
-
if f_iso else None
|
|
97
|
-
)
|
|
98
|
-
return floor_epoch[wsd]
|
|
99
|
-
|
|
100
|
-
result: dict[dt.datetime, float] = {}
|
|
101
|
-
for wsd, ws_iso, we_iso, cap_iso, pct in rows:
|
|
85
|
+
rows_in = []
|
|
86
|
+
for wsd, ws_iso, we_iso, cap_iso, pct in cur.fetchall():
|
|
102
87
|
if ws_iso is None or pct is None:
|
|
103
88
|
continue
|
|
104
|
-
|
|
105
|
-
if floor is not None and cap_iso is not None:
|
|
106
|
-
try:
|
|
107
|
-
cap_epoch = int(
|
|
108
|
-
parse_iso_datetime(str(cap_iso), "project.cap").timestamp()
|
|
109
|
-
)
|
|
110
|
-
except ValueError:
|
|
111
|
-
cap_epoch = None
|
|
112
|
-
# Drop pre-floor (stale pre-credit) snapshots from the MAX.
|
|
113
|
-
if cap_epoch is not None and cap_epoch < floor:
|
|
114
|
-
continue
|
|
115
|
-
ws = dt.datetime.fromisoformat(
|
|
116
|
-
str(ws_iso).replace("Z", "+00:00")
|
|
117
|
-
)
|
|
89
|
+
ws = dt.datetime.fromisoformat(str(ws_iso).replace("Z", "+00:00"))
|
|
118
90
|
key = ws.astimezone(dt.timezone.utc)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
91
|
+
rows_in.append((key, wsd, ws_iso, we_iso, cap_iso, pct))
|
|
92
|
+
# Reset-aware per-week max (#290): the shared reducer resolves each
|
|
93
|
+
# week's clamp floor once (keyed on week_start_date) and drops stale
|
|
94
|
+
# pre-credit captures before taking the per-week maximum. The query
|
|
95
|
+
# above already filters NULL bounds, so canonical bounds are present.
|
|
96
|
+
return _floored_week_max(conn, rows_in)
|
|
124
97
|
finally:
|
|
125
98
|
conn.close()
|
|
126
99
|
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -64,6 +64,7 @@ import pathlib
|
|
|
64
64
|
import shutil
|
|
65
65
|
import subprocess
|
|
66
66
|
import sys
|
|
67
|
+
import threading
|
|
67
68
|
import time
|
|
68
69
|
|
|
69
70
|
|
|
@@ -1769,6 +1770,84 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1769
1770
|
return 0
|
|
1770
1771
|
|
|
1771
1772
|
|
|
1773
|
+
_SETUP_PROGRESS_MIN_INTERVAL_SECONDS = 1.0
|
|
1774
|
+
_SETUP_PROGRESS_HEARTBEAT_POLL_SECONDS = 0.5
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def _setup_progress_enabled(*, json_mode: bool) -> bool:
|
|
1778
|
+
"""Whether to emit setup cold-sync progress to stderr (issue #281 S7 / R6).
|
|
1779
|
+
|
|
1780
|
+
``CCTALLY_SETUP_PROGRESS='1'`` forces on, ``'0'`` forces off (both
|
|
1781
|
+
directions: the golden harness force-suppresses, the timed
|
|
1782
|
+
fixture-ladder run force-observes). Otherwise: on iff stderr is a TTY
|
|
1783
|
+
and not ``--json`` (json is machine-readable — auto-suppress the
|
|
1784
|
+
friendly notices there). Gating on ``sys.stderr`` (not stdout) is
|
|
1785
|
+
correct because progress goes to stderr, so a user who pipes stdout
|
|
1786
|
+
still sees it while the harness (command-substitution pipe) reads
|
|
1787
|
+
non-TTY.
|
|
1788
|
+
"""
|
|
1789
|
+
env = os.environ.get("CCTALLY_SETUP_PROGRESS")
|
|
1790
|
+
if env == "1":
|
|
1791
|
+
return True
|
|
1792
|
+
if env == "0":
|
|
1793
|
+
return False
|
|
1794
|
+
return (not json_mode) and sys.stderr.isatty()
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
class _SetupProgressReporter:
|
|
1798
|
+
"""Best-effort stderr progress for setup's cold-sync bootstrap.
|
|
1799
|
+
|
|
1800
|
+
Coordinates the per-file ``sync_cache(progress=)`` callback with a
|
|
1801
|
+
background heartbeat so neither a single giant JSONL nor a slow OAuth
|
|
1802
|
+
fetch leaves a silent gap > ~2s. All emissions throttle off one shared
|
|
1803
|
+
monotonic ``last_emit`` under a lock; ``force=True`` bypasses the
|
|
1804
|
+
throttle (used for the pre-sync and pre-OAuth notices). A no-op (and no
|
|
1805
|
+
thread) when disabled — so it is inert in the golden harness / non-TTY
|
|
1806
|
+
/ ``--json``.
|
|
1807
|
+
"""
|
|
1808
|
+
|
|
1809
|
+
def __init__(self, enabled: bool) -> None:
|
|
1810
|
+
self.enabled = enabled
|
|
1811
|
+
self._last_emit = 0.0
|
|
1812
|
+
self._lock = threading.Lock()
|
|
1813
|
+
self._stop = threading.Event()
|
|
1814
|
+
self._thread: "threading.Thread | None" = None
|
|
1815
|
+
|
|
1816
|
+
def emit(self, msg: str, *, force: bool = False) -> None:
|
|
1817
|
+
if not self.enabled:
|
|
1818
|
+
return
|
|
1819
|
+
with self._lock:
|
|
1820
|
+
now = time.monotonic()
|
|
1821
|
+
if not force and (now - self._last_emit) < _SETUP_PROGRESS_MIN_INTERVAL_SECONDS:
|
|
1822
|
+
return
|
|
1823
|
+
self._last_emit = now
|
|
1824
|
+
eprint(msg)
|
|
1825
|
+
|
|
1826
|
+
def sync_callback(self, stats) -> None:
|
|
1827
|
+
# `stats` is a duck-typed cache.IngestStats (reads files_processed /
|
|
1828
|
+
# files_total). sync_cache invokes this per file; emit() throttles.
|
|
1829
|
+
self.emit(f" … synced {stats.files_processed}/{stats.files_total} session files")
|
|
1830
|
+
|
|
1831
|
+
def _heartbeat_loop(self) -> None:
|
|
1832
|
+
# Fires only when no per-file line / notice emitted in the last
|
|
1833
|
+
# interval, bounding intra-file and intra-fetch silence.
|
|
1834
|
+
while not self._stop.wait(_SETUP_PROGRESS_HEARTBEAT_POLL_SECONDS):
|
|
1835
|
+
self.emit(" … still working…")
|
|
1836
|
+
|
|
1837
|
+
def __enter__(self) -> "_SetupProgressReporter":
|
|
1838
|
+
if self.enabled:
|
|
1839
|
+
self._thread = threading.Thread(
|
|
1840
|
+
target=self._heartbeat_loop, name="cctally-setup-progress", daemon=True
|
|
1841
|
+
)
|
|
1842
|
+
self._thread.start()
|
|
1843
|
+
return self
|
|
1844
|
+
|
|
1845
|
+
def __exit__(self, *exc) -> None:
|
|
1846
|
+
self._stop.set()
|
|
1847
|
+
if self._thread is not None:
|
|
1848
|
+
self._thread.join(timeout=1.0)
|
|
1849
|
+
|
|
1850
|
+
|
|
1772
1851
|
def _setup_emit_text(lines: list[str]) -> None:
|
|
1773
1852
|
for ln in lines:
|
|
1774
1853
|
print(ln)
|
|
@@ -2075,49 +2154,72 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2075
2154
|
out.append(" leave (data is funneled correctly either way), but you can remove")
|
|
2076
2155
|
out.append(" it whenever you want. We won't touch the file.")
|
|
2077
2156
|
|
|
2078
|
-
# Bootstrap (non-fatal).
|
|
2079
|
-
#
|
|
2157
|
+
# Bootstrap (non-fatal). Progress is stderr-only + TTY-gated so the
|
|
2158
|
+
# buffered stdout transcript and --json envelope are unchanged (issue
|
|
2159
|
+
# #281 S7 / R6). The reporter wraps BOTH the cold-sync ingest and the
|
|
2160
|
+
# OAuth fetch under one daemon heartbeat, so neither a single giant
|
|
2161
|
+
# JSONL nor a slow OAuth network call leaves a silent gap > ~2s.
|
|
2162
|
+
# sync_cache is untouched — we only pass its existing progress= seam.
|
|
2080
2163
|
bootstrap_rows: int | None = None
|
|
2081
2164
|
bootstrap_oauth_status: str | None = None
|
|
2082
|
-
|
|
2083
|
-
|
|
2165
|
+
progress = _SetupProgressReporter(
|
|
2166
|
+
_setup_progress_enabled(json_mode=getattr(args, "json", False))
|
|
2167
|
+
)
|
|
2168
|
+
with progress:
|
|
2169
|
+
progress.emit(
|
|
2170
|
+
"⏳ Syncing session history (first run can take a moment on a large history)…",
|
|
2171
|
+
force=True,
|
|
2172
|
+
)
|
|
2084
2173
|
try:
|
|
2085
|
-
|
|
2086
|
-
rows = int(stats.rows_changed)
|
|
2087
|
-
finally:
|
|
2174
|
+
cache_conn = c.open_cache_db()
|
|
2088
2175
|
try:
|
|
2089
|
-
cache_conn.
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
# to the operator that the cache is alive, and (b) the dominant
|
|
2102
|
-
# case (first install) reads literally.
|
|
2103
|
-
out.append(f"✓ Synced session cache ({rows} new entries)")
|
|
2104
|
-
except Exception as exc:
|
|
2105
|
-
out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
|
|
2106
|
-
warnings += 1
|
|
2107
|
-
if oauth:
|
|
2108
|
-
try:
|
|
2109
|
-
status, _ = c._hook_tick_oauth_refresh()
|
|
2110
|
-
bootstrap_oauth_status = status
|
|
2111
|
-
if status.startswith("ok"):
|
|
2112
|
-
c._hook_tick_throttle_touch()
|
|
2113
|
-
out.append(f"✓ Bootstrapped weekly usage ({status})")
|
|
2114
|
-
else:
|
|
2115
|
-
out.append(f"⚠ Bootstrap OAuth fetch: {status}")
|
|
2176
|
+
stats = c.sync_cache(cache_conn, progress=progress.sync_callback)
|
|
2177
|
+
finally:
|
|
2178
|
+
try:
|
|
2179
|
+
cache_conn.close()
|
|
2180
|
+
except Exception:
|
|
2181
|
+
pass
|
|
2182
|
+
if stats.lock_contended:
|
|
2183
|
+
# Another process holds the cache flock — nothing was
|
|
2184
|
+
# ingested (sync_cache short-circuits with rows_changed=0).
|
|
2185
|
+
# Report honestly rather than a false "Synced (0 new
|
|
2186
|
+
# entries)"; leave bootstrap_rows None for the envelope.
|
|
2187
|
+
out.append("⚠ Another cache sync is in progress; using existing cache.")
|
|
2116
2188
|
warnings += 1
|
|
2189
|
+
bootstrap_rows = None
|
|
2190
|
+
else:
|
|
2191
|
+
rows = int(stats.rows_changed)
|
|
2192
|
+
bootstrap_rows = rows
|
|
2193
|
+
# `rows` counts both genuine INSERTs and ccusage-parity DO
|
|
2194
|
+
# UPDATE replacements (see IngestStats.rows_changed). On
|
|
2195
|
+
# first install this is always 0-vs-N pure inserts (cache is
|
|
2196
|
+
# empty), so "N new entries" is exactly accurate. On a
|
|
2197
|
+
# re-install / upgrade path with active sessions, `rows` also
|
|
2198
|
+
# counts UPSERT replacements (streaming-vs-final tiebreaker
|
|
2199
|
+
# swaps), so the count is more accurately "ingest activity"
|
|
2200
|
+
# than "rows newly added" — but we keep "new entries" because
|
|
2201
|
+
# (a) it's still a useful signal to the operator that the
|
|
2202
|
+
# cache is alive, and (b) the dominant case (first install)
|
|
2203
|
+
# reads literally.
|
|
2204
|
+
out.append(f"✓ Synced session cache ({rows} new entries)")
|
|
2117
2205
|
except Exception as exc:
|
|
2118
|
-
|
|
2119
|
-
out.append(f"⚠ Bootstrap OAuth failed: {exc}")
|
|
2206
|
+
out.append(f"⚠ sync_cache during bootstrap failed: {exc}")
|
|
2120
2207
|
warnings += 1
|
|
2208
|
+
if oauth:
|
|
2209
|
+
progress.emit("⏳ Fetching current usage…", force=True)
|
|
2210
|
+
try:
|
|
2211
|
+
status, _ = c._hook_tick_oauth_refresh()
|
|
2212
|
+
bootstrap_oauth_status = status
|
|
2213
|
+
if status.startswith("ok"):
|
|
2214
|
+
c._hook_tick_throttle_touch()
|
|
2215
|
+
out.append(f"✓ Bootstrapped weekly usage ({status})")
|
|
2216
|
+
else:
|
|
2217
|
+
out.append(f"⚠ Bootstrap OAuth fetch: {status}")
|
|
2218
|
+
warnings += 1
|
|
2219
|
+
except Exception as exc:
|
|
2220
|
+
bootstrap_oauth_status = f"err({type(exc).__name__})"
|
|
2221
|
+
out.append(f"⚠ Bootstrap OAuth failed: {exc}")
|
|
2222
|
+
warnings += 1
|
|
2121
2223
|
|
|
2122
2224
|
out.append("")
|
|
2123
2225
|
if warnings:
|
|
@@ -2125,13 +2227,6 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2125
2227
|
else:
|
|
2126
2228
|
out.append("cctally is ready.")
|
|
2127
2229
|
out.append("")
|
|
2128
|
-
# Settings.json was modified — CC caches it at session start. The
|
|
2129
|
-
# warning fires unconditionally because `_setup_install` always
|
|
2130
|
-
# rewrites settings.json (legacy migration, fresh install, repair).
|
|
2131
|
-
out.append("⚠ Restart Claude Code for the new hooks to take effect in any currently")
|
|
2132
|
-
out.append(" open sessions. New sessions launched after this point pick them up")
|
|
2133
|
-
out.append(" automatically. (settings.json is cached at session start.)")
|
|
2134
|
-
out.append("")
|
|
2135
2230
|
out.append(" Try:")
|
|
2136
2231
|
out.append(" cctally daily # last 30 days")
|
|
2137
2232
|
out.append(" cctally dashboard # live web dashboard")
|
|
@@ -2151,6 +2246,18 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2151
2246
|
out.append("Opt out anytime: cctally telemetry off (or CCTALLY_DISABLE_TELEMETRY=1)")
|
|
2152
2247
|
out.append("How it works: https://github.com/omrikais/cctally/blob/main/docs/telemetry.md")
|
|
2153
2248
|
|
|
2249
|
+
# ▶ Next step LAST: the sole required action, set off from the wall of
|
|
2250
|
+
# text (issue #281 S7 / R6 — it was previously buried mid-dump, right
|
|
2251
|
+
# after the readiness line and followed by ~12 more lines). It's an
|
|
2252
|
+
# action, not a warning, so it drops the `⚠` and never touched the
|
|
2253
|
+
# `warnings` count. settings.json is cached at session start, so open
|
|
2254
|
+
# sessions need a restart; `_setup_install` always rewrites settings.json
|
|
2255
|
+
# (legacy migration, fresh install, repair), so this always applies.
|
|
2256
|
+
out.append("")
|
|
2257
|
+
out.append("▶ Next step: restart Claude Code to activate the new hooks.")
|
|
2258
|
+
out.append(" Open sessions won't record until you restart (settings.json is cached at")
|
|
2259
|
+
out.append(" session start). New sessions started after this pick them up automatically.")
|
|
2260
|
+
|
|
2154
2261
|
if getattr(args, "json", False):
|
|
2155
2262
|
# JSON-safe telemetry disclosure (spec 2026-07-07 §4): a structured
|
|
2156
2263
|
# field, never prose. Resolved READ-ONLY via `resolve_telemetry_state`
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""cctally transcript — anonymized-by-default export + raw cross-session search
|
|
2
|
+
over the conversation cache (#281 S4 R4).
|
|
3
|
+
|
|
4
|
+
Thin CLI wrappers over the SAME kernels the dashboard HTTP routes use
|
|
5
|
+
(``get_conversation_export`` / ``search_conversations`` / ``build_anon_plan_for_db``
|
|
6
|
+
/ ``scrub_text``), so ``transcript export`` byte-matches ``GET …/export`` and the
|
|
7
|
+
viewer becomes scriptable.
|
|
8
|
+
|
|
9
|
+
- ``export``: anonymized Markdown by default; ``--raw`` disables the whole scrub
|
|
10
|
+
(identity + secrets) and is byte-identical to the dashboard's raw export.
|
|
11
|
+
Byte-exact emission: ``sys.stdout.buffer.write`` of the exact UTF-8 bytes (no
|
|
12
|
+
``print``, no added trailing newline — the render already ends in exactly one),
|
|
13
|
+
or ``--output PATH`` writes the same exact bytes; nothing else on stdout.
|
|
14
|
+
Unknown session → ``transcript: …`` on stderr, exit 1.
|
|
15
|
+
- ``search``: RAW output (a navigation surface, not a sharing artifact). Mirrors
|
|
16
|
+
the full HTTP filter surface; date-only values parse through the SAME
|
|
17
|
+
display-tz-aware helper the HTTP handler uses. Human table by default; ``--json``
|
|
18
|
+
emits a ``schemaVersion``-stamped, explicitly camelCased envelope.
|
|
19
|
+
|
|
20
|
+
Accessor discipline: no ``_cctally_*`` sibling is imported directly; every helper
|
|
21
|
+
is reached via the call-time ``_cctally()`` accessor / ``_load_sibling`` (matching
|
|
22
|
+
the other command modules), except the pure ``_lib_fmt._boxed_table`` renderer.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import pathlib
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
from _cctally_core import eprint
|
|
31
|
+
from _lib_fmt import _boxed_table
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cctally():
|
|
35
|
+
"""Call-time accessor to the cctally module namespace (ns-patchable)."""
|
|
36
|
+
return sys.modules["cctally"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_transcript(args) -> int:
|
|
40
|
+
action = getattr(args, "transcript_action", None)
|
|
41
|
+
if action == "export":
|
|
42
|
+
return _cmd_transcript_export(args)
|
|
43
|
+
if action == "search":
|
|
44
|
+
return _cmd_transcript_search(args)
|
|
45
|
+
eprint("transcript: expected a subcommand (`export` or `search`)")
|
|
46
|
+
return 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---- export ----------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def _cmd_transcript_export(args) -> int:
|
|
52
|
+
c = _cctally()
|
|
53
|
+
session_id = args.session_id
|
|
54
|
+
scope = getattr(args, "scope", "all")
|
|
55
|
+
raw = bool(getattr(args, "raw", False))
|
|
56
|
+
output = getattr(args, "output", None)
|
|
57
|
+
|
|
58
|
+
conn = c.open_cache_db()
|
|
59
|
+
try:
|
|
60
|
+
cq = c._load_sibling("_lib_conversation_query")
|
|
61
|
+
md = cq.get_conversation_export(conn, session_id, scope)
|
|
62
|
+
if md is None:
|
|
63
|
+
eprint(f"transcript: no conversation found for session {session_id!r}")
|
|
64
|
+
return 1
|
|
65
|
+
if not raw:
|
|
66
|
+
anon = c._load_sibling("_lib_conversation_anon")
|
|
67
|
+
plan = cq.build_anon_plan_for_db(conn, home_dir=os.path.expanduser("~"))
|
|
68
|
+
md = anon.scrub_text(md, plan)
|
|
69
|
+
finally:
|
|
70
|
+
conn.close()
|
|
71
|
+
|
|
72
|
+
data = md.encode("utf-8")
|
|
73
|
+
if output:
|
|
74
|
+
pathlib.Path(output).write_bytes(data)
|
|
75
|
+
else:
|
|
76
|
+
# Byte-exact: raw bytes to stdout, no `print`, no added trailing newline.
|
|
77
|
+
sys.stdout.buffer.write(data)
|
|
78
|
+
sys.stdout.buffer.flush()
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---- search ----------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _cmd_transcript_search(args) -> int:
|
|
85
|
+
c = _cctally()
|
|
86
|
+
query = args.query
|
|
87
|
+
kind = getattr(args, "kind", "all")
|
|
88
|
+
limit = getattr(args, "limit", 50)
|
|
89
|
+
offset = getattr(args, "offset", 0)
|
|
90
|
+
projects = getattr(args, "project", None) or None
|
|
91
|
+
models = getattr(args, "model", None) or None
|
|
92
|
+
cost_min = getattr(args, "cost_min", None)
|
|
93
|
+
cost_max = getattr(args, "cost_max", None)
|
|
94
|
+
rebuild_min = getattr(args, "rebuild_min", None)
|
|
95
|
+
|
|
96
|
+
# Date-only bounds parse through the SAME display-tz-aware helper the HTTP
|
|
97
|
+
# filter handler uses (no second parser) — reuse, don't reimplement.
|
|
98
|
+
date_from_in = getattr(args, "date_from", None)
|
|
99
|
+
date_to_in = getattr(args, "date_to", None)
|
|
100
|
+
df = dtt = None
|
|
101
|
+
if date_from_in or date_to_in:
|
|
102
|
+
tz = c.resolve_display_tz(args, c.load_config())
|
|
103
|
+
tz_name = tz.key if tz is not None else None
|
|
104
|
+
dates = c._load_sibling("_lib_dashboard_dates")
|
|
105
|
+
try:
|
|
106
|
+
df, dtt = dates.parse_filter_date_range(
|
|
107
|
+
date_from_in, date_to_in, tz_name=tz_name)
|
|
108
|
+
except ValueError as exc:
|
|
109
|
+
eprint(f"transcript: {exc}")
|
|
110
|
+
return 2
|
|
111
|
+
|
|
112
|
+
conn = c.open_cache_db()
|
|
113
|
+
try:
|
|
114
|
+
cq = c._load_sibling("_lib_conversation_query")
|
|
115
|
+
try:
|
|
116
|
+
result = cq.search_conversations(
|
|
117
|
+
conn, query, limit=limit, offset=offset, kind=kind,
|
|
118
|
+
date_from=df, date_to=dtt, projects=projects, models=models,
|
|
119
|
+
cost_min=cost_min, cost_max=cost_max, rebuild_min=rebuild_min)
|
|
120
|
+
except ValueError as exc: # unknown kind (belt-and-suspenders)
|
|
121
|
+
eprint(f"transcript: {exc}")
|
|
122
|
+
return 2
|
|
123
|
+
finally:
|
|
124
|
+
conn.close()
|
|
125
|
+
|
|
126
|
+
if getattr(args, "json", False):
|
|
127
|
+
payload = c.stamp_schema_version(_search_to_camel(result))
|
|
128
|
+
print(_json_dumps(payload))
|
|
129
|
+
return 0
|
|
130
|
+
_render_search_table(result)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _json_dumps(payload) -> str:
|
|
135
|
+
import json
|
|
136
|
+
return json.dumps(payload, indent=2)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _search_to_camel(result: dict) -> dict:
|
|
140
|
+
"""Explicit recursive camelCase mapping of the ``search_conversations`` result
|
|
141
|
+
(this command's own serializer — ``stamp_schema_version`` only inserts the
|
|
142
|
+
leading key). Top level then hit level, per spec §7."""
|
|
143
|
+
out = {
|
|
144
|
+
"query": result.get("query", ""),
|
|
145
|
+
"mode": result.get("mode"),
|
|
146
|
+
"hits": [_hit_to_camel(h) for h in result.get("hits", [])],
|
|
147
|
+
"total": result.get("total", 0),
|
|
148
|
+
"kind": result.get("kind"),
|
|
149
|
+
"searchDepth": result.get("search_depth"),
|
|
150
|
+
}
|
|
151
|
+
if result.get("filter_degraded"):
|
|
152
|
+
out["filterDegraded"] = True
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _hit_to_camel(h: dict) -> dict:
|
|
157
|
+
return {
|
|
158
|
+
"sessionId": h.get("session_id"),
|
|
159
|
+
"uuid": h.get("uuid"),
|
|
160
|
+
"projectLabel": h.get("project_label"),
|
|
161
|
+
"title": h.get("title"),
|
|
162
|
+
"ts": h.get("ts"),
|
|
163
|
+
"snippet": h.get("snippet"),
|
|
164
|
+
"matchKinds": h.get("match_kinds", []),
|
|
165
|
+
"costUsd": h.get("cost_usd", 0.0),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _render_search_table(result: dict) -> None:
|
|
170
|
+
hits = result.get("hits", [])
|
|
171
|
+
if not hits:
|
|
172
|
+
print("No matching transcripts.")
|
|
173
|
+
return
|
|
174
|
+
rows = []
|
|
175
|
+
for h in hits:
|
|
176
|
+
kinds = ",".join(h.get("match_kinds") or []) or "-"
|
|
177
|
+
rows.append([
|
|
178
|
+
h.get("session_id") or "",
|
|
179
|
+
h.get("ts") or "",
|
|
180
|
+
h.get("project_label") or "",
|
|
181
|
+
kinds,
|
|
182
|
+
(h.get("snippet") or "").strip(),
|
|
183
|
+
])
|
|
184
|
+
table = _boxed_table(
|
|
185
|
+
["Session", "When", "Project", "Kinds", "Snippet"], rows,
|
|
186
|
+
["left", "left", "left", "left", "left"])
|
|
187
|
+
print(table)
|
|
188
|
+
total = result.get("total", len(hits))
|
|
189
|
+
depth = result.get("search_depth")
|
|
190
|
+
suffix = f" (search depth: {depth})" if depth and depth != "full" else ""
|
|
191
|
+
print(f"\n{len(hits)} of {total} match(es){suffix}")
|