Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions docs/plans/topology-target.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions polylogue/sources/parsers/base_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ class ParsedContentBlock(BaseModel):
tool_input: Mapping[str, object] | None = None
media_type: str | None = None
metadata: dict[str, object] | None = None
# polylogue-vf9x: provider-issued cryptographic attestation for a THINKING
# block (Claude's extended-thinking `signature`; Gemini's
# `thoughtSignatures` are the same construct under a different name).
# Since roughly 2026-06 Anthropic ships thinking blocks with an EMPTY
# `thinking` body plus this signature only -- text is genuinely absent
# from the wire, not merely unparsed. Recorded so the fact that the model
# reasoned survives even when no text does; deliberately excluded from
# `_block_content_hash`/lineage prefix signatures (write.py) because the
# provider re-signs on every replay, so including it would break
# citation-anchor and fork-prefix matching for otherwise-identical
# replayed content.
signature: str | None = None
# Structured tool-result outcome (keystone): captured from the source's
# own outcome fields (Claude toolUseResult.is_error, command exit codes)
# so in-session outcomes are readable instead of regex-guessed from text.
Expand Down
20 changes: 18 additions & 2 deletions polylogue/sources/parsers/base_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,24 @@ def content_blocks_from_segments(content: object) -> list[ParsedContentBlock]:
seg_type = seg.get("type", "text")
if seg_type == "thinking":
text = seg.get("thinking") or seg.get("text") or ""
if text:
blocks.append(ParsedContentBlock(type=BlockType.THINKING, text=text))
signature = seg.get("signature")
# polylogue-vf9x: since roughly 2026-06 the wire ships thinking
# blocks with an empty `thinking` body and only a `signature` --
# the reasoning genuinely occurred but its text is not on the
# wire (verified against raw ~/.claude/projects JSONL: Feb-2026
# sessions carry non-empty text, Jul-2026 sessions are 100%
# empty-body/signature-only). Previously this `if text:` guard
# dropped the block outright, silently zeroing thinking_count
# and making the archive look like reasoning stopped -- record
# the block regardless so the fact that the model reasoned here
# (and the signature, for provenance) survives even without text.
blocks.append(
ParsedContentBlock(
type=BlockType.THINKING,
text=text or None,
signature=signature if isinstance(signature, str) and signature else None,
)
)
elif seg_type == "tool_use":
tool_name = seg.get("name")
tool_id = seg.get("id")
Expand Down
90 changes: 90 additions & 0 deletions polylogue/sources/parsers/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,86 @@ def _codex_tool_message(
return None


def _codex_reasoning_joined_text(value: object) -> str | None:
"""Join recoverable text out of a Codex ``reasoning`` record's `summary`/`content`.

Both fields share the same OpenAI Responses-API shape: either a bare
string, or a list of ``{"type": "summary_text"|"reasoning_text", "text": ...}``
(or equivalent) dicts. Anything else (encrypted ciphertext, missing
fields) yields no text -- that is a genuine absence, handled by the
caller, not an extraction bug here.
"""
if isinstance(value, str):
return value or None
if not isinstance(value, list):
return None
parts: list[str] = []
for item in value:
text: object = item.get("text") if isinstance(item, dict) else item
if isinstance(text, str) and text:
parts.append(text)
return "\n\n".join(parts) if parts else None


def _codex_reasoning_message(
record: dict[str, object],
*,
index: int,
position: int,
timestamp_fallback: str | int | float | None = None,
) -> ParsedMessage | None:
"""Materialize a Codex ``reasoning`` response_item as a THINKING-block message.

polylogue-vf9x: previously this record type was read only by
``_compact_response_payload``'s generic session_event compactor, which
has no `reasoning`-specific branch -- neither `summary` nor `content` was
read at all (not merely char-counted: the emitted session_event carries
only ``{source_index, type}``), so every one of the measured 1,182,071
Codex reasoning records in this operator's raw corpus contributed zero
words to the archive, unreachable from FTS/search even in principle.

`summary` (OpenAI's human-readable condensation) is present with
recoverable text on ~24% of records measured; `content` (the full trace)
is essentially always null on the wire -- Codex encrypts it into
`encrypted_content` instead, which this archive cannot decrypt and does
not attempt to store. Even when neither carries text, the message is
still recorded (block text=None) so the FACT that the model reasoned
here survives -- the same rationale as Claude Code's empty-body thinking
blocks (base_support.py).

Routed into `messages`/`blocks` (not left as a session_event only) so
reasoning joins the normal content tree: FTS coverage, `thinking_count`,
and the `material_origin`/`BlockType.THINKING` vocabulary every other
origin's reasoning content already uses.
"""
if _record_type(record) != "reasoning":
return None
payload = _record_payload(record)
summary_text = _codex_reasoning_joined_text(payload.get("summary"))
content_text = _codex_reasoning_joined_text(payload.get("content"))
blocks: list[ParsedContentBlock] = []
if summary_text:
blocks.append(ParsedContentBlock(type=BlockType.THINKING, text=summary_text))
if content_text and content_text != summary_text:
blocks.append(ParsedContentBlock(type=BlockType.THINKING, text=content_text))
if not blocks:
blocks.append(ParsedContentBlock(type=BlockType.THINKING, text=None))
combined_text = "\n\n".join(t for t in (summary_text, content_text) if t) or None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate identical reasoning text in the message projection

When a Codex reasoning record carries identical text in both summary and content, the block logic correctly emits one block, but combined_text still joins both copies. The stored message therefore has doubled text and an inflated word_count/message content hash, while block-based reads return only one copy. Apply the same distinctness check used for blocks when constructing the message text.

Useful? React with 👍 / 👎.

timestamp = _iso_or_none(_record_timestamp(record) or timestamp_fallback)
return ParsedMessage(
provider_message_id=f"reasoning-{index}",
role=Role.ASSISTANT,
text=combined_text,
timestamp=timestamp,
position=position,
variant_index=0,
is_active_path=True,
blocks=blocks,
message_type=MessageType.THINKING,
material_origin=MaterialOrigin.ASSISTANT_AUTHORED,
)


def _mcp_invocation_tool_name(invocation: dict[str, object]) -> str:
server = _string_value(invocation.get("server"))
tool = _string_value(invocation.get("tool"))
Expand Down Expand Up @@ -2255,6 +2335,16 @@ def _parse_records(records: Iterable[object], fallback_id: str) -> ParsedSession
messages.append(event_message)
message_position += 1
latest_message_timestamp = _newer_timestamp(latest_message_timestamp, event_message.timestamp)
reasoning_message = _codex_reasoning_message(
inner,
index=idx,
position=message_position,
timestamp_fallback=timestamp_fallback,
)
if reasoning_message is not None:
messages.append(reasoning_message)
message_position += 1
latest_message_timestamp = _newer_timestamp(latest_message_timestamp, reasoning_message.timestamp)
mcp_messages = _codex_mcp_tool_call_messages(
inner,
index=idx,
Expand Down
1 change: 1 addition & 0 deletions polylogue/storage/hydrators.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def message_from_record(
"tool_result_is_error": b.tool_result_is_error,
"tool_result_exit_code": b.tool_result_exit_code,
"tool_result_outcome_unknown_reason": b.tool_result_outcome_unknown_reason,
"signature": b.signature,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Thread signatures through query-first archive rows

This exposes signature only through the MessageRecord hydrator, but the query-first API, CLI, and MCP paths use ArchiveBlockRow instead (for example api/archive.py:589, cli/read_views/standard.py:299, and mcp/archive_support.py:729). That row's dataclass and canonical _ARCHIVE_BLOCK_QUERY_COLUMNS projection omit the new column, as do the subsequent domain conversions, so signatures written by this change remain absent from those public reads. Add the field to the archive-row projection and propagate it through those conversions.

Useful? React with 👍 / 👎.

}
)

Expand Down
5 changes: 5 additions & 0 deletions polylogue/storage/runtime/archive/records.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ class BlockRecord(BaseModel):
# (see core.enums.ToolResultUnknownReason). NULL means either the
# outcome IS known, or this read path did not select the column.
tool_result_outcome_unknown_reason: str | None = None
# polylogue-vf9x (v49): provider-issued cryptographic attestation for a
# THINKING block (Claude's extended-thinking signature; Gemini's
# thoughtSignatures are the same construct). NULL when the wire carried
# none, or this read path did not select the column.
signature: str | None = None

@field_validator("type", mode="before")
@classmethod
Expand Down
6 changes: 4 additions & 2 deletions polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Defines the single source of truth for:
- messages table structure (29 writable columns + 1 GENERATED message_id)
- blocks table structure (14 writable columns + 1 GENERATED block_id)
- blocks table structure (16 writable columns + 1 GENERATED block_id)
- Other key tables (sessions, session_events, etc.)

Each spec drives INSERT/SELECT generation and typed row extraction.
Expand Down Expand Up @@ -84,7 +84,8 @@ def _make_blocks_spec() -> TableColumnSpec:
The blocks table structure (from schema):
session_id, message_id, position, block_type, text, tool_name, tool_id,
tool_input, semantic_type, media_type, language, tool_result_is_error,
tool_result_exit_code, content_hash
tool_result_exit_code, tool_result_outcome_unknown_reason, signature,
content_hash

GENERATED (not writable):
block_id, tool_command, tool_path, search_text, tool_detail_text
Expand All @@ -105,6 +106,7 @@ def _make_blocks_spec() -> TableColumnSpec:
ColumnSpec("tool_result_is_error", "INTEGER"),
ColumnSpec("tool_result_exit_code", "INTEGER"),
ColumnSpec("tool_result_outcome_unknown_reason", "TEXT"),
ColumnSpec("signature", "TEXT"),
ColumnSpec("content_hash", "BLOB"),
ColumnSpec("tool_command", "TEXT", is_generated=True),
ColumnSpec("tool_path", "TEXT", is_generated=True),
Expand Down
42 changes: 41 additions & 1 deletion polylogue/storage/sqlite/archive_tiers/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,36 @@
#
# Both depend on parser/pricing semantics to populate/repair existing rows --
# SEMANTIC_REPARSE, matching the v42/v44/v45/v46/v47/v48 precedent above.
INDEX_SCHEMA_VERSION = 49
#
# polylogue-vf9x: v50 adds blocks.signature (nullable TEXT) and fixes two
# independent reasoning/thinking-content-loss defects found via a full-corpus
# audit:
# - Claude Code (base_support.py `content_blocks_from_segments`): since
# roughly 2026-06 the wire ships THINKING segments with an empty
# `thinking` body and a `signature` only. An `if text:` guard dropped the
# whole block, silently zeroing `thinking_count` archive-wide for every
# 2026-06+ session -- a false "reasoning declined" signal, not a real
# absence. The block is now always recorded (text=NULL when the wire
# carries none, signature captured when present).
# - Codex (codex.py): standalone `reasoning` response_item records were
# read only by the generic session_event compactor, which has no
# `reasoning`-specific branch -- summary/content were never read at all
# (not merely char-counted), so 100% of Codex reasoning text was
# discarded and unreachable from FTS/search. `reasoning` records are now
# materialized as a THINKING-block message (summary text -- the ~24%
# recoverable case -- or content text when present; text=NULL when only
# encrypted_content survives).
# Both are pure parser-semantics changes over the same already-declared
# `blocks.block_type='thinking'` vocabulary plus one additive nullable
# column -- the v42/v44/v45/v46/v48/v49 "values depend on parser semantics,
# no clone-safe SQL delta" precedent. `signature` is deliberately excluded
# from `_block_content_hash` and the lineage prefix signature (write.py)
# because providers re-sign on every replay; including it would break
# citation-anchor and fork-prefix matching for otherwise-identical replayed
# content. Resolving existing rows (recovering historical thinking/reasoning
# content) requires `polylogue ops reset --index && polylogued run` --
# deliberately NOT executed by this declaration.
INDEX_SCHEMA_VERSION = 50

# polylogue-v6i3: shared WHEN-clause fragment gating the blocks_command_trigram
# trigger BODIES on the same dedicated bulk-build guard row messages_fts's
Expand Down Expand Up @@ -436,6 +465,17 @@
-- an unknown reason".
tool_result_outcome_unknown_reason TEXT
CHECK ({nullable_check("tool_result_outcome_unknown_reason", ToolResultUnknownReason)}),
-- polylogue-vf9x (v49): provider-issued cryptographic attestation for a
-- THINKING block (Claude's extended-thinking `signature`; Gemini's
-- `thoughtSignatures` are the same construct). Populated whenever the
-- wire carries one -- notably including the empty-body thinking blocks
-- Claude Code has shipped since ~2026-06, where this is the only
-- surviving evidence besides the block's existence. Deliberately
-- excluded from content_hash below and from the lineage prefix
-- signature (write.py): providers re-sign on every replay, so including
-- it would break fork-prefix/citation-anchor matching for otherwise-
-- identical replayed content.
signature TEXT,
-- svfj: the citation anchor atom. Hashes canonical block EVIDENCE only
-- (type, text, tool_name, canonical tool_input, semantic/media/language,
-- is_error, exit_code) -- deliberately EXCLUDING session_id/message_id/
Expand Down
4 changes: 3 additions & 1 deletion polylogue/storage/sqlite/archive_tiers/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -1891,6 +1891,7 @@ def _build_block_rows(
is_error = getattr(block, "is_error", None)
exit_code = getattr(block, "exit_code", None)
outcome_unknown_reason = _enum_value(block.outcome_unknown_reason)
signature = getattr(block, "signature", None)
# Tuple built in order defined by spec.writable_columns
rows.append(
(
Expand All @@ -1908,6 +1909,7 @@ def _build_block_rows(
_sqlite_bool(is_error),
exit_code,
outcome_unknown_reason,
_sqlite_text(signature),
_block_content_hash(
block_type=block_type.value,
text=block.text,
Expand Down Expand Up @@ -1946,7 +1948,7 @@ def _write_blocks(
"""Write block rows using table-driven column specification.

The blocks table column spec (archive_tiers_specs.BLOCKS_SPEC) defines:
- writable_columns: the ordered list of columns to INSERT (14 total)
- writable_columns: the ordered list of columns to INSERT (16 total)
- The column names and placeholders are generated from the spec
- The tuple order is derived from the spec's writable_columns order

Expand Down
Loading