cctally 1.29.0 → 1.31.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 +20 -0
- package/bin/_cctally_cache.py +36 -0
- package/bin/_cctally_db.py +18 -0
- package/bin/_lib_conversation.py +38 -2
- package/bin/_lib_conversation_query.py +163 -5
- package/dashboard/static/assets/index-CSCnAwDx.css +1 -0
- package/dashboard/static/assets/index-U6iDXqCy.js +56 -0
- package/dashboard/static/assets/newsreader-latin-400-italic-CEihAR-f.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-400-italic-CNZoH1hn.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-400-normal-BFBkh4jY.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-400-normal-gRTjlS2D.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-500-normal-B66TYsaK.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-500-normal-DFwuUcdu.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-600-normal-30OJ_TG_.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-600-normal-DUnT2r2g.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-400-italic-BMTE_bNQ.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-400-italic-qdgKLcPG.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-400-normal-DYA1XoQK.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-400-normal-svq1FPys.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-500-normal-BNHmvKvI.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-500-normal-CZruMFou.woff +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-600-normal-BXv5iMHi.woff2 +0 -0
- package/dashboard/static/assets/newsreader-latin-ext-600-normal-BrbfzHZ5.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-400-italic-QbB8kb5s.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-400-italic-bZegYFuM.woff2 +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-400-normal-BekUZro8.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-400-normal-DdKr49mV.woff2 +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-500-normal-BEAbKU8A.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-500-normal-CL6a8tp2.woff2 +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-600-normal-CVAR0otO.woff +0 -0
- package/dashboard/static/assets/newsreader-vietnamese-600-normal-CaH84vfx.woff2 +0 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BGaWg6ys.js +0 -47
- package/dashboard/static/assets/index-BqQ5xdX0.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.31.0] - 2026-06-09
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Syntax-highlighted, line-numbered tool input/output in the dashboard conversation reader.** A tool call's REQUEST panel (its JSON arguments) and, for `Read` results, the RESULT panel (the file contents) are now syntax-highlighted — previously the dominant code surface in a tool-heavy transcript (a `Read` showing a Python file, a `Bash` invocation) rendered as flat uncolored text. `Read` results additionally get a dim line-number gutter in a separate column (so the numbers aren't mis-tokenized as code), with the language inferred in the browser from the file's extension; non-`Read` results (Bash stdout, Grep/Glob path lists, and the like) stay plain. Highlighting runs entirely client-side through the same refractor chokepoint as prose code fences and degrades to plain text on an unknown or unparseable language — no data-contract change and nothing to do on upgrade.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **The dashboard conversation reader now uses the full width of its pane.** A `68ch` typographic measure cap (introduced with the 1.30.0 reader redesign) limited rendered prose and fenced code blocks to a 68-character line length, leaving roughly half of a wide reader pane empty and wrapping text earlier than the available width — which read as artificial line breaks. The cap is removed so reader prose and code fences fill the pane; tool I/O code panels were never measure-capped and are unaffected.
|
|
15
|
+
|
|
16
|
+
## [1.30.0] - 2026-06-09
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Keyboard navigation in the dashboard conversation reader.** With a conversation open, `j` / `k` move a focused-turn cursor between turns (scrolling each into view and auto-loading the next page when you reach the end), `[` / `]` collapse-all / expand-all the disclosure sections in the thread, and `g` jumps back to the top. The bindings are listed in the dashboard help overlay (`?`) under a new "Conversations" group and are inert while a modal or the rail search/filter input is active.
|
|
20
|
+
- **Syntax-highlighted code blocks and copy buttons in the conversation reader.** Fenced code in transcripts is now syntax-highlighted with a language label, and one-click copy buttons appear on code blocks, tool output, and message text.
|
|
21
|
+
- **Derived conversation titles.** Each conversation now shows a short derived title in the sidebar rail, the reader header, and full-text search results — and cross-session search now matches on that title too; the rail also groups conversations under date dividers.
|
|
22
|
+
- Jump-to-message now expands the owning collapsed subagent thread when the target message lands inside one, instead of silently scrolling to a hidden turn (#160).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **The dashboard conversation viewer (introduced in 1.29.0) has been redesigned end-to-end.** Transcripts now render as serif prose (self-hosted Newsreader) on a readable ~68-character measure with higher-contrast text, laid out along a timeline spine with role-differentiated turns; assistant turns are walked in document order so each tool call renders paired with its result as an inline tool-I/O chip (chevron + preview) and stray orphan tool-result runs are collapsed; subagent sidechains render as weighted thread cards; system-command messages fold into an expandable pill; and inline-SVG icons replace the previous emoji throughout, with disclosure sections opening on a smooth animation and turns staggering in on first load behind a refined jump-flash (#161, #162, #164, #165, #168).
|
|
26
|
+
- Internal (no action needed on upgrade): a new cache migration (`003`) re-ingests existing conversation history id-aware so older transcripts pick up the tool-call linkage ids the paired tool-I/O view needs. The cache is fully re-derivable and the migration runs automatically on the next DB open.
|
|
27
|
+
|
|
8
28
|
## [1.29.0] - 2026-06-08
|
|
9
29
|
|
|
10
30
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -612,6 +612,12 @@ def sync_cache(
|
|
|
612
612
|
# run a redundant (idempotent but wasteful) offset-0 backfill pass.
|
|
613
613
|
conn.execute(
|
|
614
614
|
"DELETE FROM cache_meta WHERE key='conversation_backfill_pending'")
|
|
615
|
+
# Issue #164: a rebuild also clears + repopulates the message index
|
|
616
|
+
# id-aware via the normal offset-0 walk, so drop the 003 reingest
|
|
617
|
+
# flag too — the post-rebuild sync must not run a redundant
|
|
618
|
+
# (idempotent but wasteful) clear+backfill pass.
|
|
619
|
+
conn.execute(
|
|
620
|
+
"DELETE FROM cache_meta WHERE key='conversation_reingest_pending'")
|
|
615
621
|
conn.commit()
|
|
616
622
|
eprint("[cache-sync] rebuild: cleared Claude cached entries")
|
|
617
623
|
|
|
@@ -647,6 +653,36 @@ def sync_cache(
|
|
|
647
653
|
)
|
|
648
654
|
conn.commit()
|
|
649
655
|
|
|
656
|
+
# Issue #164: consume the deferred conversation_messages re-ingest.
|
|
657
|
+
# Cache migration 003 is flag-only — it sets
|
|
658
|
+
# ``conversation_reingest_pending`` rather than clearing inline
|
|
659
|
+
# (clearing in the handler would run WITHOUT this flock, racing a
|
|
660
|
+
# concurrent sync, and would empty the reader on stats-only /
|
|
661
|
+
# eager-migration opens or ``dashboard --no-sync``). The destructive
|
|
662
|
+
# clear + id-aware offset-0 re-derive live here, UNDER the held
|
|
663
|
+
# flock. Distinct from 002's backfill-without-clear: 003 is
|
|
664
|
+
# clear-then-backfill, re-deriving the WHOLE index id-aware so
|
|
665
|
+
# existing history pairs tool_use<->tool_result. The clear is
|
|
666
|
+
# storm-free (#138); the offset-0 backfill walks every JSONL from 0;
|
|
667
|
+
# the flag is dropped LAST so a crash mid-walk re-runs cleanly on the
|
|
668
|
+
# next sync. Never on the rebuild path (which already wipes +
|
|
669
|
+
# repopulates the index id-aware via the normal walk).
|
|
670
|
+
try:
|
|
671
|
+
_reingest = conn.execute(
|
|
672
|
+
"SELECT 1 FROM cache_meta "
|
|
673
|
+
"WHERE key='conversation_reingest_pending'"
|
|
674
|
+
).fetchone() is not None
|
|
675
|
+
except sqlite3.OperationalError:
|
|
676
|
+
_reingest = False
|
|
677
|
+
if _reingest:
|
|
678
|
+
clear_conversation_messages(conn)
|
|
679
|
+
backfill_conversation_messages(conn)
|
|
680
|
+
conn.execute(
|
|
681
|
+
"DELETE FROM cache_meta "
|
|
682
|
+
"WHERE key='conversation_reingest_pending'"
|
|
683
|
+
)
|
|
684
|
+
conn.commit()
|
|
685
|
+
|
|
650
686
|
paths: list[pathlib.Path] = list(_iter_claude_jsonl_files())
|
|
651
687
|
stats.files_total = len(paths)
|
|
652
688
|
|
package/bin/_cctally_db.py
CHANGED
|
@@ -2991,6 +2991,24 @@ def _002_conversation_messages_backfill(conn: sqlite3.Connection) -> None:
|
|
|
2991
2991
|
conn.commit()
|
|
2992
2992
|
|
|
2993
2993
|
|
|
2994
|
+
@cache_migration("003_conversation_reingest_tool_ids")
|
|
2995
|
+
def _003_conversation_reingest_tool_ids(conn: sqlite3.Connection) -> None:
|
|
2996
|
+
"""Flag-only re-ingest of conversation_messages so tool_use.id /
|
|
2997
|
+
tool_result.tool_use_id / preview land on existing history (#164).
|
|
2998
|
+
|
|
2999
|
+
The destructive clear + offset-0 backfill run in sync_cache UNDER the
|
|
3000
|
+
cache.db.lock flock — NOT here. Clearing in the handler would violate the
|
|
3001
|
+
lock discipline cache-001 follows and would empty the reader on
|
|
3002
|
+
stats-only / eager-migration opens or ``dashboard --no-sync``. A distinct
|
|
3003
|
+
flag from 002's conversation_backfill_pending: 002 = backfill-without-clear;
|
|
3004
|
+
003 = clear-then-backfill. The dispatcher stamps this migration centrally
|
|
3005
|
+
on the existing-install path (issue #140); a fresh install stamps it
|
|
3006
|
+
without running (empty table), and the flag — if ever set — is a harmless
|
|
3007
|
+
no-op there."""
|
|
3008
|
+
_set_cache_meta(conn, "conversation_reingest_pending", "1")
|
|
3009
|
+
conn.commit()
|
|
3010
|
+
|
|
3011
|
+
|
|
2994
3012
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
2995
3013
|
|
|
2996
3014
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -136,12 +136,15 @@ def _blocks_and_text(content):
|
|
|
136
136
|
blocks.append({"kind": "thinking", "text": b.get("thinking", "") or ""})
|
|
137
137
|
elif bt == "tool_use":
|
|
138
138
|
blocks.append({"kind": "tool_use", "name": b.get("name"),
|
|
139
|
-
"input_summary": _summarize(b.get("input"))
|
|
139
|
+
"input_summary": _summarize(b.get("input")),
|
|
140
|
+
"id": b.get("id"),
|
|
141
|
+
"preview": tool_preview(b.get("name"), b.get("input"))})
|
|
140
142
|
elif bt == "tool_result":
|
|
141
143
|
raw = _stringify(b.get("content"))
|
|
142
144
|
blocks.append({"kind": "tool_result", "text": raw[:_TOOL_RESULT_CAP],
|
|
143
145
|
"truncated": len(raw) > _TOOL_RESULT_CAP,
|
|
144
|
-
"is_error": bool(b.get("is_error"))
|
|
146
|
+
"is_error": bool(b.get("is_error")),
|
|
147
|
+
"tool_use_id": b.get("tool_use_id")})
|
|
145
148
|
elif bt in ("image", "document"):
|
|
146
149
|
blocks.append({"kind": bt, **_media(b.get("source"))})
|
|
147
150
|
elif bt == "tool_reference":
|
|
@@ -170,6 +173,39 @@ def _summarize(inp):
|
|
|
170
173
|
return s[:200]
|
|
171
174
|
|
|
172
175
|
|
|
176
|
+
_PREVIEW_FIELDS = {
|
|
177
|
+
"Read": "file_path", "Write": "file_path", "Edit": "file_path",
|
|
178
|
+
"MultiEdit": "file_path", "NotebookEdit": "file_path",
|
|
179
|
+
"Bash": "command", "Grep": "pattern", "Glob": "pattern",
|
|
180
|
+
"Task": "description", "WebFetch": "url", "WebSearch": "query",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def tool_preview(name, inp):
|
|
185
|
+
"""One-line, full-fidelity preview for a tool call's collapsed chip (#164,
|
|
186
|
+
C5). Runs on the RAW input dict before _summarize truncates to 200 chars.
|
|
187
|
+
Known tools map to their primary arg; Bash takes the first command line;
|
|
188
|
+
Task falls back to subagent_type; unknown/mcp tools take the first
|
|
189
|
+
string-valued arg, else the tool name. Always returns a single-line str."""
|
|
190
|
+
if not isinstance(inp, dict):
|
|
191
|
+
return ""
|
|
192
|
+
field = _PREVIEW_FIELDS.get(name or "")
|
|
193
|
+
val = None
|
|
194
|
+
if field is not None:
|
|
195
|
+
val = inp.get(field)
|
|
196
|
+
if val is None and name == "Task":
|
|
197
|
+
val = inp.get("subagent_type")
|
|
198
|
+
if val is None:
|
|
199
|
+
# generic fallback: first string-valued arg, else the tool name
|
|
200
|
+
for v in inp.values():
|
|
201
|
+
if isinstance(v, str) and v:
|
|
202
|
+
val = v
|
|
203
|
+
break
|
|
204
|
+
if not isinstance(val, str) or not val:
|
|
205
|
+
return name or ""
|
|
206
|
+
return val.splitlines()[0]
|
|
207
|
+
|
|
208
|
+
|
|
173
209
|
def _media(source):
|
|
174
210
|
if not isinstance(source, dict):
|
|
175
211
|
return {"media_type": None, "bytes": 0}
|
|
@@ -13,6 +13,7 @@ deduped session_entries row (idx_entries_dedup), via the shared pricing helper
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
import json as _json
|
|
15
15
|
import os
|
|
16
|
+
import re
|
|
16
17
|
import sqlite3
|
|
17
18
|
|
|
18
19
|
# Public surface (Plan 2): shipped in the npm tarball + brew formula + public
|
|
@@ -21,6 +22,75 @@ import sqlite3
|
|
|
21
22
|
from _lib_pricing import _calculate_entry_cost
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
# Mirror of dashboard/web/src/conversations/systemMarkers.ts::MARKER_RE — anchored
|
|
26
|
+
# whole-string (fullmatch), unrolled-lazy body for linear time (no ReDoS), \1
|
|
27
|
+
# backref forces each close tag to match its open tag. Used to SKIP slash-command
|
|
28
|
+
# plumbing when deriving a conversation title (#165 Q2). MUST stay equivalent to
|
|
29
|
+
# the TS predicate over ASCII whitespace (parity-tested); exotic Unicode/control
|
|
30
|
+
# whitespace is an explicit non-goal. See docs/dashboard-gotchas.md.
|
|
31
|
+
_MARKER_TAGS = ("command-name", "command-message", "command-args", "local-command-caveat")
|
|
32
|
+
_MARKER_RE = re.compile(
|
|
33
|
+
r"\s*(?:<(" + "|".join(_MARKER_TAGS) + r")>(?:(?!</\1>)[\s\S])*</\1>\s*)+"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_system_marker(text) -> bool:
|
|
38
|
+
"""True iff `text` is ONLY concatenated command-marker wrappers (slash-command
|
|
39
|
+
plumbing) — the title-derivation skip predicate. `fullmatch` reproduces the TS
|
|
40
|
+
`^\\s*…\\s*$` anchor (no `$`-before-trailing-`\\n` foot-gun)."""
|
|
41
|
+
return bool(text) and _MARKER_RE.fullmatch(text) is not None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_TITLE_MAX = 120
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _title_from_text(text) -> str:
|
|
48
|
+
"""First non-blank LINE of `text`, trimmed, sliced to _TITLE_MAX with a
|
|
49
|
+
trailing '…' ONLY when truncated (rstrip before the ellipsis). '' if none.
|
|
50
|
+
Semantics IDENTICAL to the client deriveReaderTitle (#165 P2.5)."""
|
|
51
|
+
for line in (text or "").split("\n"):
|
|
52
|
+
s = line.strip()
|
|
53
|
+
if s:
|
|
54
|
+
return (s[:_TITLE_MAX].rstrip() + "…") if len(s) > _TITLE_MAX else s
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _session_titles_map(conn, session_ids):
|
|
59
|
+
"""{sid: title} for the first non-marker, non-blank MAIN-session human line
|
|
60
|
+
per session (read-time, no migration). Windowed to the earliest 12 human
|
|
61
|
+
rows/session (rides idx_conv_session_ts); Python skips system markers. A
|
|
62
|
+
session whose first 12 human rows are all markers/blank is simply absent
|
|
63
|
+
(caller falls back). NOTE (Codex P1.2): the window ranks the full per-session
|
|
64
|
+
human partition before rn<=12 — confirmed index-ordered + bounded by the page
|
|
65
|
+
(≤200 sessions); per-session human counts are modest. If EXPLAIN QUERY PLAN
|
|
66
|
+
ever shows a temp B-tree sort here, switch to a per-session correlated
|
|
67
|
+
LIMIT 12 candidate fetch."""
|
|
68
|
+
if not session_ids:
|
|
69
|
+
return {}
|
|
70
|
+
titles = {}
|
|
71
|
+
ph = ",".join("?" for _ in session_ids)
|
|
72
|
+
rows = conn.execute(
|
|
73
|
+
"SELECT session_id, text FROM ("
|
|
74
|
+
" SELECT session_id, text, "
|
|
75
|
+
" ROW_NUMBER() OVER (PARTITION BY session_id "
|
|
76
|
+
" ORDER BY timestamp_utc, id) AS rn "
|
|
77
|
+
f" FROM conversation_messages "
|
|
78
|
+
f" WHERE session_id IN ({ph}) AND entry_type='human' "
|
|
79
|
+
" AND is_sidechain=0 AND COALESCE(text,'') <> ''"
|
|
80
|
+
") WHERE rn <= 12 ORDER BY session_id, rn",
|
|
81
|
+
tuple(session_ids),
|
|
82
|
+
).fetchall()
|
|
83
|
+
for sid, text in rows:
|
|
84
|
+
if sid in titles:
|
|
85
|
+
continue # already resolved to the first non-marker
|
|
86
|
+
if _is_system_marker(text):
|
|
87
|
+
continue
|
|
88
|
+
t = _title_from_text(text)
|
|
89
|
+
if t:
|
|
90
|
+
titles[sid] = t
|
|
91
|
+
return titles
|
|
92
|
+
|
|
93
|
+
|
|
24
94
|
def _project_label(cwd) -> str:
|
|
25
95
|
"""Basename of the project cwd (dashboard label posture — no reveal). Falls
|
|
26
96
|
back to the raw path for root-ish cwds, '' when absent."""
|
|
@@ -155,9 +225,11 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
|
|
|
155
225
|
models = _session_models_map(conn, session_ids)
|
|
156
226
|
# cwd/git_branch as the latest non-null (reader posture), NOT a lexical MAX().
|
|
157
227
|
meta = _session_latest_meta_map(conn, session_ids)
|
|
228
|
+
titles = _session_titles_map(conn, session_ids)
|
|
158
229
|
conversations = [
|
|
159
230
|
{
|
|
160
231
|
"session_id": sid,
|
|
232
|
+
"title": titles.get(sid) or _project_label(meta.get(sid, (None, None))[0]) or sid,
|
|
161
233
|
"project_label": _project_label(meta.get(sid, (None, None))[0]),
|
|
162
234
|
"git_branch": meta.get(sid, (None, None))[1],
|
|
163
235
|
"started_utc": started,
|
|
@@ -239,8 +311,26 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
239
311
|
# item. A turn → exactly ONE item → cost counted exactly once. Humans,
|
|
240
312
|
# tool_results, and assistant rows with a null msg_id emit as simple items at
|
|
241
313
|
# their own position.
|
|
314
|
+
# ---- Phase 1: build items + index every assistant item's tool_use ids ----
|
|
315
|
+
# A tool_result is NOT guaranteed to sort after its tool_use (a grounded
|
|
316
|
+
# transcript scan found a matched result ordered BEFORE its use, plus orphan
|
|
317
|
+
# results with no in-session use), so this is a build-and-index-ALL pass
|
|
318
|
+
# FOLLOWED by a fold pass — never a single forward pass. None ids are never
|
|
319
|
+
# indexed (the id-less degradation guard).
|
|
242
320
|
items = []
|
|
243
|
-
turn_index = {}
|
|
321
|
+
turn_index = {} # (msg_id, req_id) -> index into items
|
|
322
|
+
tooluse_index = {} # tool_use id -> (item, block_dict)
|
|
323
|
+
tool_result_items = [] # placeholder items deferred to Phase 2
|
|
324
|
+
|
|
325
|
+
def _index_tool_uses(item):
|
|
326
|
+
# Index every tool_use id -> its (item, block). Idempotent: re-scanning
|
|
327
|
+
# a turn's blocks re-maps the same id to the same (item, block). Anthropic
|
|
328
|
+
# tool_use ids are unique within a session; a collision would be
|
|
329
|
+
# last-writer-wins (a result then folds to one deterministic owner).
|
|
330
|
+
for b in item["blocks"]:
|
|
331
|
+
if b.get("kind") == "tool_use" and b.get("id") is not None:
|
|
332
|
+
tooluse_index[b["id"]] = (item, b)
|
|
333
|
+
|
|
244
334
|
for row in logical:
|
|
245
335
|
(rid, u, ts, etype, text, blocks, model, msg_id, req_id,
|
|
246
336
|
is_sc, cwd, branch, source_path, parent_uuid) = row
|
|
@@ -249,11 +339,64 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
249
339
|
idx = turn_index.get(key)
|
|
250
340
|
if idx is None:
|
|
251
341
|
turn_index[key] = len(items)
|
|
252
|
-
|
|
342
|
+
it = _build_turn([row])
|
|
343
|
+
items.append(it)
|
|
344
|
+
_index_tool_uses(it)
|
|
253
345
|
else:
|
|
254
346
|
_extend_turn(items[idx], row)
|
|
347
|
+
_index_tool_uses(items[idx]) # re-index the turn (idempotent; new fragment may add ids)
|
|
348
|
+
elif etype == "tool_result":
|
|
349
|
+
it = _build_simple(row)
|
|
350
|
+
items.append(it)
|
|
351
|
+
tool_result_items.append(it)
|
|
255
352
|
else:
|
|
256
|
-
|
|
353
|
+
it = _build_simple(row)
|
|
354
|
+
items.append(it)
|
|
355
|
+
if etype == "assistant": # null-msg_id assistant: index its uses too
|
|
356
|
+
_index_tool_uses(it)
|
|
357
|
+
|
|
358
|
+
# ---- Phase 2: fold each tool_result item into its owning assistant item ----
|
|
359
|
+
drop = set() # id() of folded placeholder items
|
|
360
|
+
for tr in tool_result_items:
|
|
361
|
+
tr_blocks = [b for b in tr["blocks"] if b.get("kind") == "tool_result"]
|
|
362
|
+
non_result = [b for b in tr["blocks"] if b.get("kind") != "tool_result"]
|
|
363
|
+
owners = []
|
|
364
|
+
resolved = []
|
|
365
|
+
for b in tr_blocks:
|
|
366
|
+
tid = b.get("tool_use_id")
|
|
367
|
+
hit = tooluse_index.get(tid) if tid is not None else None
|
|
368
|
+
if hit is None:
|
|
369
|
+
owners = None # an unresolved block -> keep standalone
|
|
370
|
+
break
|
|
371
|
+
owners.append(hit[0])
|
|
372
|
+
resolved.append((hit[1], b))
|
|
373
|
+
# fold iff every result block resolved to exactly ONE owning item, no leftovers
|
|
374
|
+
owner_ids = {id(o) for o in owners} if owners is not None else set()
|
|
375
|
+
if owners and not non_result and len(owner_ids) == 1:
|
|
376
|
+
owner = owners[0]
|
|
377
|
+
for use_block, res_block in resolved:
|
|
378
|
+
use_block["result"] = {"text": res_block.get("text", ""),
|
|
379
|
+
"truncated": bool(res_block.get("truncated")),
|
|
380
|
+
"is_error": bool(res_block.get("is_error"))}
|
|
381
|
+
owner["member_uuids"].append(tr["anchor"]["uuid"])
|
|
382
|
+
drop.add(id(tr))
|
|
383
|
+
# else: leave tr standalone (orphan / multi-owner / mixed) — a folded
|
|
384
|
+
# row's uuid then joins EXACTLY ONE item's member_uuids (the #160 anchor).
|
|
385
|
+
|
|
386
|
+
if drop:
|
|
387
|
+
items = [it for it in items if id(it) not in drop]
|
|
388
|
+
|
|
389
|
+
# ---- Phase 3: sweep every assistant item's tool_use -> tool_call ----
|
|
390
|
+
# Covers turn items AND _build_simple null-msg_id assistant items. Matched
|
|
391
|
+
# requests already carry `result`; unmatched get `result: None`
|
|
392
|
+
# (request-only). Post-migration the client never receives a bare tool_use.
|
|
393
|
+
for it in items:
|
|
394
|
+
if it["kind"] == "assistant":
|
|
395
|
+
for b in it["blocks"]:
|
|
396
|
+
if b.get("kind") == "tool_use":
|
|
397
|
+
b["kind"] = "tool_call"
|
|
398
|
+
b["tool_use_id"] = b.pop("id", None)
|
|
399
|
+
b.setdefault("result", None)
|
|
257
400
|
|
|
258
401
|
costs = _turn_cost_map(conn, list(turn_index))
|
|
259
402
|
# Stamp per-item cost first, then derive the header from the SUM of the
|
|
@@ -480,6 +623,19 @@ def _attach_costs(conn, page):
|
|
|
480
623
|
return page
|
|
481
624
|
|
|
482
625
|
|
|
626
|
+
def _attach_titles(conn, page):
|
|
627
|
+
"""Stamp each final-page hit with its session's derived title — ONE batched
|
|
628
|
+
_session_titles_map over the distinct page session_ids (parallel to
|
|
629
|
+
_attach_costs). Fallback project_label → session_id, matching
|
|
630
|
+
list_conversations (#165 Q4)."""
|
|
631
|
+
sids = list({h["session_id"] for h in page})
|
|
632
|
+
titles = _session_titles_map(conn, sids)
|
|
633
|
+
for h in page:
|
|
634
|
+
sid = h["session_id"]
|
|
635
|
+
h["title"] = titles.get(sid) or h.get("project_label") or sid
|
|
636
|
+
return page
|
|
637
|
+
|
|
638
|
+
|
|
483
639
|
def _like_pattern(q):
|
|
484
640
|
"""Build the LIKE pattern for `q`. Escape the ESCAPE char (\\) FIRST, then
|
|
485
641
|
the wildcards — otherwise a query containing a backslash (incl. a trailing
|
|
@@ -566,7 +722,8 @@ def _search_fts(conn, q, limit, offset):
|
|
|
566
722
|
snips = _fts_snippets(conn, fts_q, [r[0] for r in page])
|
|
567
723
|
hits = [_row_to_hit(uuid, sid, ts, cwd, snips.get(rid, ""), mid, rqd)
|
|
568
724
|
for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
|
|
569
|
-
return {"query": q, "mode": "fts",
|
|
725
|
+
return {"query": q, "mode": "fts",
|
|
726
|
+
"hits": _attach_titles(conn, _attach_costs(conn, hits)),
|
|
570
727
|
"total": total}
|
|
571
728
|
|
|
572
729
|
|
|
@@ -599,7 +756,8 @@ def _search_like(conn, q, limit, offset):
|
|
|
599
756
|
hits = [_row_to_hit(uuid, sid, ts, cwd,
|
|
600
757
|
_manual_snippet(texts.get(rid, ""), q), mid, rqd)
|
|
601
758
|
for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
|
|
602
|
-
return {"query": q, "mode": "like",
|
|
759
|
+
return {"query": q, "mode": "like",
|
|
760
|
+
"hits": _attach_titles(conn, _attach_costs(conn, hits)),
|
|
603
761
|
"total": total}
|
|
604
762
|
|
|
605
763
|
|