cctally 1.92.0 → 1.92.2
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 +21 -1
- package/README.md +4 -2
- package/bin/_cctally_cache.py +44 -4
- package/bin/_cctally_core.py +56 -7
- package/bin/_cctally_dashboard.py +71 -14
- package/bin/_cctally_dashboard_conversation.py +8 -4
- package/bin/_cctally_db.py +111 -11
- package/bin/_cctally_journal.py +1102 -102
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +2 -2
- package/bin/_cctally_statusline.py +6 -6
- package/bin/_cctally_store.py +783 -67
- package/bin/_cctally_tui.py +54 -6
- package/bin/_lib_codex_conversation_query.py +30 -5
- package/bin/_lib_codex_find_projection.py +147 -0
- package/bin/_lib_conversation_dispatch.py +15 -1
- package/bin/_lib_conversation_query.py +62 -2
- package/bin/_lib_journal_router.py +234 -0
- package/bin/_lib_stats_publish.py +243 -0
- package/bin/cctally +12 -3
- package/dashboard/static/assets/{index-BEzzJtUd.js → index-Dat-mza6.js} +51 -51
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +3 -1
package/bin/_cctally_tui.py
CHANGED
|
@@ -2966,7 +2966,22 @@ def _tui_build_snapshot(
|
|
|
2966
2966
|
# ``_tui_build_snapshot_once`` closes its live stats connection before
|
|
2967
2967
|
# this boundary. The replacement-capable hook can therefore satisfy
|
|
2968
2968
|
# the whole-family drain gate, and the retry opens the published family.
|
|
2969
|
-
|
|
2969
|
+
#
|
|
2970
|
+
# #496 S3 §6: the heal now DEFERS, and its signal is a
|
|
2971
|
+
# `BaseException`. This call sits before the retry's own `except`, so
|
|
2972
|
+
# the shared deferral base is caught AT the heal-call boundary and the
|
|
2973
|
+
# corruption-typed degraded frame is built directly. The retry must not
|
|
2974
|
+
# spin: after deferral the index is still corrupt, so a second attempt
|
|
2975
|
+
# would only fail again, and the frame converges on a later tick.
|
|
2976
|
+
try:
|
|
2977
|
+
_tui_heal_post_query_stats(fault.cause)
|
|
2978
|
+
except _cctally().StatsRebuildDeferred as deferred:
|
|
2979
|
+
return _tui_stats_retry_degraded_snapshot(
|
|
2980
|
+
now_utc=now_utc or dt.datetime.now(dt.timezone.utc),
|
|
2981
|
+
exc=deferred,
|
|
2982
|
+
precompute_envelope=precompute_envelope,
|
|
2983
|
+
runtime_bind=runtime_bind,
|
|
2984
|
+
)
|
|
2970
2985
|
try:
|
|
2971
2986
|
return _tui_build_snapshot_once(
|
|
2972
2987
|
now_utc=now_utc,
|
|
@@ -3279,6 +3294,8 @@ def _tui_build_snapshot_once(
|
|
|
3279
3294
|
runtime_bind=runtime_bind, raw_config=raw_config,
|
|
3280
3295
|
display_tz_pref_override=display_tz_pref_override,
|
|
3281
3296
|
source_stats_conn=conn,
|
|
3297
|
+
failures=sync_failures,
|
|
3298
|
+
stats_heal_attempted=stats_heal_attempted,
|
|
3282
3299
|
source_display_tz_name=(
|
|
3283
3300
|
getattr(_build_display_tz, "key", None)
|
|
3284
3301
|
if _build_display_tz is not None else None
|
|
@@ -4068,7 +4085,9 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
4068
4085
|
codex_ingest_contended=False,
|
|
4069
4086
|
codex_ingest_failed=False,
|
|
4070
4087
|
claude_ingest_contended=False,
|
|
4071
|
-
claude_ingest_failed=False
|
|
4088
|
+
claude_ingest_failed=False,
|
|
4089
|
+
failures=None,
|
|
4090
|
+
stats_heal_attempted=False):
|
|
4072
4091
|
"""Fresh snapshot reusing ``prior``'s heavy rows, re-patching only the
|
|
4073
4092
|
time-derived fields + the doctor payload / envelope precompute on each idle
|
|
4074
4093
|
tick (spec §3 idle path).
|
|
@@ -4108,6 +4127,7 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
4108
4127
|
envelope_precompute = _tui_precompute_envelope_config(raw_config)
|
|
4109
4128
|
except Exception as exc: # noqa: BLE001 — never crash the rebuild
|
|
4110
4129
|
errors.append(f"envelope-precompute: {exc}")
|
|
4130
|
+
idle_failures = failures if failures is not None else []
|
|
4111
4131
|
source_bundle = prior.source_bundle
|
|
4112
4132
|
if source_bundle is not None:
|
|
4113
4133
|
try:
|
|
@@ -4180,15 +4200,36 @@ def _tui_build_idle_snapshot(prior, *, now_utc, precompute_envelope,
|
|
|
4180
4200
|
prior_bundle=source_bundle,
|
|
4181
4201
|
raw_config=raw_config,
|
|
4182
4202
|
)
|
|
4203
|
+
except _StatsSnapshotCorruption:
|
|
4204
|
+
raise
|
|
4183
4205
|
except Exception as exc: # noqa: BLE001 — retain prior complete bundle
|
|
4184
|
-
|
|
4206
|
+
# #496 S3 §8 (F16). This branch read stats through
|
|
4207
|
+
# `source_stats_conn` and swallowed the failure into a plain
|
|
4208
|
+
# string, so it built no `SyncFailureAttribution`, never raised
|
|
4209
|
+
# `_StatsSnapshotCorruption`, and never reached the heal — and
|
|
4210
|
+
# `_sync_failure_envelope`, finding no typed attribution, fell to
|
|
4211
|
+
# its raw-text matcher and told the user to run
|
|
4212
|
+
# `cctally cache-sync --rebuild` for a STATS fault. Classify it at
|
|
4213
|
+
# the catch site instead, against the connection that faulted.
|
|
4214
|
+
if source_stats_conn is not None:
|
|
4215
|
+
_tui_capture_sync_failure(
|
|
4216
|
+
source_stats_conn,
|
|
4217
|
+
errors,
|
|
4218
|
+
idle_failures,
|
|
4219
|
+
leg="source-clock-refresh",
|
|
4220
|
+
database="stats_or_cache",
|
|
4221
|
+
exc=exc,
|
|
4222
|
+
stats_heal_attempted=stats_heal_attempted,
|
|
4223
|
+
)
|
|
4224
|
+
else:
|
|
4225
|
+
errors.append(f"source-clock-refresh: {exc}")
|
|
4185
4226
|
source_bundle = prior.source_bundle
|
|
4186
4227
|
return dataclasses.replace(
|
|
4187
4228
|
prior,
|
|
4188
4229
|
generated_at=now_utc,
|
|
4189
4230
|
last_sync_at=time.monotonic(),
|
|
4190
4231
|
last_sync_error=("; ".join(errors) if errors else None),
|
|
4191
|
-
sync_failures=(),
|
|
4232
|
+
sync_failures=tuple(idle_failures),
|
|
4192
4233
|
doctor_payload=doctor_payload,
|
|
4193
4234
|
envelope_precompute=envelope_precompute,
|
|
4194
4235
|
source_bundle=source_bundle,
|
|
@@ -6611,12 +6652,17 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
|
|
|
6611
6652
|
snap = dataclasses.replace(snap, last_sync_at=None, hydrating=False)
|
|
6612
6653
|
ref.set(snap)
|
|
6613
6654
|
hub.publish(snap)
|
|
6614
|
-
except _cctally().
|
|
6655
|
+
except _cctally().StatsRebuildDeferred as exc:
|
|
6615
6656
|
# #453: the first periodic tick runs before HTTP bind. Preserve the
|
|
6616
6657
|
# initial hydrating/degraded frame while the dedicated replay owns
|
|
6617
6658
|
# stats maintenance; a generic crash frame would clear the latch
|
|
6618
6659
|
# before any client could observe it. The loop retries normally on
|
|
6619
6660
|
# its next cadence and publishes a full frame after convergence.
|
|
6661
|
+
#
|
|
6662
|
+
# #496 S3: the two deferral classes must NOT flatten here. A wrong
|
|
6663
|
+
# EPOCH is a readable index (`corruption=False`); a deferred heal is
|
|
6664
|
+
# an index that could not be read, and reporting that as
|
|
6665
|
+
# non-corruption makes the envelope name the wrong fault.
|
|
6620
6666
|
prev = ref.get()
|
|
6621
6667
|
pending = dataclasses.replace(
|
|
6622
6668
|
prev,
|
|
@@ -6625,7 +6671,9 @@ def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override,
|
|
|
6625
6671
|
SyncFailureAttribution(
|
|
6626
6672
|
leg="stats-open",
|
|
6627
6673
|
database="stats",
|
|
6628
|
-
corruption=
|
|
6674
|
+
corruption=isinstance(
|
|
6675
|
+
exc, _cctally().StatsHealDeferred
|
|
6676
|
+
),
|
|
6629
6677
|
),
|
|
6630
6678
|
),
|
|
6631
6679
|
generated_at=dt.datetime.now(dt.timezone.utc),
|
|
@@ -34,11 +34,13 @@ import _lib_codex_conversation as kern
|
|
|
34
34
|
import _lib_codex_landmarks as landmarks
|
|
35
35
|
import _lib_codex_segments as segkern
|
|
36
36
|
from _lib_codex_find_projection import (
|
|
37
|
+
CODEX_FIND_PROJECTION_VERSION,
|
|
37
38
|
ProjectedLeaf,
|
|
38
39
|
RenderLeaf,
|
|
39
40
|
iter_literal_ranges,
|
|
40
41
|
iter_regex_ranges,
|
|
41
42
|
literal_ranges,
|
|
43
|
+
project_context,
|
|
42
44
|
project_markdown,
|
|
43
45
|
project_plain,
|
|
44
46
|
regex_ranges,
|
|
@@ -3006,6 +3008,7 @@ def list_codex_conversations(
|
|
|
3006
3008
|
model: str | None = None,
|
|
3007
3009
|
limit: int = 50,
|
|
3008
3010
|
cursor: str | None = None,
|
|
3011
|
+
selected: str | None = None,
|
|
3009
3012
|
) -> dict:
|
|
3010
3013
|
"""Browse envelope (§5.6 / §6.1): a page of conversation rows ordered by last
|
|
3011
3014
|
activity, with project/model facets. Dual-branch — the stored rollup fast
|
|
@@ -3021,7 +3024,13 @@ def list_codex_conversations(
|
|
|
3021
3024
|
page_rows, page = _stored_browse_page(
|
|
3022
3025
|
conn, effective_speed=effective_speed, project_key=project_key,
|
|
3023
3026
|
model=model, limit=limit, cursor=cursor)
|
|
3024
|
-
|
|
3027
|
+
result = {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
|
|
3028
|
+
if selected is not None:
|
|
3029
|
+
fields = _rollup_fields(conn, selected)
|
|
3030
|
+
if fields is not None:
|
|
3031
|
+
result["selected"] = _browse_row(
|
|
3032
|
+
conn, selected, effective_speed, fields)
|
|
3033
|
+
return result
|
|
3025
3034
|
|
|
3026
3035
|
fields_rows = _live_browse_fields(conn)
|
|
3027
3036
|
rows = [
|
|
@@ -3034,7 +3043,13 @@ def list_codex_conversations(
|
|
|
3034
3043
|
and (model is None or model in (row["models"] or []))]
|
|
3035
3044
|
filtered.sort(key=_recent_sort_key, reverse=True)
|
|
3036
3045
|
page_rows, page = _paginate_rows(filtered, cursor=cursor, limit=limit)
|
|
3037
|
-
|
|
3046
|
+
result = {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
|
|
3047
|
+
if selected is not None:
|
|
3048
|
+
selected_row = next(
|
|
3049
|
+
(row for row in rows if row["conversation_key"] == selected), None)
|
|
3050
|
+
if selected_row is not None:
|
|
3051
|
+
result["selected"] = selected_row
|
|
3052
|
+
return result
|
|
3038
3053
|
|
|
3039
3054
|
|
|
3040
3055
|
# ── search (§6.2) ─────────────────────────────────────────────────────────────
|
|
@@ -3111,7 +3126,6 @@ def _pos_to_item_key(conn: sqlite3.Connection, conversation_key: str) -> dict:
|
|
|
3111
3126
|
|
|
3112
3127
|
# ── #482 visible render-leaf projection ─────────────────────────────────────
|
|
3113
3128
|
|
|
3114
|
-
CODEX_FIND_PROJECTION_VERSION = 1
|
|
3115
3129
|
_COMPLETION_EVENT_TYPES = {
|
|
3116
3130
|
"patch_apply_end",
|
|
3117
3131
|
"web_search_end",
|
|
@@ -3120,7 +3134,7 @@ _COMPLETION_EVENT_TYPES = {
|
|
|
3120
3134
|
|
|
3121
3135
|
|
|
3122
3136
|
def _find_surface(row) -> str | None:
|
|
3123
|
-
if row.kind in {"user", "assistant", "reasoning"}:
|
|
3137
|
+
if row.kind in {"user", "assistant", "reasoning", "meta"}:
|
|
3124
3138
|
return "body"
|
|
3125
3139
|
if row.kind == "tool_call":
|
|
3126
3140
|
return "call"
|
|
@@ -3252,6 +3266,17 @@ def _project_find_row(row, *, payload: dict | None = None, block: dict | None =
|
|
|
3252
3266
|
text = _row_display(row)
|
|
3253
3267
|
if not text:
|
|
3254
3268
|
return None
|
|
3269
|
+
if row.kind == "meta":
|
|
3270
|
+
try:
|
|
3271
|
+
detail = json.loads(row.detail_json or "{}")
|
|
3272
|
+
except (TypeError, json.JSONDecodeError):
|
|
3273
|
+
detail = {}
|
|
3274
|
+
meta_kind = detail.get("meta_kind") if isinstance(detail, dict) else None
|
|
3275
|
+
if meta_kind == "command":
|
|
3276
|
+
return project_plain((RenderLeaf("t0", text),))
|
|
3277
|
+
if meta_kind == "context":
|
|
3278
|
+
return project_context(text)
|
|
3279
|
+
return project_markdown(text)
|
|
3255
3280
|
if row.kind == "reasoning" and isinstance(block, dict):
|
|
3256
3281
|
detail = block.get("detail")
|
|
3257
3282
|
reasoning = detail.get("reasoning") if isinstance(detail, dict) else None
|
|
@@ -3397,7 +3422,7 @@ def materialize_codex_find_projection(
|
|
|
3397
3422
|
return
|
|
3398
3423
|
disclosure = (
|
|
3399
3424
|
[container_block_key]
|
|
3400
|
-
if row.kind
|
|
3425
|
+
if row.kind in {"reasoning", "meta"} or surface != "body"
|
|
3401
3426
|
else []
|
|
3402
3427
|
)
|
|
3403
3428
|
conn.execute(
|
|
@@ -12,6 +12,9 @@ import re
|
|
|
12
12
|
from typing import Iterable, Iterator, Sequence
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
CODEX_FIND_PROJECTION_VERSION = 2
|
|
16
|
+
|
|
17
|
+
|
|
15
18
|
@dataclass(frozen=True)
|
|
16
19
|
class RenderLeaf:
|
|
17
20
|
key: str
|
|
@@ -301,6 +304,148 @@ def project_plain(leaves: Sequence[RenderLeaf]) -> tuple[str, tuple[ProjectedLea
|
|
|
301
304
|
return builder.value()
|
|
302
305
|
|
|
303
306
|
|
|
307
|
+
_CONTEXT_DIFF_GIT_RE = re.compile(r"diff --git a/\S+ b/\S+")
|
|
308
|
+
_CONTEXT_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
309
|
+
_CONTEXT_EXTENDED_HEADER_PREFIXES = (
|
|
310
|
+
"old mode ", "new mode ", "new file mode ", "deleted file mode ",
|
|
311
|
+
"rename from ", "rename to ", "copy from ", "copy to ",
|
|
312
|
+
"similarity index ", "dissimilarity index ", "index ",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _context_is_diff_line(line: str) -> bool:
|
|
317
|
+
if _CONTEXT_DIFF_GIT_RE.search(line):
|
|
318
|
+
return True
|
|
319
|
+
if line.startswith(("--- ", "+++ ", "@@")):
|
|
320
|
+
return True
|
|
321
|
+
if line.startswith(_CONTEXT_EXTENDED_HEADER_PREFIXES):
|
|
322
|
+
return True
|
|
323
|
+
return line == "" or line[0] in "+- \\"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _segment_context_body(text: str) -> list[tuple[str, str]]:
|
|
327
|
+
"""Mirror ``contextDiff.ts::segmentContextBody`` without rendering HTML."""
|
|
328
|
+
lines = text.split("\n")
|
|
329
|
+
if lines and lines[-1] == "":
|
|
330
|
+
lines.pop()
|
|
331
|
+
segments: list[tuple[str, str]] = []
|
|
332
|
+
prose: list[str] = []
|
|
333
|
+
diff: list[str] = []
|
|
334
|
+
in_diff = False
|
|
335
|
+
|
|
336
|
+
def flush(kind: str, values: list[str]) -> None:
|
|
337
|
+
if values:
|
|
338
|
+
segments.append((kind, "\n".join(values)))
|
|
339
|
+
values.clear()
|
|
340
|
+
|
|
341
|
+
for line in lines:
|
|
342
|
+
if not in_diff:
|
|
343
|
+
match = _CONTEXT_DIFF_GIT_RE.search(line)
|
|
344
|
+
if match is None:
|
|
345
|
+
prose.append(line)
|
|
346
|
+
continue
|
|
347
|
+
before = line[:match.start()].rstrip()
|
|
348
|
+
if before:
|
|
349
|
+
prose.append(before)
|
|
350
|
+
flush("prose", prose)
|
|
351
|
+
in_diff = True
|
|
352
|
+
diff.append(line[match.start():])
|
|
353
|
+
elif _context_is_diff_line(line):
|
|
354
|
+
diff.append(line)
|
|
355
|
+
else:
|
|
356
|
+
flush("diff", diff)
|
|
357
|
+
in_diff = False
|
|
358
|
+
prose.append(line)
|
|
359
|
+
flush("prose", prose)
|
|
360
|
+
flush("diff", diff)
|
|
361
|
+
return segments
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _context_diff_rows(text: str) -> list[tuple[int, int, int, str]]:
|
|
365
|
+
"""Mirror the visible row walk in ``contextDiff.ts::parseUnifiedDiff``."""
|
|
366
|
+
rows: list[tuple[int, int, int, str]] = []
|
|
367
|
+
file_index = -1
|
|
368
|
+
hunk_index = -1
|
|
369
|
+
row_index = 0
|
|
370
|
+
in_hunk = False
|
|
371
|
+
for line in text.split("\n"):
|
|
372
|
+
if _CONTEXT_DIFF_GIT_RE.search(line):
|
|
373
|
+
file_index += 1
|
|
374
|
+
hunk_index = -1
|
|
375
|
+
row_index = 0
|
|
376
|
+
in_hunk = False
|
|
377
|
+
continue
|
|
378
|
+
if _CONTEXT_HUNK_RE.match(line):
|
|
379
|
+
hunk_index += 1
|
|
380
|
+
row_index = 0
|
|
381
|
+
in_hunk = True
|
|
382
|
+
continue
|
|
383
|
+
if not in_hunk:
|
|
384
|
+
continue
|
|
385
|
+
if line.startswith(("--- ", "+++ ")) or line.startswith(
|
|
386
|
+
_CONTEXT_EXTENDED_HEADER_PREFIXES
|
|
387
|
+
):
|
|
388
|
+
continue
|
|
389
|
+
if line == "" or line.startswith("\\"):
|
|
390
|
+
continue
|
|
391
|
+
rows.append((file_index, hunk_index, row_index, line[1:]))
|
|
392
|
+
row_index += 1
|
|
393
|
+
return rows
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _append_projected(
|
|
397
|
+
builder: _ProjectionBuilder,
|
|
398
|
+
projected: tuple[str, tuple[ProjectedLeaf, ...]],
|
|
399
|
+
*,
|
|
400
|
+
prefix: str,
|
|
401
|
+
) -> None:
|
|
402
|
+
text, leaves = projected
|
|
403
|
+
if not text:
|
|
404
|
+
return
|
|
405
|
+
start = builder.length
|
|
406
|
+
builder.parts.append(text)
|
|
407
|
+
builder.length += len(text)
|
|
408
|
+
builder.boundary()
|
|
409
|
+
builder.leaves.extend({
|
|
410
|
+
"key": f"{prefix}/{leaf.key}",
|
|
411
|
+
"start": start + leaf.start,
|
|
412
|
+
"end": start + leaf.end,
|
|
413
|
+
} for leaf in leaves)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def project_context(source: str) -> tuple[str, tuple[ProjectedLeaf, ...]]:
|
|
417
|
+
"""Project visible prose and diff-row leaves from one context body.
|
|
418
|
+
|
|
419
|
+
File headers and +/- statistics are derived card chrome, matching #482's
|
|
420
|
+
rule that only provider-authored render leaves enter the search surface.
|
|
421
|
+
"""
|
|
422
|
+
builder = _ProjectionBuilder()
|
|
423
|
+
for segment_index, (kind, text) in enumerate(_segment_context_body(source)):
|
|
424
|
+
if kind == "prose":
|
|
425
|
+
projected = project_markdown(text)
|
|
426
|
+
if not projected[0]:
|
|
427
|
+
continue
|
|
428
|
+
if builder.parts:
|
|
429
|
+
builder.separator("\n")
|
|
430
|
+
_append_projected(
|
|
431
|
+
builder, projected, prefix=f"segments.{segment_index}.prose"
|
|
432
|
+
)
|
|
433
|
+
continue
|
|
434
|
+
for file_index, hunk_index, row_index, row_text in _context_diff_rows(text):
|
|
435
|
+
if not row_text:
|
|
436
|
+
continue
|
|
437
|
+
if builder.parts:
|
|
438
|
+
builder.separator("\n")
|
|
439
|
+
builder.emit(
|
|
440
|
+
row_text,
|
|
441
|
+
key=(
|
|
442
|
+
f"segments.{segment_index}.files.{file_index}."
|
|
443
|
+
f"hunks.{hunk_index}.rows.{row_index}"
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
return builder.value()
|
|
447
|
+
|
|
448
|
+
|
|
304
449
|
def _single_scalar_lower(value: str) -> str:
|
|
305
450
|
return "".join((lowered if len(lowered := scalar.lower()) == 1 else scalar) for scalar in value)
|
|
306
451
|
|
|
@@ -356,6 +501,7 @@ def slice_range_to_leaves(
|
|
|
356
501
|
|
|
357
502
|
|
|
358
503
|
__all__ = [
|
|
504
|
+
"CODEX_FIND_PROJECTION_VERSION",
|
|
359
505
|
"FindRange",
|
|
360
506
|
"LeafFragment",
|
|
361
507
|
"ProjectedLeaf",
|
|
@@ -364,6 +510,7 @@ __all__ = [
|
|
|
364
510
|
"iter_literal_ranges",
|
|
365
511
|
"iter_regex_ranges",
|
|
366
512
|
"project_markdown",
|
|
513
|
+
"project_context",
|
|
367
514
|
"project_plain",
|
|
368
515
|
"regex_ranges",
|
|
369
516
|
"slice_range_to_leaves",
|
|
@@ -266,6 +266,7 @@ def _claude_browse(
|
|
|
266
266
|
model: str | None = None,
|
|
267
267
|
limit: int = 50,
|
|
268
268
|
cursor: str | None = None,
|
|
269
|
+
selected: str | None = None,
|
|
269
270
|
) -> dict:
|
|
270
271
|
"""Claude browse envelope (§5.6 / §6.1). Claude is always authoritative — the
|
|
271
272
|
status is always ``ok`` (never ``normalization_pending``). Facets are built
|
|
@@ -296,7 +297,13 @@ def _claude_browse(
|
|
|
296
297
|
]
|
|
297
298
|
filtered.sort(key=q._recent_sort_key, reverse=True)
|
|
298
299
|
page_rows, page = q._paginate_rows(filtered, cursor=cursor, limit=limit)
|
|
299
|
-
|
|
300
|
+
result = {"status": "ok", "rows": page_rows, "facets": facets, "page": page}
|
|
301
|
+
if selected is not None:
|
|
302
|
+
selected_row = next(
|
|
303
|
+
(row for row in rows if row["conversation_key"] == selected), None)
|
|
304
|
+
if selected_row is not None:
|
|
305
|
+
result["selected"] = selected_row
|
|
306
|
+
return result
|
|
300
307
|
|
|
301
308
|
|
|
302
309
|
# ── Claude adapter: detail with cursor translation (§5.6) ─────────────────────
|
|
@@ -620,6 +627,13 @@ def neutral_browse(
|
|
|
620
627
|
migration 025 has not run); Claude routes to the adapter (always ``ok``).
|
|
621
628
|
``filters``: ``project_key``, ``model``, ``limit``, ``cursor``."""
|
|
622
629
|
speed = effective_speed or _DEFAULT_SPEED
|
|
630
|
+
selected = filters.pop("selected", None)
|
|
631
|
+
if selected is not None:
|
|
632
|
+
selected_ref = resolve_conversation_ref(selected)
|
|
633
|
+
selected = (selected_ref.conversation_key
|
|
634
|
+
if selected_ref is not None and selected_ref.source == source
|
|
635
|
+
else None)
|
|
636
|
+
filters["selected"] = selected
|
|
623
637
|
if source == "codex":
|
|
624
638
|
return q.list_codex_conversations(conn, effective_speed=speed, **filters)
|
|
625
639
|
if source == "claude":
|
|
@@ -1439,10 +1439,65 @@ def list_conversation_facets(conn) -> dict:
|
|
|
1439
1439
|
return {"projects": projects, "models": models}
|
|
1440
1440
|
|
|
1441
1441
|
|
|
1442
|
+
def _selected_conversation_summary(conn, session_id):
|
|
1443
|
+
"""One exact browse-row projection, independent of the current page.
|
|
1444
|
+
|
|
1445
|
+
Mirrors ``list_conversations``' authoritative/live shaping so #501 can pin
|
|
1446
|
+
the selected conversation without paging an unbounded corpus.
|
|
1447
|
+
"""
|
|
1448
|
+
if _rollup_authoritative(conn):
|
|
1449
|
+
row = conn.execute(
|
|
1450
|
+
"SELECT session_id, msg_count, started_utc, last_activity_utc, "
|
|
1451
|
+
"cost_usd, project_label, git_branch, models_json, title "
|
|
1452
|
+
"FROM conversation_sessions WHERE session_id=?",
|
|
1453
|
+
(session_id,),
|
|
1454
|
+
).fetchone()
|
|
1455
|
+
if row is None:
|
|
1456
|
+
return None
|
|
1457
|
+
(sid, msg_count, started, last_activity, cost_usd, project_label,
|
|
1458
|
+
git_branch, models_json, rollup_title) = row
|
|
1459
|
+
ai = _session_ai_titles_map(conn, [sid])
|
|
1460
|
+
return {
|
|
1461
|
+
"session_id": sid,
|
|
1462
|
+
"title": ai.get(sid) or rollup_title or project_label or sid,
|
|
1463
|
+
"project_label": project_label,
|
|
1464
|
+
"git_branch": git_branch,
|
|
1465
|
+
"started_utc": started,
|
|
1466
|
+
"last_activity_utc": last_activity,
|
|
1467
|
+
"msg_count": msg_count,
|
|
1468
|
+
"cost_usd": round(cost_usd or 0.0, 6),
|
|
1469
|
+
"models": _json.loads(models_json) if models_json else [],
|
|
1470
|
+
}
|
|
1471
|
+
row = conn.execute(
|
|
1472
|
+
"SELECT session_id, COUNT(*), MIN(timestamp_utc), MAX(timestamp_utc) "
|
|
1473
|
+
"FROM conversation_messages WHERE session_id=? GROUP BY session_id",
|
|
1474
|
+
(session_id,),
|
|
1475
|
+
).fetchone()
|
|
1476
|
+
if row is None:
|
|
1477
|
+
return None
|
|
1478
|
+
sid, msg_count, started, last_activity = row
|
|
1479
|
+
costs = _session_cost_map(conn, [sid])
|
|
1480
|
+
models = _session_models_map(conn, [sid])
|
|
1481
|
+
meta = _session_latest_meta_map(conn, [sid])
|
|
1482
|
+
titles = _session_titles_map(conn, [sid])
|
|
1483
|
+
project_label = _project_label(meta.get(sid, (None, None))[0])
|
|
1484
|
+
return {
|
|
1485
|
+
"session_id": sid,
|
|
1486
|
+
"title": titles.get(sid) or project_label or sid,
|
|
1487
|
+
"project_label": project_label,
|
|
1488
|
+
"git_branch": meta.get(sid, (None, None))[1],
|
|
1489
|
+
"started_utc": started,
|
|
1490
|
+
"last_activity_utc": last_activity,
|
|
1491
|
+
"msg_count": msg_count,
|
|
1492
|
+
"cost_usd": round(costs.get(sid, 0.0), 6),
|
|
1493
|
+
"models": models.get(sid, []),
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
|
|
1442
1497
|
def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
1443
1498
|
date_from=None, date_to=None, projects=None,
|
|
1444
1499
|
cost_min=None, cost_max=None, rebuild_min=None,
|
|
1445
|
-
models=None) -> dict:
|
|
1500
|
+
models=None, selected=None) -> dict:
|
|
1446
1501
|
"""All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
|
|
1447
1502
|
|
|
1448
1503
|
Reads the conversation_sessions rollup (Task A) when it is authoritative —
|
|
@@ -1565,10 +1620,15 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
|
|
|
1565
1620
|
# The rail surfaces this: cost/project ordering becomes available once
|
|
1566
1621
|
# the rollup finishes indexing; this page fell back to recent order.
|
|
1567
1622
|
page["sort_degraded"] = True
|
|
1568
|
-
|
|
1623
|
+
result = {
|
|
1569
1624
|
"conversations": conversations,
|
|
1570
1625
|
"page": page,
|
|
1571
1626
|
}
|
|
1627
|
+
if selected is not None:
|
|
1628
|
+
selected_row = _selected_conversation_summary(conn, selected)
|
|
1629
|
+
if selected_row is not None:
|
|
1630
|
+
result["selected"] = selected_row
|
|
1631
|
+
return result
|
|
1572
1632
|
|
|
1573
1633
|
|
|
1574
1634
|
def _turn_cost_map(conn, turn_keys):
|