thread-archive 0.0.2__py3-none-any.whl
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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TypedDict schemas for Cursor IDE chat export format.
|
|
3
|
+
|
|
4
|
+
Storage location (local machine, not devcontainers):
|
|
5
|
+
- macOS: ~/Library/Application Support/Cursor/User/workspaceStorage/
|
|
6
|
+
- Linux: ~/.config/Cursor/User/workspaceStorage/
|
|
7
|
+
- Windows: %APPDATA%\\Cursor\\User\\workspaceStorage\\
|
|
8
|
+
|
|
9
|
+
Each workspace has a hashed subdirectory containing state.vscdb (SQLite).
|
|
10
|
+
|
|
11
|
+
Export methods:
|
|
12
|
+
1. cursor-export CLI tool (recommended) -> ZIP with cursor_chats.json
|
|
13
|
+
2. Cursor UI: Chat menu -> Export Chat -> Markdown file (single chat only)
|
|
14
|
+
|
|
15
|
+
Key characteristics:
|
|
16
|
+
- Multiple format versions (v1 simple, v2 structured)
|
|
17
|
+
- Role normalization needed (human/ai/bot -> user/assistant)
|
|
18
|
+
- Timestamps may be ISO string or Unix (seconds/milliseconds)
|
|
19
|
+
- Linear conversations (no branching)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CursorContentBlock(TypedDict, total=False):
|
|
26
|
+
"""A content block in a Cursor message.
|
|
27
|
+
|
|
28
|
+
Block types:
|
|
29
|
+
- text: Plain text content
|
|
30
|
+
- tool_use: Tool/function call
|
|
31
|
+
- tool_result: Tool/function result
|
|
32
|
+
- thinking: Extended thinking (model-dependent)
|
|
33
|
+
- code: Code block with language
|
|
34
|
+
"""
|
|
35
|
+
type: str # "text", "tool_use", "tool_result", "thinking", "code"
|
|
36
|
+
text: Optional[str] # For text and thinking blocks
|
|
37
|
+
name: Optional[str] # For tool_use and tool_result
|
|
38
|
+
input: Optional[Dict[str, Any]] # For tool_use
|
|
39
|
+
content: Optional[Any] # For tool_result
|
|
40
|
+
is_error: Optional[bool] # For tool_result
|
|
41
|
+
code: Optional[str] # For code blocks
|
|
42
|
+
language: Optional[str] # For code blocks
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CursorToolCall(TypedDict, total=False):
|
|
46
|
+
"""Tool call structure in Cursor messages."""
|
|
47
|
+
name: str
|
|
48
|
+
arguments: Optional[Union[str, Dict[str, Any]]]
|
|
49
|
+
input: Optional[Dict[str, Any]]
|
|
50
|
+
args: Optional[Union[str, Dict[str, Any]]]
|
|
51
|
+
call_id: Optional[str]
|
|
52
|
+
result: Optional[Any]
|
|
53
|
+
status: Optional[str]
|
|
54
|
+
user_decision: Optional[str]
|
|
55
|
+
function: Optional[Dict[str, Any]] # OpenAI-style nesting
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CursorCodeBlock(TypedDict, total=False):
|
|
59
|
+
"""Code block from v2 export format (cursor_export.py output)."""
|
|
60
|
+
uri: Optional[Dict[str, Any]] # {path: str, ...}
|
|
61
|
+
version: Optional[int]
|
|
62
|
+
codeBlockIdx: Optional[int]
|
|
63
|
+
content: str
|
|
64
|
+
languageId: Optional[str]
|
|
65
|
+
language: Optional[str]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CursorMessage(TypedDict, total=False):
|
|
69
|
+
"""A single message in a Cursor conversation."""
|
|
70
|
+
id: Optional[str]
|
|
71
|
+
role: str # "user", "human", "assistant", "ai", "bot", "system", "tool"
|
|
72
|
+
content: Union[str, List[Any]] # String or array of content items
|
|
73
|
+
content_blocks: Optional[List[CursorContentBlock]]
|
|
74
|
+
created_at: Optional[Union[str, int, float]] # ISO or Unix timestamp
|
|
75
|
+
model: Optional[str]
|
|
76
|
+
|
|
77
|
+
# Tool-related fields
|
|
78
|
+
tool_calls: Optional[List[CursorToolCall]]
|
|
79
|
+
toolCalls: Optional[List[CursorToolCall]] # Alternative casing
|
|
80
|
+
tool_results: Optional[List[Dict[str, Any]]]
|
|
81
|
+
toolResults: Optional[List[Dict[str, Any]]] # Alternative casing
|
|
82
|
+
tool_call: Optional[CursorToolCall] # v2 single tool call
|
|
83
|
+
|
|
84
|
+
# Thinking
|
|
85
|
+
thinking: Optional[Union[str, List[Any]]]
|
|
86
|
+
reasoning: Optional[Union[str, List[Any]]]
|
|
87
|
+
thinking_duration_ms: Optional[int]
|
|
88
|
+
|
|
89
|
+
# Code blocks (v2 format)
|
|
90
|
+
code_blocks: Optional[List[CursorCodeBlock]]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CursorConversation(TypedDict, total=False):
|
|
94
|
+
"""A single Cursor conversation."""
|
|
95
|
+
id: str
|
|
96
|
+
title: Optional[str]
|
|
97
|
+
created_at: Optional[Union[str, int, float]]
|
|
98
|
+
updated_at: Optional[Union[str, int, float]]
|
|
99
|
+
workspace_hash: Optional[str]
|
|
100
|
+
workspace_name: Optional[str]
|
|
101
|
+
workspace_uri: Optional[str]
|
|
102
|
+
messages: List[CursorMessage]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CursorExport(TypedDict, total=False):
|
|
106
|
+
"""Cursor export bundle from cursor-export CLI tool."""
|
|
107
|
+
provider: Literal["cursor"]
|
|
108
|
+
conversations: List[CursorConversation]
|
|
109
|
+
export_metadata: Optional[Dict[str, Any]]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pluggable validators for the parser pipeline.
|
|
3
|
+
|
|
4
|
+
Validators check messages against rules and report errors/warnings.
|
|
5
|
+
Each validator focuses on a single concern for testability.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from ..config import ProviderConfig, get_provider_config
|
|
11
|
+
from .base import BaseValidator, ValidationContext
|
|
12
|
+
from .content import ContentValidator
|
|
13
|
+
from .referential import ReferentialIntegrityValidator
|
|
14
|
+
from .thinking import ThinkingBlockValidator
|
|
15
|
+
from .types import TypeValidator
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ValidationContext",
|
|
21
|
+
"BaseValidator",
|
|
22
|
+
"ThinkingBlockValidator",
|
|
23
|
+
"ReferentialIntegrityValidator",
|
|
24
|
+
"TypeValidator",
|
|
25
|
+
"ContentValidator",
|
|
26
|
+
"validate_messages",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Run order: content/type first (per-message shape), then thinking/referential
|
|
30
|
+
# (aggregate). All are read-only — they only collect issues into the context.
|
|
31
|
+
_VALIDATORS = (
|
|
32
|
+
ContentValidator,
|
|
33
|
+
TypeValidator,
|
|
34
|
+
ThinkingBlockValidator,
|
|
35
|
+
ReferentialIntegrityValidator,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Validators with no cross-message / conversation-aggregate state — safe to run
|
|
40
|
+
# on a *partial* conversation (an incremental import batch) without false
|
|
41
|
+
# positives. TypeValidator only scans each block/role against the known set, so
|
|
42
|
+
# it catches provider format drift (a new block type / role) on any slice.
|
|
43
|
+
_BATCH_SAFE_VALIDATORS = (TypeValidator,)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_messages(messages, conversation_id, source_provider, *, strict=False, batch_safe=False):
|
|
47
|
+
"""Run the validators over one parsed/assembled conversation and return the
|
|
48
|
+
ValidationContext (``.errors`` / ``.warnings``). Pure — no I/O; the caller
|
|
49
|
+
decides what to do with the issues (we log them, never reject).
|
|
50
|
+
|
|
51
|
+
An unknown provider falls back to a permissive default config, so the
|
|
52
|
+
universal checks (unknown block types, missing content/timestamps) still run
|
|
53
|
+
everywhere; provider-specific checks (thinking expectation, expected roles)
|
|
54
|
+
only kick in for the configured providers.
|
|
55
|
+
|
|
56
|
+
``batch_safe=True`` runs only the validators with no conversation-aggregate
|
|
57
|
+
state (just format-drift type checks), so it's correct on a *partial*
|
|
58
|
+
conversation — used by the incremental watcher path, where each poll imports
|
|
59
|
+
only a slice and the aggregate checks ("0% thinking", "no user messages")
|
|
60
|
+
would otherwise false-fire.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
config = get_provider_config(source_provider)
|
|
64
|
+
except KeyError:
|
|
65
|
+
config = ProviderConfig(provider_name=source_provider)
|
|
66
|
+
context = ValidationContext(
|
|
67
|
+
conversation_id=conversation_id or "",
|
|
68
|
+
source_provider=source_provider,
|
|
69
|
+
strict=strict,
|
|
70
|
+
)
|
|
71
|
+
for validator_cls in (_BATCH_SAFE_VALIDATORS if batch_safe else _VALIDATORS):
|
|
72
|
+
try:
|
|
73
|
+
validator_cls(config, strict=strict).validate(messages, context)
|
|
74
|
+
except Exception: # one bad validator must never break an import
|
|
75
|
+
# ...but it must never be silent either: surface the crash loudly
|
|
76
|
+
# (with traceback) while still letting the import proceed.
|
|
77
|
+
logger.exception(
|
|
78
|
+
"Validator %s crashed for conversation %r (provider %r); skipping it",
|
|
79
|
+
validator_cls.__name__,
|
|
80
|
+
conversation_id,
|
|
81
|
+
source_provider,
|
|
82
|
+
)
|
|
83
|
+
return context
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base validation infrastructure.
|
|
3
|
+
|
|
4
|
+
Provides ValidationContext and BaseValidator for building validators.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from ..base import NormalizedMessage
|
|
12
|
+
from ..config import ProviderConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ValidationSeverity(str, Enum):
|
|
16
|
+
"""Severity level for validation issues."""
|
|
17
|
+
error = "error"
|
|
18
|
+
warning = "warning"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ValidationContext:
|
|
23
|
+
"""Context for validation across a conversation.
|
|
24
|
+
|
|
25
|
+
Tracks state as messages are processed and collects errors/warnings.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
conversation_id: str
|
|
29
|
+
source_provider: str
|
|
30
|
+
strict: bool = False # If True, warnings become errors
|
|
31
|
+
|
|
32
|
+
# Counters
|
|
33
|
+
message_count: int = 0
|
|
34
|
+
first_user_msg_seen: bool = False
|
|
35
|
+
first_user_msg_has_context: bool = False
|
|
36
|
+
assistant_msg_count: int = 0
|
|
37
|
+
assistant_msgs_with_thinking: int = 0
|
|
38
|
+
assistant_msgs_with_tools: int = 0
|
|
39
|
+
|
|
40
|
+
# Model-specific tracking
|
|
41
|
+
thinking_required_msgs: int = 0
|
|
42
|
+
thinking_required_msgs_with_thinking: int = 0
|
|
43
|
+
|
|
44
|
+
# Results
|
|
45
|
+
errors: List[str] = field(default_factory=list)
|
|
46
|
+
warnings: List[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
def add_error(self, msg: str) -> None:
|
|
49
|
+
"""Add a validation error."""
|
|
50
|
+
self.errors.append(msg)
|
|
51
|
+
|
|
52
|
+
def add_warning(self, msg: str) -> None:
|
|
53
|
+
"""Add a validation warning."""
|
|
54
|
+
if self.strict:
|
|
55
|
+
self.errors.append(msg)
|
|
56
|
+
else:
|
|
57
|
+
self.warnings.append(msg)
|
|
58
|
+
|
|
59
|
+
def add_issue(self, msg: str, severity: ValidationSeverity) -> None:
|
|
60
|
+
"""Add an issue with specified severity."""
|
|
61
|
+
if severity == ValidationSeverity.error:
|
|
62
|
+
self.add_error(msg)
|
|
63
|
+
else:
|
|
64
|
+
self.add_warning(msg)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def has_errors(self) -> bool:
|
|
68
|
+
"""Check if any errors were recorded."""
|
|
69
|
+
return len(self.errors) > 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BaseValidator:
|
|
73
|
+
"""Base class for validators with common utilities.
|
|
74
|
+
|
|
75
|
+
Subclasses should override validate() and optionally
|
|
76
|
+
validate_conversation_end().
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, config: ProviderConfig, strict: bool = False):
|
|
80
|
+
"""Initialize validator.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
config: Provider-specific configuration
|
|
84
|
+
strict: If True, treat warnings as errors
|
|
85
|
+
"""
|
|
86
|
+
self.config = config
|
|
87
|
+
self.strict = strict
|
|
88
|
+
|
|
89
|
+
def validate(
|
|
90
|
+
self, messages: List[NormalizedMessage], context: ValidationContext
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Validate messages and update context.
|
|
93
|
+
|
|
94
|
+
Override in subclasses to implement validation logic.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
messages: Messages to validate
|
|
98
|
+
context: Validation context to update
|
|
99
|
+
"""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
def validate_message(
|
|
103
|
+
self, msg: NormalizedMessage, context: ValidationContext
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Validate a single message.
|
|
106
|
+
|
|
107
|
+
Override in subclasses for per-message validation.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
msg: Message to validate
|
|
111
|
+
context: Validation context to update
|
|
112
|
+
"""
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def validate_conversation_end(self, context: ValidationContext) -> None:
|
|
116
|
+
"""Perform end-of-conversation validation.
|
|
117
|
+
|
|
118
|
+
Override in subclasses for aggregate validation.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
context: Validation context to update
|
|
122
|
+
"""
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def _add_issue(
|
|
126
|
+
self, context: ValidationContext, msg: str, severity: ValidationSeverity
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Add an issue, respecting strict mode."""
|
|
129
|
+
if self.strict and severity == ValidationSeverity.warning:
|
|
130
|
+
severity = ValidationSeverity.error
|
|
131
|
+
context.add_issue(msg, severity)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _has_thinking_blocks(msg: NormalizedMessage) -> bool:
|
|
135
|
+
"""Check if message has thinking/reasoning blocks."""
|
|
136
|
+
blocks = msg.get("content_blocks", [])
|
|
137
|
+
return any(b.get("type") == "thinking" for b in blocks)
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _has_tool_blocks(msg: NormalizedMessage) -> bool:
|
|
141
|
+
"""Check if message has tool use or result blocks."""
|
|
142
|
+
blocks = msg.get("content_blocks", [])
|
|
143
|
+
return any(b.get("type") in ("tool_use", "tool_result") for b in blocks)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _has_user_context(msg: NormalizedMessage) -> bool:
|
|
147
|
+
"""Check if message has user context blocks."""
|
|
148
|
+
blocks = msg.get("content_blocks", [])
|
|
149
|
+
if any(b.get("type") == "system_context" for b in blocks):
|
|
150
|
+
return True
|
|
151
|
+
# Check provider_data for context indicators
|
|
152
|
+
provider_data = msg.get("provider_data", {})
|
|
153
|
+
raw_msg = provider_data.get("message", {})
|
|
154
|
+
metadata = raw_msg.get("metadata", {})
|
|
155
|
+
return bool(metadata.get("is_user_system_message"))
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _get_model(msg: NormalizedMessage) -> Optional[str]:
|
|
159
|
+
"""Extract model name from message."""
|
|
160
|
+
provider_data = msg.get("provider_data", {})
|
|
161
|
+
# ChatGPT stores in message.metadata.model_slug
|
|
162
|
+
raw_msg = provider_data.get("message", {})
|
|
163
|
+
metadata = raw_msg.get("metadata", {})
|
|
164
|
+
model = metadata.get("model_slug") or metadata.get("model")
|
|
165
|
+
if model:
|
|
166
|
+
return model
|
|
167
|
+
# Check top-level provider_data
|
|
168
|
+
return provider_data.get("model")
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Content validator.
|
|
3
|
+
|
|
4
|
+
Validates message content presence and timestamps.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
from ..base import NormalizedMessage
|
|
10
|
+
from .base import BaseValidator, ValidationContext, ValidationSeverity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContentValidator(BaseValidator):
|
|
14
|
+
"""Validates message content and metadata.
|
|
15
|
+
|
|
16
|
+
Checks:
|
|
17
|
+
- Messages have content_text or content_blocks
|
|
18
|
+
- Messages have timestamps
|
|
19
|
+
- First user message tracking (for context validation)
|
|
20
|
+
|
|
21
|
+
Empty messages that aren't stubs are flagged as warnings.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def validate(
|
|
25
|
+
self, messages: List[NormalizedMessage], context: ValidationContext
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Validate content across all messages."""
|
|
28
|
+
for msg in messages:
|
|
29
|
+
self._validate_message(msg, context)
|
|
30
|
+
|
|
31
|
+
# End-of-conversation check
|
|
32
|
+
self._validate_conversation_end(context)
|
|
33
|
+
|
|
34
|
+
def _validate_message(
|
|
35
|
+
self, msg: NormalizedMessage, context: ValidationContext
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Validate a single message."""
|
|
38
|
+
context.message_count += 1
|
|
39
|
+
role = msg.get("role", "")
|
|
40
|
+
|
|
41
|
+
# Track first user message
|
|
42
|
+
if role == "user" and not context.first_user_msg_seen:
|
|
43
|
+
context.first_user_msg_seen = True
|
|
44
|
+
context.first_user_msg_has_context = self._has_user_context(msg)
|
|
45
|
+
|
|
46
|
+
# Check for content
|
|
47
|
+
has_text = bool(msg.get("content_text"))
|
|
48
|
+
has_blocks = bool(msg.get("content_blocks"))
|
|
49
|
+
|
|
50
|
+
if not has_text and not has_blocks:
|
|
51
|
+
# Skip validation for stub messages (ChatGPT branching artifacts)
|
|
52
|
+
provider_data = msg.get("provider_data", {})
|
|
53
|
+
is_stub = provider_data.get("synthetic_stub") or not provider_data.get(
|
|
54
|
+
"message"
|
|
55
|
+
)
|
|
56
|
+
if not is_stub:
|
|
57
|
+
self._add_issue(
|
|
58
|
+
context,
|
|
59
|
+
f"Message {msg.get('provider_message_id', 'unknown')} "
|
|
60
|
+
f"has no content_text or content_blocks",
|
|
61
|
+
ValidationSeverity.warning,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Check for timestamp
|
|
65
|
+
if not msg.get("created_at"):
|
|
66
|
+
self._add_issue(
|
|
67
|
+
context,
|
|
68
|
+
f"Message {msg.get('provider_message_id', 'unknown')} lacks created_at timestamp",
|
|
69
|
+
ValidationSeverity.warning,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _validate_conversation_end(self, context: ValidationContext) -> None:
|
|
73
|
+
"""Perform end-of-conversation validation."""
|
|
74
|
+
# Check if we saw any user messages
|
|
75
|
+
if not context.first_user_msg_seen and context.message_count > 0:
|
|
76
|
+
self._add_issue(
|
|
77
|
+
context,
|
|
78
|
+
f"Conversation {context.conversation_id} has no user messages",
|
|
79
|
+
ValidationSeverity.warning,
|
|
80
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Referential integrity validator.
|
|
3
|
+
|
|
4
|
+
Validates that parent references resolve to existing messages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Set
|
|
8
|
+
|
|
9
|
+
from ..base import NormalizedMessage
|
|
10
|
+
from .base import BaseValidator, ValidationContext, ValidationSeverity
|
|
11
|
+
|
|
12
|
+
# Known root sentinel values that aren't real missing parents
|
|
13
|
+
ROOT_SENTINELS = {"", "root", "aaa", "00000000-0000-0000-0000-000000000000"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReferentialIntegrityValidator(BaseValidator):
|
|
17
|
+
"""Validates parent message references.
|
|
18
|
+
|
|
19
|
+
Missing parent references are common and expected in:
|
|
20
|
+
- ChatGPT: Branching exports may only include active path
|
|
21
|
+
- Claude Code: Context summarization removes old messages
|
|
22
|
+
but keeps parentUuid references
|
|
23
|
+
|
|
24
|
+
These emit warnings, not errors, since data is still usable.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def validate(
|
|
28
|
+
self, messages: List[NormalizedMessage], context: ValidationContext
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Validate parent references resolve."""
|
|
31
|
+
if not self.config.has_parent_references:
|
|
32
|
+
# Provider doesn't use parent references
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
# Build set of all message IDs
|
|
36
|
+
message_ids: Set[str] = set()
|
|
37
|
+
for msg in messages:
|
|
38
|
+
msg_id = msg.get("provider_message_id")
|
|
39
|
+
if msg_id:
|
|
40
|
+
message_ids.add(msg_id)
|
|
41
|
+
|
|
42
|
+
# Count orphaned references
|
|
43
|
+
orphan_count = 0
|
|
44
|
+
for msg in messages:
|
|
45
|
+
parent_id = msg.get("provider_parent_id")
|
|
46
|
+
if parent_id and parent_id not in message_ids:
|
|
47
|
+
# Skip root sentinels
|
|
48
|
+
if parent_id not in ROOT_SENTINELS:
|
|
49
|
+
orphan_count += 1
|
|
50
|
+
|
|
51
|
+
if orphan_count > 0:
|
|
52
|
+
self._add_issue(
|
|
53
|
+
context,
|
|
54
|
+
f"Conversation {context.conversation_id}: {orphan_count} message(s) reference "
|
|
55
|
+
f"missing parents (expected for branched/summarized conversations)",
|
|
56
|
+
ValidationSeverity.warning,
|
|
57
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Thinking block validator.
|
|
3
|
+
|
|
4
|
+
Validates thinking block presence based on provider expectations
|
|
5
|
+
and model-specific requirements.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from ..base import NormalizedMessage
|
|
11
|
+
from .base import BaseValidator, ValidationContext, ValidationSeverity
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ThinkingBlockValidator(BaseValidator):
|
|
15
|
+
"""Validates thinking block presence.
|
|
16
|
+
|
|
17
|
+
Uses ProviderConfig to determine expectations:
|
|
18
|
+
- "never": Error if thinking blocks exist (wrong parser/source)
|
|
19
|
+
- "required": Error if no thinking blocks (incomplete export)
|
|
20
|
+
- "model_specific": Validate per-message based on model
|
|
21
|
+
- "optional": No validation
|
|
22
|
+
|
|
23
|
+
This is a critical validator for catching:
|
|
24
|
+
1. Data parsed with wrong provider
|
|
25
|
+
2. Incomplete exports missing thinking data
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def validate(
|
|
29
|
+
self, messages: List[NormalizedMessage], context: ValidationContext
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Validate thinking blocks across all messages."""
|
|
32
|
+
# First pass: count and track per-message
|
|
33
|
+
for msg in messages:
|
|
34
|
+
self._track_message(msg, context)
|
|
35
|
+
|
|
36
|
+
# Second pass: aggregate validation
|
|
37
|
+
self._validate_aggregate(context)
|
|
38
|
+
|
|
39
|
+
def _track_message(
|
|
40
|
+
self, msg: NormalizedMessage, context: ValidationContext
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Track thinking block presence for a message."""
|
|
43
|
+
role = msg.get("role", "")
|
|
44
|
+
if role != "assistant":
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
context.assistant_msg_count += 1
|
|
48
|
+
has_thinking = self._has_thinking_blocks(msg)
|
|
49
|
+
|
|
50
|
+
if has_thinking:
|
|
51
|
+
context.assistant_msgs_with_thinking += 1
|
|
52
|
+
|
|
53
|
+
# Track model-specific requirements
|
|
54
|
+
if self.config.thinking_expectation == "model_specific":
|
|
55
|
+
model = self._get_model(msg)
|
|
56
|
+
if self.config.requires_thinking(model):
|
|
57
|
+
context.thinking_required_msgs += 1
|
|
58
|
+
if has_thinking:
|
|
59
|
+
context.thinking_required_msgs_with_thinking += 1
|
|
60
|
+
|
|
61
|
+
def _validate_aggregate(self, context: ValidationContext) -> None:
|
|
62
|
+
"""Perform aggregate validation at conversation end."""
|
|
63
|
+
if context.assistant_msg_count == 0:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
thinking_pct = (
|
|
67
|
+
context.assistant_msgs_with_thinking / context.assistant_msg_count * 100
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expectation = self.config.thinking_expectation
|
|
71
|
+
|
|
72
|
+
if expectation == "never":
|
|
73
|
+
self._validate_never(context, thinking_pct)
|
|
74
|
+
elif expectation == "required":
|
|
75
|
+
self._validate_required(context, thinking_pct)
|
|
76
|
+
elif expectation == "model_specific":
|
|
77
|
+
self._validate_model_specific(context)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _is_subagent(context: ValidationContext) -> bool:
|
|
81
|
+
"""Subagent sessions (agent-*) use Haiku without extended thinking."""
|
|
82
|
+
return bool(
|
|
83
|
+
context.conversation_id
|
|
84
|
+
and context.conversation_id.startswith("agent-")
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _validate_never(
|
|
88
|
+
self, context: ValidationContext, thinking_pct: float
|
|
89
|
+
) -> None:
|
|
90
|
+
"""``never``: any thinking blocks signal the wrong parser/source."""
|
|
91
|
+
if thinking_pct > 0:
|
|
92
|
+
self._add_issue(
|
|
93
|
+
context,
|
|
94
|
+
f"Conversation {context.conversation_id}: {self.config.provider_name} exports "
|
|
95
|
+
f"should NEVER have thinking blocks (found {thinking_pct:.0f}%). "
|
|
96
|
+
f"Wrong parser or data source?",
|
|
97
|
+
ValidationSeverity.error,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _validate_required(
|
|
101
|
+
self, context: ValidationContext, thinking_pct: float
|
|
102
|
+
) -> None:
|
|
103
|
+
"""``required``: every (non-subagent) assistant turn must have thinking."""
|
|
104
|
+
if self._is_subagent(context):
|
|
105
|
+
return
|
|
106
|
+
if thinking_pct == 0:
|
|
107
|
+
self._add_issue(
|
|
108
|
+
context,
|
|
109
|
+
f"Conversation {context.conversation_id}: {self.config.provider_name} exports "
|
|
110
|
+
f"MUST have thinking blocks (found 0%). Incomplete export.",
|
|
111
|
+
ValidationSeverity.error,
|
|
112
|
+
)
|
|
113
|
+
elif thinking_pct < 100:
|
|
114
|
+
self._add_issue(
|
|
115
|
+
context,
|
|
116
|
+
f"Conversation {context.conversation_id}: {thinking_pct:.0f}% of "
|
|
117
|
+
f"assistant messages have thinking blocks (expected 100%)",
|
|
118
|
+
ValidationSeverity.warning,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _validate_model_specific(self, context: ValidationContext) -> None:
|
|
122
|
+
"""``model_specific``: only thinking-capable model turns are checked."""
|
|
123
|
+
if self._is_subagent(context) or context.thinking_required_msgs <= 0:
|
|
124
|
+
return
|
|
125
|
+
model_thinking_pct = (
|
|
126
|
+
context.thinking_required_msgs_with_thinking
|
|
127
|
+
/ context.thinking_required_msgs
|
|
128
|
+
* 100
|
|
129
|
+
)
|
|
130
|
+
if model_thinking_pct == 0:
|
|
131
|
+
self._add_issue(
|
|
132
|
+
context,
|
|
133
|
+
f"Conversation {context.conversation_id}: thinking-capable model messages "
|
|
134
|
+
f"should have thinking blocks (found 0%). Incomplete export.",
|
|
135
|
+
ValidationSeverity.error,
|
|
136
|
+
)
|
|
137
|
+
elif model_thinking_pct < 100:
|
|
138
|
+
self._add_issue(
|
|
139
|
+
context,
|
|
140
|
+
f"Conversation {context.conversation_id}: {model_thinking_pct:.0f}% of "
|
|
141
|
+
f"thinking-capable model messages have thinking blocks",
|
|
142
|
+
ValidationSeverity.warning,
|
|
143
|
+
)
|