ccx-native 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +807 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +269 -0
- package/ccx/claude/host/session.py +5244 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1558 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2424 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1454 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/models.py +225 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +296 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""CCX-owned client ids for the user messages CCX sends into Codex.
|
|
2
|
+
|
|
3
|
+
Native Codex accepts an optional ``clientUserMessageId`` on ``turn/start`` and
|
|
4
|
+
``turn/steer`` and persists it on the resulting ``userMessage`` item as
|
|
5
|
+
``clientId``. That is the only durable, first-class way to tell — reading a
|
|
6
|
+
thread back cold, possibly on another machine — which user messages CCX itself
|
|
7
|
+
put there, and whether one started a turn or steered a running one.
|
|
8
|
+
|
|
9
|
+
The id names the semantic user input, not a JSON-RPC attempt: an explicit
|
|
10
|
+
overload retry re-sends the identical request and therefore reuses the same id,
|
|
11
|
+
while a genuinely new input always gets a fresh one.
|
|
12
|
+
|
|
13
|
+
Parsing is deliberately conservative. Only the exact format this module emits
|
|
14
|
+
is recognized; an id written by another client, a malformed one, a legacy
|
|
15
|
+
``null``, and anything CCX cannot prove it authored all stay unknown rather
|
|
16
|
+
than being guessed at from position, text, or turn shape.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import uuid
|
|
23
|
+
|
|
24
|
+
# Historical v1 native client message IDs remain immutable forensic facts.
|
|
25
|
+
# Mint v2 only for new full-UUID owners; no rewrite of original operation IDs.
|
|
26
|
+
FORMAT_VERSION = "v2"
|
|
27
|
+
|
|
28
|
+
START = "start"
|
|
29
|
+
STEER = "steer"
|
|
30
|
+
KINDS = (START, STEER)
|
|
31
|
+
|
|
32
|
+
_ORIGINS = {START: "ccx-start", STEER: "ccx-steer"}
|
|
33
|
+
|
|
34
|
+
# Original short-keyed v1 native client IDs remain parseable but not
|
|
35
|
+
# managed-worker addressing authority; v2 uses the complete owner UUID.
|
|
36
|
+
_PATTERN = re.compile(
|
|
37
|
+
r"^ccx:"
|
|
38
|
+
r"v1:"
|
|
39
|
+
r"(?P<short>[0-9a-f]{8}):"
|
|
40
|
+
rf"(?P<kind>{START}|{STEER}):"
|
|
41
|
+
r"(?P<uuid>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Historical v1 IDs in existing native Codex threads remain forensic
|
|
45
|
+
# provenance and are never rewritten. New full-UUID identities use v2
|
|
46
|
+
# with one independent canonical full owner (no truncated alias).
|
|
47
|
+
_PATTERN_V2 = re.compile(
|
|
48
|
+
r"^ccx:v2:"
|
|
49
|
+
r"(?P<workerId>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}):"
|
|
50
|
+
rf"(?P<kind>{START}|{STEER}):"
|
|
51
|
+
r"(?P<uuid>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def new_client_message_id(short: str, kind: str) -> str:
|
|
56
|
+
"""Mint one id for a single semantic user input.
|
|
57
|
+
|
|
58
|
+
Carries no prompt text — an id ends up in a native thread CCX does not own
|
|
59
|
+
the retention of, so it must be pure provenance.
|
|
60
|
+
"""
|
|
61
|
+
if kind not in KINDS:
|
|
62
|
+
raise ValueError(f"unknown client message kind: {kind!r}")
|
|
63
|
+
from ccx.store.identity import InvalidTarget, canonical_worker_id
|
|
64
|
+
try:
|
|
65
|
+
owner = canonical_worker_id(short)
|
|
66
|
+
except InvalidTarget as exc:
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"new Codex clientUserMessageId requires canonical full CCX UUIDv4"
|
|
69
|
+
) from exc
|
|
70
|
+
return f"ccx:{FORMAT_VERSION}:{owner}:{kind}:{uuid.uuid4()}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_client_message_id(value: object) -> dict[str, str] | None:
|
|
74
|
+
"""Decompose an id CCX can prove it authored, else ``None``."""
|
|
75
|
+
if not isinstance(value, str):
|
|
76
|
+
return None
|
|
77
|
+
match = _PATTERN_V2.match(value) or _PATTERN.match(value)
|
|
78
|
+
if match is None:
|
|
79
|
+
return None
|
|
80
|
+
parsed = match.groupdict()
|
|
81
|
+
# `uuid4()` produces the canonical lowercase form the pattern requires, so
|
|
82
|
+
# a value that parses but does not round-trip was not written by us.
|
|
83
|
+
if str(uuid.UUID(parsed["uuid"])) != parsed["uuid"]:
|
|
84
|
+
return None
|
|
85
|
+
return parsed
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def origin_of(value: object) -> str | None:
|
|
89
|
+
"""``"ccx-start"``/``"ccx-steer"`` for a CCX id, ``None`` for anything else.
|
|
90
|
+
|
|
91
|
+
``None`` is a real answer: the message exists and its origin is unknown.
|
|
92
|
+
"""
|
|
93
|
+
parsed = parse_client_message_id(value)
|
|
94
|
+
return _ORIGINS[parsed["kind"]] if parsed else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"FORMAT_VERSION",
|
|
99
|
+
"KINDS",
|
|
100
|
+
"START",
|
|
101
|
+
"STEER",
|
|
102
|
+
"new_client_message_id",
|
|
103
|
+
"origin_of",
|
|
104
|
+
"parse_client_message_id",
|
|
105
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared native Codex pending approval projection for CLI, WorkerClient and the public protocol."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pending_input(approvals: Any) -> dict[str, Any] | None:
|
|
7
|
+
"""The cross-engine normalized pending-input object for Codex approvals.
|
|
8
|
+
|
|
9
|
+
The one projection ``peek``, ``WorkerClient`` and the public protocol share. A
|
|
10
|
+
Codex approval is never dressed up as a user question: Claude's
|
|
11
|
+
AskUserQuestion is a separate capability, so ``questions`` stays empty.
|
|
12
|
+
"""
|
|
13
|
+
approvals = [value for value in approvals or [] if isinstance(value, dict)]
|
|
14
|
+
if not approvals:
|
|
15
|
+
return None
|
|
16
|
+
first = approvals[0]
|
|
17
|
+
message = first.get("reason") or first.get("command")
|
|
18
|
+
return {
|
|
19
|
+
"kind": "permission",
|
|
20
|
+
# Native request IDs may repeat after app-server restart. Only a real
|
|
21
|
+
# CCX gateId is actionable; historical previews cannot be decided.
|
|
22
|
+
"request_id": first.get("gateId") if isinstance(first.get("gateId"), str) else None,
|
|
23
|
+
"tool": first.get("kind"),
|
|
24
|
+
"message": message if isinstance(message, str) else (
|
|
25
|
+
None if message is None else str(message)
|
|
26
|
+
),
|
|
27
|
+
"questions": [],
|
|
28
|
+
"native": {"pendingApprovals": approvals},
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def now_ms() -> int:
|
|
8
|
+
return int(time.time() * 1000)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _uuid_codex_store():
|
|
12
|
+
"""ACTIVE full-ID bootstrap before any Codex record filesystem operation."""
|
|
13
|
+
from ccx.constants import CCX_ROOT
|
|
14
|
+
from ccx.store.root import require_ccx_active
|
|
15
|
+
return require_ccx_active(CCX_ROOT)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_record(target: str) -> dict[str, Any]:
|
|
19
|
+
"""Read exact managed Codex full UUID, never short/name/thread alias."""
|
|
20
|
+
from ccx.store.identity import EngineMismatch
|
|
21
|
+
store = _uuid_codex_store()
|
|
22
|
+
owner = store.resolve(target)
|
|
23
|
+
if owner.engine != "codex":
|
|
24
|
+
raise EngineMismatch("Codex record lookup requires Codex full worker UUID")
|
|
25
|
+
return store.record(owner.worker)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_record(record: dict[str, Any]) -> None:
|
|
29
|
+
"""Require original exact whole-record revision, or refuse stale overwrite."""
|
|
30
|
+
from ccx.store.root import RecordConflict, StorageIntegrityError
|
|
31
|
+
if not isinstance(record, dict) or record.get("engine") != "codex":
|
|
32
|
+
raise StorageIntegrityError("Codex full-UUID record must preserve engine")
|
|
33
|
+
original_revision = record.get("recordRevision")
|
|
34
|
+
if type(original_revision) is not int or original_revision < 0:
|
|
35
|
+
raise RecordConflict(
|
|
36
|
+
"Codex whole-record snapshot needs current original recordRevision"
|
|
37
|
+
)
|
|
38
|
+
store = _uuid_codex_store()
|
|
39
|
+
updated = store.replace_record_if_revision(
|
|
40
|
+
f"codex:{record.get('workerId')}", record,
|
|
41
|
+
expected_revision=original_revision,
|
|
42
|
+
)
|
|
43
|
+
record.clear()
|
|
44
|
+
record.update(updated)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iter_records():
|
|
48
|
+
"""Only full-UUID Codex global owners, not old codex/sessions short path."""
|
|
49
|
+
store = _uuid_codex_store()
|
|
50
|
+
for row in store.list_workers(engine="codex"):
|
|
51
|
+
yield store.record(row["worker"])
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Phase-aware selection of native Codex final assistant reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
COMMENTARY_PHASE = "commentary"
|
|
9
|
+
FINAL_ANSWER_PHASE = "final_answer"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def native_timestamp_ms(value: Any) -> int | None:
|
|
13
|
+
"""Convert a finite native Unix-seconds timestamp to integer milliseconds."""
|
|
14
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
15
|
+
try:
|
|
16
|
+
if math.isfinite(value):
|
|
17
|
+
return int(value * 1000)
|
|
18
|
+
except (OverflowError, ValueError):
|
|
19
|
+
pass
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def report_from_item(
|
|
24
|
+
item: dict[str, Any] | None,
|
|
25
|
+
*,
|
|
26
|
+
turn_id: str | None,
|
|
27
|
+
completed_at: int | None = None,
|
|
28
|
+
) -> dict[str, Any] | None:
|
|
29
|
+
"""Return a report candidate for one completed native item.
|
|
30
|
+
|
|
31
|
+
Codex providers do not consistently populate ``phase``. A missing or null
|
|
32
|
+
phase is therefore deliberately accepted as the legacy compatibility
|
|
33
|
+
fallback, while explicit commentary is never a report.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(item, dict) or item.get("type") != "agentMessage":
|
|
36
|
+
return None
|
|
37
|
+
text = item.get("text")
|
|
38
|
+
if not text:
|
|
39
|
+
return None
|
|
40
|
+
phase = item.get("phase")
|
|
41
|
+
if phase == COMMENTARY_PHASE:
|
|
42
|
+
return None
|
|
43
|
+
if phase not in (None, FINAL_ANSWER_PHASE):
|
|
44
|
+
return None
|
|
45
|
+
return {
|
|
46
|
+
"text": str(text),
|
|
47
|
+
"turnId": turn_id,
|
|
48
|
+
"itemId": item.get("id"),
|
|
49
|
+
"phase": phase,
|
|
50
|
+
"completedAt": completed_at,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def report_from_turn(turn: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
55
|
+
"""Select one completed turn's final answer, if it has one.
|
|
56
|
+
|
|
57
|
+
A strong ``final_answer`` candidate wins over every phase-less fallback in
|
|
58
|
+
the turn. Within either class, native item order determines the winner.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(turn, dict):
|
|
61
|
+
return None
|
|
62
|
+
turn_id = turn.get("id")
|
|
63
|
+
completed_at = native_timestamp_ms(turn.get("completedAt"))
|
|
64
|
+
strong = None
|
|
65
|
+
fallback = None
|
|
66
|
+
for item in turn.get("items") or []:
|
|
67
|
+
candidate = report_from_item(
|
|
68
|
+
item, turn_id=turn_id, completed_at=completed_at
|
|
69
|
+
)
|
|
70
|
+
if not candidate:
|
|
71
|
+
continue
|
|
72
|
+
if candidate["phase"] == FINAL_ANSWER_PHASE:
|
|
73
|
+
strong = candidate
|
|
74
|
+
else:
|
|
75
|
+
fallback = candidate
|
|
76
|
+
return strong or fallback
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def select_report_for_turn(
|
|
80
|
+
turn: dict[str, Any] | None, *,
|
|
81
|
+
live_report: dict[str, Any] | None = None,
|
|
82
|
+
) -> dict[str, Any] | None:
|
|
83
|
+
"""Prefer an exact-turn streamed final answer to a weaker history fallback.
|
|
84
|
+
|
|
85
|
+
Both codexd's live completion writer and its history observer use this
|
|
86
|
+
phase-aware rule; an unrelated turn's cached report is never borrowed.
|
|
87
|
+
"""
|
|
88
|
+
selected = report_from_turn(turn)
|
|
89
|
+
turn_id = turn.get("id") if isinstance(turn, dict) else None
|
|
90
|
+
if (isinstance(live_report, dict)
|
|
91
|
+
and isinstance(turn_id, str) and turn_id
|
|
92
|
+
and live_report.get("turnId") == turn_id
|
|
93
|
+
and isinstance(live_report.get("text"), str)
|
|
94
|
+
and (selected is None or (
|
|
95
|
+
live_report.get("phase") == FINAL_ANSWER_PHASE
|
|
96
|
+
and selected.get("phase") != FINAL_ANSWER_PHASE))):
|
|
97
|
+
return live_report
|
|
98
|
+
return selected
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def report_from_thread(thread: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
102
|
+
"""Select the latest report candidate from completed native turns."""
|
|
103
|
+
if not isinstance(thread, dict):
|
|
104
|
+
return None
|
|
105
|
+
for turn in reversed(thread.get("turns") or []):
|
|
106
|
+
if not isinstance(turn, dict) or turn.get("status") != "completed":
|
|
107
|
+
continue
|
|
108
|
+
candidate = report_from_turn(turn)
|
|
109
|
+
if candidate:
|
|
110
|
+
return candidate
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def should_replace_live_report(
|
|
115
|
+
current: dict[str, Any] | None,
|
|
116
|
+
candidate: dict[str, Any],
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""Keep a strong final candidate ahead of a later legacy fallback."""
|
|
119
|
+
if not isinstance(current, dict):
|
|
120
|
+
return True
|
|
121
|
+
if candidate.get("phase") == FINAL_ANSWER_PHASE:
|
|
122
|
+
return True
|
|
123
|
+
return not (
|
|
124
|
+
current.get("turnId") == candidate.get("turnId")
|
|
125
|
+
and current.get("phase") == FINAL_ANSWER_PHASE
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def merge_report_cache(
|
|
130
|
+
existing: dict[str, Any] | None,
|
|
131
|
+
candidate: dict[str, Any],
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Merge historical evidence without degrading a precise live timestamp.
|
|
134
|
+
|
|
135
|
+
Item IDs remain native to their observation surface. Explicit final-answer
|
|
136
|
+
text and turn identity are the cross-surface evidence when those IDs differ.
|
|
137
|
+
"""
|
|
138
|
+
if not isinstance(existing, dict):
|
|
139
|
+
return candidate
|
|
140
|
+
same_turn_and_text = (
|
|
141
|
+
existing.get("turnId") is not None
|
|
142
|
+
and candidate.get("turnId") is not None
|
|
143
|
+
and existing.get("turnId") == candidate.get("turnId")
|
|
144
|
+
and existing.get("text") == candidate.get("text")
|
|
145
|
+
)
|
|
146
|
+
same_item = (
|
|
147
|
+
existing.get("itemId") is not None
|
|
148
|
+
and candidate.get("itemId") is not None
|
|
149
|
+
and existing.get("itemId") == candidate.get("itemId")
|
|
150
|
+
)
|
|
151
|
+
same_explicit_final = (
|
|
152
|
+
existing.get("itemId") is not None
|
|
153
|
+
and candidate.get("itemId") is not None
|
|
154
|
+
and existing.get("phase") == FINAL_ANSWER_PHASE
|
|
155
|
+
and candidate.get("phase") == FINAL_ANSWER_PHASE
|
|
156
|
+
)
|
|
157
|
+
if (
|
|
158
|
+
same_turn_and_text
|
|
159
|
+
and (same_item or same_explicit_final)
|
|
160
|
+
and existing.get("phase") == candidate.get("phase")
|
|
161
|
+
and existing.get("completedAt") is not None
|
|
162
|
+
):
|
|
163
|
+
return {**candidate, "completedAt": existing["completedAt"]}
|
|
164
|
+
return candidate
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"COMMENTARY_PHASE",
|
|
169
|
+
"FINAL_ANSWER_PHASE",
|
|
170
|
+
"report_from_item",
|
|
171
|
+
"native_timestamp_ms",
|
|
172
|
+
"report_from_thread",
|
|
173
|
+
"report_from_turn",
|
|
174
|
+
"select_report_for_turn",
|
|
175
|
+
"merge_report_cache",
|
|
176
|
+
"should_replace_live_report",
|
|
177
|
+
]
|