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.
- package/CHANGELOG.md +21 -0
- package/bin/_cctally_cache.py +430 -8
- package/bin/_cctally_config.py +108 -0
- package/bin/_cctally_core.py +6 -1
- package/bin/_cctally_dashboard.py +106 -11
- package/bin/_cctally_db.py +445 -1
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_quota.py +58 -6
- package/bin/_lib_codex_conversation.py +509 -0
- package/bin/_lib_codex_conversation_query.py +950 -0
- package/bin/_lib_conversation_dispatch.py +547 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/cctally +1 -0
- package/dashboard/static/assets/index-BWadAHxC.js +80 -0
- package/dashboard/static/assets/index-D2nwo_ln.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-CBbErI-P.js +0 -80
- package/dashboard/static/assets/index-kDDVOLa_.css +0 -1
|
@@ -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
|