cctally 1.66.0 → 1.67.1
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 +35 -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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Pure, I/O-free anonymization kernel for conversation sharing (#281 S4).
|
|
2
|
+
|
|
3
|
+
The single scrub chokepoint for the "share this session" flow. Mirrors the
|
|
4
|
+
``_lib_share.py`` / ``_lib_conversation_export.py`` purity contract: no DB, no
|
|
5
|
+
filesystem, no env reads, no clock — every input is injected. The whole export
|
|
6
|
+
(and every per-card copy) passes through :func:`scrub_text`, so no field can
|
|
7
|
+
bypass the redaction, and one mechanism serves both the server exports and the
|
|
8
|
+
client-side copies (the TS applier is a dumb executor of :func:`plan_to_wire`).
|
|
9
|
+
|
|
10
|
+
Two passes, in order:
|
|
11
|
+
|
|
12
|
+
1. **Identity** — a SINGLE pass over the original text. All identity tokens
|
|
13
|
+
(observed project roots → ``project-N``, their dash-encoded dirname variants
|
|
14
|
+
→ ``-project-N``, home dirs → ``~``, usernames → ``user``, and project labels
|
|
15
|
+
→ their root's ``project-N``) compile into ONE alternation regex; each match
|
|
16
|
+
is resolved through a lookup map. Because a replacement's output is never
|
|
17
|
+
re-scanned, generated tokens cannot be re-matched — this is what makes
|
|
18
|
+
``scrub_text(scrub_text(x)) == scrub_text(x)`` structural, not aspirational.
|
|
19
|
+
Bounded tokens (labels, usernames) carry an explicit shared ASCII boundary
|
|
20
|
+
(``A-Za-z0-9_.-`` single-char lookarounds, deliberately NOT ``\\b``); the
|
|
21
|
+
trailing ``-`` in that class is what protects a generated ``project-N`` from
|
|
22
|
+
re-matching the bare ``project`` label. A lookup miss (impossible by
|
|
23
|
+
construction) yields the fixed sentinel ``ANON_SENTINEL`` — never passthrough.
|
|
24
|
+
|
|
25
|
+
**Trie compilation (perf, byte-identical).** The tokens are globally ordered
|
|
26
|
+
longest-text-first, so a flat ``t1|t2|…`` alternation is *longest-match-wins*
|
|
27
|
+
(Python ``re`` takes the first listed alternative that matches, and the first
|
|
28
|
+
that can match at any position is the longest, since two equal-length literals
|
|
29
|
+
cannot both match one position). A flat alternation over ~1300 tokens is
|
|
30
|
+
O(text × alternatives), though — every alternative is retried at every
|
|
31
|
+
position. So :func:`_build_identity_pattern` collapses each MAXIMAL
|
|
32
|
+
CONSECUTIVE RUN of same-boundedness tokens into a trie-structured subgroup
|
|
33
|
+
(shared prefixes fold into nested ``(?:…)`` groups; a node that is both
|
|
34
|
+
end-of-word and has children makes the children group optional-greedy so the
|
|
35
|
+
longest branch wins). An unbounded run is a bare trie; a bounded run — whose
|
|
36
|
+
members share the *same* lookarounds — is one trie wrapped in a single
|
|
37
|
+
``(?<![B])(?:…)(?![B])`` guard (the trailing lookahead backtracks through the
|
|
38
|
+
trie's greedy optionals to the longest boundary-clearing member, exactly as
|
|
39
|
+
the per-singleton flat form would). Bounded runs merge too because real DB
|
|
40
|
+
plans are heavily bounded (~26% project-label tokens), so leaving them
|
|
41
|
+
singleton would keep the identity pass O(text × labels). This is
|
|
42
|
+
byte-identical to the flat alternation *by
|
|
43
|
+
construction*: within a chunk the trie yields the longest matching member;
|
|
44
|
+
across chunks/singletons the first alternative with any match wins, and
|
|
45
|
+
because every token in an earlier chunk is ≥ every later alternative in
|
|
46
|
+
length, "first chunk that matches" is exactly "longest flat alternative that
|
|
47
|
+
matches" (proof + equivalence property test in ``test_conversation_anon``).
|
|
48
|
+
``plan_to_wire`` and the TS applier are UNTOUCHED (per-card texts are small).
|
|
49
|
+
|
|
50
|
+
2. **Secrets** — a small documented, high-precision pattern set
|
|
51
|
+
(:data:`SECRET_PATTERNS`), applied AFTER identity. Each pattern is a
|
|
52
|
+
cross-language descriptor (valid in BOTH Python ``re`` and JS ``RegExp``): no
|
|
53
|
+
inline flags, no named groups, no lookbehind, no ``$n`` templates. The
|
|
54
|
+
effective replacement is ``(group 1 if keep_group_1 else "") + [REDACTED:<name>]``.
|
|
55
|
+
|
|
56
|
+
**Honest guarantee (no over-claiming, #280 privacy):** the tested claim is
|
|
57
|
+
"zero *known* identity tokens survive" — observed project roots/labels, home
|
|
58
|
+
dirs, usernames, plus the documented secret patterns. NOT comprehensively
|
|
59
|
+
detected or guaranteed removed in v1: emails, IPs, hostnames, remote URLs,
|
|
60
|
+
session IDs, and any project identity absent from cctally's DB. Best-effort over
|
|
61
|
+
known tokens; review before sharing. See ``docs/commands/transcript.md``.
|
|
62
|
+
"""
|
|
63
|
+
from __future__ import annotations
|
|
64
|
+
|
|
65
|
+
import dataclasses
|
|
66
|
+
import re
|
|
67
|
+
|
|
68
|
+
ANON_SENTINEL = "(unknown)"
|
|
69
|
+
# Shared Python/JS boundary class for bounded tokens (deliberately NOT `\b`,
|
|
70
|
+
# whose Unicode behavior differs across runtimes). Single-char lookarounds are
|
|
71
|
+
# fixed-width — legal in Python `re` and identical in JS.
|
|
72
|
+
_BOUNDARY = "A-Za-z0-9_.-"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclasses.dataclass(frozen=True)
|
|
76
|
+
class SecretPattern:
|
|
77
|
+
"""A cross-language secret descriptor. ``source`` must compile in BOTH
|
|
78
|
+
Python ``re`` and JS ``RegExp`` (no inline flags / named groups / lookbehind).
|
|
79
|
+
``ignore_case`` is a language-neutral boolean (``re.IGNORECASE`` / JS ``i``);
|
|
80
|
+
``keep_group_1`` preserves capture group 1 (the key + separator) before the
|
|
81
|
+
``[REDACTED:<name>]`` marker."""
|
|
82
|
+
name: str
|
|
83
|
+
source: str
|
|
84
|
+
ignore_case: bool
|
|
85
|
+
keep_group_1: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclasses.dataclass(frozen=True)
|
|
89
|
+
class AnonPlan:
|
|
90
|
+
"""An ordered identity token list (``(text, replacement, bounded)``,
|
|
91
|
+
longest-text-first, tie lexical) plus the secret-pattern tuple."""
|
|
92
|
+
tokens: tuple
|
|
93
|
+
patterns: tuple
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Ordered: authorization-header BEFORE bearer-token (an ``Authorization: Bearer
|
|
97
|
+
# …`` line must redact the whole value, not just the token after ``Bearer``);
|
|
98
|
+
# anthropic-key BEFORE generic-sk-key (the specific ``sk-ant-…`` name wins).
|
|
99
|
+
SECRET_PATTERNS = (
|
|
100
|
+
SecretPattern("authorization-header", r"(\bAuthorization:[ \t]*)\S[^\r\n]*", True, True),
|
|
101
|
+
SecretPattern("bearer-token", r"(\bBearer[ \t]+)[A-Za-z0-9._~+/=-]{16,}", True, True),
|
|
102
|
+
SecretPattern("anthropic-key", r"sk-ant-[A-Za-z0-9_-]{8,}", False, False),
|
|
103
|
+
SecretPattern("generic-sk-key", r"sk-[A-Za-z0-9_-]{20,}", False, False),
|
|
104
|
+
SecretPattern("github-token", r"(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{16,}", False, False),
|
|
105
|
+
SecretPattern("aws-access-key", r"AKIA[0-9A-Z]{16}", False, False),
|
|
106
|
+
SecretPattern("slack-token", r"xox[baprs]-[A-Za-z0-9-]{10,}", False, False),
|
|
107
|
+
SecretPattern(
|
|
108
|
+
"secret-assignment",
|
|
109
|
+
r"(\b(?:api[_-]?key|secret|token|passwd|password)\b[ \t]*[=:][ \t]*)"
|
|
110
|
+
r"(?:\"[^\"\r\n]{6,}\"|'[^'\r\n]{6,}'|[^\s\"']{6,})", True, True),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _dash_encode(path: str) -> str:
|
|
115
|
+
"""``/a/b`` -> ``-a-b`` — the Claude project-dir encoding (as it appears in
|
|
116
|
+
``~/.claude/projects/<encoded>`` and scratchpad paths)."""
|
|
117
|
+
return path.replace("/", "-")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_anon_plan(*, project_roots, home_dirs, usernames) -> AnonPlan:
|
|
121
|
+
"""Build an :class:`AnonPlan` from injected identity sources.
|
|
122
|
+
|
|
123
|
+
``project_roots`` maps root path -> label. Numbering is deterministic,
|
|
124
|
+
lexical by root path (``project-1..N``), so CLI and HTTP builds from the same
|
|
125
|
+
DB produce identical plans (byte-parity depends on this). Duplicate basenames
|
|
126
|
+
map the bare label to the LOWEST-numbered of their ``project-N`` targets
|
|
127
|
+
(``setdefault`` over the sorted roots).
|
|
128
|
+
"""
|
|
129
|
+
tokens: dict = {} # (text, bounded) -> replacement
|
|
130
|
+
label_target: dict = {} # label -> project-N (lowest-N wins)
|
|
131
|
+
for n, root in enumerate(sorted(project_roots), 1):
|
|
132
|
+
if not root:
|
|
133
|
+
continue
|
|
134
|
+
rep = f"project-{n}"
|
|
135
|
+
tokens[(root, False)] = rep
|
|
136
|
+
tokens[(_dash_encode(root), False)] = f"-{rep}"
|
|
137
|
+
label = project_roots[root]
|
|
138
|
+
if label:
|
|
139
|
+
label_target.setdefault(label, rep) # lowest-N claims the label
|
|
140
|
+
for label, rep in label_target.items():
|
|
141
|
+
tokens[(label, True)] = rep
|
|
142
|
+
for h in home_dirs:
|
|
143
|
+
if h:
|
|
144
|
+
tokens.setdefault((h, False), "~") # a root already present wins
|
|
145
|
+
for u in usernames:
|
|
146
|
+
if u:
|
|
147
|
+
tokens.setdefault((u, True), "user")
|
|
148
|
+
ordered = tuple(sorted(
|
|
149
|
+
((t, rep, b) for (t, b), rep in tokens.items()),
|
|
150
|
+
key=lambda x: (-len(x[0]), x[0])))
|
|
151
|
+
return AnonPlan(tokens=ordered, patterns=SECRET_PATTERNS)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# End-of-word sentinel for the trie (no token is the empty string, so "" cannot
|
|
155
|
+
# collide with a single-character child key).
|
|
156
|
+
_WORD_END = ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _trie_to_pattern(node: dict) -> str:
|
|
160
|
+
"""Render one trie node to a regex fragment that matches the LONGEST word
|
|
161
|
+
branch reachable from it. Children are keyed by distinct next-characters, so
|
|
162
|
+
the branches of a ``(?:…|…)`` group are mutually exclusive and their order is
|
|
163
|
+
irrelevant. When the node is also end-of-word AND has children, the whole
|
|
164
|
+
descent is wrapped optional-greedy (``(?:…)?``) so the longest branch is tried
|
|
165
|
+
first and a diverging tail backtracks to the shorter complete word. The regex
|
|
166
|
+
therefore only ever matches a COMPLETE inserted word (every accept state is an
|
|
167
|
+
end-of-word node), so ``m.group(0)`` is always a live lookup key."""
|
|
168
|
+
end = _WORD_END in node
|
|
169
|
+
alts = [re.escape(ch) + _trie_to_pattern(node[ch])
|
|
170
|
+
for ch in node if ch != _WORD_END]
|
|
171
|
+
if not alts:
|
|
172
|
+
return "" # pure leaf: the word ends here
|
|
173
|
+
if end:
|
|
174
|
+
return "(?:" + "|".join(alts) + ")?"
|
|
175
|
+
if len(alts) == 1:
|
|
176
|
+
return alts[0]
|
|
177
|
+
return "(?:" + "|".join(alts) + ")"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _build_identity_pattern(tokens: tuple) -> str:
|
|
181
|
+
"""Compile the ordered identity tokens to ONE alternation string. Each MAXIMAL
|
|
182
|
+
CONSECUTIVE RUN of same-boundedness tokens collapses into a trie subgroup
|
|
183
|
+
(:func:`_trie_to_pattern`); an unbounded run stays a bare trie, a bounded run
|
|
184
|
+
is wrapped once in the shared ``(?<![B])(?:…)(?![B])`` boundary guard. Matches
|
|
185
|
+
are byte-identical to a flat longest-first ``t1|t2|…`` alternation of the same
|
|
186
|
+
tokens (unbounded literal, bounded boundary-guarded singleton) — the
|
|
187
|
+
equivalence property test proves it against exactly that flat reference.
|
|
188
|
+
|
|
189
|
+
**Bounded runs merge too (byte-identical).** Every bounded token carries the
|
|
190
|
+
*same* ``_BOUNDARY`` lookarounds, so a run's members all start at the match
|
|
191
|
+
position and share one leading ``(?<![B])``; the trailing ``(?![B])`` sits
|
|
192
|
+
after the trie, and Python backtracks through the trie's greedy optionals to
|
|
193
|
+
the longest member whose trailing char clears the boundary — exactly the
|
|
194
|
+
member a flat ``…|(?<![B])bi(?![B])|…`` alternation (longest-first) would pick.
|
|
195
|
+
This matters on real data: an observed DB plan is ~26% bounded project-label
|
|
196
|
+
tokens (336 of 1286), and leaving those as singletons keeps the identity pass
|
|
197
|
+
O(text × labels) — the very blow-up the trie exists to remove."""
|
|
198
|
+
parts: list = []
|
|
199
|
+
run: list = [] # texts of the current same-boundedness run
|
|
200
|
+
run_bounded = False # boundedness of the current run
|
|
201
|
+
|
|
202
|
+
def _flush():
|
|
203
|
+
if not run:
|
|
204
|
+
return
|
|
205
|
+
root: dict = {}
|
|
206
|
+
for word in run:
|
|
207
|
+
node = root
|
|
208
|
+
for ch in word:
|
|
209
|
+
node = node.setdefault(ch, {})
|
|
210
|
+
node[_WORD_END] = True
|
|
211
|
+
frag = _trie_to_pattern(root)
|
|
212
|
+
if run_bounded:
|
|
213
|
+
frag = f"(?<![{_BOUNDARY}])(?:{frag})(?![{_BOUNDARY}])"
|
|
214
|
+
parts.append(frag)
|
|
215
|
+
run.clear()
|
|
216
|
+
|
|
217
|
+
for t, _rep, bounded in tokens:
|
|
218
|
+
if run and bounded != run_bounded:
|
|
219
|
+
_flush() # boundary between an unbounded/bounded run
|
|
220
|
+
run_bounded = bounded
|
|
221
|
+
run.append(t)
|
|
222
|
+
_flush()
|
|
223
|
+
return "|".join(parts)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def scrub_text(text: str, plan: AnonPlan) -> str:
|
|
227
|
+
"""Identity single-pass, then secrets. Idempotent (tested)."""
|
|
228
|
+
if plan.tokens:
|
|
229
|
+
lookup = {t: rep for t, rep, _ in plan.tokens}
|
|
230
|
+
text = re.compile(_build_identity_pattern(plan.tokens)).sub(
|
|
231
|
+
lambda m: lookup.get(m.group(0), ANON_SENTINEL), text)
|
|
232
|
+
for sp in plan.patterns:
|
|
233
|
+
flags = re.IGNORECASE if sp.ignore_case else 0
|
|
234
|
+
rx = re.compile(sp.source, flags)
|
|
235
|
+
|
|
236
|
+
def _repl(m, _sp=sp):
|
|
237
|
+
prefix = m.group(1) if _sp.keep_group_1 else ""
|
|
238
|
+
return f"{prefix}[REDACTED:{_sp.name}]"
|
|
239
|
+
|
|
240
|
+
text = rx.sub(_repl, text)
|
|
241
|
+
return text
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def plan_to_wire(plan: AnonPlan) -> dict:
|
|
245
|
+
"""Project an :class:`AnonPlan` to the JSON wire contract the TS applier
|
|
246
|
+
executes: ``{"tokens": [{"text","replacement","bounded"}...],
|
|
247
|
+
"patterns": [{"name","source","ignoreCase","keepGroup1"}...]}``."""
|
|
248
|
+
return {
|
|
249
|
+
"tokens": [{"text": t, "replacement": rep, "bounded": b}
|
|
250
|
+
for t, rep, b in plan.tokens],
|
|
251
|
+
"patterns": [{"name": sp.name, "source": sp.source,
|
|
252
|
+
"ignoreCase": sp.ignore_case, "keepGroup1": sp.keep_group_1}
|
|
253
|
+
for sp in plan.patterns],
|
|
254
|
+
}
|
|
@@ -2385,6 +2385,72 @@ def get_conversation_export(conn, session_id, scope):
|
|
|
2385
2385
|
title=title, session_id=session_id)
|
|
2386
2386
|
|
|
2387
2387
|
|
|
2388
|
+
def conversation_exists(conn, session_id) -> bool:
|
|
2389
|
+
"""Cheap existence probe (#281 S4) — True iff any ``conversation_messages``
|
|
2390
|
+
row carries ``session_id``. Mirrors ``_assemble_session``'s own existence
|
|
2391
|
+
check; the ``/anon-map`` route uses it for the sibling-parity 404 on an
|
|
2392
|
+
unknown id. An ``OperationalError`` (no table) reads as absent."""
|
|
2393
|
+
try:
|
|
2394
|
+
row = conn.execute(
|
|
2395
|
+
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
2396
|
+
(session_id,)).fetchone()
|
|
2397
|
+
except sqlite3.OperationalError:
|
|
2398
|
+
return False
|
|
2399
|
+
return row is not None
|
|
2400
|
+
|
|
2401
|
+
|
|
2402
|
+
def build_anon_plan_for_db(conn, *, home_dir):
|
|
2403
|
+
"""Assemble an :class:`_lib_conversation_anon.AnonPlan` from the DB's OBSERVED
|
|
2404
|
+
identity (#281 S4, spec §4). Thin I/O glue: SELECTs + ``_project_label`` +
|
|
2405
|
+
one ``build_anon_plan`` call — the ONE helper both the HTTP handlers and the
|
|
2406
|
+
CLI route through, so CLI↔HTTP byte-parity is structural.
|
|
2407
|
+
|
|
2408
|
+
Sources: distinct non-empty ``conversation_messages.cwd`` (the conversation
|
|
2409
|
+
layer's source of truth) UNION ``session_files.project_path`` (legacy/lossy
|
|
2410
|
+
fallback only). Labels derive per root via ``_project_label`` (observed CWDs
|
|
2411
|
+
— no git-root resolution in v1). Home-dir prefixes = the caller-supplied
|
|
2412
|
+
``home_dir`` (``os.path.expanduser('~')`` at the call site — the pure kernel
|
|
2413
|
+
never reads env) plus ``/Users/<x>`` / ``/home/<x>`` prefixes derived from
|
|
2414
|
+
observed CWDs; usernames are the basenames of those home dirs.
|
|
2415
|
+
"""
|
|
2416
|
+
from _lib_conversation_anon import build_anon_plan
|
|
2417
|
+
cwds: set = set()
|
|
2418
|
+
try:
|
|
2419
|
+
for (cwd,) in conn.execute(
|
|
2420
|
+
"SELECT DISTINCT cwd FROM conversation_messages "
|
|
2421
|
+
"WHERE cwd IS NOT NULL AND cwd != ''"):
|
|
2422
|
+
if cwd:
|
|
2423
|
+
cwds.add(cwd)
|
|
2424
|
+
except sqlite3.OperationalError:
|
|
2425
|
+
pass
|
|
2426
|
+
try:
|
|
2427
|
+
for (pp,) in conn.execute(
|
|
2428
|
+
"SELECT DISTINCT project_path FROM session_files "
|
|
2429
|
+
"WHERE project_path IS NOT NULL AND project_path != ''"):
|
|
2430
|
+
if pp:
|
|
2431
|
+
cwds.add(pp)
|
|
2432
|
+
except sqlite3.OperationalError:
|
|
2433
|
+
pass
|
|
2434
|
+
project_roots = {c: _project_label(c) for c in cwds}
|
|
2435
|
+
home_dirs: set = set()
|
|
2436
|
+
usernames: set = set()
|
|
2437
|
+
if home_dir:
|
|
2438
|
+
home_dirs.add(home_dir)
|
|
2439
|
+
u = os.path.basename(home_dir.rstrip("/"))
|
|
2440
|
+
if u:
|
|
2441
|
+
usernames.add(u)
|
|
2442
|
+
for c in cwds:
|
|
2443
|
+
for prefix in ("/Users/", "/home/"):
|
|
2444
|
+
if c.startswith(prefix):
|
|
2445
|
+
user = c[len(prefix):].split("/", 1)[0]
|
|
2446
|
+
if user:
|
|
2447
|
+
home_dirs.add(prefix + user)
|
|
2448
|
+
usernames.add(user)
|
|
2449
|
+
return build_anon_plan(
|
|
2450
|
+
project_roots=project_roots,
|
|
2451
|
+
home_dirs=sorted(home_dirs), usernames=sorted(usernames))
|
|
2452
|
+
|
|
2453
|
+
|
|
2388
2454
|
_TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
|
|
2389
2455
|
|
|
2390
2456
|
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -130,6 +130,7 @@ from _cctally_core import (
|
|
|
130
130
|
open_db,
|
|
131
131
|
_command_as_of,
|
|
132
132
|
_canonicalize_optional_iso,
|
|
133
|
+
_floored_week_max,
|
|
133
134
|
parse_iso_datetime,
|
|
134
135
|
)
|
|
135
136
|
|
|
@@ -659,8 +660,9 @@ def _diff_resolve_used_pct(window: ParsedWindow) -> tuple:
|
|
|
659
660
|
|
|
660
661
|
"avg" fires for windows spanning >= 2 full weeks (e.g., last-30d
|
|
661
662
|
over 4-5 weeks): we average max(weekly_percent) across the weeks
|
|
662
|
-
that have snapshot rows
|
|
663
|
-
|
|
663
|
+
that have snapshot rows — reset-aware floored per week (#290), so a
|
|
664
|
+
credited week contributes its post-credit value. If any subscription
|
|
665
|
+
week in [start, end) lacks a snapshot, mode is downgraded to `n/a`.
|
|
664
666
|
|
|
665
667
|
The "exact" lookup is constrained to the target week's
|
|
666
668
|
`week_start_date` so a window with no recorded snapshots correctly
|
|
@@ -693,14 +695,24 @@ def _diff_resolve_used_pct(window: ParsedWindow) -> tuple:
|
|
|
693
695
|
except Exception:
|
|
694
696
|
return None, "n/a"
|
|
695
697
|
try:
|
|
696
|
-
|
|
697
|
-
|
|
698
|
+
# #290: reset-aware-floor each week's peak so a credited week
|
|
699
|
+
# contributes its post-credit value (not the stale pre-credit peak),
|
|
700
|
+
# consistent with the exact branch + every other used-% surface.
|
|
701
|
+
# Raw rows (NO NULL-bounds filter: legacy NULL rows still count via
|
|
702
|
+
# the helper's NULL tolerance); helper reduces to floored per-week
|
|
703
|
+
# max keyed on week_start_date.
|
|
704
|
+
raw = conn.execute(
|
|
705
|
+
"SELECT week_start_date, week_start_at, week_end_at, "
|
|
706
|
+
" captured_at_utc, weekly_percent "
|
|
698
707
|
"FROM weekly_usage_snapshots "
|
|
699
|
-
"WHERE captured_at_utc >= ? AND captured_at_utc < ?
|
|
700
|
-
"GROUP BY week_start_date",
|
|
708
|
+
"WHERE captured_at_utc >= ? AND captured_at_utc < ?",
|
|
701
709
|
(_iso_z(window.start_utc), _iso_z(window.end_utc)),
|
|
702
710
|
).fetchall()
|
|
703
|
-
|
|
711
|
+
floored = _floored_week_max(
|
|
712
|
+
conn,
|
|
713
|
+
[(r[0], r[0], r[1], r[2], r[3], r[4]) for r in raw],
|
|
714
|
+
)
|
|
715
|
+
vals = list(floored.values())
|
|
704
716
|
if len(vals) < window.full_weeks_count:
|
|
705
717
|
# Spec §9.3: missing-coverage weeks would skew the avg —
|
|
706
718
|
# downgrade to n/a instead of reporting a misleading number.
|
package/bin/_lib_five_hour.py
CHANGED
|
@@ -81,6 +81,33 @@ def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
|
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def _round_to_ten_minutes(d: dt.datetime) -> dt.datetime:
|
|
85
|
+
"""Round a datetime to the NEAREST 10-minute boundary (half up).
|
|
86
|
+
|
|
87
|
+
The *display*-side companion to ``_floor_to_ten_minutes``. Anthropic
|
|
88
|
+
``rate_limits.5h.resets_at`` (and the derived ``block_start_at``,
|
|
89
|
+
``block_start = reset - 5h``) carries sub-10-minute capture jitter, so
|
|
90
|
+
a reset that truly lands on ``:40`` can be recorded as ``:39``.
|
|
91
|
+
Flooring that for display shows ``:30`` — off by a full bucket — so
|
|
92
|
+
every user-facing 5h-block clock time rounds to the nearest boundary
|
|
93
|
+
instead.
|
|
94
|
+
|
|
95
|
+
Rounds the ABSOLUTE instant (epoch, tz-independent) rather than the
|
|
96
|
+
local minute, so it is correct regardless of the display zone's offset
|
|
97
|
+
(the reset is a fixed UTC instant). Returns a UTC-aware datetime;
|
|
98
|
+
callers hand it to ``format_display_dt`` for zone conversion.
|
|
99
|
+
|
|
100
|
+
NEVER use this for keys / partitioning / lookups — those require the
|
|
101
|
+
exact stored timestamp (issue #76). This is a render-only normalizer.
|
|
102
|
+
"""
|
|
103
|
+
if d.tzinfo is None:
|
|
104
|
+
d = d.replace(tzinfo=dt.timezone.utc)
|
|
105
|
+
bucket = _FIVE_HOUR_JITTER_FLOOR_SECONDS # 600s = 10 min
|
|
106
|
+
# floor(x + 0.5) is predictable half-UP (Python's round() is banker's).
|
|
107
|
+
rounded = math.floor(d.timestamp() / bucket + 0.5) * bucket
|
|
108
|
+
return dt.datetime.fromtimestamp(rounded, dt.timezone.utc)
|
|
109
|
+
|
|
110
|
+
|
|
84
111
|
def _canonical_5h_window_key(
|
|
85
112
|
resets_at_epoch: int,
|
|
86
113
|
prior_epoch: int | None = None,
|
package/bin/_lib_render.py
CHANGED
|
@@ -245,7 +245,14 @@ def _render_title_banner(title: str, *, unicode_ok: bool, color: bool) -> str:
|
|
|
245
245
|
|
|
246
246
|
|
|
247
247
|
def _fmt_block_time_local(ts: dt.datetime, tz: "ZoneInfo | None") -> str:
|
|
248
|
-
"""Block-start timestamp in display tz, ccusage `toLocaleString`-style.
|
|
248
|
+
"""Block-start timestamp in display tz, ccusage `toLocaleString`-style.
|
|
249
|
+
|
|
250
|
+
``ts`` is always a 5h-block boundary (start_time / end_time — every
|
|
251
|
+
caller passes one), so it is rounded to the nearest 10-minute boundary
|
|
252
|
+
to normalize Anthropic's reset-capture jitter (e.g. 4:39:59 → 4:40:00)
|
|
253
|
+
before rendering.
|
|
254
|
+
"""
|
|
255
|
+
ts = _cctally()._round_to_ten_minutes(ts)
|
|
249
256
|
local = ts.astimezone(tz)
|
|
250
257
|
hour_12 = local.hour % 12 or 12
|
|
251
258
|
ampm = "a.m." if local.hour < 12 else "p.m."
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -1329,8 +1329,14 @@ def build_blocks_view(
|
|
|
1329
1329
|
per_model.items(), key=lambda kv: -kv[1],
|
|
1330
1330
|
)
|
|
1331
1331
|
]
|
|
1332
|
+
# Round the DISPLAYED start to the nearest 10-min boundary
|
|
1333
|
+
# (reset-jitter normalization); ``start_at`` / ``end_at``
|
|
1334
|
+
# below stay exact — they key the React row and the
|
|
1335
|
+
# ``/api/block/:start_at`` lookup, which matches by exact
|
|
1336
|
+
# equality (issue #76).
|
|
1332
1337
|
local_label = c.format_display_dt(
|
|
1333
|
-
b.start_time,
|
|
1338
|
+
c._round_to_ten_minutes(b.start_time),
|
|
1339
|
+
display_tz, fmt="%H:%M %b %d", suffix=True,
|
|
1334
1340
|
)
|
|
1335
1341
|
rows.append(BlocksPanelRow(
|
|
1336
1342
|
start_at=b.start_time.astimezone(dt.timezone.utc).isoformat(),
|
package/bin/cctally
CHANGED
|
@@ -151,6 +151,7 @@ SETUP_SYMLINK_NAMES = (
|
|
|
151
151
|
"cctally-refresh-usage",
|
|
152
152
|
"cctally-statusline",
|
|
153
153
|
"cctally-sync-week",
|
|
154
|
+
"cctally-transcript",
|
|
154
155
|
"cctally-tui",
|
|
155
156
|
"cctally-update",
|
|
156
157
|
)
|
|
@@ -214,6 +215,7 @@ _canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
|
|
|
214
215
|
make_week_ref = _cctally_core.make_week_ref
|
|
215
216
|
_get_latest_row_for_week = _cctally_core._get_latest_row_for_week
|
|
216
217
|
_reset_aware_floor = _cctally_core._reset_aware_floor
|
|
218
|
+
_floored_week_max = _cctally_core._floored_week_max
|
|
217
219
|
get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
|
|
218
220
|
# Re-exported so the telemetry kernel's ``c._is_dev_checkout()`` accessor
|
|
219
221
|
# call resolves on the ``cctally`` module (and tests can monkeypatch it
|
|
@@ -404,6 +406,7 @@ severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
|
|
|
404
406
|
_lib_five_hour = _load_sibling("_lib_five_hour")
|
|
405
407
|
_FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
|
|
406
408
|
_floor_to_ten_minutes = _lib_five_hour._floor_to_ten_minutes
|
|
409
|
+
_round_to_ten_minutes = _lib_five_hour._round_to_ten_minutes
|
|
407
410
|
_canonical_5h_window_key = _lib_five_hour._canonical_5h_window_key
|
|
408
411
|
|
|
409
412
|
_lib_subscription_weeks = _load_sibling("_lib_subscription_weeks")
|
|
@@ -2565,6 +2568,11 @@ _cctally_diff = _load_sibling("_cctally_diff")
|
|
|
2565
2568
|
cmd_diff = _cctally_diff.cmd_diff # parser: c.cmd_diff (_cctally_parser.py:2032)
|
|
2566
2569
|
_emit_diff_debug_samples = _cctally_diff._emit_diff_debug_samples # test_debug_sample_emission mod._emit_diff_debug_samples
|
|
2567
2570
|
|
|
2571
|
+
# #281 S4: the `transcript export|search` command module. Eager re-export so the
|
|
2572
|
+
# parser's set_defaults(func=c.cmd_transcript) resolves at build_parser time.
|
|
2573
|
+
_cctally_transcript = _load_sibling("_cctally_transcript")
|
|
2574
|
+
cmd_transcript = _cctally_transcript.cmd_transcript # parser: c.cmd_transcript
|
|
2575
|
+
|
|
2568
2576
|
|
|
2569
2577
|
_cctally_weekrefs = _load_sibling("_cctally_weekrefs")
|
|
2570
2578
|
_get_canonical_boundary_for_date = _cctally_weekrefs._get_canonical_boundary_for_date
|