cctally 1.90.1 → 1.91.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.
@@ -24,6 +24,8 @@ import re
24
24
  import sqlite3
25
25
 
26
26
  import _lib_codex_conversation as kern
27
+ import _lib_codex_segments as segkern
28
+ from _lib_codex_reasoning_headings import decompose_reasoning_headings
27
29
  from _lib_conversation import _strip_ansi
28
30
  from _lib_conversation_query import _FULL_PAYLOAD_CEILING, _first_nonblank_line
29
31
  from _lib_pricing import _calculate_codex_entry_cost
@@ -167,9 +169,30 @@ def codex_item_key(
167
169
  line_offset, content_digest)`` — no population-relative ordinals, so
168
170
  deleting an earlier duplicate or an out-of-order multi-file append never
169
171
  moves an existing key, and a same-offset content replacement changes it.
172
+
173
+ Segments 1..N of a split turn use a third shape, ``(conversation_key, "seg",
174
+ fingerprint(source_path), line_offset, content_digest)``, computed from the
175
+ segment's anchor row (#463 S1). ``turn_id`` is deliberately NOT an input:
176
+ hashing the row alone is ordinal-free, so the key never changes while that
177
+ row's content stays at the same offset. Segment 0 does NOT take this shape —
178
+ it inherits its turn's ``"response"`` key unchanged, which is why every deep
179
+ link, permalink, bookmark, reading position and outline entry issued before
180
+ segmentation still resolves to the head of its turn by construction, with no
181
+ alias table and no migration.
182
+
183
+ The ``"seg"`` domain separator is what keeps a segment key from colliding
184
+ with the ``"row"`` key of the same anchor row.
170
185
  """
171
186
  if klass == "response":
172
187
  parts = ("turn", conversation_key or "", turn_id or "")
188
+ elif klass == "segment":
189
+ parts = (
190
+ "seg",
191
+ conversation_key or "",
192
+ _source_path_fingerprint(source_path),
193
+ "" if line_offset is None else str(line_offset),
194
+ content_digest or "",
195
+ )
173
196
  else:
174
197
  parts = (
175
198
  "row",
@@ -209,13 +232,24 @@ def codex_block_key(
209
232
  line_offset: int | None,
210
233
  content_digest: str | None,
211
234
  ) -> str:
212
- """Opaque, ordinal-free payload-block anchor over a tool_call row's row-class
213
- identity (§3.4). Same domain-separated hash family as ``codex_item_key``'s row
214
- class — ``(conversation_key, fingerprint(source_path), line_offset,
215
- content_digest)`` — with a DISTINCT domain, so a block key never collides with
216
- an item key. Stable per block, unique per tool_call physical row: a same-offset
217
- content replacement changes it (content_digest moves), an out-of-order append
218
- elsewhere leaves it (no population-relative ordinals)."""
235
+ """Opaque, ordinal-free anchor over a physical row's row-class identity (§3.4).
236
+
237
+ Carried by EVERY row-backed block since #463 S2. Stable identity and
238
+ payload-capability are DIFFERENT properties: a prose block has a key and no
239
+ retained payload, so a consumer must never infer payload availability from
240
+ the presence of a key. Payload readback is unchanged and gains no surface —
241
+ ``_locate_payload_block`` still resolves only tool_call rows and the marker
242
+ and lifecycle event families, and refuses everything else.
243
+
244
+ Same domain-separated hash family as ``codex_item_key``'s row class —
245
+ ``(conversation_key, fingerprint(source_path), line_offset,
246
+ content_digest)`` — with a DISTINCT domain, so a block key never collides
247
+ with an item key. Stable per block, unique per physical row: a same-offset
248
+ content replacement changes it (content_digest moves), an out-of-order
249
+ append elsewhere leaves it (no population-relative ordinals). Segment
250
+ boundaries never affect it, because nothing in the inputs is
251
+ population-relative.
252
+ """
219
253
  parts = (
220
254
  conversation_key or "",
221
255
  _source_path_fingerprint(source_path),
@@ -250,21 +284,145 @@ def _load_conversation_rows(conn: sqlite3.Connection, conversation_key: str) ->
250
284
  ]
251
285
 
252
286
 
253
- def _load_row_payloads(
287
+ # Narrow index columns (#463 S1, spec section 3, Phase A). Everything except
288
+ # ``text`` — and the event payloads are not touched at all. ``detail_json`` is
289
+ # REQUIRED, not optional: all three fold passes inside ``canonical_items`` parse
290
+ # it, and the reasoning title boundary comes from the stored projection, which
291
+ # ``search_thinking`` cannot supply (it is capped at 16,000 characters and stores
292
+ # ``summary + "\n" + body`` for a response item, so a title and a title-plus-body
293
+ # are indistinguishable there).
294
+ #
295
+ # ``length(CAST(detail_json AS BLOB))`` rather than ``length(detail_json)``:
296
+ # SQLite's ``length()`` on TEXT counts CHARACTERS, and the stored JSON is emitted
297
+ # with ``ensure_ascii=False``, so a character count understates a non-ASCII
298
+ # detail and would let a segment exceed its stated ceiling.
299
+ _NARROW_ROW_COLS = (
300
+ "source_path, line_offset, timestamp_utc, turn_id, call_id, kind, "
301
+ "event_type, record_family, model, content_digest, content_len, "
302
+ "detail_json, length(CAST(detail_json AS BLOB))"
303
+ )
304
+
305
+ # Bound on the ``line_offset`` list bound into one hydration query. SQLite's
306
+ # default host-parameter limit is 999 on older builds, and a full page can carry
307
+ # more positions than that, so the reads are chunked per source file.
308
+ _HYDRATE_CHUNK = 400
309
+
310
+
311
+ def _load_conversation_index_rows(
254
312
  conn: sqlite3.Connection, conversation_key: str,
313
+ ) -> tuple[list, dict[tuple[str, int], int]]:
314
+ """Phase A's narrow read: ``(rows, detail_bytes_by_position)``.
315
+
316
+ Rows come back as ordinary ``CodexNormalizedRow`` objects with ``text``,
317
+ ``search_tool`` and ``search_thinking`` blanked, so ``pair_mirrors`` and
318
+ ``canonical_items`` run unchanged — both key on ``turn_id``, ``kind``,
319
+ ``content_digest``, ``content_len``, ``record_family`` and the physical
320
+ position, none of which lives in the excluded columns.
321
+
322
+ Excluding ``text`` defers the bulk: on the heaviest conversation in the
323
+ corpus ``content_len`` totals 84.6 MB against 7.1 MB of ``detail_json``.
324
+ """
325
+ rows: list = []
326
+ detail_bytes: dict[tuple[str, int], int] = {}
327
+ for (source_path, line_offset, timestamp_utc, turn_id, call_id, kind,
328
+ event_type, record_family, model, content_digest, content_len,
329
+ detail_json, detail_len) in conn.execute(
330
+ "SELECT " + _NARROW_ROW_COLS + " FROM codex_conversation_messages "
331
+ "WHERE conversation_key = ? "
332
+ "ORDER BY timestamp_utc, source_path, line_offset",
333
+ (conversation_key,),
334
+ ):
335
+ rows.append(kern.CodexNormalizedRow(
336
+ conversation_key=conversation_key, source_root_key="",
337
+ source_path=source_path, line_offset=line_offset,
338
+ timestamp_utc=timestamp_utc, turn_id=turn_id, call_id=call_id,
339
+ kind=kind, event_type=event_type, record_family=record_family,
340
+ model=model, text="", content_digest=content_digest,
341
+ content_len=content_len, detail_json=detail_json,
342
+ search_tool="", search_thinking=""))
343
+ detail_bytes[(source_path, line_offset)] = detail_len or 0
344
+ return rows, detail_bytes
345
+
346
+
347
+ def _detail_bytes_of(rows) -> dict[tuple[str, int], int]:
348
+ """``detail_json`` byte sizes for callers that already hold WIDE rows.
349
+
350
+ The same quantity ``_load_conversation_index_rows`` gets from
351
+ ``length(CAST(detail_json AS BLOB))`` — BYTES, not characters, because the
352
+ stored JSON is emitted with ``ensure_ascii=False``.
353
+ """
354
+ return {
355
+ (row.source_path, row.line_offset):
356
+ len((row.detail_json or "").encode("utf-8"))
357
+ for row in rows
358
+ }
359
+
360
+
361
+ def _chunk_positions(positions) -> dict[str, list[list[int]]]:
362
+ """Group physical positions by source file, chunked for a bound IN clause."""
363
+ by_path: dict[str, list[int]] = {}
364
+ for source_path, line_offset in positions:
365
+ by_path.setdefault(source_path, []).append(line_offset)
366
+ return {
367
+ path: [offsets[i:i + _HYDRATE_CHUNK]
368
+ for i in range(0, len(offsets), _HYDRATE_CHUNK)]
369
+ for path, offsets in by_path.items()
370
+ }
371
+
372
+
373
+ def _load_rows_at_positions(
374
+ conn: sqlite3.Connection, conversation_key: str, positions,
375
+ ) -> dict[tuple[str, int], object]:
376
+ """Phase C's wide read, scoped to one page's physical positions."""
377
+ hydrated: dict[tuple[str, int], object] = {}
378
+ for path, chunks in _chunk_positions(positions).items():
379
+ for chunk in chunks:
380
+ marks = ",".join("?" for _ in chunk)
381
+ for row in conn.execute(
382
+ "SELECT " + _ROW_COLS + " FROM codex_conversation_messages "
383
+ f"WHERE conversation_key = ? AND source_path = ? "
384
+ f"AND line_offset IN ({marks})",
385
+ (conversation_key, path, *chunk),
386
+ ):
387
+ built = kern.CodexNormalizedRow(*row)
388
+ hydrated[(built.source_path, built.line_offset)] = built
389
+ return hydrated
390
+
391
+
392
+ def _load_row_payloads(
393
+ conn: sqlite3.Connection, conversation_key: str, positions=None,
255
394
  ) -> dict[tuple[str, int], tuple[str | None, dict]]:
256
395
  """Retained physical payloads for query-time card shaping.
257
396
 
258
397
  The retained payload remains the authoritative source used for full-payload
259
398
  readback and a defensive read-time re-shape; contract v3 also persists the
260
399
  same bounded card so replay-derived rollups and logical item counts converge.
400
+
401
+ ``positions`` scopes the read to one page (#463 S1, Phase C). Passing None
402
+ keeps the whole-conversation behaviour, which the export path still wants.
261
403
  """
262
404
  result: dict[tuple[str, int], tuple[str | None, dict]] = {}
263
- for source_path, line_offset, record_type, payload_json in conn.execute(
264
- "SELECT source_path,line_offset,record_type,payload_json "
265
- "FROM codex_conversation_events WHERE conversation_key = ?",
266
- (conversation_key,),
267
- ):
405
+ if positions is None:
406
+ cursor = conn.execute(
407
+ "SELECT source_path,line_offset,record_type,payload_json "
408
+ "FROM codex_conversation_events WHERE conversation_key = ?",
409
+ (conversation_key,))
410
+ rows = list(cursor)
411
+ else:
412
+ wanted = set(positions)
413
+ rows = []
414
+ for path, chunks in _chunk_positions(wanted).items():
415
+ for chunk in chunks:
416
+ marks = ",".join("?" for _ in chunk)
417
+ rows.extend(
418
+ row for row in conn.execute(
419
+ "SELECT source_path,line_offset,record_type,payload_json "
420
+ "FROM codex_conversation_events "
421
+ "WHERE conversation_key = ? AND source_path = ? "
422
+ f"AND line_offset IN ({marks})",
423
+ (conversation_key, path, *chunk))
424
+ if (row[0], row[1]) in wanted)
425
+ for source_path, line_offset, record_type, payload_json in rows:
268
426
  try:
269
427
  obj = json.loads(payload_json or "{}")
270
428
  except (json.JSONDecodeError, TypeError):
@@ -329,8 +487,68 @@ def _item_meta(item: dict) -> dict | None:
329
487
  return meta
330
488
 
331
489
 
490
+ def _turn_scoped_call_owner_count(rows: list) -> dict[str, int]:
491
+ """How many ``tool_call`` rows own each call id, counted over the WHOLE turn.
492
+
493
+ This must stay turn-scoped (#463 S1, spec section 1). Recomputing it over a
494
+ page-local segment would make a call id that appears twice in a turn but once
495
+ in the page look uniquely owned, and a ``tool_output`` would then fold into
496
+ the wrong call.
497
+ """
498
+ counts: dict[str, int] = {}
499
+ for row in rows:
500
+ if row.kind == "tool_call" and row.call_id:
501
+ counts[row.call_id] = counts.get(row.call_id, 0) + 1
502
+ return counts
503
+
504
+
505
+ def _reasoning_headings(detail, payload, block_key: str):
506
+ """The additive ``headings`` array for one reasoning block, or ``None``.
507
+
508
+ #463 S2 §2.3/§2.5. Read-time decomposition of the retained payload's
509
+ ``summary`` entries into the individual authored headings, each addressed by
510
+ ``<block_key>#<zero-based ordinal>``. The stored projection is NOT consulted
511
+ and NOT modified: it feeds ``_row_is_reasoning_title``, which is a
512
+ segmentation-boundary input.
513
+
514
+ Headings come from ``payload["summary"]`` ONLY. ``payload["content"]`` is the
515
+ body, which stays disclosure content and is never decomposed.
516
+
517
+ All-or-nothing. When the payload is absent, unreadable or malformed, this
518
+ returns ``None`` and the caller omits the field entirely, so the client falls
519
+ back to today's ``title``/``summary`` rendering. Decomposition never fails the
520
+ request and never partially populates.
521
+ """
522
+ if not isinstance(detail, dict) or not isinstance(detail.get("reasoning"), dict):
523
+ return None
524
+ if not isinstance(payload, dict):
525
+ return None
526
+ summary = payload.get("summary")
527
+ if not isinstance(summary, list) or not summary:
528
+ return None
529
+ entries = []
530
+ for entry in summary:
531
+ if not isinstance(entry, dict):
532
+ return None
533
+ text = entry.get("text")
534
+ # Mirror `_join_content_texts`, which is what produced the stored
535
+ # summary: it keeps non-empty string `text` leaves and ignores the rest.
536
+ if text is None:
537
+ continue
538
+ if not isinstance(text, str):
539
+ return None
540
+ if text:
541
+ entries.append(text)
542
+ headings = decompose_reasoning_headings(entries)
543
+ if not headings:
544
+ return None
545
+ return [{"key": f"{block_key}#{ordinal}", "text": text}
546
+ for ordinal, text in enumerate(headings)]
547
+
548
+
332
549
  def _item_blocks_with_rows(
333
550
  item: dict, payloads: dict | None = None, *, preserve_marker_text: bool = False,
551
+ call_owner_count: dict | None = None, decompose_headings: bool = False,
334
552
  ) -> list[list]:
335
553
  """Assemble an item's blocks (the historical ``_build_item_blocks`` behaviour)
336
554
  AND expose each block's underlying rows, so the detail renderer and the payload
@@ -340,8 +558,17 @@ def _item_blocks_with_rows(
340
558
  exactly one tool_call, and that call was already seen (call precedes output).
341
559
  Physical order within the item is preserved.
342
560
 
343
- Every ``tool_call`` block additionally carries an opaque ``block_key`` (§3.4) —
344
- the payload-capable anchor. Non-tool blocks carry no ``block_key``."""
561
+ EVERY block backed by a physical row carries an opaque ``block_key`` (§3.4)
562
+ since #463 S2 §1; before it, only ``tool_call`` and a few event families did.
563
+ The far smaller payload-readable set is marked separately by
564
+ ``payload_which``, because a stable anchor and a retained payload are
565
+ different properties (§1.1).
566
+
567
+ ``decompose_headings`` adds the additive ``detail.reasoning.headings`` array
568
+ (#463 S2 §2.5). It is OFF by default and off on the export path, because
569
+ ``legacy_export`` loads only marker-bearing payloads and populating the field
570
+ there would force a whole-conversation payload read to fill something the
571
+ exporter never reads."""
345
572
  rows = item["rows"]
346
573
  payloads = payloads or {}
347
574
  lifecycle_positions = {
@@ -349,10 +576,11 @@ def _item_blocks_with_rows(
349
576
  }
350
577
  row_order = {(row.source_path, row.line_offset): index
351
578
  for index, row in enumerate(rows)}
352
- call_owner_count: dict[str, int] = {}
353
- for r in rows:
354
- if r.kind == "tool_call" and r.call_id:
355
- call_owner_count[r.call_id] = call_owner_count.get(r.call_id, 0) + 1
579
+ # Turn-scoped when the caller supplies it (#463 S1 Phase C); otherwise
580
+ # computed over this item's own rows, which is the same thing for an
581
+ # unsegmented item.
582
+ if call_owner_count is None:
583
+ call_owner_count = _turn_scoped_call_owner_count(rows)
356
584
  entries: list[list] = []
357
585
  tool_entry_by_call: dict[str, int] = {}
358
586
  for r in rows:
@@ -411,17 +639,26 @@ def _item_blocks_with_rows(
411
639
  owner_card["result"] = result
412
640
  owner[2] = r
413
641
  continue
642
+ block_key = _block_key_for_row(r)
643
+ if decompose_headings and r.kind == "reasoning":
644
+ headings = _reasoning_headings(detail, payload, block_key)
645
+ if headings is not None:
646
+ detail = dict(detail)
647
+ detail["reasoning"] = dict(detail["reasoning"])
648
+ detail["reasoning"]["headings"] = headings
414
649
  block = {
415
650
  "kind": r.kind, "text": text, "detail": detail,
416
651
  "call_id": r.call_id, "timestamp_utc": r.timestamp_utc,
652
+ # #463 S2 §1 — EVERY row-backed block carries the anchor, not only
653
+ # tool_call. `payload_which` below still marks the far smaller set
654
+ # that is payload-readable, because those are different properties.
655
+ "block_key": block_key,
417
656
  }
418
657
  if (r.kind == "event" and r.event_type in {
419
658
  "web_search_end", "mcp_tool_call_end", "task_started", "task_complete"}
420
659
  or isinstance(stored_detail, dict) and stored_detail.get("markers")):
421
- block["block_key"] = _block_key_for_row(r)
422
660
  block["payload_which"] = "event"
423
661
  if r.kind == "tool_call":
424
- block["block_key"] = _block_key_for_row(r)
425
662
  if r.call_id and call_owner_count.get(r.call_id, 0) == 1:
426
663
  tool_entry_by_call[r.call_id] = len(entries)
427
664
  entries.append([block, r, None])
@@ -445,8 +682,7 @@ def _item_blocks_with_rows(
445
682
  if not (event_row.kind == "event" and isinstance(event_card, dict)
446
683
  and event_card.get("source") == "patch_apply_end"):
447
684
  continue
448
- event_key = _block_key_for_row(event_row)
449
- event_block["block_key"] = event_key
685
+ event_key = event_block["block_key"]
450
686
  event_block["payload_which"] = "event"
451
687
  same_id = [
452
688
  index for index, owner_count in patch_calls
@@ -514,7 +750,6 @@ def _item_blocks_with_rows(
514
750
  == "web_search_call")
515
751
  ]
516
752
  if len(candidates) != 1:
517
- event_block["block_key"] = _block_key_for_row(event_row)
518
753
  event_block["payload_which"] = "event"
519
754
  continue
520
755
  owner_index = candidates[0]
@@ -539,14 +774,13 @@ def _item_blocks_with_rows(
539
774
  owner_card["call_status"] = owner_payload["status"]
540
775
  owner_detail["card"] = owner_card
541
776
  if not isinstance(owner_card, dict):
542
- event_block["block_key"] = _block_key_for_row(event_row)
543
777
  event_block["payload_which"] = "event"
544
778
  continue
545
779
  owner_card["completion"] = {
546
780
  key: value for key, value in event_card.items()
547
781
  if key not in {"schema_version", "type", "source"}
548
782
  }
549
- owner_card["completion"]["event_block_key"] = _block_key_for_row(event_row)
783
+ owner_card["completion"]["event_block_key"] = event_block["block_key"]
550
784
  matched_secondary.add(owner_index)
551
785
  suppress_secondary.add(event_index)
552
786
  if suppress_secondary:
@@ -557,13 +791,16 @@ def _item_blocks_with_rows(
557
791
 
558
792
  def _build_item_blocks(
559
793
  item: dict, payloads: dict | None = None, *, preserve_marker_text: bool = False,
794
+ call_owner_count: dict | None = None, decompose_headings: bool = False,
560
795
  ) -> list[dict]:
561
796
  """Assemble an item's blocks, folding each ``tool_output`` into its
562
797
  ``tool_call`` block via ``call_id`` when that call_id has exactly one owner
563
798
  (§5.2). Physical order within the item is preserved. Thin projection of
564
799
  ``_item_blocks_with_rows`` — the single source of truth for the folding rule."""
565
800
  return [entry[0] for entry in _item_blocks_with_rows(
566
- item, payloads, preserve_marker_text=preserve_marker_text)]
801
+ item, payloads, preserve_marker_text=preserve_marker_text,
802
+ call_owner_count=call_owner_count,
803
+ decompose_headings=decompose_headings)]
567
804
 
568
805
 
569
806
  def _item_lifecycle(item: dict) -> dict | None:
@@ -1005,42 +1242,378 @@ def codex_conversation_source_paths(
1005
1242
  # ── detail assembly (§5.2 / §5.4 / §5.6) ──────────────────────────────────────
1006
1243
 
1007
1244
 
1008
- def _paginate_items(items: list[dict], *, after, before, tail: bool, limit: int):
1009
- # ``tail`` is the flag the HTTP layer parses out of ``?tail=1`` — which page
1010
- # of ``limit`` to cut, never how many items to return. Treating it as a count
1011
- # made ``min(True, limit)`` serve a one-item tail page, and ``tail=False``
1012
- # (the shape every non-tail request arrives in) skip ``limit`` altogether.
1013
- keys = [it["item_key"] for it in items]
1014
- aliases = {
1015
- alias: index
1016
- for index, item in enumerate(items)
1017
- for alias in item.get("member_item_keys", [])
1018
- }
1019
- lo, hi = 0, len(items)
1020
- if after is not None and after in keys:
1021
- lo = keys.index(after) + 1
1022
- elif after is not None and after in aliases:
1023
- lo = aliases[after] + 1
1024
- if before is not None and before in keys:
1025
- hi = keys.index(before)
1026
- elif before is not None and before in aliases:
1027
- hi = aliases[before]
1028
- window = items[lo:hi]
1029
- if limit:
1030
- window = window[-limit:] if tail else window[:limit]
1031
- first_key = window[0]["item_key"] if window else None
1032
- last_key = window[-1]["item_key"] if window else None
1033
- has_before = bool(window) and keys.index(first_key) > 0
1034
- has_after = bool(window) and keys.index(last_key) < len(items) - 1
1245
+ def _stale_codex_page(total: int) -> dict:
1246
+ """The page a cursor resolving to nothing returns (#463 S1 / F4).
1247
+
1248
+ Mirrors the Claude kernel's ``_stale_empty_page`` contract: a stale or
1249
+ deleted cursor yields an EMPTY page, never a silent re-serve of the head or
1250
+ the tail. The old Codex kernel left the bound at the edge of the list, which
1251
+ is the second path by which ``before`` returned the wrong window.
1252
+ """
1253
+ return {"total": total, "returned": 0, "before": None, "after": None,
1254
+ "has_before": False, "has_after": False}
1255
+
1256
+
1257
+ def _paginate_items(items: list[dict], *, after, before, tail: bool, limit: int,
1258
+ block_budget: int | None = None,
1259
+ byte_budget: int | None = None):
1260
+ """Cut one page from the assembled item list (#463 S1 / F4).
1261
+
1262
+ ``tail`` is the flag the HTTP layer parses out of ``?tail=1`` — which page
1263
+ of ``limit`` to cut, never how many items to return.
1264
+
1265
+ The four cursor branches mirror the Claude kernel's structure
1266
+ (``_lib_conversation_query.py`` :2372-2391) so the two can be read against
1267
+ each other, which is what stops them diverging again. TWO deliberate
1268
+ Codex-only differences, both pinned by ``tests/test_codex_pagination.py``:
1269
+
1270
+ * ``limit == 0`` means UNBOUNDED, and the export path depends on that
1271
+ sentinel (``get_codex_conversation_export`` passes ``limit=0``). Claude's
1272
+ default branch computes ``end = min(limit, N)``, which for zero yields an
1273
+ empty page — ported literally, every Codex export would contain no
1274
+ conversation items. The unbounded case is therefore explicit, ahead of
1275
+ the four branches.
1276
+ * Cursor resolution covers BOTH primary item keys and ``member_item_keys``
1277
+ aliases, so a cursor naming an item folded by a later contract version
1278
+ still resolves. Claude's ``_idx`` checks only its primary anchor id, and
1279
+ taking its resolver along with its branch arithmetic would make every
1280
+ folded-item cursor stale. The branch arithmetic is what is ported; the
1281
+ resolver is not.
1282
+
1283
+ The prior implementation computed ``lo``/``hi`` and then sliced, so a
1284
+ ``before`` request returned ``items[0:hi][:limit]`` — the conversation's
1285
+ OPENING items — with ``has_before`` False.
1286
+
1287
+ TWO per-page budgets apply alongside ``limit``, and the first of them to be
1288
+ reached closes the page (spec section 2). They bound different costs and
1289
+ neither substitutes for the other:
1290
+
1291
+ * ``block_budget`` bounds DOM construction cost, which the F2 profile
1292
+ shows dominates mounting.
1293
+ * ``byte_budget`` bounds transfer and parse cost, which is a byte cost a
1294
+ block count does not express, because a Codex block is far heavier than
1295
+ a Claude block.
1296
+
1297
+ Neither is optional in production. The profiled response was
1298
+ ``total: 78, returned: 78, has_after: false`` — 13.3 MB served in one page,
1299
+ because 78 is fewer than the requested 500 — so a change that capped items
1300
+ alone would not have bounded that conversation at all. And after
1301
+ segmentation that same conversation serves 1,713 blocks, BELOW
1302
+ ``PAGE_BLOCK_BUDGET``, so the block bound never fires on it either and the
1303
+ response is still 13.24 MB: the byte budget is what actually closes it.
1304
+
1305
+ Both are deliberately NOT applied to the unbounded ``limit == 0`` export
1306
+ case, which must stay whole. The trim comes off whichever end is not
1307
+ anchored to the cursor, so a reverse page still ends where the caller asked
1308
+ it to, and a page never shrinks below one item however large that item is.
1309
+ """
1310
+ index_by_key: dict[str, int] = {}
1311
+ for index, item in enumerate(items):
1312
+ index_by_key.setdefault(item["item_key"], index)
1313
+ for index, item in enumerate(items):
1314
+ for alias in item.get("member_item_keys", []):
1315
+ index_by_key.setdefault(alias, index)
1316
+
1317
+ n = len(items)
1318
+
1319
+ if not limit:
1320
+ start, end = 0, n
1321
+ elif tail:
1322
+ end = n
1323
+ start = max(0, n - limit)
1324
+ elif before is not None:
1325
+ b = index_by_key.get(before)
1326
+ if b is None:
1327
+ return [], _stale_codex_page(n)
1328
+ end = b
1329
+ start = max(0, end - limit)
1330
+ elif after is not None:
1331
+ a = index_by_key.get(after)
1332
+ if a is None:
1333
+ return [], _stale_codex_page(n)
1334
+ start = a + 1
1335
+ end = min(start + limit, n)
1336
+ else:
1337
+ start = 0
1338
+ end = min(limit, n)
1339
+
1340
+ if limit and end > start and (block_budget or byte_budget):
1341
+ blocks = sum(item.get("block_count", 0) for item in items[start:end])
1342
+ source = sum(item.get("source_bytes", 0) for item in items[start:end])
1343
+
1344
+ def _over() -> bool:
1345
+ return ((bool(block_budget) and blocks > block_budget)
1346
+ or (bool(byte_budget) and source > byte_budget))
1347
+
1348
+ anchored_at_end = tail or before is not None
1349
+ while end - start > 1 and _over():
1350
+ drop = items[start] if anchored_at_end else items[end - 1]
1351
+ blocks -= drop.get("block_count", 0)
1352
+ source -= drop.get("source_bytes", 0)
1353
+ if anchored_at_end:
1354
+ start += 1
1355
+ else:
1356
+ end -= 1
1357
+
1358
+ window = items[start:end]
1359
+ has_before = start > 0
1360
+ has_after = end < n
1035
1361
  page = {
1036
- "total": len(items), "returned": len(window),
1037
- "before": first_key if has_before else None,
1038
- "after": last_key if has_after else None,
1362
+ "total": n, "returned": len(window),
1363
+ "before": window[0]["item_key"] if (window and has_before) else None,
1364
+ "after": window[-1]["item_key"] if (window and has_after) else None,
1039
1365
  "has_before": has_before, "has_after": has_after,
1040
1366
  }
1041
1367
  return window, page
1042
1368
 
1043
1369
 
1370
+ def _row_source_bytes(row, detail_bytes: dict) -> int:
1371
+ """One row's source size: its content length plus its stored detail's BYTES.
1372
+
1373
+ Source bytes deliberately overstate wire bytes. The server clips block
1374
+ payloads, so the measured ratio is roughly six to eight times — a
1375
+ conversation holding 14.6 MB of ``content_len`` serves 1.8 MB, and one
1376
+ holding 84.6 MB serves 13.3 MB. Any byte threshold must therefore be stated
1377
+ in source bytes and derived from a wire target through that ratio; S1
1378
+ computes the figure and exposes it, and gates only on block count.
1379
+ """
1380
+ return (row.content_len or 0) + detail_bytes.get(
1381
+ (row.source_path, row.line_offset), 0)
1382
+
1383
+
1384
+ _UNSET = object()
1385
+
1386
+
1387
+ def _row_is_reasoning_title(row, detail=_UNSET) -> bool:
1388
+ """True when this row's stored reasoning projection produces a ``title``.
1389
+
1390
+ The first of the two semantic boundaries. Read from ``detail_json``, because
1391
+ ``search_thinking`` stores ``summary + "\\n" + body`` for a response item and
1392
+ so cannot tell ``summary="**T**", body="x"`` (a title) from
1393
+ ``summary="**T**\\nx", body=""`` (not one).
1394
+
1395
+ ``detail`` lets a caller that has already parsed the row's ``detail_json``
1396
+ pass it in. Phase A runs over every row of the conversation, so parsing the
1397
+ same JSON twice per row is worth avoiding.
1398
+ """
1399
+ if row.kind != "reasoning":
1400
+ return False
1401
+ if detail is _UNSET:
1402
+ detail = _parse_detail(row.detail_json)
1403
+ reasoning = detail.get("reasoning") if isinstance(detail, dict) else None
1404
+ return isinstance(reasoning, dict) and bool(reasoning.get("title"))
1405
+
1406
+
1407
+ def _fold_groups_for_item(item: dict, call_owner_count: dict,
1408
+ detail_bytes: dict) -> list:
1409
+ """Derive one item's atomic fold groups (#463 S1, spec section 1).
1410
+
1411
+ A group is a row together with every later row in the item that could fold
1412
+ into it. Membership is decided from ``call_id`` and the STORED card in
1413
+ ``detail_json``, deliberately without reading the retained event payloads —
1414
+ Phase A must not touch them. That makes the grouping a conservative SUPERSET
1415
+ of what the block builder will actually fold: a completion event whose card
1416
+ turns out not to fold stays grouped with its call anyway. A superset is the
1417
+ safe direction, because the only thing a group guarantees is that no boundary
1418
+ is drawn between a call and something that might fold into it.
1419
+
1420
+ ``_item_blocks_with_rows`` performs THREE folds, not one, and grouping covers
1421
+ all three:
1422
+
1423
+ * the id-matched fold — a ``tool_output`` or completion event whose
1424
+ ``call_id`` is owned by exactly one ``tool_call`` in the turn;
1425
+ * the bracketed native patch completion — a patch completion event may
1426
+ carry an INNER call id distinct from the outer custom-tool call, which
1427
+ the block builder folds by positional bracketing
1428
+ (``call_pos < event_pos < output_pos``) rather than by id. This is the
1429
+ common shape: 3,441 of the production corpus's 4,690 patch completion
1430
+ events carry a call id no ``tool_call`` in their turn owns. Such an event
1431
+ joins the most recent patch call whose output has not yet arrived;
1432
+ * the ``web_search_completion`` narrowing — that path filters its
1433
+ candidates by ``detail.name == "web_search_call"`` BEFORE requiring a
1434
+ unique candidate, and it bounds nothing about how many calls share the
1435
+ id, so it folds at ANY owner count. The registration below therefore
1436
+ imposes no owner ceiling either: it registers the web-search arm at
1437
+ ``owners >= 1``. Naming a fixed count leaves the pair ungrouped at every
1438
+ other count — an ``== 1`` gate never covers a two-owner id, and an
1439
+ ``== 2`` gate never covers a call id owned by three calls of which one
1440
+ is the web search.
1441
+
1442
+ A boundary that split any of the three would make the page-local builder emit
1443
+ a standalone event card where the whole-turn builder emits a folded
1444
+ ``completion`` — the structural divergence spec section 1 forbids.
1445
+
1446
+ Block counts are overestimated for the same reason. A ``tool_output`` whose
1447
+ call id is uniquely owned provably folds and contributes nothing; every other
1448
+ grouped row is counted as its own block even though it may fold. Over-
1449
+ counting shrinks segments slightly, which keeps the budget an upper bound.
1450
+
1451
+ Each group also carries ``first_pos``/``last_pos``, its physical row range
1452
+ inside the item, which ``plan_segments`` uses to keep a segment contiguous.
1453
+
1454
+ Lifecycle rows are excluded entirely: they never produce a block, and they
1455
+ are carried on segment 0 instead, where ``_item_lifecycle`` renders them from
1456
+ the narrow row alone.
1457
+ """
1458
+ lifecycle_positions = {
1459
+ (row.source_path, row.line_offset) for row in item.get("lifecycle_rows", [])
1460
+ }
1461
+ groups: list[dict] = []
1462
+ open_group_by_call: dict[str, dict] = {}
1463
+ open_patch_groups: list[dict] = []
1464
+ previous_kind = None
1465
+ position = 0
1466
+ for row in item["rows"]:
1467
+ if (row.source_path, row.line_offset) in lifecycle_positions:
1468
+ continue
1469
+ detail = _parse_detail(row.detail_json)
1470
+ card = detail.get("card") if isinstance(detail, dict) else None
1471
+ if not isinstance(card, dict):
1472
+ card = None
1473
+ owner = (open_group_by_call.get(row.call_id)
1474
+ if row.call_id and row.kind in {"tool_output", "event"} else None)
1475
+ if (owner is None and row.kind == "event" and card is not None
1476
+ and card.get("source") == "patch_apply_end" and open_patch_groups):
1477
+ # The most RECENT still-open patch call is the one the block
1478
+ # builder's bracket resolves to in the single-patch case; taking an
1479
+ # older one would leave the true owner ungrouped.
1480
+ owner = open_patch_groups.pop()
1481
+ if owner is not None:
1482
+ owner["rows"].append(row)
1483
+ owner["last_pos"] = position
1484
+ owner["source_bytes"] += _row_source_bytes(row, detail_bytes)
1485
+ folds = (row.kind == "tool_output"
1486
+ and call_owner_count.get(row.call_id, 0) == 1)
1487
+ if not folds:
1488
+ owner["block_count"] += 1
1489
+ # Identity, not equality. ``in`` and ``list.remove`` compare with
1490
+ # ``==``, so they would match — and delete — the FIRST group whose
1491
+ # dict merely compares equal to this one. That is correct today only
1492
+ # because two distinct groups can never hold equal contents, which is
1493
+ # an accident of the data rather than a property of this loop.
1494
+ if row.kind == "tool_output" and any(g is owner for g in open_patch_groups):
1495
+ open_patch_groups[:] = [g for g in open_patch_groups if g is not owner]
1496
+ position += 1
1497
+ continue
1498
+ group = {
1499
+ "rows": [row],
1500
+ "block_count": 1,
1501
+ "source_bytes": _row_source_bytes(row, detail_bytes),
1502
+ "is_title_boundary": _row_is_reasoning_title(row, detail),
1503
+ "is_tool_transition": (row.kind == "tool_call"
1504
+ and previous_kind in {"assistant", "reasoning"}),
1505
+ "first_pos": position,
1506
+ "last_pos": position,
1507
+ }
1508
+ groups.append(group)
1509
+ if row.kind == "tool_call" and row.call_id:
1510
+ owners = call_owner_count.get(row.call_id, 0)
1511
+ name = detail.get("name") if isinstance(detail, dict) else None
1512
+ # The web-search arm must not impose an owner ceiling the block
1513
+ # builder does not. `_pair_web_search_completions` filters the
1514
+ # candidates by `detail.name == "web_search_call"` and then requires a
1515
+ # unique survivor, with no bound on how many calls share the id — so a
1516
+ # call id owned by three calls of which exactly one is a web search
1517
+ # folds there while `owners == 2` refused to group it here, and a
1518
+ # segment boundary could fall between the call and its completion.
1519
+ # `owners >= 1` matches the builder and stays a conservative superset:
1520
+ # grouping only ever keeps rows together that the builder folds.
1521
+ if owners == 1 or (owners >= 1 and name == "web_search_call"):
1522
+ open_group_by_call.setdefault(row.call_id, group)
1523
+ if row.kind == "tool_call" and card is not None and card.get("type") == "patch":
1524
+ open_patch_groups.append(group)
1525
+ previous_kind = row.kind
1526
+ position += 1
1527
+ return [
1528
+ segkern.FoldGroup(
1529
+ rows=group["rows"], block_count=group["block_count"],
1530
+ source_bytes=group["source_bytes"],
1531
+ is_title_boundary=group["is_title_boundary"],
1532
+ is_tool_transition=group["is_tool_transition"],
1533
+ first_pos=group["first_pos"], last_pos=group["last_pos"])
1534
+ for group in groups
1535
+ ]
1536
+
1537
+
1538
+ def _build_segment_index(
1539
+ conversation_key: str, items: list[dict], detail_bytes: dict, *,
1540
+ segmented: bool, block_budget: int | None = None,
1541
+ ) -> list[dict]:
1542
+ """Phase A's output: an ordered index of segments, with no block content.
1543
+
1544
+ Each entry carries its keys, turn membership, ordinal, the physical rows it
1545
+ covers, its sizes, and the two structural facts Phase C requires and cannot
1546
+ recompute correctly on its own — the fold-group membership that makes a
1547
+ boundary legal, and the TURN-scoped ``call_owner_count``.
1548
+
1549
+ ``segmented=False`` gives each item exactly one segment holding all of its
1550
+ groups, which is what the export path uses so its item grouping stays
1551
+ byte-identical.
1552
+
1553
+ ``block_budget`` resolves to ``segkern.SEGMENT_BLOCK_BUDGET`` at CALL time
1554
+ when omitted, never as a default argument value: a default argument binds
1555
+ once at import, so a test that lowered the budget would silently keep the
1556
+ imported figure and pass vacuously.
1557
+ """
1558
+ index: list[dict] = []
1559
+ for item_index, item in enumerate(items):
1560
+ turn_key = _item_key_for_item(conversation_key, item)
1561
+ call_owner_count = _turn_scoped_call_owner_count(item["rows"])
1562
+ groups = _fold_groups_for_item(item, call_owner_count, detail_bytes)
1563
+ if not groups:
1564
+ segments = [segkern.Segment(
1565
+ ordinal=0, groups=[], block_count=0, source_bytes=0,
1566
+ anchor_row=item["anchor_row"])]
1567
+ elif segmented and item["klass"] == "response":
1568
+ segments = segkern.plan_segments(groups, block_budget=block_budget)
1569
+ else:
1570
+ segments = [segkern.Segment(
1571
+ ordinal=0, groups=groups,
1572
+ block_count=sum(group.block_count for group in groups),
1573
+ source_bytes=sum(group.source_bytes for group in groups),
1574
+ anchor_row=groups[0].rows[0])]
1575
+ for segment in segments:
1576
+ head = segment.ordinal == 0
1577
+ anchor = item["anchor_row"] if head else segment.anchor_row
1578
+ # PHYSICAL order, not group-flatten order. A folded row is appended
1579
+ # to an earlier group, so flattening the groups would move it next to
1580
+ # its call — and the patch-completion fold decides ambiguous cases by
1581
+ # positional bracketing (call < event < its output) over the item's
1582
+ # row order, which that reordering silently breaks.
1583
+ member = {(row.source_path, row.line_offset)
1584
+ for group in segment.groups for row in group.rows}
1585
+ segment_rows = [row for row in item["rows"]
1586
+ if (row.source_path, row.line_offset) in member]
1587
+ entry = {
1588
+ "item_key": turn_key if head else codex_item_key(
1589
+ conversation_key, klass="segment", turn_id=item["turn_id"],
1590
+ source_path=segment.anchor_row.source_path,
1591
+ line_offset=segment.anchor_row.line_offset,
1592
+ content_digest=segment.anchor_row.content_digest),
1593
+ "member_item_keys": (
1594
+ _member_item_keys(conversation_key, item) if head else []),
1595
+ "turn_item_key": turn_key,
1596
+ "segment_ordinal": segment.ordinal,
1597
+ "kind": _item_kind(item),
1598
+ "timestamp_utc": anchor.timestamp_utc,
1599
+ "model": anchor.model,
1600
+ "block_count": segment.block_count,
1601
+ "source_bytes": segment.source_bytes,
1602
+ # Phase C inputs — never serialized.
1603
+ "_item_index": item_index,
1604
+ "_klass": item["klass"],
1605
+ "_turn_id": item["turn_id"],
1606
+ "_anchor_row": anchor,
1607
+ "_rows": segment_rows,
1608
+ "_call_owner_count": call_owner_count,
1609
+ "_meta": _item_meta(item) if head else None,
1610
+ "_lifecycle": _item_lifecycle(item) if head else None,
1611
+ "_lifecycle_rows": item.get("lifecycle_rows", []) if head else [],
1612
+ }
1613
+ index.append(entry)
1614
+ return index
1615
+
1616
+
1044
1617
  def get_codex_conversation(
1045
1618
  conn: sqlite3.Connection,
1046
1619
  conversation_key: str,
@@ -1055,35 +1628,51 @@ def get_codex_conversation(
1055
1628
  """Detail envelope (§5.6): status ``ok`` | ``normalization_pending`` |
1056
1629
  ``not_found``. ``ok`` carries canonical items (mirror-paired, tool-folded),
1057
1630
  per-turn cost with an explicit unattributed bucket, threading, and a page
1058
- over ``item_key``."""
1631
+ over ``item_key``.
1632
+
1633
+ Assembly runs in three phases (#463 S1, finding F3). Before this change every
1634
+ step from row loading through block building processed the WHOLE
1635
+ conversation, and ``_paginate_items`` ran last, so pagination reduced
1636
+ serialization and transfer but bounded no work.
1637
+
1638
+ * **Phase A** reads every row narrowly — everything except ``text``, and no
1639
+ event payloads at all — pairs mirrors, groups canonical items, derives
1640
+ fold groups and segment boundaries, and emits a segment index.
1641
+ * **Phase B** paginates that index. It is arithmetic over a narrow list.
1642
+ * **Phase C** hydrates ONLY the requested page: the wide ``text`` read and
1643
+ the events-table payload scan are scoped to the page's physical
1644
+ positions, and blocks are built per segment.
1645
+
1646
+ What stays proportional to the conversation is the narrow index pass, because
1647
+ segment boundaries and the ``has_before``/``has_after`` flags are global
1648
+ facts. What becomes proportional to the page is everything expensive.
1649
+
1650
+ Cost attribution deliberately stays whole-conversation: ``_attribute_costs``
1651
+ reconciles per-turn costs against an unattributed bucket and the envelope
1652
+ reports conversation-level totals, so scoping it to a page would change the
1653
+ reported total. It reads ``codex_session_entries``, not the large message
1654
+ table.
1655
+
1656
+ ``page.total`` is now a count of SEGMENTS rather than of items.
1657
+ """
1059
1658
  if not codex_normalization_authoritative(conn):
1060
1659
  return {"status": "normalization_pending", "conversation_key": conversation_key,
1061
1660
  "items": [], "children": []}
1062
- rows = _load_conversation_rows(conn, conversation_key)
1661
+ # ── Phase A: the narrow index pass ───────────────────────────────────────
1662
+ rows, detail_bytes = _load_conversation_index_rows(conn, conversation_key)
1063
1663
  if not rows:
1064
1664
  return {"status": "not_found", "conversation_key": conversation_key}
1065
1665
  kept, _suppressed = kern.pair_mirrors(rows)
1066
1666
  items = kern.canonical_items(
1067
1667
  kept, fold_patch_completions=not legacy_export)
1068
- # Detail/API callers receive the exact card display projection from retained
1069
- # provider payloads. Export deliberately renders the byte-frozen legacy text
1070
- # while retaining the same additive card metadata.
1071
- payloads = _load_row_payloads(conn, conversation_key)
1072
- if legacy_export:
1073
- marker_positions = {
1074
- (row.source_path, row.line_offset)
1075
- for row in rows
1076
- if isinstance(_parse_detail(row.detail_json), dict)
1077
- and bool(_parse_detail(row.detail_json).get("markers"))
1078
- }
1079
- payloads = {
1080
- position: retained for position, retained in payloads.items()
1081
- if position in marker_positions
1082
- }
1083
1668
  turn_cost, turn_tokens, unattr_cost, unattr_tokens, total, conv_tokens = _attribute_costs(
1084
1669
  conn, conversation_key, effective_speed)
1085
1670
  # Carrier item per turn: prefer the response item, else the first item of the
1086
1671
  # turn — so every priced turn's cost lands on exactly one item (§5.4 reconcile).
1672
+ # Segmentation must not move the carrier, so the selection stays keyed on the
1673
+ # ITEM index and the cost lands on that item's segment 0. Every other segment
1674
+ # carries null rather than zero, because a zero is indistinguishable from a
1675
+ # genuinely free turn.
1087
1676
  carriers: dict[str, int] = {}
1088
1677
  for idx, it in enumerate(items):
1089
1678
  if it["klass"] == "response" and it["turn_id"] is not None and it["turn_id"] not in carriers:
@@ -1097,38 +1686,100 @@ def get_codex_conversation(
1097
1686
  if turn not in carriers:
1098
1687
  leftover_cost += cost
1099
1688
  unattributed_cost = unattr_cost + leftover_cost
1689
+ # Segmentation is disabled under legacy_export, so item grouping there stays
1690
+ # byte-identical to what the export golden already pins.
1691
+ index = _build_segment_index(
1692
+ conversation_key, items, detail_bytes, segmented=not legacy_export)
1693
+
1694
+ # ── Phase B: paginate the index ──────────────────────────────────────────
1695
+ page_index, page = _paginate_items(
1696
+ index, after=after, before=before, tail=tail, limit=limit,
1697
+ block_budget=None if legacy_export else segkern.PAGE_BLOCK_BUDGET,
1698
+ byte_budget=None if legacy_export else segkern.PAGE_SOURCE_BYTE_BUDGET)
1699
+
1700
+ # ── Phase C: hydrate only the page ───────────────────────────────────────
1701
+ page_positions = {
1702
+ (row.source_path, row.line_offset)
1703
+ for entry in page_index for row in entry["_rows"]
1704
+ }
1705
+ hydrated = _load_rows_at_positions(conn, conversation_key, page_positions)
1706
+ if len(hydrated) != len(page_positions):
1707
+ # A miss here is a bug, not a degradation. The narrow index pass and this
1708
+ # wide read select from the SAME table on the same conversation key, so a
1709
+ # position that appears in one and not the other means the two reads
1710
+ # disagree. Falling back to the narrow row would silently render an empty
1711
+ # block, because the narrow row carries no ``text``.
1712
+ missing = sorted(page_positions - set(hydrated))[:5]
1713
+ raise RuntimeError(
1714
+ f"codex detail hydration missed {len(page_positions) - len(hydrated)} "
1715
+ f"of {len(page_positions)} page rows for {conversation_key}; "
1716
+ f"first missing positions: {missing}")
1717
+ # Detail/API callers receive the exact card display projection from retained
1718
+ # provider payloads. Export deliberately renders the byte-frozen legacy text
1719
+ # while retaining the same additive card metadata, so it scopes the payload
1720
+ # read to marker-bearing rows across the WHOLE conversation rather than to
1721
+ # the page — a different set from page_positions, hence a different name.
1722
+ if legacy_export:
1723
+ marker_positions = {
1724
+ (row.source_path, row.line_offset)
1725
+ for row in rows
1726
+ if isinstance(_parse_detail(row.detail_json), dict)
1727
+ and bool(_parse_detail(row.detail_json).get("markers"))
1728
+ }
1729
+ payloads = _load_row_payloads(conn, conversation_key, marker_positions)
1730
+ else:
1731
+ payloads = _load_row_payloads(conn, conversation_key, page_positions)
1100
1732
  built: list[dict] = []
1101
- for idx, it in enumerate(items):
1102
- turn = it["turn_id"]
1103
- cost = None
1104
- tokens = None
1105
- if turn is not None and carriers.get(turn) == idx and turn in turn_cost:
1733
+ for entry in page_index:
1734
+ page_rows = [
1735
+ hydrated[(row.source_path, row.line_offset)]
1736
+ for row in entry["_rows"]
1737
+ ]
1738
+ page_item = {
1739
+ "klass": entry["_klass"], "rows": page_rows,
1740
+ "turn_id": entry["_turn_id"], "anchor_row": entry["_anchor_row"],
1741
+ }
1742
+ item_index = entry["_item_index"]
1743
+ turn = entry["_turn_id"]
1744
+ cost = tokens = None
1745
+ if (entry["segment_ordinal"] == 0 and turn is not None
1746
+ and carriers.get(turn) == item_index and turn in turn_cost):
1106
1747
  cost = turn_cost[turn]
1107
1748
  tokens = _tokens_union(turn_tokens[turn])
1108
1749
  item = {
1109
- "item_key": _item_key_for_item(conversation_key, it),
1110
- "member_item_keys": _member_item_keys(conversation_key, it),
1111
- "kind": _item_kind(it),
1112
- "timestamp_utc": it["anchor_row"].timestamp_utc,
1113
- "model": it["anchor_row"].model,
1750
+ "item_key": entry["item_key"],
1751
+ "member_item_keys": entry["member_item_keys"],
1752
+ "turn_item_key": entry["turn_item_key"],
1753
+ "segment_ordinal": entry["segment_ordinal"],
1754
+ "kind": entry["kind"],
1755
+ "timestamp_utc": entry["timestamp_utc"],
1756
+ "model": entry["model"],
1114
1757
  "blocks": _build_item_blocks(
1115
- it, payloads, preserve_marker_text=legacy_export),
1758
+ page_item, payloads, preserve_marker_text=legacy_export,
1759
+ call_owner_count=entry["_call_owner_count"],
1760
+ # #463 S2 §2.5 — never under legacy_export: that path loads only
1761
+ # marker-bearing payloads, so populating `headings` there would
1762
+ # force a whole-conversation payload read for a field the
1763
+ # exporter never reads.
1764
+ decompose_headings=not legacy_export),
1116
1765
  "cost_usd": cost,
1117
1766
  "tokens": tokens,
1118
1767
  }
1119
- meta = _item_meta(it)
1120
- if meta is not None:
1121
- item.update(meta)
1122
- lifecycle = _item_lifecycle(it)
1123
- if lifecycle is not None:
1124
- item["lifecycle"] = lifecycle
1768
+ if entry["_meta"] is not None:
1769
+ item.update(entry["_meta"])
1770
+ if entry["_lifecycle"] is not None:
1771
+ item["lifecycle"] = entry["_lifecycle"]
1125
1772
  built.append(item)
1126
1773
  _attach_spawn_child_links(conn, conversation_key, built)
1127
- page_items, page = _paginate_items(built, after=after, before=before, tail=tail, limit=limit)
1774
+ page_items = built
1128
1775
  return {
1129
1776
  "status": "ok",
1130
1777
  "conversation_key": conversation_key,
1131
- "title": _conversation_display_title(conn, conversation_key, rows),
1778
+ # NOT the Phase A rows: those carry no ``text``, and the live-recompute
1779
+ # fallback inside _rollup_fields derives the title from it. Passing None
1780
+ # keeps the stored fast path unchanged and lets the rare no-rollup case
1781
+ # do its own wide read rather than titling the conversation "".
1782
+ "title": _conversation_display_title(conn, conversation_key),
1132
1783
  "items": page_items,
1133
1784
  "page": page,
1134
1785
  "children": _children_of(conn, conversation_key, effective_speed),
@@ -1158,18 +1809,49 @@ def get_codex_conversation_outline(
1158
1809
  ) -> dict:
1159
1810
  """Outline envelope (§5.6): one ``turns[]`` entry per canonical item (label
1160
1811
  via the shared first-non-blank-line helper), plus stats, file touches, and
1161
- child summaries."""
1812
+ child summaries.
1813
+
1814
+ The outline stays TURN-granular: ``turns[].item_key`` remains a turn key,
1815
+ which is still valid because it is segment 0's key.
1816
+
1817
+ Turn-granular keys alone are not sufficient, though (#463 S1). On a cold
1818
+ jump ``loadToTarget`` resolves the target through the outline and does
1819
+ nothing when the identifier is absent, and outline membership carried only
1820
+ folded-item aliases — no segment keys at all — so a deep link into any
1821
+ segment but a turn's first would silently fail to navigate. Each turn
1822
+ therefore also carries ``segment_item_keys``, where entry ``i`` is the key of
1823
+ segment ``i``.
1824
+
1825
+ That channel is deliberately DISTINCT from ``member_item_keys``. Putting
1826
+ segment keys there would make ``loadToTarget``'s "is it already loaded" test
1827
+ report true for a segment that has not been fetched, so the drain would never
1828
+ run and the jump would land nowhere. Membership for navigation and membership
1829
+ for "this item subsumes that key" are different relations.
1830
+ """
1162
1831
  if not codex_normalization_authoritative(conn):
1163
1832
  return {"status": "normalization_pending", "conversation_key": conversation_key,
1164
1833
  "turns": [], "files": [], "children": []}
1834
+ # Deliberately the WIDE read: an outline label is the first non-blank line of
1835
+ # its anchor row's display text, which the narrow index pass does not carry.
1836
+ # The outline is not the route F3 bounds, and it stays turn-granular.
1165
1837
  rows = _load_conversation_rows(conn, conversation_key)
1166
1838
  if not rows:
1167
1839
  return {"status": "not_found", "conversation_key": conversation_key}
1840
+ detail_bytes = _detail_bytes_of(rows)
1168
1841
  kept, _suppressed = kern.pair_mirrors(rows)
1169
1842
  items = kern.canonical_items(kept)
1843
+ segment_keys: dict[int, list[str]] = {}
1844
+ for entry in _build_segment_index(
1845
+ conversation_key, items, detail_bytes, segmented=True):
1846
+ segment_keys.setdefault(entry["_item_index"], []).append(entry["item_key"])
1170
1847
  turns: list[dict] = []
1171
1848
  kind_totals: dict[str, int] = {}
1172
- for it in items:
1849
+ # Keyed on the ITEM index, which is what _build_segment_index records. Using
1850
+ # ``len(turns)`` would be correct only for as long as this loop appends a
1851
+ # turn for every item without exception; a later ``continue`` would misalign
1852
+ # every subsequent turn's segment keys, and the plausible-looking
1853
+ # ``[item_key]`` fallback would hide it by returning a well-formed answer.
1854
+ for index, it in enumerate(items):
1173
1855
  meta = _item_meta(it)
1174
1856
  anchor_text = _row_display(it["anchor_row"])
1175
1857
  if meta is not None:
@@ -1180,9 +1862,11 @@ def get_codex_conversation_outline(
1180
1862
  for r in it["rows"]:
1181
1863
  kinds[r.kind] = kinds.get(r.kind, 0) + 1
1182
1864
  kind_totals[r.kind] = kind_totals.get(r.kind, 0) + 1
1865
+ item_key = _item_key_for_item(conversation_key, it)
1183
1866
  turn = {
1184
- "item_key": _item_key_for_item(conversation_key, it),
1867
+ "item_key": item_key,
1185
1868
  "member_item_keys": _member_item_keys(conversation_key, it),
1869
+ "segment_item_keys": segment_keys.get(index, [item_key]),
1186
1870
  "label": label,
1187
1871
  "timestamp_utc": it["anchor_row"].timestamp_utc,
1188
1872
  "kinds": kinds,
@@ -1321,27 +2005,56 @@ def _search_mode(conn: sqlite3.Connection) -> str:
1321
2005
  return "fts"
1322
2006
 
1323
2007
 
1324
- def _pos_to_item_key(conn: sqlite3.Connection, conversation_key: str) -> dict:
1325
- """Map every physical row ``(source_path, line_offset)`` of a conversation to
1326
- its canonical ``item_key`` (§6.2). Suppressed mirror members fold to their
1327
- canonical partner's key, so both members of a pair share one item_key and can
1328
- never double-count."""
1329
- rows = _load_conversation_rows(conn, conversation_key)
2008
+ def _pos_to_item_key_and_order(
2009
+ conn: sqlite3.Connection, conversation_key: str,
2010
+ ) -> tuple[dict, list[str]]:
2011
+ """``_pos_to_item_key``'s map, plus the segment keys in detail document order.
2012
+
2013
+ Any caller that turns matched POSITIONS back into an ordered anchor list needs
2014
+ both halves, and it must take the order from the SAME segment index the map
2015
+ came from. Rebuilding the order from ``kern.canonical_items`` instead yields
2016
+ turn keys, which agree with the map only for segment 0 — so every hit past the
2017
+ first segment of a turn silently disappears.
2018
+
2019
+ Suppressed mirror members fold to their canonical partner's key, so both
2020
+ members of a pair share one key and can never double-count.
2021
+
2022
+ Resolving to the turn rather than to the segment is the defect that most
2023
+ nearly shipped: search and find derive their anchors here, so a find hit
2024
+ would jump to the head of a turn instead of to the matching content.
2025
+
2026
+ The narrow index read is enough — the map needs positions and keys, not
2027
+ ``text``.
2028
+ """
2029
+ rows, detail_bytes = _load_conversation_index_rows(conn, conversation_key)
1330
2030
  partners = kern.pair_mirror_partners(rows)
1331
2031
  kept, _suppressed = kern.pair_mirrors(rows)
1332
2032
  items = kern.canonical_items(kept)
1333
2033
  pos_map: dict[tuple, str] = {}
1334
- for item in items:
1335
- item_key = _item_key_for_item(conversation_key, item)
1336
- for r in item["rows"]:
1337
- pos_map[(r.source_path, r.line_offset)] = item_key
2034
+ order: list[str] = []
2035
+ for entry in _build_segment_index(
2036
+ conversation_key, items, detail_bytes, segmented=True):
2037
+ order.append(entry["item_key"])
2038
+ for r in entry["_rows"]:
2039
+ pos_map[(r.source_path, r.line_offset)] = entry["item_key"]
2040
+ # Lifecycle rows produce no block and so belong to no fold group, but
2041
+ # they are still physical rows a search hit can name. They resolve to
2042
+ # their turn's head segment.
2043
+ for r in entry["_lifecycle_rows"]:
2044
+ pos_map.setdefault((r.source_path, r.line_offset), entry["item_key"])
1338
2045
  for sup_idx, canon_idx in partners.items():
1339
2046
  sup = rows[sup_idx]
1340
2047
  canon = rows[canon_idx]
1341
2048
  canon_key = pos_map.get((canon.source_path, canon.line_offset))
1342
2049
  if canon_key is not None:
1343
2050
  pos_map[(sup.source_path, sup.line_offset)] = canon_key
1344
- return pos_map
2051
+ return pos_map, order
2052
+
2053
+
2054
+ def _pos_to_item_key(conn: sqlite3.Connection, conversation_key: str) -> dict:
2055
+ """Map every physical row ``(source_path, line_offset)`` of a conversation to
2056
+ the ``item_key`` of the SEGMENT that contains it (§6.2, #463 S1)."""
2057
+ return _pos_to_item_key_and_order(conn, conversation_key)[0]
1345
2058
 
1346
2059
 
1347
2060
  def _fts_query(query: str, column: str | None) -> str:
@@ -1685,22 +2398,24 @@ def find_in_codex_conversation(
1685
2398
  return base
1686
2399
  # Collapse matched physical positions to canonical item_key (mirror-safe), then
1687
2400
  # emit anchors in detail document order.
1688
- pos_map = _pos_to_item_key(conn, conversation_key)
2401
+ # The ORDER must come from the same segment index as the map (#463 S1). It
2402
+ # used to be rebuilt by walking `kern.canonical_items` and keying each entry
2403
+ # with `_item_key_for_item`, which produces a TURN key; a follower segment's
2404
+ # key can never equal one, so every hit past segment 0 of a turn was dropped
2405
+ # from the anchor list and from `total`. The FindBar then reported fewer
2406
+ # matches than exist and could navigate to none of the missing ones.
2407
+ pos_map, order = _pos_to_item_key_and_order(conn, conversation_key)
1689
2408
  by_item: dict[str, set] = {}
1690
2409
  for pos, labels in matched.items():
1691
2410
  item_key = pos_map.get(pos)
1692
2411
  if item_key is None:
1693
2412
  continue
1694
2413
  by_item.setdefault(item_key, set()).update(labels)
1695
- kept, _suppressed = kern.pair_mirrors(rows)
1696
- items = kern.canonical_items(kept)
1697
- anchors = []
1698
- for it in items:
1699
- item_key = _item_key_for_item(conversation_key, it)
1700
- if item_key in by_item:
1701
- anchors.append({
1702
- "item_key": item_key,
1703
- "match_kinds": sorted(l for l in by_item[item_key] if l != "prose")})
2414
+ anchors = [
2415
+ {"item_key": item_key,
2416
+ "match_kinds": sorted(l for l in by_item[item_key] if l != "prose")}
2417
+ for item_key in order if item_key in by_item
2418
+ ]
1704
2419
  total = len(anchors)
1705
2420
  return {**base, "total": total, "anchors": anchors[:cap],
1706
2421
  "anchors_truncated": total > cap}