cctally 1.70.0 → 1.72.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.
@@ -0,0 +1,509 @@
1
+ """#294 S6 — pure Codex conversation normalization kernel.
2
+
3
+ Maps the S1 ``CodexPhysicalEvent`` batch for one file window to normalized rows +
4
+ file touches (the §3.1 storage shape), and provides the shared assembly helpers
5
+ (mirror pairing, canonical item grouping, rollup item count, title derivation)
6
+ that detail assembly, browse rollups, and search reuse.
7
+
8
+ Pure: no I/O, no DB, no config reads. The digest contract, taxonomy mapping,
9
+ sticky turn/model state, caps, and pairing are all deterministic functions of the
10
+ event batch, so ingest and the migration-025 replay converge.
11
+
12
+ Interface names here are imported verbatim by later S6 tasks — do not rename.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import hashlib
18
+ import json
19
+ from typing import Any, Iterable
20
+
21
+ # Reuse the Claude display helpers by IMPORT (never move them). _strip_ansi lives
22
+ # in _lib_conversation; the title cap + first-non-blank-line helper in
23
+ # _lib_conversation_query. These modules are stdlib-only pure kernels.
24
+ from _lib_conversation import _strip_ansi
25
+ from _lib_conversation_query import _TITLE_MAX, _first_nonblank_line
26
+
27
+
28
+ # ── digest contract (§3.1) ────────────────────────────────────────────────────
29
+
30
+ CODEX_CONVERSATION_DIGEST_DOMAIN = b"cctally-codex-conversation-digest-v1\0"
31
+
32
+
33
+ def canonical_content(text: str | None) -> str:
34
+ """Canonicalize row content for the digest: line-ending normalization ONLY
35
+ (``\\r\\n`` / ``\\r`` → ``\\n``). Internal whitespace, indentation, and ANSI
36
+ sequences are PRESERVED so differently-formatted prose or code can never share
37
+ a digest. ``None`` canonicalizes to ``""``."""
38
+ if not text:
39
+ return ""
40
+ return text.replace("\r\n", "\n").replace("\r", "\n")
41
+
42
+
43
+ def content_digest(text: str | None) -> str:
44
+ """Versioned, domain-separated digest of the exact pre-cap canonical text:
45
+ first 32 hex chars of ``sha256(DOMAIN + utf8(canonical_content(text)))``."""
46
+ canonical = canonical_content(text)
47
+ return hashlib.sha256(
48
+ CODEX_CONVERSATION_DIGEST_DOMAIN + canonical.encode("utf-8")
49
+ ).hexdigest()[:32]
50
+
51
+
52
+ def content_len(text: str | None) -> int:
53
+ """UTF-8 byte length of the canonical (pre-cap) text (§3.1)."""
54
+ return len(canonical_content(text).encode("utf-8"))
55
+
56
+
57
+ # ── caps + display (§4.4 / §4.3) ──────────────────────────────────────────────
58
+
59
+ # Display/search extract cap. Mirrors _lib_conversation._TOOL_RESULT_CAP (16000):
60
+ # the ``text`` / ``search_*`` columns are capped, but the digest + content_len are
61
+ # taken over the PRE-cap canonical text (§3.1), so a stale cursor can never point
62
+ # at silently-different content. Equal-by-test to the Claude tool cap.
63
+ CODEX_TEXT_CAP = 16000
64
+
65
+ # Display title cap. Equal-by-test to the Claude first-prompt titler's _TITLE_MAX.
66
+ CODEX_TITLE_MAX = _TITLE_MAX
67
+
68
+ # Structural wrapper prefixes skipped during title selection (§4.3), pinned from
69
+ # the corpus (title-wrapper-window). Prefix-structural, never content heuristics.
70
+ CODEX_TITLE_SKIP_PREFIXES: tuple[str, ...] = (
71
+ "<environment_context>",
72
+ "<user_instructions>",
73
+ )
74
+
75
+ _PROSE_KINDS = frozenset({"user", "assistant", "reasoning"})
76
+
77
+
78
+ def _cap(text: str) -> tuple[str, bool]:
79
+ """Return (capped_text, was_truncated) at CODEX_TEXT_CAP."""
80
+ if len(text) > CODEX_TEXT_CAP:
81
+ return text[:CODEX_TEXT_CAP], True
82
+ return text, False
83
+
84
+
85
+ def _display_title(text: str | None) -> str:
86
+ """Whitespace-collapsed, ANSI-stripped, capped display title (§4.3)."""
87
+ if not text:
88
+ return ""
89
+ collapsed = " ".join(_strip_ansi(text).split())
90
+ if not collapsed:
91
+ return ""
92
+ if len(collapsed) > CODEX_TITLE_MAX:
93
+ return collapsed[:CODEX_TITLE_MAX].rstrip() + "…"
94
+ return collapsed
95
+
96
+
97
+ # ── normalized-row / file-touch / sticky-state dataclasses ────────────────────
98
+
99
+
100
+ @dataclasses.dataclass(frozen=True)
101
+ class CodexNormalizedRow:
102
+ """Mirrors codex_conversation_messages (§3.1) minus the ``id`` rowid alias."""
103
+
104
+ conversation_key: str
105
+ source_root_key: str
106
+ source_path: str
107
+ line_offset: int
108
+ timestamp_utc: str | None
109
+ turn_id: str | None
110
+ call_id: str | None
111
+ kind: str
112
+ event_type: str | None
113
+ record_family: str
114
+ model: str | None
115
+ text: str
116
+ content_digest: str
117
+ content_len: int
118
+ detail_json: str | None
119
+ search_tool: str
120
+ search_thinking: str
121
+
122
+
123
+ @dataclasses.dataclass(frozen=True)
124
+ class CodexFileTouch:
125
+ """A write-class file touch (§3.3). Message linkage is resolved at write time
126
+ via ``(source_path, line_offset)`` of the owning normalized row."""
127
+
128
+ conversation_key: str
129
+ source_path: str
130
+ file_path: str
131
+ tool: str
132
+ line_offset: int
133
+
134
+
135
+ @dataclasses.dataclass
136
+ class CodexStickyState:
137
+ """Sticky per-file turn + model state (§4.2). Terminal value persists to
138
+ codex_session_files for delta resumes."""
139
+
140
+ turn_id: str | None = None
141
+ model: str | None = None
142
+
143
+
144
+ @dataclasses.dataclass
145
+ class CodexNormalizationResult:
146
+ rows: list[CodexNormalizedRow]
147
+ touches: list[CodexFileTouch]
148
+ terminal: CodexStickyState
149
+
150
+
151
+ # ── taxonomy mapping (§4.1) ───────────────────────────────────────────────────
152
+
153
+ # response_item payload subtype -> (kind, is_tool_call, is_tool_output)
154
+ _RESPONSE_TOOL_CALLS = frozenset(
155
+ {"function_call", "custom_tool_call", "tool_search_call", "web_search_call"})
156
+ _RESPONSE_TOOL_OUTPUTS = frozenset(
157
+ {"function_call_output", "custom_tool_call_output", "tool_search_output"})
158
+ # event_msg payload subtype -> kind (prose families + provider-truthful cards)
159
+ _EVENT_PROSE_KIND = {
160
+ "user_message": "user",
161
+ "agent_message": "assistant",
162
+ "agent_reasoning": "reasoning",
163
+ }
164
+ _EVENT_CARD_TYPES = frozenset({
165
+ "task_started", "task_complete", "context_compacted", "patch_apply_end",
166
+ "mcp_tool_call_end", "web_search_end",
167
+ })
168
+
169
+
170
+ def _canonical_json(value: Any) -> str:
171
+ try:
172
+ return json.dumps(value, sort_keys=True, separators=(",", ":"),
173
+ ensure_ascii=False, allow_nan=False)
174
+ except (TypeError, ValueError):
175
+ return ""
176
+
177
+
178
+ def _stringify(value: Any) -> str:
179
+ if value is None:
180
+ return ""
181
+ if isinstance(value, str):
182
+ return value
183
+ return _canonical_json(value)
184
+
185
+
186
+ def _join_content_texts(content: Any) -> str:
187
+ """Join the ``text`` leaves of a response_item message/reasoning content list."""
188
+ if not isinstance(content, list):
189
+ return ""
190
+ parts = []
191
+ for item in content:
192
+ if isinstance(item, dict):
193
+ txt = item.get("text")
194
+ if isinstance(txt, str) and txt:
195
+ parts.append(txt)
196
+ return "\n".join(parts)
197
+
198
+
199
+ @dataclasses.dataclass
200
+ class _Extracted:
201
+ kind: str
202
+ content_text: str # pre-cap, feeds digest + content_len
203
+ column: str # "text" | "search_tool" | "search_thinking"
204
+ detail: dict | None
205
+ touches: list[tuple[str, str]] # (file_path, tool)
206
+
207
+
208
+ def _extract(record_type: str | None, payload: dict) -> _Extracted | None:
209
+ """Map one physical record to (kind, content, column, detail, touches), or
210
+ None when the record produces no normalized row (§4.1). Malformed fields
211
+ degrade to empty; the record is never aborted."""
212
+ ptype = payload.get("type") if isinstance(payload, dict) else None
213
+ if record_type == "response_item":
214
+ if ptype == "message":
215
+ role = payload.get("role")
216
+ kind = "user" if role == "user" else "assistant"
217
+ text = _join_content_texts(payload.get("content"))
218
+ return _Extracted(kind, text, "text", None, [])
219
+ if ptype == "reasoning":
220
+ summary = _join_content_texts(payload.get("summary"))
221
+ body = _join_content_texts(payload.get("content"))
222
+ text = "\n".join(p for p in (summary, body) if p)
223
+ return _Extracted("reasoning", text, "search_thinking", None, [])
224
+ if ptype in _RESPONSE_TOOL_CALLS:
225
+ name = payload.get("name") or ptype
226
+ if ptype == "function_call":
227
+ args = _stringify(payload.get("arguments"))
228
+ elif ptype == "web_search_call":
229
+ args = _stringify(payload.get("action"))
230
+ else:
231
+ args = _stringify(payload.get("input") or payload.get("arguments"))
232
+ text = f"{name}\n{args}" if args else str(name)
233
+ detail = {"name": name, "args": args[:CODEX_TEXT_CAP]}
234
+ return _Extracted("tool_call", text, "search_tool", detail, [])
235
+ if ptype in _RESPONSE_TOOL_OUTPUTS:
236
+ body = _stringify(payload.get("output") or payload.get("tools"))
237
+ return _Extracted("tool_output", body, "search_tool", None, [])
238
+ return None # unknown response_item subtype: version tolerance
239
+ if record_type == "event_msg":
240
+ if ptype in _EVENT_PROSE_KIND:
241
+ kind = _EVENT_PROSE_KIND[ptype]
242
+ text = _stringify(payload.get("message") or payload.get("text"))
243
+ column = "search_thinking" if kind == "reasoning" else "text"
244
+ return _Extracted(kind, text, column, None, [])
245
+ if ptype in _EVENT_CARD_TYPES:
246
+ text, touches = _event_card(ptype, payload)
247
+ detail = {"event": ptype}
248
+ return _Extracted("event", text, "search_tool", detail, touches)
249
+ return None # token_count (accounting) + unknown event types: no row
250
+ # session_meta / turn_context / unknown record types: no normalized row
251
+ return None
252
+
253
+
254
+ def _event_card(ptype: str, payload: dict) -> tuple[str, list[tuple[str, str]]]:
255
+ """Provider-truthful searchable card text + any file touches for an event."""
256
+ touches: list[tuple[str, str]] = []
257
+ if ptype == "task_complete":
258
+ text = " ".join(p for p in ("task_complete", _stringify(
259
+ payload.get("last_agent_message"))) if p)
260
+ elif ptype == "patch_apply_end":
261
+ paths = []
262
+ changes = payload.get("changes")
263
+ if isinstance(changes, list):
264
+ for change in changes:
265
+ if isinstance(change, dict) and isinstance(change.get("path"), str):
266
+ paths.append(change["path"])
267
+ touches.append((change["path"], "apply_patch"))
268
+ text = " ".join(["patch_apply", *paths]) if paths else "patch_apply"
269
+ elif ptype == "mcp_tool_call_end":
270
+ invocation = payload.get("invocation")
271
+ name = invocation.get("name") if isinstance(invocation, dict) else None
272
+ text = f"mcp_tool_call {name}" if name else "mcp_tool_call"
273
+ elif ptype == "web_search_end":
274
+ query = _stringify(payload.get("query"))
275
+ text = f"web_search {query}".strip()
276
+ else: # task_started, context_compacted
277
+ text = ptype
278
+ return text, touches
279
+
280
+
281
+ def normalize_codex_events(
282
+ events: Iterable[Any], *, initial: CodexStickyState
283
+ ) -> CodexNormalizationResult:
284
+ """Map one file window's CodexPhysicalEvent batch (offset order) to normalized
285
+ rows + file touches, replaying sticky turn/model state seeded from ``initial``.
286
+
287
+ Returns the terminal sticky state for persistence to codex_session_files."""
288
+ sticky = CodexStickyState(turn_id=initial.turn_id, model=initial.model)
289
+ rows: list[CodexNormalizedRow] = []
290
+ touches: list[CodexFileTouch] = []
291
+ for event in events:
292
+ record_type = getattr(event, "record_type", None)
293
+ if record_type == "session_meta":
294
+ sticky.turn_id = None
295
+ sticky.model = None
296
+ continue
297
+ try:
298
+ obj = json.loads(getattr(event, "payload_json", "") or "{}")
299
+ except (json.JSONDecodeError, TypeError):
300
+ obj = {}
301
+ payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
302
+ if record_type == "turn_context":
303
+ own = getattr(event, "turn_id", None)
304
+ sticky.turn_id = own if own is not None else payload.get("turn_id")
305
+ model = payload.get("model")
306
+ sticky.model = model if isinstance(model, str) and model else None
307
+ continue
308
+ # Identity-less events (before any session_meta / no resume seed) are
309
+ # unaddressable and stay physical-only (§4.1) — never normalized.
310
+ conversation_key = getattr(event, "conversation_key", None)
311
+ if conversation_key is None:
312
+ continue
313
+ extracted = _extract(record_type, payload)
314
+ if extracted is None:
315
+ continue
316
+ own_turn = getattr(event, "turn_id", None)
317
+ eff_turn = own_turn if own_turn is not None else sticky.turn_id
318
+ text_full = extracted.content_text or ""
319
+ capped, truncated = _cap(text_full)
320
+ text_col = ""
321
+ search_tool = ""
322
+ search_thinking = ""
323
+ if extracted.column == "text":
324
+ text_col = capped
325
+ elif extracted.column == "search_thinking":
326
+ search_thinking = capped
327
+ else:
328
+ search_tool = capped
329
+ detail = dict(extracted.detail) if extracted.detail else None
330
+ if truncated:
331
+ detail = detail or {}
332
+ detail["truncated"] = True
333
+ source_path = getattr(event, "source_path", "")
334
+ line_offset = getattr(event, "line_offset", 0)
335
+ rows.append(CodexNormalizedRow(
336
+ conversation_key=conversation_key,
337
+ source_root_key=getattr(event, "source_root_key", None) or "",
338
+ source_path=source_path,
339
+ line_offset=line_offset,
340
+ timestamp_utc=getattr(event, "timestamp_utc", None),
341
+ turn_id=eff_turn,
342
+ call_id=getattr(event, "call_id", None),
343
+ kind=extracted.kind,
344
+ event_type=getattr(event, "event_type", None),
345
+ record_family="event_msg" if record_type == "event_msg" else "response_item",
346
+ model=sticky.model,
347
+ text=text_col,
348
+ content_digest=content_digest(text_full),
349
+ content_len=content_len(text_full),
350
+ detail_json=_canonical_json(detail) if detail else None,
351
+ search_tool=search_tool,
352
+ search_thinking=search_thinking,
353
+ ))
354
+ for file_path, tool in extracted.touches:
355
+ touches.append(CodexFileTouch(
356
+ conversation_key=conversation_key,
357
+ source_path=source_path,
358
+ file_path=file_path,
359
+ tool=tool,
360
+ line_offset=line_offset,
361
+ ))
362
+ return CodexNormalizationResult(
363
+ rows=rows, touches=touches,
364
+ terminal=CodexStickyState(turn_id=sticky.turn_id, model=sticky.model),
365
+ )
366
+
367
+
368
+ # ── mirror pairing + canonical items (§5.3 / §5.2) ────────────────────────────
369
+
370
+
371
+ def _pair_mirrors_impl(
372
+ rows: list[CodexNormalizedRow],
373
+ ) -> tuple[set[int], dict[int, int]]:
374
+ """Core mirror-pairing pass (§5.3): returns ``(suppressed_indexes,
375
+ partner_map)`` where ``partner_map[suppressed_idx] = canonical_idx`` — the
376
+ single source of truth for both ``pair_mirrors`` and ``pair_mirror_partners``.
377
+ """
378
+ suppressed: set[int] = set()
379
+ partners: dict[int, int] = {}
380
+
381
+ # Turned pairing: group prose rows by (turn_id, kind, digest, len). Multiset
382
+ # one-to-one in physical order — the k-th event member pairs the k-th response
383
+ # member (three identical event copies never collapse into one).
384
+ turned: dict[tuple, dict[str, list[int]]] = {}
385
+ for i, row in enumerate(rows):
386
+ if row.kind not in _PROSE_KINDS or row.turn_id is None:
387
+ continue
388
+ key = (row.turn_id, row.kind, row.content_digest, row.content_len)
389
+ group = turned.setdefault(key, {"R": [], "E": []})
390
+ group["E" if row.record_family == "event_msg" else "R"].append(i)
391
+ for group in turned.values():
392
+ pair_count = min(len(group["R"]), len(group["E"]))
393
+ for k in range(pair_count):
394
+ event_idx = group["E"][k]
395
+ suppressed.add(event_idx)
396
+ partners[event_idx] = group["R"][k]
397
+
398
+ # Unturned pairing: adjacency over the nearest preceding same-kind prose row.
399
+ last_prose_by_kind: dict[str, int] = {}
400
+ paired_response: set[int] = set()
401
+ for j, row in enumerate(rows):
402
+ if row.kind not in _PROSE_KINDS or row.turn_id is not None:
403
+ continue
404
+ prev_idx = last_prose_by_kind.get(row.kind)
405
+ if row.record_family == "event_msg" and prev_idx is not None:
406
+ prev = rows[prev_idx]
407
+ if (prev.record_family != "event_msg"
408
+ and prev_idx not in paired_response
409
+ and prev.content_digest == row.content_digest
410
+ and prev.content_len == row.content_len
411
+ and prev.source_path == row.source_path):
412
+ suppressed.add(j)
413
+ partners[j] = prev_idx
414
+ paired_response.add(prev_idx)
415
+ last_prose_by_kind[row.kind] = j
416
+
417
+ return suppressed, partners
418
+
419
+
420
+ def pair_mirrors(rows: list[CodexNormalizedRow]) -> tuple[list[CodexNormalizedRow], set[int]]:
421
+ """Suppress the event_msg member of each digest-exact mirror pair (§5.3).
422
+
423
+ Returns ``(kept_rows_in_input_order, suppressed_input_indexes)``. Pairing is
424
+ digest-exact (never on capped text) and correlation-gated:
425
+ * turned rows (turn_id set) pair one-to-one as multisets within the same
426
+ effective turn (three identical event copies never collapse into one);
427
+ * unturned rows require the same source file AND adjacency — no intervening
428
+ same-kind prose row between the two members.
429
+ The response_item member is canonical; the event_msg member is suppressed.
430
+ """
431
+ suppressed, _partners = _pair_mirrors_impl(rows)
432
+ kept = [row for i, row in enumerate(rows) if i not in suppressed]
433
+ return kept, suppressed
434
+
435
+
436
+ def pair_mirror_partners(rows: list[CodexNormalizedRow]) -> dict[int, int]:
437
+ """Map each suppressed mirror-member index to its canonical (kept) partner
438
+ index (§5.3). Used by search's item-level collapse so a suppressed event_msg
439
+ row folds into the same ``item_key`` as its response_item partner (§6.2),
440
+ keeping mirror rows from double-counting."""
441
+ _suppressed, partners = _pair_mirrors_impl(rows)
442
+ return partners
443
+
444
+
445
+ def _item_pos(row: CodexNormalizedRow) -> tuple:
446
+ return (row.timestamp_utc or "", row.source_path, row.line_offset)
447
+
448
+
449
+ def canonical_items(rows: list[CodexNormalizedRow]) -> list[dict]:
450
+ """Group KEPT (post-pairing) rows into canonical rendered items (§5.2).
451
+
452
+ Response items bundle a turn's assistant/reasoning/tool rows; prompt/event/
453
+ unturned items are one-per-row. Ordered by (timestamp_utc, source_path,
454
+ line_offset). Each item dict: {"klass", "rows", "turn_id", "anchor_row"}.
455
+ """
456
+ ordered = sorted(rows, key=_item_pos)
457
+ items: list[dict] = []
458
+ response_by_turn: dict[str, dict] = {}
459
+ for row in ordered:
460
+ if row.turn_id is None:
461
+ items.append({"klass": "unturned", "rows": [row], "turn_id": None,
462
+ "anchor_row": row})
463
+ elif row.kind == "user":
464
+ items.append({"klass": "prompt", "rows": [row], "turn_id": row.turn_id,
465
+ "anchor_row": row})
466
+ elif row.kind == "event":
467
+ items.append({"klass": "event", "rows": [row], "turn_id": row.turn_id,
468
+ "anchor_row": row})
469
+ else:
470
+ item = response_by_turn.get(row.turn_id)
471
+ if item is None:
472
+ item = {"klass": "response", "rows": [], "turn_id": row.turn_id,
473
+ "anchor_row": row}
474
+ response_by_turn[row.turn_id] = item
475
+ items.append(item)
476
+ item["rows"].append(row)
477
+ items.sort(key=lambda it: _item_pos(it["anchor_row"]))
478
+ return items
479
+
480
+
481
+ def rollup_item_count(rows: list[CodexNormalizedRow]) -> int:
482
+ """Count rendered LOGICAL items (mirror-paired), not physical rows (§3.2)."""
483
+ kept, _ = pair_mirrors(rows)
484
+ return len(canonical_items(kept))
485
+
486
+
487
+ # ── title derivation (§4.3) ───────────────────────────────────────────────────
488
+
489
+
490
+ def derive_title(rows: list[CodexNormalizedRow]) -> str | None:
491
+ """First meaningful user prompt over the first 12 LOGICAL prompts (after
492
+ mirror pairing), skipping structural wrapper noise; display-normalized +
493
+ capped; ``None`` when no meaningful prompt exists in the window (§4.3)."""
494
+ kept, _ = pair_mirrors(rows)
495
+ items = canonical_items(kept)
496
+ prompts = [
497
+ it for it in items
498
+ if it["klass"] == "prompt"
499
+ or (it["klass"] == "unturned" and it["anchor_row"].kind == "user")
500
+ ]
501
+ for item in prompts[:12]:
502
+ raw = item["anchor_row"].text
503
+ first_line = _first_nonblank_line(_strip_ansi(raw or "")) if raw else ""
504
+ if any(first_line.startswith(prefix) for prefix in CODEX_TITLE_SKIP_PREFIXES):
505
+ continue
506
+ candidate = _display_title(raw)
507
+ if candidate:
508
+ return candidate
509
+ return None