cctally 1.29.0 → 1.30.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/_cctally_cache.py +36 -0
  3. package/bin/_cctally_db.py +18 -0
  4. package/bin/_lib_conversation.py +38 -2
  5. package/bin/_lib_conversation_query.py +163 -5
  6. package/dashboard/static/assets/index-4OxMhN7N.js +53 -0
  7. package/dashboard/static/assets/index-DEDO-eqP.css +1 -0
  8. package/dashboard/static/assets/newsreader-latin-400-italic-CEihAR-f.woff2 +0 -0
  9. package/dashboard/static/assets/newsreader-latin-400-italic-CNZoH1hn.woff +0 -0
  10. package/dashboard/static/assets/newsreader-latin-400-normal-BFBkh4jY.woff2 +0 -0
  11. package/dashboard/static/assets/newsreader-latin-400-normal-gRTjlS2D.woff +0 -0
  12. package/dashboard/static/assets/newsreader-latin-500-normal-B66TYsaK.woff2 +0 -0
  13. package/dashboard/static/assets/newsreader-latin-500-normal-DFwuUcdu.woff +0 -0
  14. package/dashboard/static/assets/newsreader-latin-600-normal-30OJ_TG_.woff2 +0 -0
  15. package/dashboard/static/assets/newsreader-latin-600-normal-DUnT2r2g.woff +0 -0
  16. package/dashboard/static/assets/newsreader-latin-ext-400-italic-BMTE_bNQ.woff2 +0 -0
  17. package/dashboard/static/assets/newsreader-latin-ext-400-italic-qdgKLcPG.woff +0 -0
  18. package/dashboard/static/assets/newsreader-latin-ext-400-normal-DYA1XoQK.woff +0 -0
  19. package/dashboard/static/assets/newsreader-latin-ext-400-normal-svq1FPys.woff2 +0 -0
  20. package/dashboard/static/assets/newsreader-latin-ext-500-normal-BNHmvKvI.woff2 +0 -0
  21. package/dashboard/static/assets/newsreader-latin-ext-500-normal-CZruMFou.woff +0 -0
  22. package/dashboard/static/assets/newsreader-latin-ext-600-normal-BXv5iMHi.woff2 +0 -0
  23. package/dashboard/static/assets/newsreader-latin-ext-600-normal-BrbfzHZ5.woff +0 -0
  24. package/dashboard/static/assets/newsreader-vietnamese-400-italic-QbB8kb5s.woff +0 -0
  25. package/dashboard/static/assets/newsreader-vietnamese-400-italic-bZegYFuM.woff2 +0 -0
  26. package/dashboard/static/assets/newsreader-vietnamese-400-normal-BekUZro8.woff +0 -0
  27. package/dashboard/static/assets/newsreader-vietnamese-400-normal-DdKr49mV.woff2 +0 -0
  28. package/dashboard/static/assets/newsreader-vietnamese-500-normal-BEAbKU8A.woff +0 -0
  29. package/dashboard/static/assets/newsreader-vietnamese-500-normal-CL6a8tp2.woff2 +0 -0
  30. package/dashboard/static/assets/newsreader-vietnamese-600-normal-CVAR0otO.woff +0 -0
  31. package/dashboard/static/assets/newsreader-vietnamese-600-normal-CaH84vfx.woff2 +0 -0
  32. package/dashboard/static/dashboard.html +2 -2
  33. package/package.json +1 -1
  34. package/dashboard/static/assets/index-BGaWg6ys.js +0 -47
  35. package/dashboard/static/assets/index-BqQ5xdX0.css +0 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.30.0] - 2026-06-09
9
+
10
+ ### Added
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - 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).
15
+
16
+ ### Changed
17
+ - **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).
18
+ - 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.
19
+
8
20
  ## [1.29.0] - 2026-06-08
9
21
 
10
22
  ### Added
@@ -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
 
@@ -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")
@@ -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 = {} # (msg_id, req_id) -> index into items
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
- items.append(_build_turn([row]))
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
- items.append(_build_simple(row))
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", "hits": _attach_costs(conn, hits),
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", "hits": _attach_costs(conn, hits),
759
+ return {"query": q, "mode": "like",
760
+ "hits": _attach_titles(conn, _attach_costs(conn, hits)),
603
761
  "total": total}
604
762
 
605
763