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.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/bin/_cctally_core.py +14 -0
- package/bin/_cctally_dashboard_envelope.py +67 -12
- package/bin/_cctally_dashboard_sources.py +27 -1
- package/bin/_lib_cache_report_wire.py +8 -20
- package/bin/_lib_codex_conversation.py +108 -0
- package/bin/_lib_codex_conversation_query.py +832 -117
- package/bin/_lib_codex_reasoning_headings.py +73 -0
- package/bin/_lib_codex_segments.py +259 -0
- package/bin/_lib_dashboard_sources.py +33 -32
- package/dashboard/static/assets/index-CILAoEja.js +90 -0
- package/dashboard/static/assets/index-Dwirao3Y.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-Bar8-S1i.css +0 -1
- package/dashboard/static/assets/index-CRogVlEC.js +0 -92
|
@@ -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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 =
|
|
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"] =
|
|
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
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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":
|
|
1037
|
-
"before":
|
|
1038
|
-
"after":
|
|
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
|
-
|
|
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
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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":
|
|
1110
|
-
"member_item_keys":
|
|
1111
|
-
"
|
|
1112
|
-
"
|
|
1113
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
|
1774
|
+
page_items = built
|
|
1128
1775
|
return {
|
|
1129
1776
|
"status": "ok",
|
|
1130
1777
|
"conversation_key": conversation_key,
|
|
1131
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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}
|