cctally 1.52.1 → 1.53.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,27 @@ 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
  )
285
290
 
286
291
 
287
292
  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
293
+ """Read-time fallback for nested (grandchild) subagent results on rows that
294
+ were ingested BEFORE the #217 S1 U6 structured stamp (un-reingested history).
295
+ Delegates to the parser's single-source rule over the (already-clipped) block
296
+ text so a >16 KB result whose `agentId:` trailer was cut survives only via
297
+ the ingest stamp + the migration-017 offset-0 reingest, never here. Returns
298
+ (agent_id, meta) or None when no `agentId:` is present. Linking on `agentId`
299
+ ALONE is sufficient (Codex P1-B)."""
300
+ return _nested_agent_stamp_from_text(text)
308
301
 
309
302
 
310
303
  def _iso_ms(ts):
@@ -374,122 +367,338 @@ def _cache_read_saved_usd(model, cache_read):
374
367
  return full - read
375
368
 
376
369
 
370
+ # A normalized cache-failure event. ``compaction`` marks a context-compaction
371
+ # boundary (resets the running-max); otherwise it is an assistant-token event
372
+ # carrying its per-thread/per-model key + the cache_creation/cache_read signal.
373
+ # Both the full-assembly stamp path and the lightweight rebuild-count path build
374
+ # a document-ordered list of these and feed it to the ONE predicate below — the
375
+ # rule is implemented exactly once (U1, #217 S1).
376
+ class _CFEvent:
377
+ __slots__ = ("compaction", "key", "cc", "cr", "model")
378
+
379
+ def __init__(self, *, compaction=False, key=None, cc=0, cr=0, model=None):
380
+ self.compaction = compaction
381
+ self.key = key
382
+ self.cc = cc
383
+ self.cr = cr
384
+ self.model = model
385
+
386
+
387
+ def _iter_cache_failures(events):
388
+ """The single cache-failure rule (spec §1), as a generator over a
389
+ document-ordered ``_CFEvent`` stream. Yields ``(index, prev_cached, lost,
390
+ model)`` for each FLAGGED assistant event — ``index`` is the event's position
391
+ in ``events`` so a caller can map a flag back to its source item.
392
+
393
+ Maintains a running-max of ``cache_read`` keyed by the event's ``key``
394
+ (``(subagent_key, model)`` — ``None`` subagent_key = main session). The key
395
+ is per-thread AND per-model because Anthropic prompt caches are model-
396
+ specific: a model switch within a thread legitimately starts a fresh cache
397
+ (cr ~ 0) and must not read as a loss, and the compound key makes that the
398
+ first event under its own key (no prior rm). The whole map is RESET on a
399
+ compaction event, since compaction legitimately invalidates the prefix and
400
+ the post-compaction re-prime is not a failure.
401
+
402
+ For each assistant event — cc, cr, and rm (the running-max for this key
403
+ BEFORE this event) — flag iff rm >= CACHE_FLOOR and cc >= CREATE_FLOOR and
404
+ cr <= COLLAPSE_FRACTION*rm and cc/(cc+cr) >= RECREATE_FRACTION. The fourth
405
+ term keeps the rule aligned with the Evidence: it requires that most of THIS
406
+ event's context was freshly created, not merely that cache_read dipped. After
407
+ the check, ``running_max[key] = max(rm, cr)`` (a failure's small cr never
408
+ lowers the high-water mark; the next healthy event re-establishes it).
409
+
410
+ ``lost`` is the lost-prefix basis (NOT raw cc, which over-counts when the
411
+ turn also writes genuinely-new cacheable content): ``min(cc, max(0,
412
+ rm - cr))``. Order-dependent by construction; run over document-ordered
413
+ events."""
414
+ running_max = {} # (subagent_key, model) -> high-water cache_read
415
+ for i, ev in enumerate(events):
416
+ if ev.compaction:
417
+ # Reset the whole map (compaction invalidates every thread's prefix
418
+ # in this session view; a per-key reset would need a thread tag the
419
+ # meta row does not reliably carry).
420
+ running_max.clear()
421
+ continue
422
+ cc, cr = ev.cc, ev.cr
423
+ rm = running_max.get(ev.key, 0)
424
+ total = cc + cr
425
+ if (rm >= _CACHE_FAILURE_CACHE_FLOOR
426
+ and cc >= _CACHE_FAILURE_CREATE_FLOOR
427
+ and cr <= _CACHE_FAILURE_COLLAPSE_FRACTION * rm
428
+ and total > 0
429
+ and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
430
+ lost = min(cc, max(0, rm - cr))
431
+ yield (i, rm, lost, ev.model)
432
+ running_max[ev.key] = max(rm, cr)
433
+
434
+
435
+ def _cache_failure_count_over_events(events) -> int:
436
+ """Count of flagged events in a normalized ``_CFEvent`` stream (the pure
437
+ predicate the lightweight rebuild-count path consumes). Single source of
438
+ truth — shares ``_iter_cache_failures`` with the full-assembly stamp."""
439
+ return sum(1 for _ in _iter_cache_failures(events))
440
+
441
+
442
+ def _cache_failure_events_from_items(items):
443
+ """Build the document-ordered ``_CFEvent`` stream from assembled reader
444
+ ``items`` plus a parallel ``sources`` list (the source item per event, for
445
+ stamping). A compaction event per ``meta``/``compaction`` item; an assistant-
446
+ token event per assistant item carrying a ``tokens`` dict. Items lacking a
447
+ ``tokens`` dict (no session_entries row, or non-assistant non-compaction
448
+ items) emit NOTHING and so neither flag nor move the running-max — exactly
449
+ the pre-U1 skip behavior."""
450
+ events = []
451
+ sources = [] # parallel list: the source item per event (for stamping)
452
+ for it in items:
453
+ if it.get("kind") == "meta" and it.get("meta_kind") == "compaction":
454
+ events.append(_CFEvent(compaction=True))
455
+ sources.append(it)
456
+ continue
457
+ if it.get("kind") != "assistant":
458
+ continue
459
+ tok = it.get("tokens")
460
+ if not isinstance(tok, dict):
461
+ continue # no session_entries row -> skip
462
+ events.append(_CFEvent(
463
+ key=(it.get("subagent_key"), it.get("model")),
464
+ cc=tok.get("cache_creation", 0) or 0,
465
+ cr=tok.get("cache_read", 0) or 0,
466
+ model=it.get("model")))
467
+ sources.append(it)
468
+ return events, sources
469
+
470
+
377
471
  def _stamp_cache_failures(items):
378
472
  """Stamp ``item["cache_failure"]`` on each assistant turn that re-creates the
379
473
  bulk of its cached prefix instead of reading it (spec §1). Mutates `items` in
380
474
  place; healthy turns are left WITHOUT the key (absent, not zero — matching the
381
475
  ``tokens?`` "absent, not zero" convention).
382
476
 
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):
477
+ Builds a document-ordered ``_CFEvent`` stream from the items (a compaction
478
+ event per compaction-meta, an assistant-token event per token-bearing
479
+ assistant turn) and runs the SAME ``_iter_cache_failures`` rule the
480
+ lightweight rebuild-count path uses (single source of truth the rule lives
481
+ in exactly one place; U1). The payload is computed here on the lost-prefix
482
+ basis (NOT raw cc, which would over-count when the turn also writes
483
+ genuinely-new cacheable content that was never cached):
405
484
  lost = min(cc, max(0, rm - cr))
406
485
  tokens_recreated = lost
407
486
  prev_cached = rm
408
487
  est_wasted_usd = write(lost) - read(lost)
488
+ """
489
+ events, sources = _cache_failure_events_from_items(items)
490
+ for idx, prev_cached, lost, model in _iter_cache_failures(events):
491
+ sources[idx]["cache_failure"] = {
492
+ "tokens_recreated": lost,
493
+ "prev_cached": prev_cached,
494
+ "est_wasted_usd": _cache_failure_wasted_usd(model, lost),
495
+ }
496
+
409
497
 
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.
498
+ def _lightweight_rebuild_events(conn, session_id):
499
+ """Build the document-ordered ``_CFEvent`` stream for a session WITHOUT a
500
+ full ``_assemble_session`` (U1, #217 S1). Skips the expensive assembly work
501
+ (no ``blocks_json`` body parse, no tool-result folding, no meta-classify /
502
+ ANSI strip, no subagent correlation) but faithfully reproduces the canonical
503
+ normalization the cache-failure predicate depends on:
504
+
505
+ - UUID dedup: canonical row per ``(session_id, uuid)`` = first occurrence
506
+ in (timestamp_utc, id) order (a replay carries the original uuid);
507
+ - turn grouping by ``(msg_id, req_id)``: an assistant turn — possibly split
508
+ across non-consecutive fragments interleaved by a tool_result — emits ONE
509
+ event at the FIRST fragment's position, with cost-once-per-(msg_id,req_id)
510
+ token attribution from the deduped session_entries row;
511
+ - first-prose MODEL promotion: the turn's model is the FIRST prose-bearing
512
+ fragment's model (the canonical anchor), matching ``_build_turn`` /
513
+ ``_fold_fragment``; a turn with no prose keeps its seed fragment's model;
514
+ - seed ``source_path -> subagent_key`` derivation (``_subagent_key``);
515
+ - compaction-reset detection: an ``entry_type in ('meta','human')`` row
516
+ whose all-text body is a compaction summary (``_is_compaction_body``)
517
+ and which is NOT a slash-command invocation — mirroring the assembly
518
+ meta-classify — emits a compaction event.
519
+
520
+ The event ORDER is the turn's first-fragment document position, byte-for-byte
521
+ the order ``_assemble_session`` walks (which emits each turn item at its first
522
+ fragment, and each meta/human row at its own position). That preserves the
523
+ running-max walk the predicate requires.
413
524
  """
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":
525
+ exists = conn.execute(
526
+ "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
527
+ (session_id,)).fetchone()
528
+ if exists is None:
529
+ return []
530
+ # Narrow column set: enough to dedup, group turns, derive the subagent_key,
531
+ # detect compaction, and join the token row. No blocks_json body parse beyond
532
+ # the all-text compaction guard (which needs text OR the text-block join).
533
+ rows = conn.execute(
534
+ "SELECT id, uuid, entry_type, text, blocks_json, model, "
535
+ " msg_id, req_id, source_path "
536
+ "FROM conversation_messages WHERE session_id=? "
537
+ "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
538
+ seen_uuid = set()
539
+ logical = []
540
+ for row in rows:
541
+ u = row[1]
542
+ if u in seen_uuid:
543
+ continue # UUID dedup: keep the first (canonical) occurrence
544
+ seen_uuid.add(u)
545
+ logical.append(row)
546
+
547
+ # Group assistant turns by (msg_id, req_id) at the first-fragment position;
548
+ # later same-key fragments fold their model (first-prose promotion) without
549
+ # adding a second event. A `None`-msg_id assistant row carries no turn key and
550
+ # no session_entries cost, so it never contributes a token event.
551
+ events = []
552
+ sources = [] # parallel: the turn key per assistant event
553
+ turn_index = {} # (msg_id, req_id) -> index into events
554
+ turn_has_prose = {} # (msg_id, req_id) -> bool (model promoted yet?)
555
+ turn_keys = [] # ordered list of grouped turn keys (for the token map)
556
+ for (rid, u, etype, text, blocks, model, msg_id, req_id,
557
+ source_path) in logical:
558
+ if etype == "assistant" and msg_id is not None:
559
+ key = (msg_id, req_id)
560
+ frag_text = (text or "").strip()
561
+ ev_idx = turn_index.get(key)
562
+ if ev_idx is None:
563
+ ev_idx = len(events)
564
+ turn_index[key] = ev_idx
565
+ turn_keys.append(key)
566
+ ev = _CFEvent(key=(_subagent_key(source_path), model),
567
+ model=model)
568
+ events.append(ev)
569
+ sources.append(key)
570
+ # The seed model holds until the first prose fragment promotes it.
571
+ turn_has_prose[key] = bool(frag_text)
572
+ else:
573
+ ev = events[ev_idx]
574
+ # First prose fragment promotes the turn's model anchor. The full
575
+ # path (`_build_turn`) SEEDS subagent_key from the first fragment
576
+ # and re-promotes ONLY the model; mirror that here by keeping the
577
+ # seeded `ev.key[0]` and re-promoting just the model (#218 I-A.1).
578
+ # subagent_key is per-file-invariant across a turn's fragments (one
579
+ # (msg_id,req_id) = one file), so this matches in all real data and
580
+ # is exact structural parity for the engineered cross-file split.
581
+ if not turn_has_prose.get(key) and frag_text:
582
+ ev.key = (ev.key[0], model)
583
+ ev.model = model
584
+ turn_has_prose[key] = True
585
+ elif etype in ("meta", "human"):
586
+ # Mirror the assembly meta-classify's compaction branch: an all-text
587
+ # body that is a compaction summary AND is not a slash-command
588
+ # invocation. A command invocation (#188) is promoted to a human turn
589
+ # BEFORE meta-classify, so a body carrying <command-args> never reads
590
+ # as compaction; compaction bodies have no <command-args>, so in
591
+ # practice the invocation check is a faithful belt-and-suspenders.
592
+ try:
593
+ parsed_blocks = _json.loads(blocks or "[]")
594
+ except (ValueError, TypeError):
595
+ parsed_blocks = []
596
+ body = text or _join_text_blocks(parsed_blocks)
597
+ all_text = all(b.get("kind") == "text"
598
+ for b in (parsed_blocks or []))
599
+ if (all_text and _is_compaction_body(body)
600
+ and _extract_command_invocation(parsed_blocks, body) is None):
601
+ events.append(_CFEvent(compaction=True))
602
+ sources.append(None)
603
+ # tool_result rows and null-msg_id assistant rows carry no token event.
604
+
605
+ # Cost-once token attribution: ONE deduped session_entries row per
606
+ # (msg_id, req_id) — the same map the full path stamps from. An assistant
607
+ # turn key absent from session_entries carries NO tokens, so its event is
608
+ # dropped (neither flags nor moves the running-max) — parity with
609
+ # _cache_failure_events_from_items's `tokens` skip.
610
+ usage = _turn_usage_map(conn, turn_keys)
611
+ filtered = []
612
+ si = 0
613
+ for ev in events:
614
+ if ev.compaction:
615
+ filtered.append(ev)
616
+ si += 1
425
617
  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)
618
+ key = sources[si]
619
+ si += 1
620
+ tok = usage.get(key)
621
+ if tok is None:
622
+ continue # no session_entries row -> skip (don't move max)
623
+ ev.cc = tok.get("cache_creation", 0) or 0
624
+ ev.cr = tok.get("cache_read", 0) or 0
625
+ filtered.append(ev)
626
+ return filtered
446
627
 
447
628
 
448
629
  def session_cache_rebuild_count(conn, session_id) -> int:
449
630
  """Per-session count of cache-rebuild (cache-failure) turns.
450
631
 
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.
632
+ Single source of truth: builds a document-ordered cache-failure event stream
633
+ via the LIGHTWEIGHT builder (``_lightweight_rebuild_events`` no full
634
+ ``_assemble_session``, so this stays cheap on the flock-held rollup path; U1,
635
+ #217 S1) and runs it through the SAME ``_cache_failure_count_over_events``
636
+ predicate the reader's full assembly stamps with. The light builder
637
+ reproduces the canonical normalization (UUID dedup, turn grouping, first-prose
638
+ model promotion, seed subagent_key, cost-once token attribution, compaction
639
+ reset), so the count is byte-identical to the full-assembly count (guarded by
640
+ ``test_session_cache_rebuild_count_lightweight_matches_full_assembly``).
641
+
642
+ /outline omits stats.cache_failures when the count is 0, so "no flagged
643
+ items" -> 0 (byte-identical to outline). Stored on the conversation_sessions
644
+ rollup by _recompute_conversation_sessions; filtered by
645
+ list_conversations(rebuild_min=...).
463
646
  """
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)
647
+ return _cache_failure_count_over_events(
648
+ _lightweight_rebuild_events(conn, session_id))
649
+
650
+
651
+ def _turn_costs_for_keys(conn, keys):
652
+ """{(msg_id, req_id): cost_usd} for the given turn keys, joined ONCE to the
653
+ deduped session_entries row per (msg_id, req_id) (#217 S1 / U7b — the SINGLE
654
+ cost-attribution chokepoint both ``_turn_cost_map`` and ``_session_cost_map``
655
+ delegate to, so the cost-once-per-turn rule is never reimplemented).
656
+
657
+ NULL-filters the keys, chunks the OR-of-pairs to stay well under SQLite's
658
+ variable limit, and computes each cost via the shared pricing helper
659
+ (honoring a vendor ``cost_usd_raw`` override). Keys absent from
660
+ session_entries (e.g. ``<synthetic>`` walker-skipped rows) are simply not
661
+ present in the result -> cost 0 by omission. Parity guard:
662
+ ``test_session_and_turn_cost_map_share_helper_parity``."""
663
+ costs = {}
664
+ norm = [(m, r) for (m, r) in keys if m is not None and r is not None]
665
+ if not norm:
666
+ return costs
667
+ for i in range(0, len(norm), 400):
668
+ chunk = norm[i:i + 400]
669
+ cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
670
+ params = [v for pair in chunk for v in pair]
671
+ sql = ("SELECT msg_id, req_id, model, input_tokens, output_tokens, "
672
+ "cache_create_tokens, cache_read_tokens, cost_usd_raw "
673
+ "FROM session_entries WHERE " + cond)
674
+ for m, r, model, inp, out, cc, cr, raw in conn.execute(sql, params):
675
+ costs[(m, r)] = _entry_cost(model, inp, out, cc, cr, raw)
676
+ return costs
470
677
 
471
678
 
472
679
  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."""
680
+ """{session_id: total_cost_usd} for the given sessions. Resolves each
681
+ session's distinct (msg_id, req_id) turn keys, then attributes cost via the
682
+ shared ``_turn_costs_for_keys`` helper (cost-once per deduped session_entries
683
+ row), summing into the owning session. (msg_id, req_id) is globally unique in
684
+ session_entries and maps to exactly one session_id, so per-session sums are
685
+ clean and a turn replayed across files contributes once."""
478
686
  costs = {sid: 0.0 for sid in session_ids}
479
687
  if not session_ids:
480
688
  return costs
481
689
  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)
690
+ pairs = conn.execute(
691
+ "SELECT DISTINCT session_id, msg_id, req_id "
692
+ "FROM conversation_messages "
693
+ "WHERE session_id IN (%s) AND msg_id IS NOT NULL AND req_id IS NOT NULL"
694
+ % placeholders,
695
+ list(session_ids),
696
+ ).fetchall()
697
+ if not pairs:
698
+ return costs
699
+ key_cost = _turn_costs_for_keys(conn, [(m, r) for _, m, r in pairs])
700
+ for sid, m, r in pairs:
701
+ costs[sid] = costs.get(sid, 0.0) + key_cost.get((m, r), 0.0)
493
702
  return costs
494
703
 
495
704
 
@@ -513,23 +722,31 @@ def _session_latest_meta_map(conn, session_ids):
513
722
  """{session_id: (cwd, git_branch)} using the most-recent NON-NULL value per
514
723
  column — the SAME posture as get_conversation's _latest, so the rail and the
515
724
  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."""
725
+ MAX() picks the lexical max, not the latest). The latest non-null cwd and the
726
+ latest non-null git_branch may land on DIFFERENT rows (the newest row can
727
+ carry one but not the other), so each is resolved independently.
728
+
729
+ #217 S1 / U7b: consolidated from the prior TWIN correlated subqueries into a
730
+ SINGLE windowed scan — two ``FIRST_VALUE`` window functions over one
731
+ partition pass per column, each ordered ``(<col> IS NULL), timestamp_utc DESC,
732
+ id DESC`` so the most-recent non-null value sorts first (and an all-null
733
+ session yields NULL, exactly as the LIMIT-1 subquery did). Guarded by
734
+ ``test_session_latest_meta_map_parity_*`` against the prior semantics; if the
735
+ window form could not match it, the old correlated SQL would be kept (it
736
+ matched cleanly, so the consolidation lands)."""
519
737
  meta = {sid: (None, None) for sid in session_ids}
520
738
  if not session_ids:
521
739
  return meta
522
740
  placeholders = ",".join("?" for _ in session_ids)
523
741
  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
742
+ "SELECT DISTINCT session_id, "
743
+ " FIRST_VALUE(cwd) OVER ("
744
+ " PARTITION BY session_id "
745
+ " ORDER BY (cwd IS NULL), timestamp_utc DESC, id DESC), "
746
+ " FIRST_VALUE(git_branch) OVER ("
747
+ " PARTITION BY session_id "
748
+ " ORDER BY (git_branch IS NULL), timestamp_utc DESC, id DESC) "
749
+ "FROM conversation_messages WHERE session_id IN (%s)" % placeholders
533
750
  )
534
751
  for sid, cwd, branch in conn.execute(sql, list(session_ids)):
535
752
  meta[sid] = (cwd, branch)
@@ -561,16 +778,38 @@ def session_source_paths(conn, session_id):
561
778
  _SORTS = {
562
779
  "recent": "last_activity_utc DESC, session_id DESC",
563
780
  "oldest": "started_utc ASC, session_id ASC",
781
+ # #217 S4 / I-2.1: stored-column rollup sorts. Each tie-broken by
782
+ # session_id for stable pagination. ``project`` uses an explicit CASE so a
783
+ # NULL label (not-yet-filled row) or '' (the no-cwd _project_label(None)
784
+ # sentinel) sorts LAST — a plain COLLATE NOCASE ASC would put them first.
785
+ "cost": "cost_usd DESC, session_id DESC",
786
+ "messages": "msg_count DESC, session_id DESC",
787
+ "project": ("CASE WHEN project_label IS NULL OR project_label = '' "
788
+ "THEN 1 ELSE 0 END, project_label COLLATE NOCASE ASC, "
789
+ "session_id ASC"),
564
790
  }
565
791
 
566
792
  # The matching ORDER BY for the live GROUP BY fallback: the rollup's
567
793
  # last_activity_utc IS MAX(timestamp_utc) and started_utc IS MIN(timestamp_utc),
568
794
  # so the two branches paginate in byte-identical order (the load-bearing
569
795
  # invariant). Keep this table in lockstep with _SORTS.
796
+ #
797
+ # ``messages`` has FULL live parity (COUNT(*) DESC over the raw messages == the
798
+ # rollup's stored msg_count). ``cost`` and ``project`` have NO raw-message
799
+ # column, so they are INTENTIONALLY ABSENT here — list_conversations resolves
800
+ # them to the ``recent`` live ordering and flags page.sort_degraded (a brief,
801
+ # self-correcting non-authoritative window). #217 S4 / I-2.1.
570
802
  _SORTS_LIVE = {
571
803
  "recent": "MAX(timestamp_utc) DESC, session_id DESC",
572
804
  "oldest": "MIN(timestamp_utc) ASC, session_id ASC",
805
+ "messages": "COUNT(*) DESC, session_id DESC",
573
806
  }
807
+ # The rollup-only sorts — expressible on the rollup branch but NOT in the live
808
+ # GROUP BY fallback. A request for one of these under the live branch falls back
809
+ # to the recent ordering AND sets page.sort_degraded (keyed to the REQUESTED
810
+ # sort, never the effective order — an unknown sort also falls back to recent
811
+ # but must stay byte-stable, so it is excluded here).
812
+ _DEGRADABLE_SORTS = frozenset(("cost", "project"))
574
813
 
575
814
 
576
815
  def _rollup_authoritative(conn) -> bool:
@@ -663,6 +902,47 @@ def _live_having(filters):
663
902
  return (" HAVING " + " AND ".join(having)) if having else "", params
664
903
 
665
904
 
905
+ def _resolve_search_session_filter(conn, filters):
906
+ """Resolve the filtered-search session-scope restriction (#217 S2 /
907
+ Filtered-search). Returns ``(subquery_sql, params, degraded)`` where
908
+ ``subquery_sql`` is a complete ``"SELECT session_id FROM ... "`` the caller
909
+ wraps as ``" AND <col> IN (<subquery_sql>)"``, or ``""`` (no restriction) when
910
+ no axis is set.
911
+
912
+ Mirrors the browse dual-branch parity (``list_conversations``):
913
+
914
+ * Rollup AUTHORITATIVE (``conversation_sessions_backfill_pending`` clear):
915
+ every axis is a stored column on the rollup, so restrict to
916
+ ``SELECT session_id FROM conversation_sessions <_rollup_where(filters)>``
917
+ (the SAME ``_rollup_where`` the browse rail uses, verbatim). ``degraded``
918
+ is False.
919
+ * Rollup PENDING (live fallback) AND a rollup-only axis
920
+ (project/cost/rebuild) is requested (P1-5): drop the rollup-only axes,
921
+ set ``degraded`` True, and express ONLY the date axis via a LIVE session
922
+ prefilter over ``conversation_messages`` keyed on
923
+ ``MAX(timestamp_utc)`` — the SAME session-activity semantics as
924
+ ``_live_having``/``_list_session_rows_live``. We restrict by session
925
+ ACTIVITY, never by a matched row's own timestamp (a session whose match is
926
+ old but whose activity is in-window must stay).
927
+ * Rollup pending but ONLY date axis requested: the rollup is not needed
928
+ (date is expressible live), so use the live prefilter and ``degraded``
929
+ stays False (no rollup-only axis was dropped — byte-stable with browse).
930
+ """
931
+ any_axis = any(filters[k] is not None for k in _FILTER_KEYS)
932
+ if not any_axis:
933
+ return "", [], False
934
+ if _rollup_authoritative(conn):
935
+ where, params = _rollup_where(filters)
936
+ return "SELECT session_id FROM conversation_sessions" + where, params, False
937
+ # Live fallback: only the date axis is expressible over raw messages.
938
+ degraded = any(filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
939
+ having, hparams = _live_having(filters)
940
+ sub = (
941
+ "SELECT session_id FROM conversation_messages "
942
+ "WHERE session_id IS NOT NULL GROUP BY session_id" + having)
943
+ return sub, hparams, degraded
944
+
945
+
666
946
  def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
667
947
  """FAST path: read the pre-aggregated rail rows straight from the
668
948
  conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
@@ -754,6 +1034,7 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
754
1034
  "rebuild_min": rebuild_min,
755
1035
  }
756
1036
  degraded = False
1037
+ sort_degraded = False
757
1038
  if _rollup_authoritative(conn):
758
1039
  order = _SORTS.get(sort, _SORTS["recent"])
759
1040
  rows = _list_session_rows_rollup(conn, order, limit, offset, filters)
@@ -761,6 +1042,10 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
761
1042
  order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
762
1043
  degraded = any(
763
1044
  filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
1045
+ # sort_degraded is keyed to the REQUESTED sort, not the effective order:
1046
+ # an unknown sort ALSO falls back to recent here (via _SORTS_LIVE.get),
1047
+ # but must stay byte-stable, so only the known rollup-only sorts flag it.
1048
+ sort_degraded = sort in _DEGRADABLE_SORTS
764
1049
  rows = _list_session_rows_live(conn, order, limit, offset, filters)
765
1050
  has_more = len(rows) > limit
766
1051
  rows = rows[:limit]
@@ -792,6 +1077,10 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
792
1077
  # The rail surfaces this: project/cost/rebuild filters apply once the
793
1078
  # rollup finishes indexing; only the date axis held this page.
794
1079
  page["filter_degraded"] = True
1080
+ if sort_degraded:
1081
+ # The rail surfaces this: cost/project ordering becomes available once
1082
+ # the rollup finishes indexing; this page fell back to recent order.
1083
+ page["sort_degraded"] = True
795
1084
  return {
796
1085
  "conversations": conversations,
797
1086
  "page": page,
@@ -801,22 +1090,13 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
801
1090
  def _turn_cost_map(conn, turn_keys):
802
1091
  """{(msg_id, req_id): cost_usd} for the given non-null turn keys, joined ONCE
803
1092
  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
1093
+ <synthetic> walker-skipped rows) are simply not present → cost 0 by omission.
1094
+
1095
+ Thin wrapper over the shared ``_turn_costs_for_keys`` chokepoint (#217 S1 /
1096
+ U7b) the search path's ``_attach_costs`` still consumes this name + its
1097
+ float-valued contract, so the public signature is unchanged while the body
1098
+ is single-sourced with ``_session_cost_map``."""
1099
+ return _turn_costs_for_keys(conn, turn_keys)
820
1100
 
821
1101
 
822
1102
  def _turn_usage_map(conn, turn_keys):
@@ -996,6 +1276,14 @@ def _assemble_session(conn, session_id):
996
1276
  tlist_ = b.pop("task_list", None)
997
1277
  if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
998
1278
  task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
1279
+ # §4 symmetry (#218 I-B.5): the structured-agent_id populate above runs
1280
+ # mid-loop, before spawn_kind is complete, so it cannot self-gate on spawn
1281
+ # ownership. Re-validate here now that spawn_kind is final — drop any
1282
+ # agent_link entry whose owning tool_use is NOT a spawn, mirroring the nested
1283
+ # fallback's `_tuid in spawn_kind` gate so BOTH link paths only ever carry
1284
+ # spawn-owned results. Inert today (every agent_link consumer is itself
1285
+ # spawn-gated); this protects a future, un-gated agent_link consumer.
1286
+ agent_link = {t: v for t, v in agent_link.items() if t in spawn_kind}
999
1287
  # §4 1a — nested grandchild results: gate the text-parse on the owning
1000
1288
  # tool_use being a spawn (in spawn_kind), so an ordinary tool result that
1001
1289
  # merely mentions "agentId" never matches.
@@ -1285,13 +1573,27 @@ def _assemble_session(conn, session_id):
1285
1573
  "subagent_meta": subagent_meta, "header_cost": header_cost}
1286
1574
 
1287
1575
 
1288
- def get_conversation(conn, session_id, *, after=None, limit=500):
1576
+ def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
1577
+ limit=500):
1289
1578
  """Reader payload for one session (spec §3.2). Returns None for an unknown
1290
1579
  session. Dedups logical messages by (session_id, uuid) (canonical = earliest
1291
1580
  timestamp), groups assistant fragments into turn items by (msg_id, req_id),
1292
1581
  joins cost once, anchors a turn on its prose-bearing fragment, and exposes
1293
1582
  every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
1294
- id); ~500 items/page."""
1583
+ id); ~500 items/page.
1584
+
1585
+ Three mutually-exclusive cursor modes over the fully-assembled ascending
1586
+ `items` list (#217 S2 / U4): `after=X` pages forward (existing), `before=X`
1587
+ pages backward (returns the `limit` items immediately before X), and
1588
+ `tail=1` opens at the bottom (returns the last page in one request). More
1589
+ than one supplied raises ValueError (the handler maps it to 400). The
1590
+ boundary keys are mode-uniform (`has_more = end < N`, `has_prev = start > 0`)
1591
+ so a short `before` head-page still reports `has_more` (P2-8); the existing
1592
+ `after`/no-cursor responses stay byte-stable except the two additive
1593
+ `prev_before`/`has_prev` keys (for them `start + limit < N == end < N`, under
1594
+ `end = min(start+limit, N)`)."""
1595
+ if sum(1 for x in (after is not None, before is not None, bool(tail)) if x) > 1:
1596
+ raise ValueError("after/before/tail are mutually exclusive")
1295
1597
  limit = max(1, min(int(limit), 1000))
1296
1598
  asm = _assemble_session(conn, session_id)
1297
1599
  if asm is None:
@@ -1322,34 +1624,60 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1322
1624
  _la = items[-1]["anchor"]
1323
1625
  last_anchor = {"session_id": session_id, "uuid": _la["uuid"], "id": _la["id"]}
1324
1626
 
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
1627
+ # Cursor pagination over the item list (anchored to each item's canonical
1628
+ # id). Resolve `start`/`end` per mode, then `page = items[start:end]` and
1629
+ # compute the boundary keys UNIFORMLY (#217 S2 / U4 P2-8). A non-None
1630
+ # `after`/`before` that matches no item's anchor (stale/deleted cursor)
1631
+ # yields an EMPTY page — never silently re-serves the head/tail (M1).
1632
+ N = len(items)
1633
+
1634
+ def _idx(cur):
1331
1635
  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)
1636
+ if str(it["anchor"]["id"]) == str(cur):
1637
+ return k
1638
+ return None
1639
+
1640
+ def _stale_empty_page():
1641
+ return {
1642
+ "session_id": session_id,
1643
+ "title": _title,
1644
+ "project_label": _pl,
1645
+ "git_branch": _latest(logical, 11),
1646
+ "started_utc": logical[0][2],
1647
+ "last_activity_utc": logical[-1][2],
1648
+ "cost_usd": header_cost,
1649
+ "models": sorted({r[6] for r in logical if r[6]}),
1650
+ "last_anchor": last_anchor,
1651
+ "items": [],
1652
+ "subagent_meta": subagent_meta,
1653
+ "page": {"next_after": None, "has_more": False,
1654
+ "prev_before": None, "has_prev": False},
1655
+ }
1656
+
1657
+ if tail:
1658
+ end = N
1659
+ start = max(0, N - limit)
1660
+ elif before is not None:
1661
+ b = _idx(before)
1662
+ if b is None: # stale cursor → empty page (M1)
1663
+ return _stale_empty_page()
1664
+ end = b
1665
+ start = max(0, end - limit)
1666
+ elif after is not None:
1667
+ a = _idx(after)
1668
+ if a is None: # stale cursor → empty page (M1)
1669
+ return _stale_empty_page()
1670
+ start = a + 1
1671
+ end = min(start + limit, N)
1672
+ else:
1673
+ start = 0
1674
+ end = min(limit, N)
1675
+
1676
+ page = items[start:end]
1677
+ has_more = end < N
1678
+ has_prev = start > 0
1352
1679
  next_after = page[-1]["anchor"]["id"] if (page and has_more) else None
1680
+ prev_before = page[0]["anchor"]["id"] if (page and has_prev) else None
1353
1681
 
1354
1682
  # Stamp the session_id into each anchor (spec anchor is (session_id, uuid);
1355
1683
  # the dict literals are built session-agnostic, so fill it here where the
@@ -1381,7 +1709,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1381
1709
  "last_anchor": last_anchor,
1382
1710
  "items": page,
1383
1711
  "subagent_meta": subagent_meta,
1384
- "page": {"next_after": next_after, "has_more": has_more},
1712
+ "page": {"next_after": next_after, "has_more": has_more,
1713
+ "prev_before": prev_before, "has_prev": has_prev},
1385
1714
  }
1386
1715
 
1387
1716
 
@@ -1432,6 +1761,23 @@ def get_conversation_outline(conn, session_id):
1432
1761
  cf_wasted = 0.0
1433
1762
  cf_rebuilds = [] # per-rebuild list (worst-first), spec §1
1434
1763
  cache_saved = 0.0 # session cache-value-saved, spec §1
1764
+ # #217 S3 E6(a) — per-subagent cost, summed COST-ONCE over the assembled
1765
+ # items (each item already carries the cost-once-per-turn `cost_usd`, so a
1766
+ # plain per-subagent_key sum never double-counts across folded fragments —
1767
+ # the same discipline `cache_saved` above uses). DISPLAY-ONLY (never a
1768
+ # reconciled / budget / snapshot figure, like `cache_saved_usd`). Emitted as
1769
+ # a SEPARATE top-level `subagent_costs` map — NOT inside `subagent_meta` —
1770
+ # so the outline↔reader `subagent_meta` parity assert stays byte-for-byte
1771
+ # unchanged AND every bucket is covered, including a subagent whose
1772
+ # `subagent_meta` is absent (the s7 case: deriveOutline emits a row for it,
1773
+ # so its cost must be present to render). Every non-null subagent_key bucket
1774
+ # seeds at 0.0 so a zero-cost bucket still appears.
1775
+ subagent_costs = {} # subagent_key -> cost (cost-once)
1776
+ for it in items:
1777
+ sk = it.get("subagent_key")
1778
+ if sk is not None:
1779
+ subagent_costs.setdefault(sk, 0.0)
1780
+ subagent_costs[sk] += it.get("cost_usd") or 0.0
1435
1781
  for it in items:
1436
1782
  kind = it["kind"]
1437
1783
  turn_counts["total"] += 1
@@ -1519,6 +1865,9 @@ def get_conversation_outline(conn, session_id):
1519
1865
  stats["cache_saved_usd"] = cache_saved
1520
1866
  return {"session_id": session_id,
1521
1867
  "subagent_meta": asm["subagent_meta"],
1868
+ # #217 S3 E6(a) — display-only per-subagent cost (cost-once). 6dp,
1869
+ # matching the per-item / header cost display precision.
1870
+ "subagent_costs": {k: round(v, 6) for k, v in subagent_costs.items()},
1522
1871
  "stats": stats,
1523
1872
  "turns": turns}
1524
1873
 
@@ -1758,7 +2107,9 @@ def _search_depth(conn) -> str:
1758
2107
 
1759
2108
 
1760
2109
  def search_conversations(conn, query, *, limit=50, offset=0,
1761
- kind="all", fts_available=None) -> dict:
2110
+ kind="all", fts_available=None,
2111
+ date_from=None, date_to=None, projects=None,
2112
+ cost_min=None, cost_max=None, rebuild_min=None) -> dict:
1762
2113
  """Cross-session search (spec §3.3). Uses FTS5 when available (bm25 rank +
1763
2114
  snippet); else a LIKE scan with a manual snippet. Hits deduped by
1764
2115
  (session_id, uuid); each carries the turn's cost. `fts_available` overrides
@@ -1767,11 +2118,27 @@ def search_conversations(conn, query, *, limit=50, offset=0,
1767
2118
  #177 S6: ``kind`` (one of ``_SEARCH_KINDS``) scopes the search to a column
1768
2119
  family — ``all`` is unfiltered, ``prompts``/``assistant`` filter the prose
1769
2120
  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)."""
2121
+ #217 S2 / E7: ``title`` searches the external-content title FTS over
2122
+ conversation_ai_titles one SESSION-level hit per matching session, anchored
2123
+ to that session's first turn, ``match_kinds:["title"]``, snippet = the matched
2124
+ title. #217 S2 / I-3: ``files`` searches ``conversation_file_touches`` (the
2125
+ write-class file-touch axis) one hit per DISTINCT ``(session_id, file_path)``,
2126
+ anchored to that group's MOST-RECENT touch, ``match_kinds:["file"]``, snippet =
2127
+ the file path. Every hit gains ``match_kinds`` (sorted badges; prose never badges).
2128
+ The response carries additive ``kind`` + ``search_depth`` so the client can
2129
+ degrade the Tools/Thinking facets during the one-time column split
2130
+ (``search_depth == 'prose-only'`` short-circuits those two kinds to empty).
2131
+ An unknown ``kind`` raises ``ValueError`` (route → 400).
2132
+
2133
+ #217 S2 / Filtered-search: the browse filters (``date_from``/``date_to``/
2134
+ ``projects``/``cost_min``/``cost_max``/``rebuild_min``) restrict the search to
2135
+ matching sessions, applied uniformly across EVERY kind as a session-scope
2136
+ ``session_id IN (...)`` predicate (``_resolve_search_session_filter`` reuses
2137
+ ``_rollup_where`` verbatim). When the rollup backfill is pending and a
2138
+ rollup-only axis is requested, only the date axis applies (via a live
2139
+ ``MAX(timestamp_utc)`` prefilter, P1-5) and the response carries additive
2140
+ ``filter_degraded: True`` — exactly mirroring browse. A no-filter request
2141
+ produces byte-stable existing output (no ``filter_degraded`` key)."""
1775
2142
  if kind not in _SEARCH_KINDS:
1776
2143
  raise ValueError(f"unknown kind: {kind}")
1777
2144
  q = (query or "").strip()
@@ -1783,20 +2150,60 @@ def search_conversations(conn, query, *, limit=50, offset=0,
1783
2150
  mode = "fts" if fts_available else "like"
1784
2151
  base = {"query": q, "mode": mode, "hits": [], "total": 0,
1785
2152
  "kind": kind, "search_depth": depth}
2153
+ # Resolve the filtered-search session-scope restriction ONCE (reused by every
2154
+ # kind). ``filt_sql`` is "" when no axis is set, so the no-filter path stays
2155
+ # byte-stable; ``degraded`` flags the dropped rollup-only axes (P1-5).
2156
+ filters = {
2157
+ "date_from": date_from, "date_to": date_to,
2158
+ "projects": list(projects) if projects else None,
2159
+ "cost_min": cost_min, "cost_max": cost_max, "rebuild_min": rebuild_min,
2160
+ }
2161
+ filt_sql, filt_params, degraded = _resolve_search_session_filter(conn, filters)
2162
+ if degraded:
2163
+ base["filter_degraded"] = True
2164
+
2165
+ def _finish(out):
2166
+ # Stamp the additive degraded flag onto whatever the search path returns,
2167
+ # without disturbing the no-filter byte-stable shape.
2168
+ if degraded:
2169
+ out["filter_degraded"] = True
2170
+ return out
2171
+
1786
2172
  # Prose-only interim: the split columns are not yet indexed, so tools /
1787
2173
  # thinking can't match — short-circuit them to empty (spec §1 interim).
1788
2174
  if not q or (depth == "prose-only" and kind in ("tools", "thinking")):
1789
2175
  return base
2176
+ if kind == "title":
2177
+ if fts_available:
2178
+ try:
2179
+ out = _search_title(conn, q, limit, offset, True,
2180
+ filt_sql, filt_params)
2181
+ out.update(kind="title", search_depth=depth)
2182
+ return _finish(out)
2183
+ except sqlite3.OperationalError:
2184
+ pass # title FTS missing/corrupt at query time (the flag is
2185
+ # derived from fts5_unavailable, not a per-table probe) →
2186
+ # fall through to the title LIKE scan, mirroring the
2187
+ # message-FTS path's resilience
2188
+ out = _search_title(conn, q, limit, offset, False,
2189
+ filt_sql, filt_params)
2190
+ out.update(kind="title", search_depth=depth)
2191
+ return _finish(out)
2192
+ if kind == "files":
2193
+ out = _search_files(conn, q, limit, offset, filt_sql, filt_params)
2194
+ out.update(kind="files", search_depth=depth)
2195
+ return _finish(out)
1790
2196
  if fts_available:
1791
2197
  try:
1792
- out = _search_fts(conn, q, limit, offset, kind, depth)
2198
+ out = _search_fts(conn, q, limit, offset, kind, depth,
2199
+ filt_sql, filt_params)
1793
2200
  out.update(kind=kind, search_depth=depth)
1794
- return out
2201
+ return _finish(out)
1795
2202
  except sqlite3.OperationalError:
1796
2203
  pass # corrupt/missing FTS at query time → fall through to LIKE
1797
- out = _search_like(conn, q, limit, offset, kind, depth)
2204
+ out = _search_like(conn, q, limit, offset, kind, depth, filt_sql, filt_params)
1798
2205
  out.update(kind=kind, mode="like", search_depth=depth)
1799
- return out
2206
+ return _finish(out)
1800
2207
 
1801
2208
 
1802
2209
  def _row_to_hit(uuid_, sid, ts, cwd, snippet, msg_id, req_id, match_kinds=None):
@@ -1841,13 +2248,40 @@ def _attach_titles(conn, page):
1841
2248
  return page
1842
2249
 
1843
2250
 
1844
- def _like_pattern(q):
1845
- """Build the LIKE pattern for `q`. Escape the ESCAPE char (\\) FIRST, then
2251
+ def _session_filter_pred(col, filt_sql):
2252
+ """The session-scope filter fragment shared by the four ``_search_*``
2253
+ subroutines (#219 S2.2): ``" AND <col> IN (<filt_sql>)"`` when a browse filter
2254
+ is active, else ``""`` (the no-filter fast path). ``col`` is the caller's
2255
+ table-qualified session_id column (``cm.session_id`` / ``t.session_id`` /
2256
+ ``ft.session_id`` / bare ``session_id``)."""
2257
+ return f" AND {col} IN ({filt_sql})" if filt_sql else ""
2258
+
2259
+
2260
+ def _attach_search_meta(conn, hits):
2261
+ """The shared search-hit enrichment tail (#219 S2.2): per-turn cost then
2262
+ per-session title/label. Every ``_search_*`` subroutine returns
2263
+ ``_attach_titles(conn, _attach_costs(conn, hits))`` — centralized here so the
2264
+ two-step order has one definition."""
2265
+ return _attach_titles(conn, _attach_costs(conn, hits))
2266
+
2267
+
2268
+ def _like_escape(q):
2269
+ """Escape the LIKE wildcards/escape-char in ``q`` (no surrounding ``%``),
2270
+ paired with ``ESCAPE '\\'``. The ESCAPE char (``\\``) is escaped FIRST, then
1846
2271
  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
- + "%")
2272
+ one) mis-escapes any appended ``%`` and the LIKE silently matches nothing.
2273
+
2274
+ Single source of the escape chain (#217 S2 / I-3 review Minor #3):
2275
+ ``_like_pattern`` (substring) and ``_search_files``'s prefix pattern both build
2276
+ on this so the escaping is defined exactly once."""
2277
+ return q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
2278
+
2279
+
2280
+ def _like_pattern(q):
2281
+ """Build the SUBSTRING LIKE pattern for `q` — ``%`` + escaped(q) + ``%`` —
2282
+ paired with ``ESCAPE '\\'`` in the queries below. The escape chain is
2283
+ single-sourced in ``_like_escape``."""
2284
+ return "%" + _like_escape(q) + "%"
1851
2285
 
1852
2286
 
1853
2287
  def _fts_snippets(conn, fts_q, ids, col=0):
@@ -1915,7 +2349,8 @@ def _match_kinds(conn, fts_q, rids_by_group):
1915
2349
  for grp, rids in rids_by_group.items()}
1916
2350
 
1917
2351
 
1918
- def _search_fts(conn, q, limit, offset, kind, depth):
2352
+ def _search_fts(conn, q, limit, offset, kind, depth,
2353
+ filt_sql="", filt_params=()):
1919
2354
  # All of dedup + paging + total live in SQL (#149) so Python never holds
1920
2355
  # more than one page of hits/snippets, regardless of corpus match count.
1921
2356
  #
@@ -1932,6 +2367,11 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1932
2367
  entry_type = _KIND_ENTRY_TYPE.get(kind)
1933
2368
  et_pred = " AND cm.entry_type = ?" if entry_type is not None else ""
1934
2369
  et_args = (entry_type,) if entry_type is not None else ()
2370
+ # #217 S2 / Filtered-search: session-scope restriction (empty when no filter,
2371
+ # so the no-filter path stays byte-stable). cm.session_id is the FTS join's
2372
+ # message column.
2373
+ fpred = _session_filter_pred("cm.session_id", filt_sql)
2374
+ fargs = tuple(filt_params)
1935
2375
  # Exact post-dedup logical total — counted in C with no snippet generation
1936
2376
  # and no Python row materialization.
1937
2377
  total = conn.execute(
@@ -1939,8 +2379,8 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1939
2379
  " SELECT DISTINCT cm.session_id, cm.uuid "
1940
2380
  " FROM conversation_fts "
1941
2381
  " JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1942
- f" WHERE conversation_fts MATCH ?{et_pred})",
1943
- (match_expr, *et_args),
2382
+ f" WHERE conversation_fts MATCH ?{et_pred}{fpred})",
2383
+ (match_expr, *et_args, *fargs),
1944
2384
  ).fetchone()[0]
1945
2385
  # One row per logical (session_id, uuid): ROW_NUMBER()=1 keeps the SAME row
1946
2386
  # the old Python dedup kept as its FIRST occurrence (order: bm25, ts DESC,
@@ -1964,7 +2404,7 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1964
2404
  f" {bm25_expr} AS rank "
1965
2405
  " FROM conversation_fts "
1966
2406
  " JOIN conversation_messages cm ON cm.id = conversation_fts.rowid "
1967
- f" WHERE conversation_fts MATCH ?{et_pred}), "
2407
+ f" WHERE conversation_fts MATCH ?{et_pred}{fpred}), "
1968
2408
  "ranked AS ("
1969
2409
  " SELECT *, ROW_NUMBER() OVER ("
1970
2410
  " PARTITION BY sid, uuid ORDER BY rank, ts DESC, rid DESC"
@@ -1972,14 +2412,14 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1972
2412
  " FROM matched) "
1973
2413
  "SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
1974
2414
  "ORDER BY rank, ts DESC, rid DESC LIMIT ? OFFSET ?",
1975
- (match_expr, *et_args, limit, offset),
2415
+ (match_expr, *et_args, *fargs, limit, offset),
1976
2416
  ).fetchall()
1977
2417
  page_groups = {(sid, uuid): rid for (rid, sid, uuid, ts, cwd, mid, rqd) in page}
1978
2418
  if legacy:
1979
2419
  badges = {grp: [] for grp in page_groups}
1980
2420
  else:
1981
2421
  rids_by_group = _all_matched_rids_by_group(
1982
- conn, match_expr, et_pred, et_args, list(page_groups))
2422
+ conn, et_pred, et_args, list(page_groups))
1983
2423
  badges = _match_kinds(conn, fts_q, rids_by_group)
1984
2424
  snips = _fts_snippets(conn, match_expr, [r[0] for r in page], col=0)
1985
2425
  # For hits badged tool/thinking but with no prose match, draw the snippet
@@ -1990,48 +2430,86 @@ def _search_fts(conn, q, limit, offset, kind, depth):
1990
2430
  match_kinds=badges.get((sid, uuid), []))
1991
2431
  for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
1992
2432
  return {"query": q, "mode": "fts",
1993
- "hits": _attach_titles(conn, _attach_costs(conn, hits)),
2433
+ "hits": _attach_search_meta(conn, hits),
1994
2434
  "total": total}
1995
2435
 
1996
2436
 
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."""
2437
+ def _all_matched_rids_by_group(conn, et_pred, et_args, groups):
2438
+ """{(sid, uuid) -> [rids]} for the page groups: ALL physical rows of each
2439
+ page group (a SUPERSET of the FTS-matched subset), so badges aggregate
2440
+ completely. ``_match_kinds`` re-runs a per-column MATCH restricted to these
2441
+ rids, so returning every physical row of the ≤200 page groups stays correct
2442
+ for the column facets (the per-column MATCH filters).
2443
+
2444
+ U3 (#217 S1): a BOUNDED base-table lookup of the page groups' rows — NOT a
2445
+ third full-corpus ``conversation_fts MATCH``. We stop scanning the corpus a
2446
+ third time per request. The term expression is carried by the per-column
2447
+ MATCH in ``_match_kinds``, so this lookup needs no match expression — the
2448
+ formerly-vestigial ``match_expr`` param was dropped (#218 I-A.2).
2449
+
2450
+ Facet-scope fix (Codex P2): carry the SAME ``et_pred``/``et_args`` the page
2451
+ query used (``kind=prompts``/``assistant`` filter ``cm.entry_type``), so a
2452
+ same-group physical row OUTSIDE the facet can never contribute a badge. The
2453
+ ``(session_id, uuid)`` match is NULL-safe (``IS``), since a pre-006 row may
2454
+ carry a NULL uuid that a plain ``IN`` row-value would silently drop."""
2000
2455
  if not groups:
2001
2456
  return {}
2002
2457
  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)
2458
+ # Chunk the OR-of-pairs to stay well under SQLite's variable limit (≤200
2459
+ # page groups, 2 params each → one or two chunks in practice).
2460
+ for i in range(0, len(groups), 300):
2461
+ chunk = groups[i:i + 300]
2462
+ cond = " OR ".join(
2463
+ "(cm.session_id IS ? AND cm.uuid IS ?)" for _ in chunk)
2464
+ params = [v for g in chunk for v in g]
2465
+ rows = conn.execute(
2466
+ "SELECT cm.id, cm.session_id, cm.uuid FROM conversation_messages cm "
2467
+ f"WHERE ({cond}){et_pred}",
2468
+ (*params, *et_args),
2469
+ ).fetchall()
2470
+ for rid, sid, uuid in rows:
2471
+ g = (sid, uuid)
2472
+ if g in rids_by_group:
2473
+ rids_by_group[g].append(rid)
2013
2474
  return rids_by_group
2014
2475
 
2015
2476
 
2016
2477
  def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
2017
2478
  """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."""
2479
+ prose column did NOT match (prose → tool → thinking preference). Probes which
2480
+ of the badged survivors match prose, then re-snippets the non-prose ones from
2481
+ their preferred column.
2482
+
2483
+ U3 (#217 S1): the per-hit ``rowid = ?`` prose probe (one query per badged
2484
+ page hit, up to ~200 at limit=200) is collapsed into ONE bounded
2485
+ ``rowid IN (…) AND MATCH text:(…)`` returning the prose-matching set;
2486
+ subtracting gives the non-prose survivors routed to the already-batched
2487
+ tool/thinking snippet calls."""
2488
+ # The badged survivors are the only candidates (a hit with no badges keeps
2489
+ # its col-0 prose snippet). Collect their survivor rowids.
2490
+ badged = [(rid, sid, uuid)
2491
+ for (rid, sid, uuid, ts, cwd, mid, rqd) in page
2492
+ if badges.get((sid, uuid))]
2493
+ if not badged:
2494
+ return snips
2495
+ badged_rids = [rid for (rid, _s, _u) in badged]
2496
+ # ONE bounded probe: which badged survivors ALSO match prose? Keep their
2497
+ # col-0 snippet; the rest fall through to their preferred column.
2498
+ prose_hits = set()
2499
+ for i in range(0, len(badged_rids), 300):
2500
+ chunk = badged_rids[i:i + 300]
2501
+ ph = ",".join("?" for _ in chunk)
2502
+ rows = conn.execute(
2503
+ "SELECT conversation_fts.rowid FROM conversation_fts "
2504
+ f"WHERE conversation_fts MATCH ? AND conversation_fts.rowid IN ({ph})",
2505
+ (f"{{text}}: ({fts_q})", *chunk)).fetchall()
2506
+ prose_hits.update(r[0] for r in rows)
2020
2507
  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
2508
+ for (rid, sid, uuid) in badged:
2509
+ if rid in prose_hits:
2510
+ continue # prose matched the survivor → keep col 0
2033
2511
  for label, col in _SNIPPET_COL_PREFERENCE:
2034
- if label in kinds:
2512
+ if label in badges.get((sid, uuid), []):
2035
2513
  by_col.setdefault(col, []).append(rid)
2036
2514
  break
2037
2515
  for col, rids in by_col.items():
@@ -2042,7 +2520,8 @@ def _prefer_snippet_columns(conn, fts_q, page, page_groups, badges, snips):
2042
2520
  return snips
2043
2521
 
2044
2522
 
2045
- def _search_like(conn, q, limit, offset, kind, depth):
2523
+ def _search_like(conn, q, limit, offset, kind, depth,
2524
+ filt_sql="", filt_params=()):
2046
2525
  # SQL-bounded mirror of _search_fts for the no-FTS5 fallback (#149); the
2047
2526
  # COUNT + page each scan the table once (the degraded path already lacks an
2048
2527
  # index for the substring match). #177 S6: kind → column list (single-
@@ -2062,11 +2541,14 @@ def _search_like(conn, q, limit, offset, kind, depth):
2062
2541
  entry_type = _KIND_ENTRY_TYPE.get(kind)
2063
2542
  et_pred = " AND entry_type = ?" if entry_type is not None else ""
2064
2543
  et_args = (entry_type,) if entry_type is not None else ()
2544
+ # #217 S2 / Filtered-search: session-scope restriction (empty when no filter).
2545
+ fpred = _session_filter_pred("session_id", filt_sql)
2546
+ fargs = tuple(filt_params)
2065
2547
  total = conn.execute(
2066
2548
  "SELECT COUNT(*) FROM ("
2067
2549
  " SELECT DISTINCT session_id, uuid FROM conversation_messages "
2068
- f" WHERE {col_pred}{et_pred})",
2069
- (*like_args, *et_args),
2550
+ f" WHERE {col_pred}{et_pred}{fpred})",
2551
+ (*like_args, *et_args, *fargs),
2070
2552
  ).fetchone()[0]
2071
2553
  page = conn.execute(
2072
2554
  "WITH ranked AS ("
@@ -2077,10 +2559,10 @@ def _search_like(conn, q, limit, offset, kind, depth):
2077
2559
  " ORDER BY timestamp_utc DESC, id DESC"
2078
2560
  " ) AS rn "
2079
2561
  " FROM conversation_messages "
2080
- f" WHERE {col_pred}{et_pred}) "
2562
+ f" WHERE {col_pred}{et_pred}{fpred}) "
2081
2563
  "SELECT rid, sid, uuid, ts, cwd, mid, rqd FROM ranked WHERE rn = 1 "
2082
2564
  "ORDER BY ts DESC, rid DESC LIMIT ? OFFSET ?",
2083
- (*like_args, *et_args, limit, offset),
2565
+ (*like_args, *et_args, *fargs, limit, offset),
2084
2566
  ).fetchall()
2085
2567
  texts = _texts_for_ids(conn, [r[0] for r in page])
2086
2568
  if legacy:
@@ -2093,7 +2575,171 @@ def _search_like(conn, q, limit, offset, kind, depth):
2093
2575
  match_kinds=badges.get((sid, uuid), []))
2094
2576
  for (rid, sid, uuid, ts, cwd, mid, rqd) in page]
2095
2577
  return {"query": q, "mode": "like",
2096
- "hits": _attach_titles(conn, _attach_costs(conn, hits)),
2578
+ "hits": _attach_search_meta(conn, hits),
2579
+ "total": total}
2580
+
2581
+
2582
+ def _search_title(conn, q, limit, offset, fts_available, filt_sql="", filt_params=()):
2583
+ """#217 S2 / E7: ``kind=title`` cross-session search over the per-session AI
2584
+ title (conversation_ai_titles). Emits ONE SESSION-level hit per matching
2585
+ session, anchored to that session's FIRST turn (the earliest
2586
+ ``(timestamp_utc, id)`` conversation_messages row), ``match_kinds:["title"]``,
2587
+ snippet = the matched title.
2588
+
2589
+ FTS5 available: ``MATCH ai_title`` over the external-content
2590
+ ``conversation_title_fts``, mapping each hit rowid →
2591
+ ``conversation_ai_titles.session_id``; the snippet comes from FTS ``snippet()``.
2592
+ Else: a LIKE scan over ``conversation_ai_titles`` with the whole title as the
2593
+ snippet (degraded; no marked span).
2594
+
2595
+ P2-9 (load-bearing): a session's title counts toward ``total`` and the page
2596
+ ONLY when it has an anchorable first turn. Both the COUNT and the page JOIN
2597
+ the SAME first-message anchor (``INNER JOIN`` against the per-session
2598
+ first-turn CTE), so a title row whose session has no surviving message rows is
2599
+ excluded from both — no lying count.
2600
+
2601
+ Filtered-search: the ``filt_sql`` session-scope restriction (over
2602
+ conversation_ai_titles.session_id) applies the same as the message kinds.
2603
+ """
2604
+ fpred = _session_filter_pred("t.session_id", filt_sql)
2605
+ fargs = tuple(filt_params)
2606
+ # First-turn anchor per session: earliest (timestamp_utc, id) row. Rides
2607
+ # idx_conv_session_ts. One row per session_id.
2608
+ anchor_cte = (
2609
+ "anchor AS ("
2610
+ " SELECT session_id, aid, auuid, ats, acwd, amid, arqd FROM ("
2611
+ " SELECT session_id, id AS aid, uuid AS auuid, timestamp_utc AS ats, "
2612
+ " cwd AS acwd, msg_id AS amid, req_id AS arqd, "
2613
+ " ROW_NUMBER() OVER (PARTITION BY session_id "
2614
+ " ORDER BY timestamp_utc, id) AS rn "
2615
+ " FROM conversation_messages WHERE session_id IS NOT NULL"
2616
+ " ) WHERE rn = 1)")
2617
+
2618
+ if fts_available:
2619
+ # matched titles: rowid → session_id + snippet, INNER-joined to the
2620
+ # anchor so only anchorable sessions survive (P2-9).
2621
+ fts_q = _fts_query(q, prefix_last=True)
2622
+ total = conn.execute(
2623
+ "WITH " + anchor_cte + " "
2624
+ "SELECT COUNT(*) FROM conversation_title_fts f "
2625
+ "JOIN conversation_ai_titles t ON t.rowid = f.rowid "
2626
+ "JOIN anchor a ON a.session_id = t.session_id "
2627
+ f"WHERE f.conversation_title_fts MATCH ?{fpred}",
2628
+ (fts_q, *fargs)).fetchone()[0]
2629
+ rows = conn.execute(
2630
+ "WITH " + anchor_cte + " "
2631
+ "SELECT t.session_id, a.auuid, a.ats, a.acwd, a.amid, a.arqd, "
2632
+ " snippet(conversation_title_fts, 0, '[', ']', ' … ', 12) AS snip "
2633
+ "FROM conversation_title_fts f "
2634
+ "JOIN conversation_ai_titles t ON t.rowid = f.rowid "
2635
+ "JOIN anchor a ON a.session_id = t.session_id "
2636
+ f"WHERE f.conversation_title_fts MATCH ?{fpred} "
2637
+ "ORDER BY a.ats DESC, t.session_id DESC LIMIT ? OFFSET ?",
2638
+ (fts_q, *fargs, limit, offset)).fetchall()
2639
+ mode = "fts"
2640
+ else:
2641
+ like = _like_pattern(q)
2642
+ total = conn.execute(
2643
+ "WITH " + anchor_cte + " "
2644
+ "SELECT COUNT(*) FROM conversation_ai_titles t "
2645
+ "JOIN anchor a ON a.session_id = t.session_id "
2646
+ f"WHERE t.ai_title LIKE ? ESCAPE '\\'{fpred}",
2647
+ (like, *fargs)).fetchone()[0]
2648
+ rows = conn.execute(
2649
+ "WITH " + anchor_cte + " "
2650
+ "SELECT t.session_id, a.auuid, a.ats, a.acwd, a.amid, a.arqd, "
2651
+ " t.ai_title AS snip "
2652
+ "FROM conversation_ai_titles t "
2653
+ "JOIN anchor a ON a.session_id = t.session_id "
2654
+ f"WHERE t.ai_title LIKE ? ESCAPE '\\'{fpred} "
2655
+ "ORDER BY a.ats DESC, t.session_id DESC LIMIT ? OFFSET ?",
2656
+ (like, *fargs, limit, offset)).fetchall()
2657
+ mode = "like"
2658
+
2659
+ hits = [_row_to_hit(auuid, sid, ats, acwd, snip, amid, arqd,
2660
+ match_kinds=["title"])
2661
+ for (sid, auuid, ats, acwd, amid, arqd, snip) in rows]
2662
+ return {"query": q, "mode": mode,
2663
+ "hits": _attach_search_meta(conn, hits),
2664
+ "total": total}
2665
+
2666
+
2667
+ # #217 S2 / I-3: a path query "looks like a prefix" when it carries no leading
2668
+ # path separator — ``bin/cctally`` is a prefix probe, ``/dashboard`` (or a query
2669
+ # that intentionally starts mid-path) forces the substring scan. The set is the
2670
+ # common path separators (POSIX + Windows).
2671
+ _PATH_SEPARATORS = ("/", "\\")
2672
+
2673
+
2674
+ def _search_files(conn, q, limit, offset, filt_sql="", filt_params=()):
2675
+ """#217 S2 / I-3: ``kind=files`` cross-session search over the write-class
2676
+ file-touch axis (``conversation_file_touches``). Emits one hit per DISTINCT
2677
+ ``(session_id, file_path)``, anchored to that group's MOST-RECENT touch
2678
+ (``MAX(message_id)`` → that message's ``(uuid, ts, cwd, msg_id, req_id)``),
2679
+ ``match_kinds:["file"]``, snippet = the file path. ``total`` = the distinct
2680
+ ``(session_id, file_path)`` count.
2681
+
2682
+ P3-10 (match shape): a PREFIX-looking query (no leading path separator) binds a
2683
+ literal-prefix pattern (escaped(q) + ``%``) into ``file_path LIKE ? ESCAPE '\\'``
2684
+ and is GENUINELY index-assisted — it rides ``idx_file_touches_path`` as a SEARCH
2685
+ over a key range (verified by EXPLAIN QUERY PLAN in
2686
+ ``test_kind_files_prefix_branch_uses_index_substring_scans``). That assistance
2687
+ depends on ``idx_file_touches_path`` being ``COLLATE NOCASE``: the default
2688
+ ``LIKE`` is case-insensitive, and SQLite only optimizes ``LIKE 'prefix%'`` onto
2689
+ a btree when the index folds the same way (a BINARY index leaves it a full
2690
+ SCAN). The ``ESCAPE '\\'`` clause does NOT defeat the optimization — the literal
2691
+ prefix is still constant. A query with a leading separator binds ``%`` +
2692
+ escaped(q) + ``%`` and is a full SCAN (a leading wildcard can never use the
2693
+ index — the intentional, documented substring fallback over the modest table).
2694
+ LIKE is case-insensitive for ASCII (matching the NOCASE collation exactly).
2695
+
2696
+ Filtered-search: the ``filt_sql`` session-scope restriction is a post-match
2697
+ ``ft.session_id IN (<subquery>)`` (the same as the message kinds) — there is NO
2698
+ dedicated session index (the filter is a small IN over the already-narrowed
2699
+ match set, so a ``session_id`` btree earned nothing; dropped per review Minor #2).
2700
+ """
2701
+ esc = _like_escape(q)
2702
+ prefix = not q.startswith(_PATH_SEPARATORS)
2703
+ pat = (esc + "%") if prefix else ("%" + esc + "%")
2704
+ fpred = _session_filter_pred("ft.session_id", filt_sql)
2705
+ fargs = tuple(filt_params)
2706
+
2707
+ # The anchor per (session_id, file_path) group is its MAX(message_id). Compute
2708
+ # the groups (filtered + matched), then join to conversation_messages on the
2709
+ # anchor id for the turn's render/cost metadata.
2710
+ #
2711
+ # P2-9 (#219 S2.1): the COUNT INNER-JOINs the SAME anchor the page joins, so a
2712
+ # group whose MAX(message_id) anchor is absent from conversation_messages is
2713
+ # excluded from BOTH (never a lying count where total > page-reachable). Inert
2714
+ # in a quiescent cache (touches are deleted with their message), but it keeps
2715
+ # `total` provably equal to the page-reachable set — matching `_search_title`.
2716
+ total = conn.execute(
2717
+ "WITH g AS ("
2718
+ " SELECT ft.session_id AS sid, ft.file_path AS fp, "
2719
+ " MAX(ft.message_id) AS aid "
2720
+ " FROM conversation_file_touches ft "
2721
+ f" WHERE ft.file_path LIKE ? ESCAPE '\\'{fpred} "
2722
+ " GROUP BY ft.session_id, ft.file_path) "
2723
+ "SELECT COUNT(*) FROM g JOIN conversation_messages cm ON cm.id = g.aid",
2724
+ (pat, *fargs)).fetchone()[0]
2725
+ rows = conn.execute(
2726
+ "WITH g AS ("
2727
+ " SELECT ft.session_id AS sid, ft.file_path AS fp, "
2728
+ " MAX(ft.message_id) AS aid "
2729
+ " FROM conversation_file_touches ft "
2730
+ f" WHERE ft.file_path LIKE ? ESCAPE '\\'{fpred} "
2731
+ " GROUP BY ft.session_id, ft.file_path) "
2732
+ "SELECT g.sid, g.fp, cm.uuid, cm.timestamp_utc, cm.cwd, "
2733
+ " cm.msg_id, cm.req_id "
2734
+ "FROM g JOIN conversation_messages cm ON cm.id = g.aid "
2735
+ "ORDER BY cm.timestamp_utc DESC, g.sid DESC, g.fp DESC "
2736
+ "LIMIT ? OFFSET ?",
2737
+ (pat, *fargs, limit, offset)).fetchall()
2738
+
2739
+ hits = [_row_to_hit(uuid_, sid, ts, cwd, fp, mid, rqd, match_kinds=["file"])
2740
+ for (sid, fp, uuid_, ts, cwd, mid, rqd) in rows]
2741
+ return {"query": q, "mode": "like",
2742
+ "hits": _attach_search_meta(conn, hits),
2097
2743
  "total": total}
2098
2744
 
2099
2745
 
@@ -2118,9 +2764,19 @@ def _like_badges(conn, like, groups):
2118
2764
 
2119
2765
  # #177 S6: kind facets. `all` is the unfiltered MATCH; `prompts`/`assistant`
2120
2766
  # 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")
2767
+ # split index columns. #217 S2 / E7: `title` is a CROSS-SESSION-SEARCH-ONLY kind
2768
+ # (external-content title FTS over conversation_ai_titles, session-level hits).
2769
+ # Validated in search_conversations (an unknown kind raises ValueError → the
2770
+ # route maps it to a 400).
2771
+ #
2772
+ # P1-1 (load-bearing kind-validation SPLIT): `_SEARCH_KINDS` (cross-session
2773
+ # search) carries `title` (and is ready for `files` in I-3); `_FIND_KINDS` (the
2774
+ # in-conversation /find route) does NOT — `find_in_conversation` indexes
2775
+ # `_FIND_KIND_COLUMNS[kind]`, which has no entry for `title`/`files`, so accepting
2776
+ # them there would KeyError → 500. Keeping the two enums distinct makes
2777
+ # `/find?kind=title` a clean 400, never a 500.
2778
+ _SEARCH_KINDS = ("all", "prompts", "assistant", "tools", "thinking", "title", "files")
2779
+ _FIND_KINDS = ("all", "prompts", "assistant", "tools", "thinking")
2124
2780
  _KIND_COLUMN = {"prompts": "text", "assistant": "text",
2125
2781
  "tools": "search_tool", "thinking": "search_thinking"}
2126
2782
  _KIND_ENTRY_TYPE = {"prompts": "human", "assistant": "assistant"}
@@ -2156,6 +2812,14 @@ def _kind_match_expr(kind, fts_q):
2156
2812
 
2157
2813
  _FIND_ANCHOR_CAP = 500
2158
2814
 
2815
+ # #217 S4 / I-1.1: bound the regex/case Python-scan (Codex P2, ReDoS/perf).
2816
+ # An over-long pattern/query is rejected outright (empty result, no scan); each
2817
+ # scanned column value is also clipped before the predicate runs so a single
2818
+ # huge tool result can't pin a catastrophic-backtracking regex. Best-effort —
2819
+ # not a full ReDoS defense, but it bounds the worst case to a fixed budget.
2820
+ _FIND_REGEX_MAX_LEN = 1000
2821
+ _FIND_SCAN_TEXT_CAP = 200_000
2822
+
2159
2823
  # Which physical-row columns the find match probes per kind, and the badge label
2160
2824
  # each non-prose column contributes. ``text`` maps to the synthetic ``prose``
2161
2825
  # label so a prose-only match still anchors a turn but never badges.
@@ -2170,7 +2834,8 @@ _FIND_KIND_COLUMNS = {
2170
2834
 
2171
2835
 
2172
2836
  def find_in_conversation(conn, session_id, query, *, kind="all",
2173
- fts_available=None, cap=_FIND_ANCHOR_CAP):
2837
+ fts_available=None, cap=_FIND_ANCHOR_CAP,
2838
+ regex=False, case=False):
2174
2839
  """Document-ordered rendered-turn anchors for in-conversation find (#177 S6).
2175
2840
 
2176
2841
  Anchor identity is rendered-turn identity (spec F1): the FTS/LIKE match for
@@ -2183,8 +2848,29 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
2183
2848
  rendered-turn anchors PRE-cap; the list caps at ``cap`` with
2184
2849
  ``anchors_truncated``. Returns None for an unknown session; an unknown
2185
2850
  ``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:
2851
+ prose-only depth + tools/thinking kinds → empty (the split index is pending).
2852
+
2853
+ P1-1: validates against ``_FIND_KINDS`` (NOT ``_SEARCH_KINDS``), so the
2854
+ cross-session-only ``title``/``files`` kinds raise ValueError here (the route
2855
+ → 400) rather than reaching ``_FIND_KIND_COLUMNS[kind]`` and KeyError → 500.
2856
+
2857
+ #217 S4 / I-1.1 — ``regex`` / ``case``: when either is set the FTS/LIKE fast
2858
+ path is bypassed for a **physical-row Python scan** of ``conversation_messages``
2859
+ (Codex P0 — *not* the rendered ``_assemble_session`` items, which drop the
2860
+ ``search_tool``/``search_thinking`` corpus). The scan reuses the SAME column
2861
+ derivation the FTS path matches on (``_find_kind_columns(kind, "full")`` +
2862
+ ``_KIND_ENTRY_TYPE``) and produces the SAME ``{uuid -> labels}`` map, then
2863
+ feeds the SAME assembly-to-anchor loop, so anchors / ordering / member_uuids /
2864
+ match_kinds / the cap are byte-identical to the FTS path — only the matcher
2865
+ differs. ``case`` only → case-sensitive substring (``mode == "like"``);
2866
+ ``regex`` → per-row ``re.search`` compiled once (``re.IGNORECASE`` unless
2867
+ ``case``; ``mode == "regex"``). Because the scan reads the full corpus
2868
+ columns, ``search_depth == "full"`` — regex/case is NOT gated by the
2869
+ prose-only split-index window. The scan is bounded by ``_FIND_REGEX_MAX_LEN``
2870
+ (query/pattern length) and ``_FIND_SCAN_TEXT_CAP`` (per-row scanned text).
2871
+ Default ``regex=False, case=False`` leaves every existing response
2872
+ byte-stable (the ``_find_matched_rows`` path is untouched)."""
2873
+ if kind not in _FIND_KINDS:
2188
2874
  raise ValueError(f"unknown kind: {kind}")
2189
2875
  # Cheap existence probe (one indexed SELECT) BEFORE the full assembly, so an
2190
2876
  # empty/prose-only-blocked query opening the find bar pays nothing — yet the
@@ -2198,17 +2884,55 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
2198
2884
  if fts_available is None:
2199
2885
  fts_available = not _fts_flag_unavailable(conn)
2200
2886
  q = (query or "").strip()
2887
+ # #217 S4 / I-1.1: a regex/case request scans the full corpus columns, so it
2888
+ # reports search_depth "full" regardless of the prose-only split-index state,
2889
+ # and is never blocked by it (the scan reads the base table, not the FTS).
2890
+ scan = bool(regex or case)
2891
+ eff_depth = "full" if scan else depth
2201
2892
  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")):
2893
+ "search_depth": eff_depth, "kind": kind,
2894
+ "mode": ("regex" if regex else "like") if scan
2895
+ else ("fts" if fts_available else "like")}
2896
+ if not q:
2205
2897
  return base
2898
+ if scan:
2899
+ # Bound the scan (Codex P2): reject an over-long pattern/query outright
2900
+ # rather than risk a catastrophic-backtracking regex over the corpus.
2901
+ if len(q) > _FIND_REGEX_MAX_LEN:
2902
+ return base
2903
+ mode = "regex" if regex else "like"
2904
+ matched = _find_matched_scan(conn, session_id, q, kind, regex, case)
2905
+ else:
2906
+ if depth == "prose-only" and kind in ("tools", "thinking"):
2907
+ return base
2908
+ # U2 (#217 S1): run the match probe BEFORE assembly so a non-empty query
2909
+ # that matches ZERO rows in this session returns early — skipping the full
2910
+ # `_assemble_session` walk a no-result find used to pay. The two functions
2911
+ # are independent (neither consumes the other's output); a non-empty match
2912
+ # still assembles, since the uuid → member_uuids anchor mapping needs the
2913
+ # items. `mode` is taken from the match probe (it may fall through to LIKE
2914
+ # on an FTS error) so the empty-match base carries the actually-used mode,
2915
+ # not the `fts_available` default — byte-identical to the prior
2916
+ # post-assembly answer.
2917
+ mode, matched = _find_matched_rows(
2918
+ conn, session_id, q, kind, depth, fts_available)
2919
+ if not matched:
2920
+ return {**base, "mode": mode}
2206
2921
  asm = _assemble_session(conn, session_id)
2207
2922
  if asm is None:
2208
2923
  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"}}
2924
+ anchors = _find_anchors_from_matched(asm, matched)
2925
+ total = len(anchors)
2926
+ return {**base, "mode": mode, "total": total,
2927
+ "anchors": anchors[:cap], "anchors_truncated": total > cap}
2928
+
2929
+
2930
+ def _find_anchors_from_matched(asm, matched):
2931
+ """Map the ``{uuid -> set(labels)}`` match map onto document-ordered
2932
+ rendered-turn anchors (shared by the FTS/LIKE path and the regex/case scan,
2933
+ so the two can never diverge on ordering / member folding / match_kinds).
2934
+ ``matched`` labels are in ``{"prose", "tool", "thinking"}``; the synthetic
2935
+ ``prose`` label anchors a turn but never badges."""
2212
2936
  anchors = []
2213
2937
  for it in asm["items"]:
2214
2938
  hit_kinds = set()
@@ -2222,9 +2946,51 @@ def find_in_conversation(conn, session_id, query, *, kind="all",
2222
2946
  anchors.append({
2223
2947
  "uuid": it["anchor"]["uuid"],
2224
2948
  "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}
2949
+ return anchors
2950
+
2951
+
2952
+ def _find_matched_scan(conn, session_id, q, kind, regex, case):
2953
+ """({uuid -> {labels}}) for a regex/case PHYSICAL-ROW scan (Codex P0). Reads
2954
+ the same corpus columns the FTS path matches on (``_find_kind_columns(kind,
2955
+ "full")``) over the base ``conversation_messages`` rows — NOT the rendered
2956
+ ``_assemble_session`` items, which drop the ``search_tool``/``search_thinking``
2957
+ columns. Honors ``_KIND_ENTRY_TYPE`` exactly like ``_find_matched_like``.
2958
+ Each scanned value is clipped to ``_FIND_SCAN_TEXT_CAP`` before the predicate
2959
+ so one huge tool result can't pin the regex engine.
2960
+
2961
+ Precondition: ``regex or case``. ``find_in_conversation`` only enters the
2962
+ physical-row scan when ``scan = bool(regex or case)`` (#217 S4 / I-1.1), so a
2963
+ plain case-insensitive-substring scan is never requested here — the FTS/LIKE
2964
+ path (``_find_matched_rows``) owns that case. The two regex/case predicates
2965
+ below are therefore exhaustive; the assert documents (and enforces) that
2966
+ rather than carrying an unreachable third branch."""
2967
+ assert regex or case, "_find_matched_scan requires regex or case"
2968
+ cols = _find_kind_columns(kind, "full")
2969
+ if not cols:
2970
+ return {}
2971
+ et = _KIND_ENTRY_TYPE.get(kind)
2972
+ et_pred = " AND entry_type = ?" if et is not None else ""
2973
+ et_args = (et,) if et is not None else ()
2974
+ if regex:
2975
+ rx = re.compile(q, 0 if case else re.IGNORECASE)
2976
+ pred = lambda text: rx.search(text) is not None
2977
+ else: # case-sensitive substring (case=True, regex=False)
2978
+ pred = lambda text: q in text
2979
+ out = {}
2980
+ col_list = ", ".join(c for c, _ in cols)
2981
+ rows = conn.execute(
2982
+ f"SELECT uuid, {col_list} FROM conversation_messages "
2983
+ f"WHERE session_id = ?{et_pred}",
2984
+ (session_id, *et_args)).fetchall()
2985
+ for row in rows:
2986
+ u = row[0]
2987
+ for idx, (_col, label) in enumerate(cols):
2988
+ val = row[idx + 1]
2989
+ if not val:
2990
+ continue
2991
+ if pred(val[:_FIND_SCAN_TEXT_CAP]):
2992
+ out.setdefault(u, set()).add(label)
2993
+ return out
2228
2994
 
2229
2995
 
2230
2996
  def _find_matched_rows(conn, session_id, q, kind, depth, fts_available):
@@ -2347,74 +3113,6 @@ def locate_tool_payload(conn, session_id, tool_use_id, which):
2347
3113
  return None
2348
3114
 
2349
3115
 
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
3116
  def read_full_payload(source_path, byte_offset, tool_use_id, which):
2419
3117
  """Re-read the raw JSONL line at ``(source_path, byte_offset)`` and return the
2420
3118
  FULL (un-capped) payload for ``tool_use_id``:
@@ -2471,6 +3169,12 @@ def read_full_payload(source_path, byte_offset, tool_use_id, which):
2471
3169
  if (isinstance(tur, dict) and isinstance(tur.get("stderr"), str)
2472
3170
  and tur.get("stderr")):
2473
3171
  resp["stderr"] = tur["stderr"][:_FULL_PAYLOAD_CEILING] # per-stream bound (see above)
3172
+ # #217 S1 / U5 (data-contract honesty): `truncated` describes ONLY
3173
+ # the `text` stream, but `stderr` is bounded INDEPENDENTLY against
3174
+ # the same ceiling — so the client cannot tell from `truncated`
3175
+ # alone whether stderr was clipped. Emit a dedicated per-stream
3176
+ # flag whenever stderr is present (mirrors `stderr` itself).
3177
+ resp["stderr_truncated"] = len(tur["stderr"]) > _FULL_PAYLOAD_CEILING
2474
3178
  return resp
2475
3179
  return None
2476
3180