cctally 1.52.1 → 1.54.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.
@@ -26,6 +26,11 @@ from _lib_pricing import _calculate_entry_cost
26
26
  # content block the same way the parser does at ingest — reuse the parser's
27
27
  # _stringify so the full (un-capped) result text matches the cached/capped one.
28
28
  from _lib_conversation import _stringify
29
+ # #217 S1 / U5: the payload-clip helpers moved DOWN into the parser module (low
30
+ # in the dependency graph) so _bound_input's total-cap backstop and this module's
31
+ # read_full_payload share one bound-guaranteeing implementation. Imported here
32
+ # (query depends on the parser, never the reverse — verified at #217).
33
+ from _lib_conversation import _clip_payload_input, _shrink_largest_leaf
29
34
  # #177 S4: the media-route reader walks a content array with the SAME ordinal
30
35
  # generator the ingest placeholders used, so "media item N" addresses one item.
31
36
  from _lib_conversation import iter_media_items
@@ -272,39 +277,124 @@ def _subagent_key(source_path):
272
277
  return stem or None
273
278
 
274
279
 
275
- # §4 1a — nested (grandchild) subagent result parse. The new Claude Code format
276
- # emits a grandchild's spawn result as STRING content (no structured
277
- # `agent_id`/`subagent_meta`), with a trailing `agentId: <hash> (use SendMessage
278
- # …)` line and an OPTIONAL `<usage>` totals wrapper. Anchored on the literal
279
- # `<usage>` wrapper; tolerant of whitespace/newline variants (re.DOTALL).
280
- _NESTED_AGENT_ID_RE = re.compile(r"agentId:\s*([0-9a-f]+)")
281
- _NESTED_USAGE_RE = re.compile(
282
- r"<usage>\s*subagent_tokens:\s*(\d+)\s*tool_uses:\s*(\d+)\s*duration_ms:\s*(\d+)\s*</usage>",
283
- re.DOTALL,
280
+ # §4 1a / #217 S1 U6 — nested (grandchild) subagent result parse. The new Claude
281
+ # Code format emits a grandchild's spawn result as STRING content (no structured
282
+ # record-level toolUseResult.agentId), with a trailing `agentId: <hash> (use
283
+ # SendMessage …)` line and an OPTIONAL `<usage>` totals wrapper. The regex
284
+ # constants + the parse rule moved DOWN into the parser (_lib_conversation) so
285
+ # the INGEST stamp runs them over the FULL raw before the 16 KB clip (#217 S1
286
+ # U6); re-exported here for the read-time fallback over un-reingested rows.
287
+ from _lib_conversation import (
288
+ _NESTED_AGENT_ID_RE, _NESTED_USAGE_RE, _nested_agent_stamp_from_text,
284
289
  )
290
+ # #217 S5 F2 — the true per-edit +N/-M stat, recomputed at outline-build time for
291
+ # non-truncated edits (the stamped `block["edit_stat"]` only exists on truncated
292
+ # inputs, where the bounded copy can't be recounted; Codex P1-7).
293
+ from _lib_conversation import _edit_stat_for
294
+
295
+ # #217 S5 F2 — the files-touched aggregation is DELIBERATELY scoped to the three
296
+ # named edit tools, NOT `_lib_conversation._FILE_TOUCH_TOOLS` (which also includes
297
+ # NotebookEdit — rare; out of scope, Codex P2-4). Lower-cased for case-insensitive
298
+ # block-name matching.
299
+ _EXPORT_EDIT_TOOLS = {"edit", "multiedit", "write"}
300
+
301
+
302
+ def _collect_files(items):
303
+ """Whole-session files-touched aggregation (#217 S5 F2, spec §2).
304
+
305
+ For every Edit / MultiEdit / Write ``tool_call`` block (NOT NotebookEdit —
306
+ the narrower set is deliberate, Codex P2-4) carrying a string ``file_path``,
307
+ emit one ``touch`` per call and a per-path running ``+N/-M`` sum, preserving
308
+ first-touch document order. The signed stat is the stamped ``edit_stat`` when
309
+ present (truncated inputs), else a recompute via ``_edit_stat_for``; when
310
+ NEITHER is available the touch is STILL listed with ``add``/``del`` = ``None``
311
+ and the file total sums only the known values (``None`` only when every touch
312
+ is unknown — Codex P1-7). Pure over the assembled ``items``.
313
+ """
314
+ order, agg = [], {}
315
+ for it in items:
316
+ anchor_uuid = it["anchor"]["uuid"]
317
+ for b in it.get("blocks", []):
318
+ if b.get("kind") not in ("tool_call", "tool_use"):
319
+ continue
320
+ nm = (b.get("name") or "").lower()
321
+ if nm not in _EXPORT_EDIT_TOOLS:
322
+ continue
323
+ inp = b.get("input")
324
+ path = inp.get("file_path") if isinstance(inp, dict) else None
325
+ if not isinstance(path, str) or not path:
326
+ continue
327
+ st = b.get("edit_stat")
328
+ if st is None:
329
+ st = _edit_stat_for(b.get("name"), inp)
330
+ add = st.get("add") if isinstance(st, dict) else None
331
+ dele = st.get("del") if isinstance(st, dict) else None
332
+ if path not in agg:
333
+ agg[path] = {"path": path, "add": None, "del": None, "touches": []}
334
+ order.append(path)
335
+ entry = agg[path]
336
+ entry["touches"].append({
337
+ "uuid": anchor_uuid,
338
+ "tool_use_id": b.get("tool_use_id") or b.get("id"),
339
+ "op": nm,
340
+ "add": add,
341
+ "del": dele,
342
+ })
343
+ if add is not None:
344
+ entry["add"] = (entry["add"] or 0) + add
345
+ if dele is not None:
346
+ entry["del"] = (entry["del"] or 0) + dele
347
+ return [agg[p] for p in order]
348
+
349
+
350
+ def _task_completion(items):
351
+ """Whole-session task-completion detection (#217 S5 F7, spec §3).
352
+
353
+ Scan the assembled ``items`` in document order, MAIN THREAD ONLY
354
+ (``subagent_key is None`` AND not ``is_sidechain`` — Codex P1-1, because
355
+ ``_fold_task_runs`` keeps task state PER subagent thread, so a global
356
+ last-snapshot could describe a subagent's checklist rather than the
357
+ session's). Take the LAST main-thread block carrying a folded
358
+ ``task_snapshot`` (the Task* family, stamped by ``_fold_task_runs``) or a
359
+ legacy ``TodoWrite`` (``name == "TodoWrite"``, ``input.todos``). Returns
360
+ ``{all_done, total, completed, anchor_uuid}`` or ``None`` when the
361
+ main thread has no tasks. Pure over the assembled ``items``.
362
+ """
363
+ final = None
364
+ final_uuid = None
365
+ for it in items:
366
+ if it.get("subagent_key") is not None or it.get("is_sidechain"):
367
+ continue
368
+ for b in it.get("blocks", []):
369
+ if b.get("kind") not in ("tool_call", "tool_use"):
370
+ continue
371
+ snap = b.get("task_snapshot")
372
+ if snap is None and b.get("name") == "TodoWrite":
373
+ inp = b.get("input")
374
+ snap = inp.get("todos") if isinstance(inp, dict) else None
375
+ if isinstance(snap, list):
376
+ final = snap
377
+ final_uuid = it["anchor"]["uuid"]
378
+ if not final:
379
+ return None
380
+ total = len(final)
381
+ completed = sum(1 for t in final
382
+ if isinstance(t, dict) and t.get("status") == "completed")
383
+ return {"all_done": total > 0 and completed == total,
384
+ "total": total,
385
+ "completed": completed,
386
+ "anchor_uuid": final_uuid}
285
387
 
286
388
 
287
389
  def _parse_nested_agent_result(text):
288
- """Nested (grandchild) subagent result: string-content, no structured
289
- `agentId` (spec §4 1a). Parse `agentId` (+ OPTIONAL `<usage>` totals) out of
290
- the result text. Returns (agent_id, meta) meta is {} when `<usage>` is
291
- absent/clipped past the 16 KB cap, populated with completed totals when
292
- present. Returns None when no `agentId` is present (an ordinary result, or a
293
- >16 KB clip past the `agentId:` line itself degrades to a flat card, no
294
- mis-link). Linking on `agentId` ALONE is sufficient (Codex P1-B)."""
295
- if not text:
296
- return None
297
- m = _NESTED_AGENT_ID_RE.search(text)
298
- if not m:
299
- return None
300
- meta = {}
301
- u = _NESTED_USAGE_RE.search(text)
302
- if u:
303
- meta["total_tokens"] = int(u.group(1))
304
- meta["total_tool_use_count"] = int(u.group(2))
305
- meta["total_duration_ms"] = int(u.group(3))
306
- meta["status"] = "completed"
307
- return m.group(1), meta
390
+ """Read-time fallback for nested (grandchild) subagent results on rows that
391
+ were ingested BEFORE the #217 S1 U6 structured stamp (un-reingested history).
392
+ Delegates to the parser's single-source rule over the (already-clipped) block
393
+ text so a >16 KB result whose `agentId:` trailer was cut survives only via
394
+ the ingest stamp + the migration-017 offset-0 reingest, never here. Returns
395
+ (agent_id, meta) or None when no `agentId:` is present. Linking on `agentId`
396
+ ALONE is sufficient (Codex P1-B)."""
397
+ return _nested_agent_stamp_from_text(text)
308
398
 
309
399
 
310
400
  def _iso_ms(ts):
@@ -374,122 +464,338 @@ def _cache_read_saved_usd(model, cache_read):
374
464
  return full - read
375
465
 
376
466
 
467
+ # A normalized cache-failure event. ``compaction`` marks a context-compaction
468
+ # boundary (resets the running-max); otherwise it is an assistant-token event
469
+ # carrying its per-thread/per-model key + the cache_creation/cache_read signal.
470
+ # Both the full-assembly stamp path and the lightweight rebuild-count path build
471
+ # a document-ordered list of these and feed it to the ONE predicate below — the
472
+ # rule is implemented exactly once (U1, #217 S1).
473
+ class _CFEvent:
474
+ __slots__ = ("compaction", "key", "cc", "cr", "model")
475
+
476
+ def __init__(self, *, compaction=False, key=None, cc=0, cr=0, model=None):
477
+ self.compaction = compaction
478
+ self.key = key
479
+ self.cc = cc
480
+ self.cr = cr
481
+ self.model = model
482
+
483
+
484
+ def _iter_cache_failures(events):
485
+ """The single cache-failure rule (spec §1), as a generator over a
486
+ document-ordered ``_CFEvent`` stream. Yields ``(index, prev_cached, lost,
487
+ model)`` for each FLAGGED assistant event — ``index`` is the event's position
488
+ in ``events`` so a caller can map a flag back to its source item.
489
+
490
+ Maintains a running-max of ``cache_read`` keyed by the event's ``key``
491
+ (``(subagent_key, model)`` — ``None`` subagent_key = main session). The key
492
+ is per-thread AND per-model because Anthropic prompt caches are model-
493
+ specific: a model switch within a thread legitimately starts a fresh cache
494
+ (cr ~ 0) and must not read as a loss, and the compound key makes that the
495
+ first event under its own key (no prior rm). The whole map is RESET on a
496
+ compaction event, since compaction legitimately invalidates the prefix and
497
+ the post-compaction re-prime is not a failure.
498
+
499
+ For each assistant event — cc, cr, and rm (the running-max for this key
500
+ BEFORE this event) — flag iff rm >= CACHE_FLOOR and cc >= CREATE_FLOOR and
501
+ cr <= COLLAPSE_FRACTION*rm and cc/(cc+cr) >= RECREATE_FRACTION. The fourth
502
+ term keeps the rule aligned with the Evidence: it requires that most of THIS
503
+ event's context was freshly created, not merely that cache_read dipped. After
504
+ the check, ``running_max[key] = max(rm, cr)`` (a failure's small cr never
505
+ lowers the high-water mark; the next healthy event re-establishes it).
506
+
507
+ ``lost`` is the lost-prefix basis (NOT raw cc, which over-counts when the
508
+ turn also writes genuinely-new cacheable content): ``min(cc, max(0,
509
+ rm - cr))``. Order-dependent by construction; run over document-ordered
510
+ events."""
511
+ running_max = {} # (subagent_key, model) -> high-water cache_read
512
+ for i, ev in enumerate(events):
513
+ if ev.compaction:
514
+ # Reset the whole map (compaction invalidates every thread's prefix
515
+ # in this session view; a per-key reset would need a thread tag the
516
+ # meta row does not reliably carry).
517
+ running_max.clear()
518
+ continue
519
+ cc, cr = ev.cc, ev.cr
520
+ rm = running_max.get(ev.key, 0)
521
+ total = cc + cr
522
+ if (rm >= _CACHE_FAILURE_CACHE_FLOOR
523
+ and cc >= _CACHE_FAILURE_CREATE_FLOOR
524
+ and cr <= _CACHE_FAILURE_COLLAPSE_FRACTION * rm
525
+ and total > 0
526
+ and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
527
+ lost = min(cc, max(0, rm - cr))
528
+ yield (i, rm, lost, ev.model)
529
+ running_max[ev.key] = max(rm, cr)
530
+
531
+
532
+ def _cache_failure_count_over_events(events) -> int:
533
+ """Count of flagged events in a normalized ``_CFEvent`` stream (the pure
534
+ predicate the lightweight rebuild-count path consumes). Single source of
535
+ truth — shares ``_iter_cache_failures`` with the full-assembly stamp."""
536
+ return sum(1 for _ in _iter_cache_failures(events))
537
+
538
+
539
+ def _cache_failure_events_from_items(items):
540
+ """Build the document-ordered ``_CFEvent`` stream from assembled reader
541
+ ``items`` plus a parallel ``sources`` list (the source item per event, for
542
+ stamping). A compaction event per ``meta``/``compaction`` item; an assistant-
543
+ token event per assistant item carrying a ``tokens`` dict. Items lacking a
544
+ ``tokens`` dict (no session_entries row, or non-assistant non-compaction
545
+ items) emit NOTHING and so neither flag nor move the running-max — exactly
546
+ the pre-U1 skip behavior."""
547
+ events = []
548
+ sources = [] # parallel list: the source item per event (for stamping)
549
+ for it in items:
550
+ if it.get("kind") == "meta" and it.get("meta_kind") == "compaction":
551
+ events.append(_CFEvent(compaction=True))
552
+ sources.append(it)
553
+ continue
554
+ if it.get("kind") != "assistant":
555
+ continue
556
+ tok = it.get("tokens")
557
+ if not isinstance(tok, dict):
558
+ continue # no session_entries row -> skip
559
+ events.append(_CFEvent(
560
+ key=(it.get("subagent_key"), it.get("model")),
561
+ cc=tok.get("cache_creation", 0) or 0,
562
+ cr=tok.get("cache_read", 0) or 0,
563
+ model=it.get("model")))
564
+ sources.append(it)
565
+ return events, sources
566
+
567
+
377
568
  def _stamp_cache_failures(items):
378
569
  """Stamp ``item["cache_failure"]`` on each assistant turn that re-creates the
379
570
  bulk of its cached prefix instead of reading it (spec §1). Mutates `items` in
380
571
  place; healthy turns are left WITHOUT the key (absent, not zero — matching the
381
572
  ``tokens?`` "absent, not zero" convention).
382
573
 
383
- Walks items in DOCUMENT ORDER (the order `_assemble_session` produces),
384
- maintaining a running-max of ``cache_read`` keyed by ``(subagent_key, model)``
385
- ``None`` subagent_key = main session. The key is per-thread AND per-model
386
- because Anthropic prompt caches are model-specific: a model switch within a
387
- thread legitimately starts a fresh cache (cr ~ 0) and must not read as a loss,
388
- and the compound key makes that the first turn under its own key (no prior rm).
389
- The per-key running-max is RESET when a context-compaction boundary is crossed
390
- (a ``meta`` item whose ``meta_kind == "compaction"``), since compaction
391
- legitimately invalidates the prefix and the post-compaction re-prime is not a
392
- failure.
393
-
394
- For each assistant item with a ``tokens`` dict — cc = tokens["cache_creation"],
395
- cr = tokens["cache_read"], rm = the running-max for this key BEFORE this turn —
396
- flag iff rm >= CACHE_FLOOR and cc >= CREATE_FLOOR and cr <= COLLAPSE_FRACTION*rm
397
- and cc/(cc+cr) >= RECREATE_FRACTION. The fourth term keeps the rule aligned
398
- with the Evidence: it requires that most of THIS turn's context was freshly
399
- created, not merely that cache_read dipped. After the check, update
400
- ``running_max[key] = max(rm, cr)`` (a failure's small cr never lowers the
401
- high-water mark; the next healthy turn re-establishes it).
402
-
403
- Payload (lost-prefix basis — NOT raw cc, which would over-count when the turn
404
- also writes genuinely-new cacheable content that was never cached):
574
+ Builds a document-ordered ``_CFEvent`` stream from the items (a compaction
575
+ event per compaction-meta, an assistant-token event per token-bearing
576
+ assistant turn) and runs the SAME ``_iter_cache_failures`` rule the
577
+ lightweight rebuild-count path uses (single source of truth the rule lives
578
+ in exactly one place; U1). The payload is computed here on the lost-prefix
579
+ basis (NOT raw cc, which would over-count when the turn also writes
580
+ genuinely-new cacheable content that was never cached):
405
581
  lost = min(cc, max(0, rm - cr))
406
582
  tokens_recreated = lost
407
583
  prev_cached = rm
408
584
  est_wasted_usd = write(lost) - read(lost)
585
+ """
586
+ events, sources = _cache_failure_events_from_items(items)
587
+ for idx, prev_cached, lost, model in _iter_cache_failures(events):
588
+ sources[idx]["cache_failure"] = {
589
+ "tokens_recreated": lost,
590
+ "prev_cached": prev_cached,
591
+ "est_wasted_usd": _cache_failure_wasted_usd(model, lost),
592
+ }
593
+
409
594
 
410
- Items lacking a ``tokens`` dict (no session_entries row, or non-assistant
411
- items other than a compaction meta) are skipped and do NOT move the
412
- running-max. Order-dependent by construction; run over document-ordered items.
595
+ def _lightweight_rebuild_events(conn, session_id):
596
+ """Build the document-ordered ``_CFEvent`` stream for a session WITHOUT a
597
+ full ``_assemble_session`` (U1, #217 S1). Skips the expensive assembly work
598
+ (no ``blocks_json`` body parse, no tool-result folding, no meta-classify /
599
+ ANSI strip, no subagent correlation) but faithfully reproduces the canonical
600
+ normalization the cache-failure predicate depends on:
601
+
602
+ - UUID dedup: canonical row per ``(session_id, uuid)`` = first occurrence
603
+ in (timestamp_utc, id) order (a replay carries the original uuid);
604
+ - turn grouping by ``(msg_id, req_id)``: an assistant turn — possibly split
605
+ across non-consecutive fragments interleaved by a tool_result — emits ONE
606
+ event at the FIRST fragment's position, with cost-once-per-(msg_id,req_id)
607
+ token attribution from the deduped session_entries row;
608
+ - first-prose MODEL promotion: the turn's model is the FIRST prose-bearing
609
+ fragment's model (the canonical anchor), matching ``_build_turn`` /
610
+ ``_fold_fragment``; a turn with no prose keeps its seed fragment's model;
611
+ - seed ``source_path -> subagent_key`` derivation (``_subagent_key``);
612
+ - compaction-reset detection: an ``entry_type in ('meta','human')`` row
613
+ whose all-text body is a compaction summary (``_is_compaction_body``)
614
+ and which is NOT a slash-command invocation — mirroring the assembly
615
+ meta-classify — emits a compaction event.
616
+
617
+ The event ORDER is the turn's first-fragment document position, byte-for-byte
618
+ the order ``_assemble_session`` walks (which emits each turn item at its first
619
+ fragment, and each meta/human row at its own position). That preserves the
620
+ running-max walk the predicate requires.
413
621
  """
414
- running_max = {} # (subagent_key, model) -> high-water cache_read
415
- for it in items:
416
- # Context-compaction boundary resets the per-key prefix high-water mark:
417
- # the post-compaction re-prime is a legitimate fresh build, not a loss.
418
- # Reset the whole map (compaction invalidates every thread's prefix in
419
- # this session view; a per-key reset would need a thread tag the meta row
420
- # does not reliably carry).
421
- if it.get("kind") == "meta" and it.get("meta_kind") == "compaction":
422
- running_max.clear()
423
- continue
424
- if it.get("kind") != "assistant":
622
+ exists = conn.execute(
623
+ "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
624
+ (session_id,)).fetchone()
625
+ if exists is None:
626
+ return []
627
+ # Narrow column set: enough to dedup, group turns, derive the subagent_key,
628
+ # detect compaction, and join the token row. No blocks_json body parse beyond
629
+ # the all-text compaction guard (which needs text OR the text-block join).
630
+ rows = conn.execute(
631
+ "SELECT id, uuid, entry_type, text, blocks_json, model, "
632
+ " msg_id, req_id, source_path "
633
+ "FROM conversation_messages WHERE session_id=? "
634
+ "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
635
+ seen_uuid = set()
636
+ logical = []
637
+ for row in rows:
638
+ u = row[1]
639
+ if u in seen_uuid:
640
+ continue # UUID dedup: keep the first (canonical) occurrence
641
+ seen_uuid.add(u)
642
+ logical.append(row)
643
+
644
+ # Group assistant turns by (msg_id, req_id) at the first-fragment position;
645
+ # later same-key fragments fold their model (first-prose promotion) without
646
+ # adding a second event. A `None`-msg_id assistant row carries no turn key and
647
+ # no session_entries cost, so it never contributes a token event.
648
+ events = []
649
+ sources = [] # parallel: the turn key per assistant event
650
+ turn_index = {} # (msg_id, req_id) -> index into events
651
+ turn_has_prose = {} # (msg_id, req_id) -> bool (model promoted yet?)
652
+ turn_keys = [] # ordered list of grouped turn keys (for the token map)
653
+ for (rid, u, etype, text, blocks, model, msg_id, req_id,
654
+ source_path) in logical:
655
+ if etype == "assistant" and msg_id is not None:
656
+ key = (msg_id, req_id)
657
+ frag_text = (text or "").strip()
658
+ ev_idx = turn_index.get(key)
659
+ if ev_idx is None:
660
+ ev_idx = len(events)
661
+ turn_index[key] = ev_idx
662
+ turn_keys.append(key)
663
+ ev = _CFEvent(key=(_subagent_key(source_path), model),
664
+ model=model)
665
+ events.append(ev)
666
+ sources.append(key)
667
+ # The seed model holds until the first prose fragment promotes it.
668
+ turn_has_prose[key] = bool(frag_text)
669
+ else:
670
+ ev = events[ev_idx]
671
+ # First prose fragment promotes the turn's model anchor. The full
672
+ # path (`_build_turn`) SEEDS subagent_key from the first fragment
673
+ # and re-promotes ONLY the model; mirror that here by keeping the
674
+ # seeded `ev.key[0]` and re-promoting just the model (#218 I-A.1).
675
+ # subagent_key is per-file-invariant across a turn's fragments (one
676
+ # (msg_id,req_id) = one file), so this matches in all real data and
677
+ # is exact structural parity for the engineered cross-file split.
678
+ if not turn_has_prose.get(key) and frag_text:
679
+ ev.key = (ev.key[0], model)
680
+ ev.model = model
681
+ turn_has_prose[key] = True
682
+ elif etype in ("meta", "human"):
683
+ # Mirror the assembly meta-classify's compaction branch: an all-text
684
+ # body that is a compaction summary AND is not a slash-command
685
+ # invocation. A command invocation (#188) is promoted to a human turn
686
+ # BEFORE meta-classify, so a body carrying <command-args> never reads
687
+ # as compaction; compaction bodies have no <command-args>, so in
688
+ # practice the invocation check is a faithful belt-and-suspenders.
689
+ try:
690
+ parsed_blocks = _json.loads(blocks or "[]")
691
+ except (ValueError, TypeError):
692
+ parsed_blocks = []
693
+ body = text or _join_text_blocks(parsed_blocks)
694
+ all_text = all(b.get("kind") == "text"
695
+ for b in (parsed_blocks or []))
696
+ if (all_text and _is_compaction_body(body)
697
+ and _extract_command_invocation(parsed_blocks, body) is None):
698
+ events.append(_CFEvent(compaction=True))
699
+ sources.append(None)
700
+ # tool_result rows and null-msg_id assistant rows carry no token event.
701
+
702
+ # Cost-once token attribution: ONE deduped session_entries row per
703
+ # (msg_id, req_id) — the same map the full path stamps from. An assistant
704
+ # turn key absent from session_entries carries NO tokens, so its event is
705
+ # dropped (neither flags nor moves the running-max) — parity with
706
+ # _cache_failure_events_from_items's `tokens` skip.
707
+ usage = _turn_usage_map(conn, turn_keys)
708
+ filtered = []
709
+ si = 0
710
+ for ev in events:
711
+ if ev.compaction:
712
+ filtered.append(ev)
713
+ si += 1
425
714
  continue
426
- tok = it.get("tokens")
427
- if not isinstance(tok, dict):
428
- continue # no session_entries row -> skip, don't move max
429
- cc = tok.get("cache_creation", 0) or 0
430
- cr = tok.get("cache_read", 0) or 0
431
- key = (it.get("subagent_key"), it.get("model"))
432
- rm = running_max.get(key, 0)
433
- total = cc + cr
434
- if (rm >= _CACHE_FAILURE_CACHE_FLOOR
435
- and cc >= _CACHE_FAILURE_CREATE_FLOOR
436
- and cr <= _CACHE_FAILURE_COLLAPSE_FRACTION * rm
437
- and total > 0
438
- and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
439
- lost = min(cc, max(0, rm - cr))
440
- it["cache_failure"] = {
441
- "tokens_recreated": lost,
442
- "prev_cached": rm,
443
- "est_wasted_usd": _cache_failure_wasted_usd(it.get("model"), lost),
444
- }
445
- running_max[key] = max(rm, cr)
715
+ key = sources[si]
716
+ si += 1
717
+ tok = usage.get(key)
718
+ if tok is None:
719
+ continue # no session_entries row -> skip (don't move max)
720
+ ev.cc = tok.get("cache_creation", 0) or 0
721
+ ev.cr = tok.get("cache_read", 0) or 0
722
+ filtered.append(ev)
723
+ return filtered
446
724
 
447
725
 
448
726
  def session_cache_rebuild_count(conn, session_id) -> int:
449
727
  """Per-session count of cache-rebuild (cache-failure) turns.
450
728
 
451
- Single source of truth: assembles the session via the SAME pipeline the
452
- reader/outline use and runs the SAME _stamp_cache_failures kernel, then
453
- counts flagged items. /outline omits stats.cache_failures when the count is
454
- 0, so "no flagged items" -> 0 (byte-identical to outline). Stored on the
455
- conversation_sessions rollup by _recompute_conversation_sessions; filtered
456
- by list_conversations(rebuild_min=...).
457
-
458
- _assemble_session already runs _stamp_cache_failures internally before
459
- returning, so the items carry the flag on entry; re-running the kernel here
460
- is an idempotent recompute (it only ADDS the key on a flagged turn, never
461
- clears one, and the predicate is deterministic over the same items) that
462
- keeps the helper correct even if assembly ever stops stamping.
729
+ Single source of truth: builds a document-ordered cache-failure event stream
730
+ via the LIGHTWEIGHT builder (``_lightweight_rebuild_events`` no full
731
+ ``_assemble_session``, so this stays cheap on the flock-held rollup path; U1,
732
+ #217 S1) and runs it through the SAME ``_cache_failure_count_over_events``
733
+ predicate the reader's full assembly stamps with. The light builder
734
+ reproduces the canonical normalization (UUID dedup, turn grouping, first-prose
735
+ model promotion, seed subagent_key, cost-once token attribution, compaction
736
+ reset), so the count is byte-identical to the full-assembly count (guarded by
737
+ ``test_session_cache_rebuild_count_lightweight_matches_full_assembly``).
738
+
739
+ /outline omits stats.cache_failures when the count is 0, so "no flagged
740
+ items" -> 0 (byte-identical to outline). Stored on the conversation_sessions
741
+ rollup by _recompute_conversation_sessions; filtered by
742
+ list_conversations(rebuild_min=...).
463
743
  """
464
- asm = _assemble_session(conn, session_id)
465
- if asm is None:
466
- return 0
467
- items = asm["items"]
468
- _stamp_cache_failures(items)
469
- return sum(1 for it in items if "cache_failure" in it)
744
+ return _cache_failure_count_over_events(
745
+ _lightweight_rebuild_events(conn, session_id))
746
+
747
+
748
+ def _turn_costs_for_keys(conn, keys):
749
+ """{(msg_id, req_id): cost_usd} for the given turn keys, joined ONCE to the
750
+ deduped session_entries row per (msg_id, req_id) (#217 S1 / U7b — the SINGLE
751
+ cost-attribution chokepoint both ``_turn_cost_map`` and ``_session_cost_map``
752
+ delegate to, so the cost-once-per-turn rule is never reimplemented).
753
+
754
+ NULL-filters the keys, chunks the OR-of-pairs to stay well under SQLite's
755
+ variable limit, and computes each cost via the shared pricing helper
756
+ (honoring a vendor ``cost_usd_raw`` override). Keys absent from
757
+ session_entries (e.g. ``<synthetic>`` walker-skipped rows) are simply not
758
+ present in the result -> cost 0 by omission. Parity guard:
759
+ ``test_session_and_turn_cost_map_share_helper_parity``."""
760
+ costs = {}
761
+ norm = [(m, r) for (m, r) in keys if m is not None and r is not None]
762
+ if not norm:
763
+ return costs
764
+ for i in range(0, len(norm), 400):
765
+ chunk = norm[i:i + 400]
766
+ cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
767
+ params = [v for pair in chunk for v in pair]
768
+ sql = ("SELECT msg_id, req_id, model, input_tokens, output_tokens, "
769
+ "cache_create_tokens, cache_read_tokens, cost_usd_raw "
770
+ "FROM session_entries WHERE " + cond)
771
+ for m, r, model, inp, out, cc, cr, raw in conn.execute(sql, params):
772
+ costs[(m, r)] = _entry_cost(model, inp, out, cc, cr, raw)
773
+ return costs
470
774
 
471
775
 
472
776
  def _session_cost_map(conn, session_ids):
473
- """{session_id: total_cost_usd} for the given sessions. Joins
474
- conversation_messages turn keys to the single deduped session_entries row
475
- per (msg_id, req_id), so a turn replayed across files contributes once.
476
- (msg_id, req_id) is globally unique in session_entries and maps to exactly
477
- one session_id, so per-session sums are clean."""
777
+ """{session_id: total_cost_usd} for the given sessions. Resolves each
778
+ session's distinct (msg_id, req_id) turn keys, then attributes cost via the
779
+ shared ``_turn_costs_for_keys`` helper (cost-once per deduped session_entries
780
+ row), summing into the owning session. (msg_id, req_id) is globally unique in
781
+ session_entries and maps to exactly one session_id, so per-session sums are
782
+ clean and a turn replayed across files contributes once."""
478
783
  costs = {sid: 0.0 for sid in session_ids}
479
784
  if not session_ids:
480
785
  return costs
481
786
  placeholders = ",".join("?" for _ in session_ids)
482
- sql = (
483
- "SELECT cm.session_id, se.model, se.input_tokens, se.output_tokens, "
484
- " se.cache_create_tokens, se.cache_read_tokens, se.cost_usd_raw "
485
- "FROM (SELECT DISTINCT session_id, msg_id, req_id "
486
- " FROM conversation_messages "
487
- " WHERE session_id IN (%s) AND msg_id IS NOT NULL AND req_id IS NOT NULL) cm "
488
- "JOIN session_entries se ON se.msg_id = cm.msg_id AND se.req_id = cm.req_id"
489
- % placeholders
490
- )
491
- for sid, model, inp, out, cc, cr, raw in conn.execute(sql, list(session_ids)):
492
- costs[sid] = costs.get(sid, 0.0) + _entry_cost(model, inp, out, cc, cr, raw)
787
+ pairs = conn.execute(
788
+ "SELECT DISTINCT session_id, msg_id, req_id "
789
+ "FROM conversation_messages "
790
+ "WHERE session_id IN (%s) AND msg_id IS NOT NULL AND req_id IS NOT NULL"
791
+ % placeholders,
792
+ list(session_ids),
793
+ ).fetchall()
794
+ if not pairs:
795
+ return costs
796
+ key_cost = _turn_costs_for_keys(conn, [(m, r) for _, m, r in pairs])
797
+ for sid, m, r in pairs:
798
+ costs[sid] = costs.get(sid, 0.0) + key_cost.get((m, r), 0.0)
493
799
  return costs
494
800
 
495
801
 
@@ -513,23 +819,31 @@ def _session_latest_meta_map(conn, session_ids):
513
819
  """{session_id: (cwd, git_branch)} using the most-recent NON-NULL value per
514
820
  column — the SAME posture as get_conversation's _latest, so the rail and the
515
821
  reader agree on a session whose cwd/branch changed over its lifetime (a plain
516
- MAX() picks the lexical max, not the latest). Bounded to the page's sessions
517
- via per-session correlated lookups over idx (session_id, timestamp_utc, id),
518
- mirroring _session_cost_map / _session_models_map."""
822
+ MAX() picks the lexical max, not the latest). The latest non-null cwd and the
823
+ latest non-null git_branch may land on DIFFERENT rows (the newest row can
824
+ carry one but not the other), so each is resolved independently.
825
+
826
+ #217 S1 / U7b: consolidated from the prior TWIN correlated subqueries into a
827
+ SINGLE windowed scan — two ``FIRST_VALUE`` window functions over one
828
+ partition pass per column, each ordered ``(<col> IS NULL), timestamp_utc DESC,
829
+ id DESC`` so the most-recent non-null value sorts first (and an all-null
830
+ session yields NULL, exactly as the LIMIT-1 subquery did). Guarded by
831
+ ``test_session_latest_meta_map_parity_*`` against the prior semantics; if the
832
+ window form could not match it, the old correlated SQL would be kept (it
833
+ matched cleanly, so the consolidation lands)."""
519
834
  meta = {sid: (None, None) for sid in session_ids}
520
835
  if not session_ids:
521
836
  return meta
522
837
  placeholders = ",".join("?" for _ in session_ids)
523
838
  sql = (
524
- "SELECT s.session_id, "
525
- " (SELECT c.cwd FROM conversation_messages c "
526
- " WHERE c.session_id = s.session_id AND c.cwd IS NOT NULL "
527
- " ORDER BY c.timestamp_utc DESC, c.id DESC LIMIT 1), "
528
- " (SELECT b.git_branch FROM conversation_messages b "
529
- " WHERE b.session_id = s.session_id AND b.git_branch IS NOT NULL "
530
- " ORDER BY b.timestamp_utc DESC, b.id DESC LIMIT 1) "
531
- "FROM (SELECT DISTINCT session_id FROM conversation_messages "
532
- " WHERE session_id IN (%s)) s" % placeholders
839
+ "SELECT DISTINCT session_id, "
840
+ " FIRST_VALUE(cwd) OVER ("
841
+ " PARTITION BY session_id "
842
+ " ORDER BY (cwd IS NULL), timestamp_utc DESC, id DESC), "
843
+ " FIRST_VALUE(git_branch) OVER ("
844
+ " PARTITION BY session_id "
845
+ " ORDER BY (git_branch IS NULL), timestamp_utc DESC, id DESC) "
846
+ "FROM conversation_messages WHERE session_id IN (%s)" % placeholders
533
847
  )
534
848
  for sid, cwd, branch in conn.execute(sql, list(session_ids)):
535
849
  meta[sid] = (cwd, branch)
@@ -561,16 +875,38 @@ def session_source_paths(conn, session_id):
561
875
  _SORTS = {
562
876
  "recent": "last_activity_utc DESC, session_id DESC",
563
877
  "oldest": "started_utc ASC, session_id ASC",
878
+ # #217 S4 / I-2.1: stored-column rollup sorts. Each tie-broken by
879
+ # session_id for stable pagination. ``project`` uses an explicit CASE so a
880
+ # NULL label (not-yet-filled row) or '' (the no-cwd _project_label(None)
881
+ # sentinel) sorts LAST — a plain COLLATE NOCASE ASC would put them first.
882
+ "cost": "cost_usd DESC, session_id DESC",
883
+ "messages": "msg_count DESC, session_id DESC",
884
+ "project": ("CASE WHEN project_label IS NULL OR project_label = '' "
885
+ "THEN 1 ELSE 0 END, project_label COLLATE NOCASE ASC, "
886
+ "session_id ASC"),
564
887
  }
565
888
 
566
889
  # The matching ORDER BY for the live GROUP BY fallback: the rollup's
567
890
  # last_activity_utc IS MAX(timestamp_utc) and started_utc IS MIN(timestamp_utc),
568
891
  # so the two branches paginate in byte-identical order (the load-bearing
569
892
  # invariant). Keep this table in lockstep with _SORTS.
893
+ #
894
+ # ``messages`` has FULL live parity (COUNT(*) DESC over the raw messages == the
895
+ # rollup's stored msg_count). ``cost`` and ``project`` have NO raw-message
896
+ # column, so they are INTENTIONALLY ABSENT here — list_conversations resolves
897
+ # them to the ``recent`` live ordering and flags page.sort_degraded (a brief,
898
+ # self-correcting non-authoritative window). #217 S4 / I-2.1.
570
899
  _SORTS_LIVE = {
571
900
  "recent": "MAX(timestamp_utc) DESC, session_id DESC",
572
901
  "oldest": "MIN(timestamp_utc) ASC, session_id ASC",
902
+ "messages": "COUNT(*) DESC, session_id DESC",
573
903
  }
904
+ # The rollup-only sorts — expressible on the rollup branch but NOT in the live
905
+ # GROUP BY fallback. A request for one of these under the live branch falls back
906
+ # to the recent ordering AND sets page.sort_degraded (keyed to the REQUESTED
907
+ # sort, never the effective order — an unknown sort also falls back to recent
908
+ # but must stay byte-stable, so it is excluded here).
909
+ _DEGRADABLE_SORTS = frozenset(("cost", "project"))
574
910
 
575
911
 
576
912
  def _rollup_authoritative(conn) -> bool:
@@ -663,6 +999,47 @@ def _live_having(filters):
663
999
  return (" HAVING " + " AND ".join(having)) if having else "", params
664
1000
 
665
1001
 
1002
+ def _resolve_search_session_filter(conn, filters):
1003
+ """Resolve the filtered-search session-scope restriction (#217 S2 /
1004
+ Filtered-search). Returns ``(subquery_sql, params, degraded)`` where
1005
+ ``subquery_sql`` is a complete ``"SELECT session_id FROM ... "`` the caller
1006
+ wraps as ``" AND <col> IN (<subquery_sql>)"``, or ``""`` (no restriction) when
1007
+ no axis is set.
1008
+
1009
+ Mirrors the browse dual-branch parity (``list_conversations``):
1010
+
1011
+ * Rollup AUTHORITATIVE (``conversation_sessions_backfill_pending`` clear):
1012
+ every axis is a stored column on the rollup, so restrict to
1013
+ ``SELECT session_id FROM conversation_sessions <_rollup_where(filters)>``
1014
+ (the SAME ``_rollup_where`` the browse rail uses, verbatim). ``degraded``
1015
+ is False.
1016
+ * Rollup PENDING (live fallback) AND a rollup-only axis
1017
+ (project/cost/rebuild) is requested (P1-5): drop the rollup-only axes,
1018
+ set ``degraded`` True, and express ONLY the date axis via a LIVE session
1019
+ prefilter over ``conversation_messages`` keyed on
1020
+ ``MAX(timestamp_utc)`` — the SAME session-activity semantics as
1021
+ ``_live_having``/``_list_session_rows_live``. We restrict by session
1022
+ ACTIVITY, never by a matched row's own timestamp (a session whose match is
1023
+ old but whose activity is in-window must stay).
1024
+ * Rollup pending but ONLY date axis requested: the rollup is not needed
1025
+ (date is expressible live), so use the live prefilter and ``degraded``
1026
+ stays False (no rollup-only axis was dropped — byte-stable with browse).
1027
+ """
1028
+ any_axis = any(filters[k] is not None for k in _FILTER_KEYS)
1029
+ if not any_axis:
1030
+ return "", [], False
1031
+ if _rollup_authoritative(conn):
1032
+ where, params = _rollup_where(filters)
1033
+ return "SELECT session_id FROM conversation_sessions" + where, params, False
1034
+ # Live fallback: only the date axis is expressible over raw messages.
1035
+ degraded = any(filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
1036
+ having, hparams = _live_having(filters)
1037
+ sub = (
1038
+ "SELECT session_id FROM conversation_messages "
1039
+ "WHERE session_id IS NOT NULL GROUP BY session_id" + having)
1040
+ return sub, hparams, degraded
1041
+
1042
+
666
1043
  def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
667
1044
  """FAST path: read the pre-aggregated rail rows straight from the
668
1045
  conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
@@ -754,6 +1131,7 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
754
1131
  "rebuild_min": rebuild_min,
755
1132
  }
756
1133
  degraded = False
1134
+ sort_degraded = False
757
1135
  if _rollup_authoritative(conn):
758
1136
  order = _SORTS.get(sort, _SORTS["recent"])
759
1137
  rows = _list_session_rows_rollup(conn, order, limit, offset, filters)
@@ -761,6 +1139,10 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
761
1139
  order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
762
1140
  degraded = any(
763
1141
  filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
1142
+ # sort_degraded is keyed to the REQUESTED sort, not the effective order:
1143
+ # an unknown sort ALSO falls back to recent here (via _SORTS_LIVE.get),
1144
+ # but must stay byte-stable, so only the known rollup-only sorts flag it.
1145
+ sort_degraded = sort in _DEGRADABLE_SORTS
764
1146
  rows = _list_session_rows_live(conn, order, limit, offset, filters)
765
1147
  has_more = len(rows) > limit
766
1148
  rows = rows[:limit]
@@ -792,6 +1174,10 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
792
1174
  # The rail surfaces this: project/cost/rebuild filters apply once the
793
1175
  # rollup finishes indexing; only the date axis held this page.
794
1176
  page["filter_degraded"] = True
1177
+ if sort_degraded:
1178
+ # The rail surfaces this: cost/project ordering becomes available once
1179
+ # the rollup finishes indexing; this page fell back to recent order.
1180
+ page["sort_degraded"] = True
795
1181
  return {
796
1182
  "conversations": conversations,
797
1183
  "page": page,
@@ -801,22 +1187,13 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
801
1187
  def _turn_cost_map(conn, turn_keys):
802
1188
  """{(msg_id, req_id): cost_usd} for the given non-null turn keys, joined ONCE
803
1189
  to the deduped session_entries row. Keys absent from session_entries (e.g.
804
- <synthetic> walker-skipped rows) are simply not present → cost 0 by omission."""
805
- costs = {}
806
- keys = [(m, r) for (m, r) in turn_keys if m is not None and r is not None]
807
- if not keys:
808
- return costs
809
- # Chunk the OR-of-pairs to stay well under SQLite's variable limit.
810
- for i in range(0, len(keys), 400):
811
- chunk = keys[i:i + 400]
812
- cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
813
- params = [v for pair in chunk for v in pair]
814
- sql = ("SELECT msg_id, req_id, model, input_tokens, output_tokens, "
815
- "cache_create_tokens, cache_read_tokens, cost_usd_raw "
816
- "FROM session_entries WHERE " + cond)
817
- for m, r, model, inp, out, cc, cr, raw in conn.execute(sql, params):
818
- costs[(m, r)] = _entry_cost(model, inp, out, cc, cr, raw)
819
- return costs
1190
+ <synthetic> walker-skipped rows) are simply not present → cost 0 by omission.
1191
+
1192
+ Thin wrapper over the shared ``_turn_costs_for_keys`` chokepoint (#217 S1 /
1193
+ U7b) the search path's ``_attach_costs`` still consumes this name + its
1194
+ float-valued contract, so the public signature is unchanged while the body
1195
+ is single-sourced with ``_session_cost_map``."""
1196
+ return _turn_costs_for_keys(conn, turn_keys)
820
1197
 
821
1198
 
822
1199
  def _turn_usage_map(conn, turn_keys):
@@ -846,6 +1223,13 @@ def _turn_usage_map(conn, turn_keys):
846
1223
  return usage
847
1224
 
848
1225
 
1226
+ # #225: the two tool names that spawn a subagent. `Agent` = modern, `Task` =
1227
+ # older. Exact-match (does NOT match the TaskCreate/TaskUpdate/TaskList checklist
1228
+ # family). A spawn is identified by this name even when it carried no
1229
+ # subagent_type (the default general-purpose dispatch).
1230
+ _SPAWN_TOOL_NAMES = ("Agent", "Task")
1231
+
1232
+
849
1233
  def _assemble_session(conn, session_id):
850
1234
  """Shared assembly for get_conversation / get_conversation_outline (#177 S5).
851
1235
 
@@ -943,12 +1327,15 @@ def _assemble_session(conn, session_id):
943
1327
  # tool_call/tool_result block shapes are unchanged — the only new output is
944
1328
  # the top-level subagent_meta map (no undocumented block keys leak). Join is
945
1329
  # spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
946
- spawn_kind = {} # tool_use id -> subagent_type
947
- spawn_desc = {} # tool_use id -> spawning Task description (#193)
1330
+ spawn_kind = {} # tool_use id -> subagent_type (kind), only when declared
1331
+ spawn_ids = {} # tool_use id -> None: ordered set of Agent/Task spawn ids
1332
+ # (#225, typed OR untyped). spawn_kind ⊆ spawn_ids. Dict
1333
+ # (NOT a set) so iteration stays document-ordered.
1334
+ spawn_desc = {} # tool_use id -> spawning Agent/Task description (#193/#225)
948
1335
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
949
1336
  nested_candidates = [] # §4 1a: (tool_use_id, block) — string-content spawn
950
1337
  # results with no structured agent_id, resolved after
951
- # spawn_kind is complete (text still present pre-fold)
1338
+ # the spawn maps are complete (text still present pre-fold)
952
1339
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
953
1340
  bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
954
1341
  web_search_link = {} # tool_use id -> web_search payload (#177 S4)
@@ -959,15 +1346,23 @@ def _assemble_session(conn, session_id):
959
1346
  k = b.get("kind")
960
1347
  if k == "tool_use":
961
1348
  st = b.pop("subagent_type", None)
962
- if st and b.get("id") is not None:
963
- spawn_kind[b["id"]] = st
964
- # #193: harvest the spawning Task description from the
965
- # already-stored bounded input. Guarded on subagent_type, so
966
- # a Bash `description` (no subagent_type) is NEVER picked up.
1349
+ _id = b.get("id")
1350
+ # #225: a spawn is an Agent/Task tool_use. Identify it by tool
1351
+ # NAME (so an untyped general-purpose spawn no subagent_type —
1352
+ # still counts) OR by a recorded subagent_type. The name gate is
1353
+ # what keeps a Bash/Read `description` (and a coincidental
1354
+ # structured agent_id, #218 I-B.5) from ever being read as a
1355
+ # spawn.
1356
+ if _id is not None and (st or b.get("name") in _SPAWN_TOOL_NAMES):
1357
+ spawn_ids[_id] = None
1358
+ if st:
1359
+ spawn_kind[_id] = st # #166: declared kind
1360
+ # #193/#225: harvest the spawn description from the bounded
1361
+ # input for ANY spawn (typed or untyped); name-gated above.
967
1362
  _inp = b.get("input")
968
1363
  _d = _inp.get("description") if isinstance(_inp, dict) else None
969
1364
  if isinstance(_d, str) and _d.strip():
970
- spawn_desc[b["id"]] = _d
1365
+ spawn_desc[_id] = _d
971
1366
  elif k == "tool_result":
972
1367
  aid = b.pop("agent_id", None)
973
1368
  meta = b.pop("subagent_meta", None)
@@ -975,7 +1370,7 @@ def _assemble_session(conn, session_id):
975
1370
  agent_link[b["tool_use_id"]] = (aid, meta or {})
976
1371
  elif b.get("tool_use_id") is not None:
977
1372
  # §4 1a candidate — a tool_result with no structured agentId.
978
- # Resolved AFTER spawn_kind is complete (text still present,
1373
+ # Resolved AFTER the spawn maps are complete (text still present,
979
1374
  # pre-Phase-2-fold). The block's `text` holds the result body.
980
1375
  nested_candidates.append((b["tool_use_id"], b))
981
1376
  ans = b.pop("ask_answers", None) # #177 S2
@@ -996,22 +1391,33 @@ def _assemble_session(conn, session_id):
996
1391
  tlist_ = b.pop("task_list", None)
997
1392
  if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
998
1393
  task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
1394
+ # §4 symmetry (#218 I-B.5 / #225): drop any agent_link entry whose owning
1395
+ # tool_use is NOT a spawn — an ordinary tool result that coincidentally
1396
+ # carries a structured agent_id (e.g. a Read). Gated on spawn_ids (every
1397
+ # Agent/Task spawn, typed OR untyped) rather than the old spawn_kind, which
1398
+ # wrongly excluded untyped general-purpose spawns. A structured agent_id on a
1399
+ # real spawn is authoritative; the heuristic nested text-parse below stays
1400
+ # spawn-gated all the same.
1401
+ agent_link = {t: v for t, v in agent_link.items() if t in spawn_ids}
999
1402
  # §4 1a — nested grandchild results: gate the text-parse on the owning
1000
- # tool_use being a spawn (in spawn_kind), so an ordinary tool result that
1403
+ # tool_use being a spawn (in spawn_ids), so an ordinary tool result that
1001
1404
  # merely mentions "agentId" never matches.
1002
1405
  for _tuid, _block in nested_candidates:
1003
- if _tuid in spawn_kind and _tuid not in agent_link:
1406
+ if _tuid in spawn_ids and _tuid not in agent_link:
1004
1407
  parsed = _parse_nested_agent_result(_block.get("text"))
1005
1408
  if parsed is not None:
1006
1409
  agent_link[_tuid] = parsed
1007
1410
  subagent_meta = {}
1008
- for _tuid, _kind in spawn_kind.items():
1411
+ for _tuid in spawn_ids: # document order (ordered dict)
1009
1412
  _link = agent_link.get(_tuid)
1010
1413
  if _link is None:
1011
1414
  continue # spawn with no (yet) result -> title-only
1012
1415
  _aid, _raw = _link
1013
- _entry = {"kind": _kind}
1014
- if spawn_desc.get(_tuid): # #193: spawning Task description
1416
+ # #225: kind defaults to "general-purpose" for an untyped Agent/Task
1417
+ # spawn (subagent_type omitted at dispatch ⟹ general-purpose; 0
1418
+ # counterexamples across 3085 real sidecars).
1419
+ _entry = {"kind": spawn_kind.get(_tuid, "general-purpose")}
1420
+ if spawn_desc.get(_tuid): # #193/#225: spawn description
1015
1421
  _entry["description"] = spawn_desc[_tuid]
1016
1422
  for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
1017
1423
  if _raw.get(_f) is not None:
@@ -1285,13 +1691,27 @@ def _assemble_session(conn, session_id):
1285
1691
  "subagent_meta": subagent_meta, "header_cost": header_cost}
1286
1692
 
1287
1693
 
1288
- def get_conversation(conn, session_id, *, after=None, limit=500):
1694
+ def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
1695
+ limit=500):
1289
1696
  """Reader payload for one session (spec §3.2). Returns None for an unknown
1290
1697
  session. Dedups logical messages by (session_id, uuid) (canonical = earliest
1291
1698
  timestamp), groups assistant fragments into turn items by (msg_id, req_id),
1292
1699
  joins cost once, anchors a turn on its prose-bearing fragment, and exposes
1293
1700
  every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
1294
- id); ~500 items/page."""
1701
+ id); ~500 items/page.
1702
+
1703
+ Three mutually-exclusive cursor modes over the fully-assembled ascending
1704
+ `items` list (#217 S2 / U4): `after=X` pages forward (existing), `before=X`
1705
+ pages backward (returns the `limit` items immediately before X), and
1706
+ `tail=1` opens at the bottom (returns the last page in one request). More
1707
+ than one supplied raises ValueError (the handler maps it to 400). The
1708
+ boundary keys are mode-uniform (`has_more = end < N`, `has_prev = start > 0`)
1709
+ so a short `before` head-page still reports `has_more` (P2-8); the existing
1710
+ `after`/no-cursor responses stay byte-stable except the two additive
1711
+ `prev_before`/`has_prev` keys (for them `start + limit < N == end < N`, under
1712
+ `end = min(start+limit, N)`)."""
1713
+ if sum(1 for x in (after is not None, before is not None, bool(tail)) if x) > 1:
1714
+ raise ValueError("after/before/tail are mutually exclusive")
1295
1715
  limit = max(1, min(int(limit), 1000))
1296
1716
  asm = _assemble_session(conn, session_id)
1297
1717
  if asm is None:
@@ -1322,34 +1742,60 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1322
1742
  _la = items[-1]["anchor"]
1323
1743
  last_anchor = {"session_id": session_id, "uuid": _la["uuid"], "id": _la["id"]}
1324
1744
 
1325
- # Cursor pagination over the item list (anchored to each item's canonical id).
1326
- # A non-None `after` that matches no item's anchor (stale/deleted cursor)
1327
- # yields an EMPTY page never silently re-serves the head (M1).
1328
- start = 0
1329
- if after is not None:
1330
- start = None
1745
+ # Cursor pagination over the item list (anchored to each item's canonical
1746
+ # id). Resolve `start`/`end` per mode, then `page = items[start:end]` and
1747
+ # compute the boundary keys UNIFORMLY (#217 S2 / U4 P2-8). A non-None
1748
+ # `after`/`before` that matches no item's anchor (stale/deleted cursor)
1749
+ # yields an EMPTY page — never silently re-serves the head/tail (M1).
1750
+ N = len(items)
1751
+
1752
+ def _idx(cur):
1331
1753
  for k, it in enumerate(items):
1332
- if str(it["anchor"]["id"]) == str(after):
1333
- start = k + 1
1334
- break
1335
- if start is None:
1336
- return {
1337
- "session_id": session_id,
1338
- "title": _title,
1339
- "project_label": _pl,
1340
- "git_branch": _latest(logical, 11),
1341
- "started_utc": logical[0][2],
1342
- "last_activity_utc": logical[-1][2],
1343
- "cost_usd": header_cost,
1344
- "models": sorted({r[6] for r in logical if r[6]}),
1345
- "last_anchor": last_anchor,
1346
- "items": [],
1347
- "subagent_meta": subagent_meta,
1348
- "page": {"next_after": None, "has_more": False},
1349
- }
1350
- page = items[start:start + limit]
1351
- has_more = start + limit < len(items)
1754
+ if str(it["anchor"]["id"]) == str(cur):
1755
+ return k
1756
+ return None
1757
+
1758
+ def _stale_empty_page():
1759
+ return {
1760
+ "session_id": session_id,
1761
+ "title": _title,
1762
+ "project_label": _pl,
1763
+ "git_branch": _latest(logical, 11),
1764
+ "started_utc": logical[0][2],
1765
+ "last_activity_utc": logical[-1][2],
1766
+ "cost_usd": header_cost,
1767
+ "models": sorted({r[6] for r in logical if r[6]}),
1768
+ "last_anchor": last_anchor,
1769
+ "items": [],
1770
+ "subagent_meta": subagent_meta,
1771
+ "page": {"next_after": None, "has_more": False,
1772
+ "prev_before": None, "has_prev": False},
1773
+ }
1774
+
1775
+ if tail:
1776
+ end = N
1777
+ start = max(0, N - limit)
1778
+ elif before is not None:
1779
+ b = _idx(before)
1780
+ if b is None: # stale cursor → empty page (M1)
1781
+ return _stale_empty_page()
1782
+ end = b
1783
+ start = max(0, end - limit)
1784
+ elif after is not None:
1785
+ a = _idx(after)
1786
+ if a is None: # stale cursor → empty page (M1)
1787
+ return _stale_empty_page()
1788
+ start = a + 1
1789
+ end = min(start + limit, N)
1790
+ else:
1791
+ start = 0
1792
+ end = min(limit, N)
1793
+
1794
+ page = items[start:end]
1795
+ has_more = end < N
1796
+ has_prev = start > 0
1352
1797
  next_after = page[-1]["anchor"]["id"] if (page and has_more) else None
1798
+ prev_before = page[0]["anchor"]["id"] if (page and has_prev) else None
1353
1799
 
1354
1800
  # Stamp the session_id into each anchor (spec anchor is (session_id, uuid);
1355
1801
  # the dict literals are built session-agnostic, so fill it here where the
@@ -1381,7 +1827,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1381
1827
  "last_anchor": last_anchor,
1382
1828
  "items": page,
1383
1829
  "subagent_meta": subagent_meta,
1384
- "page": {"next_after": next_after, "has_more": has_more},
1830
+ "page": {"next_after": next_after, "has_more": has_more,
1831
+ "prev_before": prev_before, "has_prev": has_prev},
1385
1832
  }
1386
1833
 
1387
1834
 
@@ -1432,6 +1879,23 @@ def get_conversation_outline(conn, session_id):
1432
1879
  cf_wasted = 0.0
1433
1880
  cf_rebuilds = [] # per-rebuild list (worst-first), spec §1
1434
1881
  cache_saved = 0.0 # session cache-value-saved, spec §1
1882
+ # #217 S3 E6(a) — per-subagent cost, summed COST-ONCE over the assembled
1883
+ # items (each item already carries the cost-once-per-turn `cost_usd`, so a
1884
+ # plain per-subagent_key sum never double-counts across folded fragments —
1885
+ # the same discipline `cache_saved` above uses). DISPLAY-ONLY (never a
1886
+ # reconciled / budget / snapshot figure, like `cache_saved_usd`). Emitted as
1887
+ # a SEPARATE top-level `subagent_costs` map — NOT inside `subagent_meta` —
1888
+ # so the outline↔reader `subagent_meta` parity assert stays byte-for-byte
1889
+ # unchanged AND every bucket is covered, including a subagent whose
1890
+ # `subagent_meta` is absent (the s7 case: deriveOutline emits a row for it,
1891
+ # so its cost must be present to render). Every non-null subagent_key bucket
1892
+ # seeds at 0.0 so a zero-cost bucket still appears.
1893
+ subagent_costs = {} # subagent_key -> cost (cost-once)
1894
+ for it in items:
1895
+ sk = it.get("subagent_key")
1896
+ if sk is not None:
1897
+ subagent_costs.setdefault(sk, 0.0)
1898
+ subagent_costs[sk] += it.get("cost_usd") or 0.0
1435
1899
  for it in items:
1436
1900
  kind = it["kind"]
1437
1901
  turn_counts["total"] += 1
@@ -1519,10 +1983,64 @@ def get_conversation_outline(conn, session_id):
1519
1983
  stats["cache_saved_usd"] = cache_saved
1520
1984
  return {"session_id": session_id,
1521
1985
  "subagent_meta": asm["subagent_meta"],
1986
+ # #217 S3 E6(a) — display-only per-subagent cost (cost-once). 6dp,
1987
+ # matching the per-item / header cost display precision.
1988
+ "subagent_costs": {k: round(v, 6) for k, v in subagent_costs.items()},
1522
1989
  "stats": stats,
1990
+ # #217 S5 F2 — whole-session files-touched aggregation (always
1991
+ # present, possibly []; additive — existing consumers are byte-stable).
1992
+ "files": _collect_files(items),
1993
+ # #217 S5 F7 — main-thread task-completion (always present; None when
1994
+ # the main thread has no tasks; additive — byte-stable for consumers).
1995
+ "task_completion": _task_completion(items),
1523
1996
  "turns": turns}
1524
1997
 
1525
1998
 
1999
+ def get_conversation_prompts(conn, session_id):
2000
+ """``{session_id, prompts:[{uuid,text}]}`` — ordered main-thread human
2001
+ prompts with full text (#217 S7, F10 — the session-comparison spine).
2002
+
2003
+ Reuses the SAME ``_assemble_session`` pass as ``get_conversation_outline``
2004
+ (one grouping/dedup pipeline, never a parallel one) and the SAME main-thread
2005
+ predicate as the recipe/prompts export (``extract_prompt_entries`` →
2006
+ ``_human_prompts``), so the spine aligns 1:1 with the outline's human turns
2007
+ and can never drift from the export. Returns ``None`` for an unknown session
2008
+ (the handler's 404 sentinel) — the same contract ``get_conversation_outline``
2009
+ uses (``asm is None``).
2010
+ """
2011
+ asm = _assemble_session(conn, session_id)
2012
+ if asm is None:
2013
+ return None
2014
+ from _lib_conversation_export import extract_prompt_entries
2015
+ return {"session_id": session_id,
2016
+ "prompts": extract_prompt_entries(asm["items"])}
2017
+
2018
+
2019
+ def get_conversation_export(conn, session_id, scope):
2020
+ """Whole-session Markdown export for one scope (#217 S5, F1/F5).
2021
+
2022
+ Runs the SAME full ``_assemble_session`` the reader/outline use, then hands
2023
+ the assembled ``items`` (+ ``subagent_meta``) to the pure
2024
+ ``export_session_markdown`` serializer (`_lib_conversation_export.py`), so
2025
+ the renderer stays I/O-free and golden-testable. Returns the Markdown string,
2026
+ or ``None`` for an unknown session (the handler's 404 sentinel). ``scope`` is
2027
+ pre-validated by the handler (an unknown scope never reaches here)."""
2028
+ from _lib_conversation_export import export_session_markdown
2029
+ asm = _assemble_session(conn, session_id)
2030
+ if asm is None:
2031
+ return None
2032
+ items = asm["items"]
2033
+ subagent_meta = asm["subagent_meta"]
2034
+ logical = asm["logical"]
2035
+ # Title via the SAME fallback chain the reader header uses (#193):
2036
+ # ai-title → first human prompt → project label → session_id.
2037
+ _pl = _project_label(_latest(logical, 10))
2038
+ title = (_session_titles_map(conn, [session_id]).get(session_id)
2039
+ or _pl or session_id)
2040
+ return export_session_markdown(items, scope, subagent_meta=subagent_meta,
2041
+ title=title, session_id=session_id)
2042
+
2043
+
1526
2044
  _TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
1527
2045
 
1528
2046
 
@@ -1758,7 +2276,9 @@ def _search_depth(conn) -> str:
1758
2276
 
1759
2277
 
1760
2278
  def search_conversations(conn, query, *, limit=50, offset=0,
1761
- kind="all", fts_available=None) -> dict:
2279
+ kind="all", fts_available=None,
2280
+ date_from=None, date_to=None, projects=None,
2281
+ cost_min=None, cost_max=None, rebuild_min=None) -> dict:
1762
2282
  """Cross-session search (spec §3.3). Uses FTS5 when available (bm25 rank +
1763
2283
  snippet); else a LIKE scan with a manual snippet. Hits deduped by
1764
2284
  (session_id, uuid); each carries the turn's cost. `fts_available` overrides
@@ -1767,11 +2287,27 @@ def search_conversations(conn, query, *, limit=50, offset=0,
1767
2287
  #177 S6: ``kind`` (one of ``_SEARCH_KINDS``) scopes the search to a column
1768
2288
  family — ``all`` is unfiltered, ``prompts``/``assistant`` filter the prose
1769
2289
  column + entry_type, ``tools``/``thinking`` filter the split index columns.
1770
- Every hit gains ``match_kinds`` (sorted ``['tool', 'thinking']`` badges;
1771
- prose never badges). The response carries additive ``kind`` + ``search_depth``
1772
- so the client can degrade the Tools/Thinking facets during the one-time
1773
- column split (``search_depth == 'prose-only'`` short-circuits those two
1774
- kinds to empty). An unknown ``kind`` raises ``ValueError`` (route → 400)."""
2290
+ #217 S2 / E7: ``title`` searches the external-content title FTS over
2291
+ conversation_ai_titles one SESSION-level hit per matching session, anchored
2292
+ to that session's first turn, ``match_kinds:["title"]``, snippet = the matched
2293
+ title. #217 S2 / I-3: ``files`` searches ``conversation_file_touches`` (the
2294
+ write-class file-touch axis) one hit per DISTINCT ``(session_id, file_path)``,
2295
+ anchored to that group's MOST-RECENT touch, ``match_kinds:["file"]``, snippet =
2296
+ the file path. Every hit gains ``match_kinds`` (sorted badges; prose never badges).
2297
+ The response carries additive ``kind`` + ``search_depth`` so the client can
2298
+ degrade the Tools/Thinking facets during the one-time column split
2299
+ (``search_depth == 'prose-only'`` short-circuits those two kinds to empty).
2300
+ An unknown ``kind`` raises ``ValueError`` (route → 400).
2301
+
2302
+ #217 S2 / Filtered-search: the browse filters (``date_from``/``date_to``/
2303
+ ``projects``/``cost_min``/``cost_max``/``rebuild_min``) restrict the search to
2304
+ matching sessions, applied uniformly across EVERY kind as a session-scope
2305
+ ``session_id IN (...)`` predicate (``_resolve_search_session_filter`` reuses
2306
+ ``_rollup_where`` verbatim). When the rollup backfill is pending and a
2307
+ rollup-only axis is requested, only the date axis applies (via a live
2308
+ ``MAX(timestamp_utc)`` prefilter, P1-5) and the response carries additive
2309
+ ``filter_degraded: True`` — exactly mirroring browse. A no-filter request
2310
+ produces byte-stable existing output (no ``filter_degraded`` key)."""
1775
2311
  if kind not in _SEARCH_KINDS:
1776
2312
  raise ValueError(f"unknown kind: {kind}")
1777
2313
  q = (query or "").strip()
@@ -1783,20 +2319,60 @@ def search_conversations(conn, query, *, limit=50, offset=0,
1783
2319
  mode = "fts" if fts_available else "like"
1784
2320
  base = {"query": q, "mode": mode, "hits": [], "total": 0,
1785
2321
  "kind": kind, "search_depth": depth}
2322
+ # Resolve the filtered-search session-scope restriction ONCE (reused by every
2323
+ # kind). ``filt_sql`` is "" when no axis is set, so the no-filter path stays
2324
+ # byte-stable; ``degraded`` flags the dropped rollup-only axes (P1-5).
2325
+ filters = {
2326
+ "date_from": date_from, "date_to": date_to,
2327
+ "projects": list(projects) if projects else None,
2328
+ "cost_min": cost_min, "cost_max": cost_max, "rebuild_min": rebuild_min,
2329
+ }
2330
+ filt_sql, filt_params, degraded = _resolve_search_session_filter(conn, filters)
2331
+ if degraded:
2332
+ base["filter_degraded"] = True
2333
+
2334
+ def _finish(out):
2335
+ # Stamp the additive degraded flag onto whatever the search path returns,
2336
+ # without disturbing the no-filter byte-stable shape.
2337
+ if degraded:
2338
+ out["filter_degraded"] = True
2339
+ return out
2340
+
1786
2341
  # Prose-only interim: the split columns are not yet indexed, so tools /
1787
2342
  # thinking can't match — short-circuit them to empty (spec §1 interim).
1788
2343
  if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
1789
2344
  return base
2345
+ if kind == "title":
2346
+ if fts_available:
2347
+ try:
2348
+ out = _search_title(conn, q, limit, offset, True,
2349
+ filt_sql, filt_params)
2350
+ out.update(kind="title", search_depth=depth)
2351
+ return _finish(out)
2352
+ except sqlite3.OperationalError:
2353
+ pass # title FTS missing/corrupt at query time (the flag is
2354
+ # derived from fts5_unavailable, not a per-table probe) →
2355
+ # fall through to the title LIKE scan, mirroring the
2356
+ # message-FTS path's resilience
2357
+ out = _search_title(conn, q, limit, offset, False,
2358
+ filt_sql, filt_params)
2359
+ out.update(kind="title", search_depth=depth)
2360
+ return _finish(out)
2361
+ if kind == "files":
2362
+ out = _search_files(conn, q, limit, offset, filt_sql, filt_params)
2363
+ out.update(kind="files", search_depth=depth)
2364
+ return _finish(out)
1790
2365
  if fts_available:
1791
2366
  try:
1792
- out = _search_fts(conn, q, limit, offset, kind, depth)
2367
+ out = _search_fts(conn, q, limit, offset, kind, depth,
2368
+ filt_sql, filt_params)
1793
2369
  out.update(kind=kind, search_depth=depth)
1794
- return out
2370
+ return _finish(out)
1795
2371
  except sqlite3.OperationalError:
1796
2372
  pass # corrupt/missing FTS at query time → fall through to LIKE
1797
- out = _search_like(conn, q, limit, offset, kind, depth)
2373
+ out = _search_like(conn, q, limit, offset, kind, depth, filt_sql, filt_params)
1798
2374
  out.update(kind=kind, mode="like", search_depth=depth)
1799
- return out
2375
+ return _finish(out)
1800
2376
 
1801
2377
 
1802
2378
  def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id, match_kinds=None):
@@ -1841,13 +2417,40 @@ def _attach_titles(conn, page):
1841
2417
  return page
1842
2418
 
1843
2419
 
1844
- def _like_pattern(q):
1845
- """Build the LIKE pattern for `q`. Escape the ESCAPE char (\\) FIRST, then
2420
+ def _session_filter_pred(col, filt_sql):
2421
+ """The session-scope filter fragment shared by the four ``_search_*``
2422
+ subroutines (#219 S2.2): ``" AND <col> IN (<filt_sql>)"`` when a browse filter
2423
+ is active, else ``""`` (the no-filter fast path). ``col`` is the caller's
2424
+ table-qualified session_id column (``cm.session_id`` / ``t.session_id`` /
2425
+ ``ft.session_id`` / bare ``session_id``)."""
2426
+ return f" AND {col} IN ({filt_sql})" if filt_sql else ""
2427
+
2428
+
2429
+ def _attach_search_meta(conn, hits):
2430
+ """The shared search-hit enrichment tail (#219 S2.2): per-turn cost then
2431
+ per-session title/label. Every ``_search_*`` subroutine returns
2432
+ ``_attach_titles(conn, _attach_costs(conn, hits))`` — centralized here so the
2433
+ two-step order has one definition."""
2434
+ return _attach_titles(conn, _attach_costs(conn, hits))
2435
+
2436
+
2437
+ def _like_escape(q):
2438
+ """Escape the LIKE wildcards/escape-char in ``q`` (no surrounding ``%``),
2439
+ paired with ``ESCAPE '\\'``. The ESCAPE char (``\\``) is escaped FIRST, then
1846
2440
  the wildcards — otherwise a query containing a backslash (incl. a trailing
1847
- one) mis-escapes the appended '%' and the LIKE silently matches nothing
1848
- (paired with ESCAPE '\\' in the queries below)."""
1849
- return ("%" + q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
1850
- + "%")
2441
+ one) mis-escapes any appended ``%`` and the LIKE silently matches nothing.
2442
+
2443
+ Single source of the escape chain (#217 S2 / I-3 review Minor #3):
2444
+ ``_like_pattern`` (substring) and ``_search_files``'s substring pattern both
2445
+ build on this so the escaping is defined exactly once."""
2446
+ return q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
2447
+
2448
+
2449
+ def _like_pattern(q):
2450
+ """Build the SUBSTRING LIKE pattern for `q` — ``%`` + escaped(q) + ``%`` —
2451
+ paired with ``ESCAPE '\\'`` in the queries below. The escape chain is
2452
+ single-sourced in ``_like_escape``."""
2453
+ return "%" + _like_escape(q) + "%"
1851
2454
 
1852
2455
 
1853
2456
  def _fts_snippets(conn, fts_q, ids, col=0):
@@ -1915,7 +2518,8 @@ def _match_kinds(conn, fts_q, rids_by_group):
1915
2518
  for grp, rids in rids_by_group.items()}
1916
2519
 
1917
2520
 
1918
- def _search_fts(conn, q, limit, offset, kind, depth):
2521
+ def _search_fts(conn, q, limit, offset, kind, depth,
2522
+ filt_sql="", filt_params=()):
1919
2523
  # All of dedup + paging + total live in SQL (#149) so Python never holds
1920
2524
  # more than one page of hits/snippets, regardless of corpus match count.
1921
2525
  #
@@ -1932,6 +2536,11 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1932
2536
  entry_type = _KIND_ENTRY_TYPE.get(kind)
1933
2537
  et_pred = " AND cm.entry_type = ?" if entry_type is not None else ""
1934
2538
  et_args = (entry_type,) if entry_type is not None else ()
2539
+ # #217 S2 / Filtered-search: session-scope restriction (empty when no filter,
2540
+ # so the no-filter path stays byte-stable). cm.session_id is the FTS join's
2541
+ # message column.
2542
+ fpred = _session_filter_pred("cm.session_id", filt_sql)
2543
+ fargs = tuple(filt_params)
1935
2544
  # Exact post-dedup logical total — counted in C with no snippet generation
1936
2545
  # and no Python row materialization.
1937
2546
  total = conn.execute(
@@ -1939,8 +2548,8 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1939
2548
  " SELECT DISTINCT cm.session_id, cm.uuid "
1940
2549
  " FROM conversation_fts "
1941
2550
  " JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1942
- f" WHERE conversation_fts MATCH ?{et_pred})",
1943
- (match_expr, *et_args),
2551
+ f" WHERE conversation_fts MATCH ?{et_pred}{fpred})",
2552
+ (match_expr, *et_args, *fargs),
1944
2553
  ).fetchone()[0]
1945
2554
  # One row per logical (session_id, uuid): ROW_NUMBER()=1 keeps the SAME row
1946
2555
  # the old Python dedup kept as its FIRST occurrence (order: bm25, ts DESC,
@@ -1964,7 +2573,7 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1964
2573
  f" {bm25_expr} AS rank "
1965
2574
  " FROM conversation_fts "
1966
2575
  " JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1967
- f" WHERE conversation_fts MATCH ?{et_pred}), "
2576
+ f" WHERE conversation_fts MATCH ?{et_pred}{fpred}), "
1968
2577
  "ranked AS ("
1969
2578
  " SELECT *, ROW_NUMBER() OVER ("
1970
2579
  " PARTITION BY sid, uuid ORDER BY rank, ts DESC, rid DESC"
@@ -1972,14 +2581,14 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1972
2581
  " FROM matched) "
1973
2582
  "SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
1974
2583
  "ORDER BY rank, ts DESC, rid DESC LIMIT ? OFFSET ?",
1975
- (match_expr, *et_args, limit, offset),
2584
+ (match_expr, *et_args, *fargs, limit, offset),
1976
2585
  ).fetchall()
1977
2586
  page_groups = {(sid, uuid): rid for (rid, sid, uuid, ts, cwd, mid, rqd) in page}
1978
2587
  if legacy:
1979
2588
  badges = {grp: [] for grp in page_groups}
1980
2589
  else:
1981
2590
  rids_by_group = _all_matched_rids_by_group(
1982
- conn, match_expr, et_pred, et_args, list(page_groups))
2591
+ conn, et_pred, et_args, list(page_groups))
1983
2592
  badges = _match_kinds(conn, fts_q, rids_by_group)
1984
2593
  snips = _fts_snippets(conn, match_expr, [r[0] for r in page], col=0)
1985
2594
  # For hits badged tool/thinking but with no prose match, draw the snippet
@@ -1990,48 +2599,86 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1990
2599
  match_kinds=badges.get((sid, uuid), []))
1991
2600
  for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
1992
2601
  return {"query": q, "mode": "fts",
1993
- "hits": _attach_titles(conn, _attach_costs(conn, hits)),
2602
+ "hits": _attach_search_meta(conn, hits),
1994
2603
  "total": total}
1995
2604
 
1996
2605
 
1997
- def _all_matched_rids_by_group(conn, match_expr, et_pred, et_args, groups):
1998
- """{(sid, uuid) -> [rids]} for the page groups: ALL matched physical rows of
1999
- each group (not just the rank-survivor), so badges aggregate completely."""
2606
+ def _all_matched_rids_by_group(conn, et_pred, et_args, groups):
2607
+ """{(sid, uuid) -> [rids]} for the page groups: ALL physical rows of each
2608
+ page group (a SUPERSET of the FTS-matched subset), so badges aggregate
2609
+ completely. ``_match_kinds`` re-runs a per-column MATCH restricted to these
2610
+ rids, so returning every physical row of the ≤200 page groups stays correct
2611
+ for the column facets (the per-column MATCH filters).
2612
+
2613
+ U3 (#217 S1): a BOUNDED base-table lookup of the page groups' rows — NOT a
2614
+ third full-corpus ``conversation_fts MATCH``. We stop scanning the corpus a
2615
+ third time per request. The term expression is carried by the per-column
2616
+ MATCH in ``_match_kinds``, so this lookup needs no match expression — the
2617
+ formerly-vestigial ``match_expr`` param was dropped (#218 I-A.2).
2618
+
2619
+ Facet-scope fix (Codex P2): carry the SAME ``et_pred``/``et_args`` the page
2620
+ query used (``kind=prompts``/``assistant`` filter ``cm.entry_type``), so a
2621
+ same-group physical row OUTSIDE the facet can never contribute a badge. The
2622
+ ``(session_id, uuid)`` match is NULL-safe (``IS``), since a pre-006 row may
2623
+ carry a NULL uuid that a plain ``IN`` row-value would silently drop."""
2000
2624
  if not groups:
2001
2625
  return {}
2002
2626
  rids_by_group = {g: [] for g in groups}
2003
- rows = conn.execute(
2004
- "SELECT cm.id, cm.session_id, cm.uuid FROM conversation_fts "
2005
- "JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
2006
- f"WHERE conversation_fts MATCH ?{et_pred}",
2007
- (match_expr, *et_args),
2008
- ).fetchall()
2009
- for rid, sid, uuid in rows:
2010
- g = (sid, uuid)
2011
- if g in rids_by_group:
2012
- rids_by_group[g].append(rid)
2627
+ # Chunk the OR-of-pairs to stay well under SQLite's variable limit (≤200
2628
+ # page groups, 2 params each → one or two chunks in practice).
2629
+ for i in range(0, len(groups), 300):
2630
+ chunk = groups[i:i + 300]
2631
+ cond = " OR ".join(
2632
+ "(cm.session_id IS ? AND cm.uuid IS ?)" for _ in chunk)
2633
+ params = [v for g in chunk for v in g]
2634
+ rows = conn.execute(
2635
+ "SELECT cm.id, cm.session_id, cm.uuid FROM conversation_messages cm "
2636
+ f"WHERE ({cond}){et_pred}",
2637
+ (*params, *et_args),
2638
+ ).fetchall()
2639
+ for rid, sid, uuid in rows:
2640
+ g = (sid, uuid)
2641
+ if g in rids_by_group:
2642
+ rids_by_group[g].append(rid)
2013
2643
  return rids_by_group
2014
2644
 
2015
2645
 
2016
2646
  def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
2017
2647
  """Replace a hit's prose snippet with its matched column's snippet when the
2018
- prose column did NOT match (prose → tool → thinking preference). Probes
2019
- which column matched the survivor rowid, then re-snippets that column."""
2648
+ prose column did NOT match (prose → tool → thinking preference). Probes which
2649
+ of the badged survivors match prose, then re-snippets the non-prose ones from
2650
+ their preferred column.
2651
+
2652
+ U3 (#217 S1): the per-hit ``rowid = ?`` prose probe (one query per badged
2653
+ page hit, up to ~200 at limit=200) is collapsed into ONE bounded
2654
+ ``rowid IN (…) AND MATCH text:(…)`` returning the prose-matching set;
2655
+ subtracting gives the non-prose survivors routed to the already-batched
2656
+ tool/thinking snippet calls."""
2657
+ # The badged survivors are the only candidates (a hit with no badges keeps
2658
+ # its col-0 prose snippet). Collect their survivor rowids.
2659
+ badged = [(rid, sid, uuid)
2660
+ for (rid, sid, uuid, ts, cwd, mid, rqd) in page
2661
+ if badges.get((sid, uuid))]
2662
+ if not badged:
2663
+ return snips
2664
+ badged_rids = [rid for (rid, _s, _u) in badged]
2665
+ # ONE bounded probe: which badged survivors ALSO match prose? Keep their
2666
+ # col-0 snippet; the rest fall through to their preferred column.
2667
+ prose_hits = set()
2668
+ for i in range(0, len(badged_rids), 300):
2669
+ chunk = badged_rids[i:i + 300]
2670
+ ph = ",".join("?" for _ in chunk)
2671
+ rows = conn.execute(
2672
+ "SELECT conversation_fts.rowid FROM conversation_fts "
2673
+ f"WHERE conversation_fts MATCH ? AND conversation_fts.rowid IN ({ph})",
2674
+ (f"{{text}}: ({fts_q})", *chunk)).fetchall()
2675
+ prose_hits.update(r[0] for r in rows)
2020
2676
  by_col = {} # snippet column index -> [rids needing it]
2021
- for (rid, sid, uuid, ts, cwd, mid, rqd) in page:
2022
- grp = (sid, uuid)
2023
- kinds = badges.get(grp, [])
2024
- if not kinds:
2025
- continue # prose hit (or unbadged) — keep col-0 snippet
2026
- # Does THIS survivor row match prose? If so keep col 0.
2027
- prose_hit = conn.execute(
2028
- "SELECT 1 FROM conversation_fts "
2029
- "WHERE conversation_fts MATCH ? AND conversation_fts.rowid = ?",
2030
- (f"{{text}}: ({fts_q})", rid)).fetchone()
2031
- if prose_hit:
2032
- continue
2677
+ for (rid, sid, uuid) in badged:
2678
+ if rid in prose_hits:
2679
+ continue # prose matched the survivor → keep col 0
2033
2680
  for label, col in _SNIPPET_COL_PREFERENCE:
2034
- if label in kinds:
2681
+ if label in badges.get((sid, uuid), []):
2035
2682
  by_col.setdefault(col, []).append(rid)
2036
2683
  break
2037
2684
  for col, rids in by_col.items():
@@ -2042,7 +2689,8 @@ def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
2042
2689
  return snips
2043
2690
 
2044
2691
 
2045
- def _search_like(conn, q, limit, offset, kind, depth):
2692
+ def _search_like(conn, q, limit, offset, kind, depth,
2693
+ filt_sql="", filt_params=()):
2046
2694
  # SQL-bounded mirror of _search_fts for the no-FTS5 fallback (#149); the
2047
2695
  # COUNT + page each scan the table once (the degraded path already lacks an
2048
2696
  # index for the substring match). #177 S6: kind → column list (single-
@@ -2062,11 +2710,14 @@ def _search_like(conn, q, limit, offset, kind, depth):
2062
2710
  entry_type = _KIND_ENTRY_TYPE.get(kind)
2063
2711
  et_pred = " AND entry_type = ?" if entry_type is not None else ""
2064
2712
  et_args = (entry_type,) if entry_type is not None else ()
2713
+ # #217 S2 / Filtered-search: session-scope restriction (empty when no filter).
2714
+ fpred = _session_filter_pred("session_id", filt_sql)
2715
+ fargs = tuple(filt_params)
2065
2716
  total = conn.execute(
2066
2717
  "SELECT COUNT(*) FROM ("
2067
2718
  " SELECT DISTINCT session_id, uuid FROM conversation_messages "
2068
- f" WHERE {col_pred}{et_pred})",
2069
- (*like_args, *et_args),
2719
+ f" WHERE {col_pred}{et_pred}{fpred})",
2720
+ (*like_args, *et_args, *fargs),
2070
2721
  ).fetchone()[0]
2071
2722
  page = conn.execute(
2072
2723
  "WITH ranked AS ("
@@ -2077,10 +2728,10 @@ def _search_like(conn, q, limit, offset, kind, depth):
2077
2728
  " ORDER BY timestamp_utc DESC, id DESC"
2078
2729
  " ) AS rn "
2079
2730
  " FROM conversation_messages "
2080
- f" WHERE {col_pred}{et_pred}) "
2731
+ f" WHERE {col_pred}{et_pred}{fpred}) "
2081
2732
  "SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
2082
2733
  "ORDER BY ts DESC, rid DESC LIMIT ? OFFSET ?",
2083
- (*like_args, *et_args, limit, offset),
2734
+ (*like_args, *et_args, *fargs, limit, offset),
2084
2735
  ).fetchall()
2085
2736
  texts = _texts_for_ids(conn, [r[0] for r in page])
2086
2737
  if legacy:
@@ -2093,7 +2744,158 @@ def _search_like(conn, q, limit, offset, kind, depth):
2093
2744
  match_kinds=badges.get((sid, uuid), []))
2094
2745
  for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
2095
2746
  return {"query": q, "mode": "like",
2096
- "hits": _attach_titles(conn, _attach_costs(conn, hits)),
2747
+ "hits": _attach_search_meta(conn, hits),
2748
+ "total": total}
2749
+
2750
+
2751
+ def _search_title(conn, q, limit, offset, fts_available, filt_sql="", filt_params=()):
2752
+ """#217 S2 / E7: ``kind=title`` cross-session search over the per-session AI
2753
+ title (conversation_ai_titles). Emits ONE SESSION-level hit per matching
2754
+ session, anchored to that session's FIRST turn (the earliest
2755
+ ``(timestamp_utc, id)`` conversation_messages row), ``match_kinds:["title"]``,
2756
+ snippet = the matched title.
2757
+
2758
+ FTS5 available: ``MATCH ai_title`` over the external-content
2759
+ ``conversation_title_fts``, mapping each hit rowid →
2760
+ ``conversation_ai_titles.session_id``; the snippet comes from FTS ``snippet()``.
2761
+ Else: a LIKE scan over ``conversation_ai_titles`` with the whole title as the
2762
+ snippet (degraded; no marked span).
2763
+
2764
+ P2-9 (load-bearing): a session's title counts toward ``total`` and the page
2765
+ ONLY when it has an anchorable first turn. Both the COUNT and the page JOIN
2766
+ the SAME first-message anchor (``INNER JOIN`` against the per-session
2767
+ first-turn CTE), so a title row whose session has no surviving message rows is
2768
+ excluded from both — no lying count.
2769
+
2770
+ Filtered-search: the ``filt_sql`` session-scope restriction (over
2771
+ conversation_ai_titles.session_id) applies the same as the message kinds.
2772
+ """
2773
+ fpred = _session_filter_pred("t.session_id", filt_sql)
2774
+ fargs = tuple(filt_params)
2775
+ # First-turn anchor per session: earliest (timestamp_utc, id) row. Rides
2776
+ # idx_conv_session_ts. One row per session_id.
2777
+ anchor_cte = (
2778
+ "anchor AS ("
2779
+ " SELECT session_id, aid, auuid, ats, acwd, amid, arqd FROM ("
2780
+ " SELECT session_id, id AS aid, uuid AS auuid, timestamp_utc AS ats, "
2781
+ " cwd AS acwd, msg_id AS amid, req_id AS arqd, "
2782
+ " ROW_NUMBER() OVER (PARTITION BY session_id "
2783
+ " ORDER BY timestamp_utc, id) AS rn "
2784
+ " FROM conversation_messages WHERE session_id IS NOT NULL"
2785
+ " ) WHERE rn = 1)")
2786
+
2787
+ if fts_available:
2788
+ # matched titles: rowid → session_id + snippet, INNER-joined to the
2789
+ # anchor so only anchorable sessions survive (P2-9).
2790
+ fts_q = _fts_query(q, prefix_last=True)
2791
+ total = conn.execute(
2792
+ "WITH " + anchor_cte + " "
2793
+ "SELECT COUNT(*) FROM conversation_title_fts f "
2794
+ "JOIN conversation_ai_titles t ON t.rowid = f.rowid "
2795
+ "JOIN anchor a ON a.session_id = t.session_id "
2796
+ f"WHERE f.conversation_title_fts MATCH ?{fpred}",
2797
+ (fts_q, *fargs)).fetchone()[0]
2798
+ rows = conn.execute(
2799
+ "WITH " + anchor_cte + " "
2800
+ "SELECT t.session_id, a.auuid, a.ats, a.acwd, a.amid, a.arqd, "
2801
+ " snippet(conversation_title_fts, 0, '[', ']', ' … ', 12) AS snip "
2802
+ "FROM conversation_title_fts f "
2803
+ "JOIN conversation_ai_titles t ON t.rowid = f.rowid "
2804
+ "JOIN anchor a ON a.session_id = t.session_id "
2805
+ f"WHERE f.conversation_title_fts MATCH ?{fpred} "
2806
+ "ORDER BY a.ats DESC, t.session_id DESC LIMIT ? OFFSET ?",
2807
+ (fts_q, *fargs, limit, offset)).fetchall()
2808
+ mode = "fts"
2809
+ else:
2810
+ like = _like_pattern(q)
2811
+ total = conn.execute(
2812
+ "WITH " + anchor_cte + " "
2813
+ "SELECT COUNT(*) FROM conversation_ai_titles t "
2814
+ "JOIN anchor a ON a.session_id = t.session_id "
2815
+ f"WHERE t.ai_title LIKE ? ESCAPE '\\'{fpred}",
2816
+ (like, *fargs)).fetchone()[0]
2817
+ rows = conn.execute(
2818
+ "WITH " + anchor_cte + " "
2819
+ "SELECT t.session_id, a.auuid, a.ats, a.acwd, a.amid, a.arqd, "
2820
+ " t.ai_title AS snip "
2821
+ "FROM conversation_ai_titles t "
2822
+ "JOIN anchor a ON a.session_id = t.session_id "
2823
+ f"WHERE t.ai_title LIKE ? ESCAPE '\\'{fpred} "
2824
+ "ORDER BY a.ats DESC, t.session_id DESC LIMIT ? OFFSET ?",
2825
+ (like, *fargs, limit, offset)).fetchall()
2826
+ mode = "like"
2827
+
2828
+ hits = [_row_to_hit(auuid, sid, ats, acwd, snip, amid, arqd,
2829
+ match_kinds=["title"])
2830
+ for (sid, auuid, ats, acwd, amid, arqd, snip) in rows]
2831
+ return {"query": q, "mode": mode,
2832
+ "hits": _attach_search_meta(conn, hits),
2833
+ "total": total}
2834
+
2835
+
2836
+ def _search_files(conn, q, limit, offset, filt_sql="", filt_params=()):
2837
+ """#217 S2 / I-3: ``kind=files`` cross-session search over the write-class
2838
+ file-touch axis (``conversation_file_touches``). Emits one hit per DISTINCT
2839
+ ``(session_id, file_path)``, anchored to that group's MOST-RECENT touch
2840
+ (``MAX(message_id)`` → that message's ``(uuid, ts, cwd, msg_id, req_id)``),
2841
+ ``match_kinds:["file"]``, snippet = the file path. ``total`` = the distinct
2842
+ ``(session_id, file_path)`` count.
2843
+
2844
+ Match shape (#223): the query binds a SUBSTRING pattern (``%`` + escaped(q)
2845
+ + ``%``) into ``file_path LIKE ? ESCAPE '\\'`` for EVERY query — a bare
2846
+ basename, a mid-path fragment, or a leading-separator path all match. This
2847
+ is a deliberate full SCAN over the modest ``conversation_file_touches``
2848
+ table (a leading wildcard can never use ``idx_file_touches_path``),
2849
+ consistent with how the content facets match; it replaces the former
2850
+ prefix/separator special-case so a basename like ``package.json`` is
2851
+ findable. ``LIKE`` stays case-insensitive (ASCII), matching the NOCASE path
2852
+ index collation even though the index no longer drives a prefix probe.
2853
+
2854
+ Filtered-search: the ``filt_sql`` session-scope restriction is a post-match
2855
+ ``ft.session_id IN (<subquery>)`` (the same as the message kinds) — there is NO
2856
+ dedicated session index (the filter is a small IN over the already-narrowed
2857
+ match set, so a ``session_id`` btree earned nothing; dropped per review Minor #2).
2858
+ """
2859
+ pat = _like_pattern(q)
2860
+ fpred = _session_filter_pred("ft.session_id", filt_sql)
2861
+ fargs = tuple(filt_params)
2862
+
2863
+ # The anchor per (session_id, file_path) group is its MAX(message_id). Compute
2864
+ # the groups (filtered + matched), then join to conversation_messages on the
2865
+ # anchor id for the turn's render/cost metadata.
2866
+ #
2867
+ # P2-9 (#219 S2.1): the COUNT INNER-JOINs the SAME anchor the page joins, so a
2868
+ # group whose MAX(message_id) anchor is absent from conversation_messages is
2869
+ # excluded from BOTH (never a lying count where total > page-reachable). Inert
2870
+ # in a quiescent cache (touches are deleted with their message), but it keeps
2871
+ # `total` provably equal to the page-reachable set — matching `_search_title`.
2872
+ total = conn.execute(
2873
+ "WITH g AS ("
2874
+ " SELECT ft.session_id AS sid, ft.file_path AS fp, "
2875
+ " MAX(ft.message_id) AS aid "
2876
+ " FROM conversation_file_touches ft "
2877
+ f" WHERE ft.file_path LIKE ? ESCAPE '\\'{fpred} "
2878
+ " GROUP BY ft.session_id, ft.file_path) "
2879
+ "SELECT COUNT(*) FROM g JOIN conversation_messages cm ON cm.id = g.aid",
2880
+ (pat, *fargs)).fetchone()[0]
2881
+ rows = conn.execute(
2882
+ "WITH g AS ("
2883
+ " SELECT ft.session_id AS sid, ft.file_path AS fp, "
2884
+ " MAX(ft.message_id) AS aid "
2885
+ " FROM conversation_file_touches ft "
2886
+ f" WHERE ft.file_path LIKE ? ESCAPE '\\'{fpred} "
2887
+ " GROUP BY ft.session_id, ft.file_path) "
2888
+ "SELECT g.sid, g.fp, cm.uuid, cm.timestamp_utc, cm.cwd, "
2889
+ " cm.msg_id, cm.req_id "
2890
+ "FROM g JOIN conversation_messages cm ON cm.id = g.aid "
2891
+ "ORDER BY cm.timestamp_utc DESC, g.sid DESC, g.fp DESC "
2892
+ "LIMIT ? OFFSET ?",
2893
+ (pat, *fargs, limit, offset)).fetchall()
2894
+
2895
+ hits = [_row_to_hit(uuid_, sid, ts, cwd, fp, mid, rqd, match_kinds=["file"])
2896
+ for (sid, fp, uuid_, ts, cwd, mid, rqd) in rows]
2897
+ return {"query": q, "mode": "like",
2898
+ "hits": _attach_search_meta(conn, hits),
2097
2899
  "total": total}
2098
2900
 
2099
2901
 
@@ -2118,9 +2920,19 @@ def _like_badges(conn, like, groups):
2118
2920
 
2119
2921
  # #177 S6: kind facets. `all` is the unfiltered MATCH; `prompts`/`assistant`
2120
2922
  # filter the prose column AND the entry_type; `tools`/`thinking` filter the
2121
- # split index columns. Validated in search_conversations / find_in_conversation
2122
- # (an unknown kind raises ValueError the route maps it to a 400).
2123
- _SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
2923
+ # split index columns. #217 S2 / E7: `title` is a CROSS-SESSION-SEARCH-ONLY kind
2924
+ # (external-content title FTS over conversation_ai_titles, session-level hits).
2925
+ # Validated in search_conversations (an unknown kind raises ValueError → the
2926
+ # route maps it to a 400).
2927
+ #
2928
+ # P1-1 (load-bearing kind-validation SPLIT): `_SEARCH_KINDS` (cross-session
2929
+ # search) carries `title` (and is ready for `files` in I-3); `_FIND_KINDS` (the
2930
+ # in-conversation /find route) does NOT — `find_in_conversation` indexes
2931
+ # `_FIND_KIND_COLUMNS[kind]`, which has no entry for `title`/`files`, so accepting
2932
+ # them there would KeyError → 500. Keeping the two enums distinct makes
2933
+ # `/find?kind=title` a clean 400, never a 500.
2934
+ _SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking", "title", "files")
2935
+ _FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
2124
2936
  _KIND_COLUMN = {"prompts": "text", "assistant": "text",
2125
2937
  "tools": "search_tool", "thinking": "search_thinking"}
2126
2938
  _KIND_ENTRY_TYPE = {"prompts": "human", "assistant": "assistant"}
@@ -2156,6 +2968,14 @@ def _kind_match_expr(kind, fts_q):
2156
2968
 
2157
2969
  _FIND_ANCHOR_CAP = 500
2158
2970
 
2971
+ # #217 S4 / I-1.1: bound the regex/case Python-scan (Codex P2, ReDoS/perf).
2972
+ # An over-long pattern/query is rejected outright (empty result, no scan); each
2973
+ # scanned column value is also clipped before the predicate runs so a single
2974
+ # huge tool result can't pin a catastrophic-backtracking regex. Best-effort —
2975
+ # not a full ReDoS defense, but it bounds the worst case to a fixed budget.
2976
+ _FIND_REGEX_MAX_LEN = 1000
2977
+ _FIND_SCAN_TEXT_CAP = 200_000
2978
+
2159
2979
  # Which physical-row columns the find match probes per kind, and the badge label
2160
2980
  # each non-prose column contributes. ``text`` maps to the synthetic ``prose``
2161
2981
  # label so a prose-only match still anchors a turn but never badges.
@@ -2170,7 +2990,8 @@ _FIND_KIND_COLUMNS = {
2170
2990
 
2171
2991
 
2172
2992
  def find_in_conversation(conn, session_id, query, *, kind="all",
2173
- fts_available=None, cap=_FIND_ANCHOR_CAP):
2993
+ fts_available=None, cap=_FIND_ANCHOR_CAP,
2994
+ regex=False, case=False):
2174
2995
  """Document-ordered rendered-turn anchors for in-conversation find (#177 S6).
2175
2996
 
2176
2997
  Anchor identity is rendered-turn identity (spec F1): the FTS/LIKE match for
@@ -2183,8 +3004,29 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
2183
3004
  rendered-turn anchors PRE-cap; the list caps at ``cap`` with
2184
3005
  ``anchors_truncated``. Returns None for an unknown session; an unknown
2185
3006
  ``kind`` raises ValueError (route → 400). Empty/whitespace query → empty;
2186
- prose-only depth + tools/thinking kinds → empty (the split index is pending)."""
2187
- if kind not in _SEARCH_KINDS:
3007
+ prose-only depth + tools/thinking kinds → empty (the split index is pending).
3008
+
3009
+ P1-1: validates against ``_FIND_KINDS`` (NOT ``_SEARCH_KINDS``), so the
3010
+ cross-session-only ``title``/``files`` kinds raise ValueError here (the route
3011
+ → 400) rather than reaching ``_FIND_KIND_COLUMNS[kind]`` and KeyError → 500.
3012
+
3013
+ #217 S4 / I-1.1 — ``regex`` / ``case``: when either is set the FTS/LIKE fast
3014
+ path is bypassed for a **physical-row Python scan** of ``conversation_messages``
3015
+ (Codex P0 — *not* the rendered ``_assemble_session`` items, which drop the
3016
+ ``search_tool``/``search_thinking`` corpus). The scan reuses the SAME column
3017
+ derivation the FTS path matches on (``_find_kind_columns(kind, "full")`` +
3018
+ ``_KIND_ENTRY_TYPE``) and produces the SAME ``{uuid -> labels}`` map, then
3019
+ feeds the SAME assembly-to-anchor loop, so anchors / ordering / member_uuids /
3020
+ match_kinds / the cap are byte-identical to the FTS path — only the matcher
3021
+ differs. ``case`` only → case-sensitive substring (``mode == "like"``);
3022
+ ``regex`` → per-row ``re.search`` compiled once (``re.IGNORECASE`` unless
3023
+ ``case``; ``mode == "regex"``). Because the scan reads the full corpus
3024
+ columns, ``search_depth == "full"`` — regex/case is NOT gated by the
3025
+ prose-only split-index window. The scan is bounded by ``_FIND_REGEX_MAX_LEN``
3026
+ (query/pattern length) and ``_FIND_SCAN_TEXT_CAP`` (per-row scanned text).
3027
+ Default ``regex=False, case=False`` leaves every existing response
3028
+ byte-stable (the ``_find_matched_rows`` path is untouched)."""
3029
+ if kind not in _FIND_KINDS:
2188
3030
  raise ValueError(f"unknown kind: {kind}")
2189
3031
  # Cheap existence probe (one indexed SELECT) BEFORE the full assembly, so an
2190
3032
  # empty/prose-only-blocked query opening the find bar pays nothing — yet the
@@ -2198,17 +3040,55 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
2198
3040
  if fts_available is None:
2199
3041
  fts_available = not _fts_flag_unavailable(conn)
2200
3042
  q = (query or "").strip()
3043
+ # #217 S4 / I-1.1: a regex/case request scans the full corpus columns, so it
3044
+ # reports search_depth "full" regardless of the prose-only split-index state,
3045
+ # and is never blocked by it (the scan reads the base table, not the FTS).
3046
+ scan = bool(regex or case)
3047
+ eff_depth = "full" if scan else depth
2201
3048
  base = {"total": 0, "anchors": [], "anchors_truncated": False,
2202
- "search_depth": depth, "kind": kind,
2203
- "mode": "fts" if fts_available else "like"}
2204
- if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
3049
+ "search_depth": eff_depth, "kind": kind,
3050
+ "mode": ("regex" if regex else "like") if scan
3051
+ else ("fts" if fts_available else "like")}
3052
+ if not q:
2205
3053
  return base
3054
+ if scan:
3055
+ # Bound the scan (Codex P2): reject an over-long pattern/query outright
3056
+ # rather than risk a catastrophic-backtracking regex over the corpus.
3057
+ if len(q) > _FIND_REGEX_MAX_LEN:
3058
+ return base
3059
+ mode = "regex" if regex else "like"
3060
+ matched = _find_matched_scan(conn, session_id, q, kind, regex, case)
3061
+ else:
3062
+ if depth == "prose-only" and kind in ("tools", "thinking"):
3063
+ return base
3064
+ # U2 (#217 S1): run the match probe BEFORE assembly so a non-empty query
3065
+ # that matches ZERO rows in this session returns early — skipping the full
3066
+ # `_assemble_session` walk a no-result find used to pay. The two functions
3067
+ # are independent (neither consumes the other's output); a non-empty match
3068
+ # still assembles, since the uuid → member_uuids anchor mapping needs the
3069
+ # items. `mode` is taken from the match probe (it may fall through to LIKE
3070
+ # on an FTS error) so the empty-match base carries the actually-used mode,
3071
+ # not the `fts_available` default — byte-identical to the prior
3072
+ # post-assembly answer.
3073
+ mode, matched = _find_matched_rows(
3074
+ conn, session_id, q, kind, depth, fts_available)
3075
+ if not matched:
3076
+ return {**base, "mode": mode}
2206
3077
  asm = _assemble_session(conn, session_id)
2207
3078
  if asm is None:
2208
3079
  return None
2209
- mode, matched = _find_matched_rows(
2210
- conn, session_id, q, kind, depth, fts_available)
2211
- # matched: {uuid -> set of labels in {"prose", "tool", "thinking"}}
3080
+ anchors = _find_anchors_from_matched(asm, matched)
3081
+ total = len(anchors)
3082
+ return {**base, "mode": mode, "total": total,
3083
+ "anchors": anchors[:cap], "anchors_truncated": total > cap}
3084
+
3085
+
3086
+ def _find_anchors_from_matched(asm, matched):
3087
+ """Map the ``{uuid -> set(labels)}`` match map onto document-ordered
3088
+ rendered-turn anchors (shared by the FTS/LIKE path and the regex/case scan,
3089
+ so the two can never diverge on ordering / member folding / match_kinds).
3090
+ ``matched`` labels are in ``{"prose", "tool", "thinking"}``; the synthetic
3091
+ ``prose`` label anchors a turn but never badges."""
2212
3092
  anchors = []
2213
3093
  for it in asm["items"]:
2214
3094
  hit_kinds = set()
@@ -2222,9 +3102,51 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
2222
3102
  anchors.append({
2223
3103
  "uuid": it["anchor"]["uuid"],
2224
3104
  "match_kinds": sorted(k for k in hit_kinds if k != "prose")})
2225
- total = len(anchors)
2226
- return {**base, "mode": mode, "total": total,
2227
- "anchors": anchors[:cap], "anchors_truncated": total > cap}
3105
+ return anchors
3106
+
3107
+
3108
+ def _find_matched_scan(conn, session_id, q, kind, regex, case):
3109
+ """({uuid -> {labels}}) for a regex/case PHYSICAL-ROW scan (Codex P0). Reads
3110
+ the same corpus columns the FTS path matches on (``_find_kind_columns(kind,
3111
+ "full")``) over the base ``conversation_messages`` rows — NOT the rendered
3112
+ ``_assemble_session`` items, which drop the ``search_tool``/``search_thinking``
3113
+ columns. Honors ``_KIND_ENTRY_TYPE`` exactly like ``_find_matched_like``.
3114
+ Each scanned value is clipped to ``_FIND_SCAN_TEXT_CAP`` before the predicate
3115
+ so one huge tool result can't pin the regex engine.
3116
+
3117
+ Precondition: ``regex or case``. ``find_in_conversation`` only enters the
3118
+ physical-row scan when ``scan = bool(regex or case)`` (#217 S4 / I-1.1), so a
3119
+ plain case-insensitive-substring scan is never requested here — the FTS/LIKE
3120
+ path (``_find_matched_rows``) owns that case. The two regex/case predicates
3121
+ below are therefore exhaustive; the assert documents (and enforces) that
3122
+ rather than carrying an unreachable third branch."""
3123
+ assert regex or case, "_find_matched_scan requires regex or case"
3124
+ cols = _find_kind_columns(kind, "full")
3125
+ if not cols:
3126
+ return {}
3127
+ et = _KIND_ENTRY_TYPE.get(kind)
3128
+ et_pred = " AND entry_type = ?" if et is not None else ""
3129
+ et_args = (et,) if et is not None else ()
3130
+ if regex:
3131
+ rx = re.compile(q, 0 if case else re.IGNORECASE)
3132
+ pred = lambda text: rx.search(text) is not None
3133
+ else: # case-sensitive substring (case=True, regex=False)
3134
+ pred = lambda text: q in text
3135
+ out = {}
3136
+ col_list = ", ".join(c for c, _ in cols)
3137
+ rows = conn.execute(
3138
+ f"SELECT uuid, {col_list} FROM conversation_messages "
3139
+ f"WHERE session_id = ?{et_pred}",
3140
+ (session_id, *et_args)).fetchall()
3141
+ for row in rows:
3142
+ u = row[0]
3143
+ for idx, (_col, label) in enumerate(cols):
3144
+ val = row[idx + 1]
3145
+ if not val:
3146
+ continue
3147
+ if pred(val[:_FIND_SCAN_TEXT_CAP]):
3148
+ out.setdefault(u, set()).add(label)
3149
+ return out
2228
3150
 
2229
3151
 
2230
3152
  def _find_matched_rows(conn, session_id, q, kind, depth, fts_available):
@@ -2347,74 +3269,6 @@ def locate_tool_payload(conn, session_id, tool_use_id, which):
2347
3269
  return None
2348
3270
 
2349
3271
 
2350
- def _clip_payload_input(inp, ceiling):
2351
- """Clip a structured input so the returned dict serializes to ``ceiling`` chars
2352
- or fewer, and report whether anything was clipped — the input-side analogue of
2353
- the result-side ceiling. A degenerate multi-MB input (one giant leaf OR many
2354
- sub-ceiling leaves that sum past the ceiling) is bounded so the HTTP server /
2355
- browser is protected, while every real payload returns whole.
2356
-
2357
- The guarantee is AGGREGATE, not merely per-leaf: a shared remaining-char budget
2358
- is threaded through the walk (mirroring ``_bound_input``'s total-size backstop) —
2359
- each string leaf is clipped against the running budget and the budget is
2360
- decremented as we go, so once it is exhausted later leaves clip to ''.
2361
- ``truncated`` is True iff any leaf was clipped (or the post-walk serialized size
2362
- still exceeds ``ceiling``, the structural-overhead backstop). Post-condition:
2363
- ``len(json.dumps(clipped, ensure_ascii=False)) <= ceiling`` always."""
2364
- truncated = False
2365
- remaining = [ceiling] # boxed so the nested walk can decrement it
2366
-
2367
- def walk(v):
2368
- nonlocal truncated
2369
- if isinstance(v, str):
2370
- if len(v) > remaining[0]:
2371
- truncated = True
2372
- v = v[:remaining[0]]
2373
- remaining[0] -= len(v)
2374
- return v
2375
- if isinstance(v, dict):
2376
- return {k: walk(x) for k, x in v.items()}
2377
- if isinstance(v, list):
2378
- return [walk(x) for x in v]
2379
- return v
2380
-
2381
- clipped = walk(inp)
2382
- # Backstop: structural JSON overhead (braces/quotes/keys) can push a
2383
- # budget-exact payload a few chars past the ceiling. Hard-clip the largest
2384
- # remaining string leaf(s) until the whole dict serializes within the ceiling.
2385
- while len(_json.dumps(clipped, ensure_ascii=False)) > ceiling:
2386
- truncated = True
2387
- if not _shrink_largest_leaf(clipped):
2388
- break # no string leaf left to shrink (e.g. pure numeric/structural)
2389
- return clipped, truncated
2390
-
2391
-
2392
- def _shrink_largest_leaf(obj):
2393
- """Halve the longest string leaf reachable in ``obj`` (a dict/list/scalar),
2394
- in place, and return True if one was shrunk — the post-walk backstop for the
2395
- rare structural-overhead overshoot. A leaf already at length 1 is truncated to
2396
- ''. Returns False when no non-empty string leaf exists."""
2397
- best = {"len": 0, "container": None, "key": None}
2398
-
2399
- def scan(v, container, key):
2400
- if isinstance(v, str):
2401
- if len(v) > best["len"]:
2402
- best.update(len=len(v), container=container, key=key)
2403
- elif isinstance(v, dict):
2404
- for k, x in v.items():
2405
- scan(x, v, k)
2406
- elif isinstance(v, list):
2407
- for i, x in enumerate(v):
2408
- scan(x, v, i)
2409
-
2410
- scan(obj, None, None)
2411
- if best["container"] is None or best["len"] == 0:
2412
- return False
2413
- s = best["container"][best["key"]]
2414
- best["container"][best["key"]] = s[: len(s) // 2]
2415
- return True
2416
-
2417
-
2418
3272
  def read_full_payload(source_path, byte_offset, tool_use_id, which):
2419
3273
  """Re-read the raw JSONL line at ``(source_path, byte_offset)`` and return the
2420
3274
  FULL (un-capped) payload for ``tool_use_id``:
@@ -2471,6 +3325,12 @@ def read_full_payload(source_path, byte_offset, tool_use_id, which):
2471
3325
  if (isinstance(tur, dict) and isinstance(tur.get("stderr"), str)
2472
3326
  and tur.get("stderr")):
2473
3327
  resp["stderr"] = tur["stderr"][:_FULL_PAYLOAD_CEILING] # per-stream bound (see above)
3328
+ # #217 S1 / U5 (data-contract honesty): `truncated` describes ONLY
3329
+ # the `text` stream, but `stderr` is bounded INDEPENDENTLY against
3330
+ # the same ceiling — so the client cannot tell from `truncated`
3331
+ # alone whether stderr was clipped. Emit a dedicated per-stream
3332
+ # flag whenever stderr is present (mirrors `stderr` itself).
3333
+ resp["stderr_truncated"] = len(tur["stderr"]) > _FULL_PAYLOAD_CEILING
2474
3334
  return resp
2475
3335
  return None
2476
3336