continuum-toolkit 1.0.0__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.
Files changed (72) hide show
  1. cli/__init__.py +12 -0
  2. cli/main.py +415 -0
  3. confidence/__init__.py +13 -0
  4. confidence/calculator.py +308 -0
  5. confidence/models.py +40 -0
  6. context/__init__.py +17 -0
  7. context/models.py +108 -0
  8. context/pruner.py +228 -0
  9. context/selector.py +193 -0
  10. continuum_toolkit-1.0.0.dist-info/METADATA +511 -0
  11. continuum_toolkit-1.0.0.dist-info/RECORD +72 -0
  12. continuum_toolkit-1.0.0.dist-info/WHEEL +5 -0
  13. continuum_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
  14. continuum_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
  15. continuum_toolkit-1.0.0.dist-info/top_level.txt +13 -0
  16. contradictions/__init__.py +19 -0
  17. contradictions/detector.py +442 -0
  18. contradictions/models.py +75 -0
  19. core/__init__.py +97 -0
  20. core/enums.py +130 -0
  21. core/evidence.py +117 -0
  22. core/interfaces.py +209 -0
  23. core/schema.py +391 -0
  24. core/serializer.py +84 -0
  25. core/state_models.py +530 -0
  26. daemon/__init__.py +12 -0
  27. daemon/service.py +170 -0
  28. extractors/__init__.py +51 -0
  29. extractors/base.py +117 -0
  30. extractors/config/parsers.py +288 -0
  31. extractors/config/secret_sanitizer.py +91 -0
  32. extractors/config_extractor.py +172 -0
  33. extractors/conversation/analyzers.py +193 -0
  34. extractors/conversation/models.py +148 -0
  35. extractors/conversation_extractor.py +166 -0
  36. extractors/git_extractor.py +305 -0
  37. extractors/parsers/base.py +91 -0
  38. extractors/parsers/comment_parser.py +51 -0
  39. extractors/parsers/js_ts_parser.py +171 -0
  40. extractors/parsers/python_parser.py +180 -0
  41. extractors/verification/runners.py +278 -0
  42. extractors/verification_extractor.py +263 -0
  43. extractors/workspace_extractor.py +221 -0
  44. graph/__init__.py +24 -0
  45. graph/diff.py +109 -0
  46. graph/manager.py +473 -0
  47. graph/models.py +62 -0
  48. graph/propagator.py +194 -0
  49. graph/query.py +86 -0
  50. graph/snapshot.py +85 -0
  51. handoff/__init__.py +28 -0
  52. handoff/adapters/__init__.py +45 -0
  53. handoff/adapters/base.py +90 -0
  54. handoff/adapters/claude_adapter.py +176 -0
  55. handoff/adapters/codex_gpt_adapter.py +151 -0
  56. handoff/adapters/gemini_adapter.py +151 -0
  57. handoff/adapters/local_model_adapter.py +130 -0
  58. handoff/models.py +88 -0
  59. handoff/packager.py +288 -0
  60. pipeline/__init__.py +9 -0
  61. pipeline/orchestrator.py +260 -0
  62. resolution/__init__.py +15 -0
  63. resolution/resolver.py +311 -0
  64. storage/__init__.py +18 -0
  65. storage/hooks.py +125 -0
  66. storage/manager.py +127 -0
  67. storage/models.py +39 -0
  68. storage/recovery.py +98 -0
  69. watcher/__init__.py +17 -0
  70. watcher/detector.py +136 -0
  71. watcher/models.py +62 -0
  72. watcher/updater.py +163 -0
@@ -0,0 +1,91 @@
1
+ """
2
+ Project Continuum - Secret Redaction and Sanitization Engine
3
+ ============================================================
4
+ Ensures that credentials, API keys, private tokens, passwords, and secrets
5
+ are never collected, stored, or exposed in Continuum state or evidence.
6
+ """
7
+
8
+ import re
9
+ from typing import Any, Dict, List, Set, Union
10
+
11
+
12
+ class SecretSanitizer:
13
+ """
14
+ Detects and redacts sensitive information such as API keys, tokens,
15
+ passwords, and private keys.
16
+ """
17
+
18
+ # Known sensitive key substrings (case-insensitive)
19
+ SENSITIVE_KEY_NAMES: Set[str] = {
20
+ "password",
21
+ "secret",
22
+ "token",
23
+ "api_key",
24
+ "apikey",
25
+ "access_key",
26
+ "auth_token",
27
+ "jwt",
28
+ "private_key",
29
+ "client_secret",
30
+ "ssh_key",
31
+ "database_url",
32
+ "db_pass",
33
+ "encryption_key",
34
+ "signature",
35
+ "credential"
36
+ }
37
+
38
+ # Regex patterns for common secret formats
39
+ SECRET_VALUE_PATTERNS = [
40
+ re.compile(r"-----BEGIN (?:[A-Z0-9\s]+ )?PRIVATE KEY-----.*?-----END (?:[A-Z0-9\s]+ )?PRIVATE KEY-----", re.DOTALL),
41
+ re.compile(r"AKIA[0-9A-Z]{16}"), # AWS Access Key ID
42
+ re.compile(r"ghp_[a-zA-Z0-9]{36}"), # GitHub Personal Access Token
43
+ re.compile(r"gho_[a-zA-Z0-9]{36}"), # GitHub OAuth Access Token
44
+ re.compile(r"glpat-[a-zA-Z0-9\-_]{20,32}"), # GitLab Personal Access Token
45
+ re.compile(r"sk-[a-zA-Z0-9]{20,64}"), # OpenAI Secret Key
46
+ re.compile(r"AIza[0-9A-Za-z\-_]{25,45}"), # Google API Key
47
+ re.compile(r"ey[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*"), # JWT Token
48
+ re.compile(r"[a-f0-9]{32,64}"), # Raw Hex Key / Token
49
+ ]
50
+
51
+ @classmethod
52
+ def is_sensitive_key(cls, key_name: str) -> bool:
53
+ normalized = key_name.lower().replace("-", "_").replace(".", "_")
54
+ return any(sensitive in normalized for sensitive in cls.SENSITIVE_KEY_NAMES)
55
+
56
+ @classmethod
57
+ def contains_secret_pattern(cls, value: str) -> bool:
58
+ if not isinstance(value, str) or len(value) < 8:
59
+ return False
60
+ return any(pattern.search(value) for pattern in cls.SECRET_VALUE_PATTERNS)
61
+
62
+ @classmethod
63
+ def sanitize_value(cls, key: str, value: Any) -> Any:
64
+ if cls.is_sensitive_key(key):
65
+ return "<REDACTED_SECRET>"
66
+ if isinstance(value, str):
67
+ for pattern in cls.SECRET_VALUE_PATTERNS:
68
+ if pattern.search(value):
69
+ return "<REDACTED_SECRET>"
70
+ return value
71
+ elif isinstance(value, dict):
72
+ return cls.sanitize_dict(value)
73
+ elif isinstance(value, list):
74
+ return [cls.sanitize_value(key, item) for item in value]
75
+ return value
76
+
77
+ @classmethod
78
+ def sanitize_dict(cls, data: Dict[str, Any]) -> Dict[str, Any]:
79
+ sanitized = {}
80
+ for k, v in data.items():
81
+ if cls.is_sensitive_key(k):
82
+ sanitized[k] = "<REDACTED_SECRET>"
83
+ elif isinstance(v, dict):
84
+ sanitized[k] = cls.sanitize_dict(v)
85
+ elif isinstance(v, list):
86
+ sanitized[k] = [cls.sanitize_value(k, item) for item in v]
87
+ elif isinstance(v, str):
88
+ sanitized[k] = cls.sanitize_value(k, v)
89
+ else:
90
+ sanitized[k] = v
91
+ return sanitized
@@ -0,0 +1,172 @@
1
+ """
2
+ Project Continuum - Configuration & Environment Evidence Extractor
3
+ ===================================================================
4
+ Milestone 2 - Phase 2: Configuration, Environment & Metadata Extraction.
5
+ Scans for project manifests (package.json, pyproject.toml, Cargo.toml, go.mod,
6
+ pubspec.yaml, Dockerfile, docker-compose.yml), detects lockfiles, extracts
7
+ dependencies, scripts, and build environments while redacting all secrets.
8
+ """
9
+
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional, Set
12
+
13
+ from core.enums import EvidenceType, EvidenceLevel
14
+ from core.evidence import Evidence
15
+ from core.state_models import (
16
+ CanonicalProjectState,
17
+ ManifestInfo,
18
+ ProjectState,
19
+ )
20
+ from extractors.base import BaseEvidenceExtractor, DEFAULT_IGNORE_DIRS
21
+ from extractors.config.parsers import ManifestParser
22
+ from extractors.config.secret_sanitizer import SecretSanitizer
23
+
24
+
25
+ LOCKFILE_NAMES: Set[str] = {
26
+ "package-lock.json",
27
+ "yarn.lock",
28
+ "pnpm-lock.yaml",
29
+ "Cargo.lock",
30
+ "poetry.lock",
31
+ "Pipfile.lock",
32
+ "pubspec.lock",
33
+ "go.sum",
34
+ "composer.lock"
35
+ }
36
+
37
+
38
+ class ConfigEvidenceExtractor(BaseEvidenceExtractor):
39
+ """
40
+ Extractor responsible for Phase 2: Manifest, config, and environment discovery.
41
+ """
42
+
43
+ @property
44
+ def extractor_name(self) -> str:
45
+ return "ConfigEvidenceExtractor"
46
+
47
+ @property
48
+ def supported_evidence_types(self) -> List[EvidenceType]:
49
+ return [
50
+ EvidenceType.CONFIG_FILE
51
+ ]
52
+
53
+ def can_extract(self, target_path_or_input: str) -> bool:
54
+ path = Path(target_path_or_input)
55
+ return path.exists() and path.is_dir()
56
+
57
+ def extract(self, target_path_or_input: str, context: Optional[Dict[str, Any]] = None) -> List[Evidence]:
58
+ root_path = Path(target_path_or_input).resolve()
59
+ evidence_list: List[Evidence] = []
60
+
61
+ manifests: List[ManifestInfo] = []
62
+ lockfiles_detected: List[str] = []
63
+ container_configs: List[Dict[str, Any]] = []
64
+ env_templates: List[Dict[str, Any]] = []
65
+
66
+ for path in sorted(root_path.rglob("*")):
67
+ if not path.is_file():
68
+ continue
69
+
70
+ parts = set(path.relative_to(root_path).parts)
71
+ if any(ignored in parts for ignored in DEFAULT_IGNORE_DIRS):
72
+ continue
73
+
74
+ filename = path.name
75
+ rel_path = path.relative_to(root_path).as_posix()
76
+
77
+ # 1. Lockfiles
78
+ if filename in LOCKFILE_NAMES:
79
+ lockfiles_detected.append(rel_path)
80
+ continue
81
+
82
+ # Read content safely
83
+ try:
84
+ content = path.read_text(encoding="utf-8", errors="replace")
85
+ except Exception:
86
+ continue
87
+
88
+ # 2. Manifests
89
+ if filename == "package.json":
90
+ m = ManifestParser.parse_package_json(rel_path, content)
91
+ manifests.append(m)
92
+ elif filename == "pyproject.toml":
93
+ m = ManifestParser.parse_pyproject_toml(rel_path, content)
94
+ manifests.append(m)
95
+ elif filename == "Cargo.toml":
96
+ m = ManifestParser.parse_cargo_toml(rel_path, content)
97
+ manifests.append(m)
98
+ elif filename == "go.mod":
99
+ m = ManifestParser.parse_go_mod(rel_path, content)
100
+ manifests.append(m)
101
+ elif filename == "pubspec.yaml":
102
+ m = ManifestParser.parse_pubspec_yaml(rel_path, content)
103
+ manifests.append(m)
104
+ elif filename == "Dockerfile" or filename.startswith("Dockerfile."):
105
+ d = ManifestParser.parse_dockerfile(rel_path, content)
106
+ container_configs.append(d)
107
+ elif filename in {".env.example", ".env.template", ".env.sample"}:
108
+ e = ManifestParser.parse_env_template(rel_path, content)
109
+ env_templates.append(e)
110
+
111
+ # 3. Create Manifest Evidence Records
112
+ for m in manifests:
113
+ ev = self.create_evidence(
114
+ evidence_type=EvidenceType.CONFIG_FILE,
115
+ summary=f"Discovered manifest {m.manifest_type} ({m.project_name or 'unnamed'} v{m.version or '0.0.0'}) with {len(m.dependencies)} dependencies",
116
+ raw_payload={
117
+ "manifest_type": m.manifest_type,
118
+ "file_path": m.file_path,
119
+ "project_name": m.project_name,
120
+ "version": m.version,
121
+ "dependencies": m.dependencies,
122
+ "dev_dependencies": m.dev_dependencies,
123
+ "scripts": m.scripts
124
+ },
125
+ source_uri=m.file_path,
126
+ locator=f"manifest:{m.file_path}"
127
+ )
128
+ m.evidence_id = ev.id
129
+ evidence_list.append(ev)
130
+
131
+ # 4. Create Project Environment & Container Evidence
132
+ if container_configs or env_templates or lockfiles_detected:
133
+ evidence_list.append(self.create_evidence(
134
+ evidence_type=EvidenceType.CONFIG_FILE,
135
+ summary=f"Discovered project configuration: {len(lockfiles_detected)} lockfiles, {len(container_configs)} containers, {len(env_templates)} env templates",
136
+ raw_payload={
137
+ "lockfiles": lockfiles_detected,
138
+ "container_configs": container_configs,
139
+ "env_templates": env_templates
140
+ },
141
+ source_uri=str(root_path),
142
+ locator="config:environment"
143
+ ))
144
+
145
+ return evidence_list
146
+
147
+ def populate_project_state(self, root_dir: str, canonical_state: CanonicalProjectState) -> ProjectState:
148
+ """Populates ProjectState.manifests and registers config evidence."""
149
+ root_path = Path(root_dir).resolve()
150
+ evidence_list = self.extract(str(root_path))
151
+
152
+ manifest_records: List[ManifestInfo] = []
153
+
154
+ for ev in evidence_list:
155
+ canonical_state.add_evidence(ev)
156
+ if "manifest_type" in ev.raw_payload:
157
+ m = ManifestInfo(
158
+ manifest_type=ev.raw_payload["manifest_type"],
159
+ file_path=ev.raw_payload["file_path"],
160
+ project_name=ev.raw_payload.get("project_name"),
161
+ version=ev.raw_payload.get("version"),
162
+ dependencies=ev.raw_payload.get("dependencies", {}),
163
+ dev_dependencies=ev.raw_payload.get("dev_dependencies", {}),
164
+ scripts=ev.raw_payload.get("scripts", {}),
165
+ evidence_id=ev.id
166
+ )
167
+ manifest_records.append(m)
168
+
169
+ ps = canonical_state.project_state
170
+ ps.manifests.extend(manifest_records)
171
+ ps.evidence_ids.extend([ev.id for ev in evidence_list])
172
+ return ps
@@ -0,0 +1,193 @@
1
+ """
2
+ Project Continuum - Conversational Intent, Claim & Execution Analyzer
3
+ =====================================================================
4
+ Analyzes conversation transcripts to extract requirements, unverified
5
+ agent claims, architectural decisions, assumptions, unresolved questions,
6
+ file references, and tactical execution state.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ import re
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ from core.enums import Status
14
+ from core.state_models import (
15
+ AgentClaim,
16
+ ArchitecturalDecision,
17
+ NextActionRecommendation,
18
+ Requirement,
19
+ TacticalTask,
20
+ UnresolvedQuestion,
21
+ )
22
+ from extractors.conversation.models import ConversationTranscript, ConversationTurn, TurnRole
23
+
24
+
25
+ @dataclass
26
+ class TranscriptAnalysisResult:
27
+ """Structured insights extracted from a conversation transcript."""
28
+ requirements: List[Requirement] = field(default_factory=list)
29
+ agent_claims: List[AgentClaim] = field(default_factory=list)
30
+ decisions: List[ArchitecturalDecision] = field(default_factory=list)
31
+ assumptions: List[str] = field(default_factory=list)
32
+ unresolved_questions: List[UnresolvedQuestion] = field(default_factory=list)
33
+ tactical_tasks: List[TacticalTask] = field(default_factory=list)
34
+ referenced_files: List[str] = field(default_factory=list)
35
+ next_action: Optional[NextActionRecommendation] = None
36
+
37
+
38
+ class TranscriptAnalyzer:
39
+ """
40
+ Parses conversational dialogue turns and extracts semantic intent, claims,
41
+ and execution state using pattern matching and ontological heuristics.
42
+ """
43
+
44
+ # Requirements keywords (user intent)
45
+ REQUIREMENT_PATTERNS = [
46
+ re.compile(r"(?:we need to|please|i want to|implement|create|build|add support for|must have|requirement:?)\s+([^.\n]+)", re.IGNORECASE),
47
+ re.compile(r"(?:feature request:?|goal:?)\s+([^.\n]+)", re.IGNORECASE)
48
+ ]
49
+
50
+ # Completion / Progress claims (assistant statements)
51
+ CLAIM_PATTERNS = [
52
+ re.compile(r"(?:i have|i've|we have|we've)\s+(?:completed|implemented|finished|created|fixed|verified|tested|added)\s+([^.\n]+)", re.IGNORECASE),
53
+ re.compile(r"(?:is now|are now)\s+(?:complete|working|passing all tests|implemented|ready)\b([^.\n]*)", re.IGNORECASE),
54
+ re.compile(r"(?:tests are passing|all tests pass|build succeeded)", re.IGNORECASE)
55
+ ]
56
+
57
+ # Architectural decisions
58
+ DECISION_PATTERNS = [
59
+ re.compile(r"(?:decided to|let's use|we will use|architectural decision:?|decision:?|chosen approach:?)\s+([^.\n]+)", re.IGNORECASE),
60
+ re.compile(r"(?:instead of\s+[\w\s]+,\s*we(?:'ll| will)\s+use\s+([^.\n]+))", re.IGNORECASE)
61
+ ]
62
+
63
+ # Questions & dilemmas
64
+ QUESTION_PATTERNS = [
65
+ re.compile(r"^(?:.*?\s+)?([^.!\n]+\?)", re.MULTILINE),
66
+ ]
67
+
68
+ # File path detector
69
+ FILE_PATH_PATTERN = re.compile(
70
+ r"""(?:[\w\-_\/.]+\.(?:py|js|ts|tsx|jsx|json|toml|yaml|yml|rs|go|md|txt|sql|html|css|dart|c|cpp|h))""",
71
+ re.IGNORECASE
72
+ )
73
+
74
+ # Next action patterns
75
+ NEXT_ACTION_PATTERNS = [
76
+ re.compile(r"(?:next steps?:?|next action:?|next,?\s+(?:we should|let's|i will))\s+([^.\n]+)", re.IGNORECASE),
77
+ re.compile(r"(?:now we can proceed to|moving on to)\s+([^.\n]+)", re.IGNORECASE)
78
+ ]
79
+
80
+ @classmethod
81
+ def analyze(cls, transcript: ConversationTranscript) -> TranscriptAnalysisResult:
82
+ result = TranscriptAnalysisResult()
83
+ req_id_counter = 1
84
+ claim_id_counter = 1
85
+ dec_id_counter = 1
86
+ q_id_counter = 1
87
+ task_id_counter = 1
88
+
89
+ all_file_refs: Set[str] = set()
90
+
91
+ for turn in transcript.turns:
92
+ content = turn.content.strip()
93
+ if not content:
94
+ continue
95
+
96
+ # Detect file references across all turns
97
+ for match in cls.FILE_PATH_PATTERN.finditer(content):
98
+ path_match = match.group(0).strip("`'\"(),")
99
+ if "/" in path_match or "." in path_match:
100
+ all_file_refs.add(path_match)
101
+
102
+ # 1. Analyze User Turns (Requirements & Assumptions)
103
+ if turn.role == TurnRole.USER:
104
+ for pat in cls.REQUIREMENT_PATTERNS:
105
+ for match in pat.finditer(content):
106
+ title = match.group(1).strip()
107
+ if len(title) > 5 and not any(r.title.lower() == title.lower() for r in result.requirements):
108
+ result.requirements.append(Requirement(
109
+ id=f"req_{req_id_counter:03d}",
110
+ title=title,
111
+ description=f"Extracted from User turn #{turn.turn_index}: '{match.group(0).strip()}'",
112
+ source_turn=turn.turn_index,
113
+ status=Status.PENDING
114
+ ))
115
+ req_id_counter += 1
116
+
117
+ # Check for explicit assumptions
118
+ if "assuming" in content.lower() or "assumption" in content.lower():
119
+ result.assumptions.append(f"User turn #{turn.turn_index}: {content[:120]}")
120
+
121
+ # 2. Analyze Assistant Turns (Claims, Decisions, Questions, Tasks)
122
+ elif turn.role == TurnRole.ASSISTANT:
123
+ # Claims
124
+ for pat in cls.CLAIM_PATTERNS:
125
+ for match in pat.finditer(content):
126
+ claim_text = match.group(0).strip()
127
+ if len(claim_text) > 8:
128
+ result.agent_claims.append(AgentClaim(
129
+ id=f"claim_{claim_id_counter:03d}",
130
+ claim_text=claim_text,
131
+ target_component=cls._extract_target_component(claim_text),
132
+ claimed_status=Status.VERIFIED,
133
+ source_agent=turn.author_model or "assistant",
134
+ turn_id=turn.turn_index
135
+ ))
136
+ claim_id_counter += 1
137
+
138
+ # Architectural Decisions
139
+ for pat in cls.DECISION_PATTERNS:
140
+ for match in pat.finditer(content):
141
+ dec_title = match.group(1).strip()
142
+ if len(dec_title) > 5:
143
+ result.decisions.append(ArchitecturalDecision(
144
+ id=f"arch_{dec_id_counter:03d}",
145
+ title=dec_title,
146
+ rationale=f"Agreed in Assistant turn #{turn.turn_index}",
147
+ recorded_at=turn.timestamp
148
+ ))
149
+ dec_id_counter += 1
150
+
151
+ # Unresolved Questions (Assistant asking user)
152
+ if "?" in content:
153
+ for match in cls.QUESTION_PATTERNS:
154
+ for q_match in match.finditer(content):
155
+ q_text = q_match.group(0).strip()
156
+ if len(q_text) > 10 and not any(q.question == q_text for q in result.unresolved_questions):
157
+ result.unresolved_questions.append(UnresolvedQuestion(
158
+ id=f"q_{q_id_counter:03d}",
159
+ question=q_text,
160
+ context=f"Asked by assistant at turn #{turn.turn_index}",
161
+ blocking=True
162
+ ))
163
+ q_id_counter += 1
164
+
165
+ # Tactical Tasks (In-flight work)
166
+ if "task" in content.lower() or "working on" in content.lower() or "currently" in content.lower():
167
+ result.tactical_tasks.append(TacticalTask(
168
+ id=f"task_{task_id_counter:03d}",
169
+ title=f"Turn #{turn.turn_index} action item",
170
+ notes=content[:200],
171
+ status=Status.IN_PROGRESS
172
+ ))
173
+ task_id_counter += 1
174
+
175
+ # Next Action
176
+ for pat in cls.NEXT_ACTION_PATTERNS:
177
+ match = pat.search(content)
178
+ if match:
179
+ action_desc = match.group(1).strip()
180
+ result.next_action = NextActionRecommendation(
181
+ action_type="CONTINUE_EXECUTION",
182
+ target_uri="workspace",
183
+ description=action_desc
184
+ )
185
+
186
+ result.referenced_files = sorted(list(all_file_refs))
187
+ return result
188
+
189
+ @staticmethod
190
+ def _extract_target_component(claim_text: str) -> Optional[str]:
191
+ """Heuristically extracts symbol or component name from claim text."""
192
+ words = re.findall(r"[A-Z][a-zA-Z0-9]+|\b[\w\-_\/.]+\.\w+\b", claim_text)
193
+ return words[0] if words else None
@@ -0,0 +1,148 @@
1
+ """
2
+ Project Continuum - Vendor-Neutral Conversation Models
3
+ ======================================================
4
+ Defines universal representations for conversational transcripts,
5
+ turns, roles, and tool interactions across AI models/platforms.
6
+ """
7
+
8
+ from dataclasses import dataclass, field, asdict
9
+ from datetime import datetime, timezone
10
+ from enum import Enum
11
+ import json
12
+ from typing import Any, Dict, List, Optional
13
+ import uuid
14
+
15
+
16
+ class TurnRole(str, Enum):
17
+ USER = "user"
18
+ ASSISTANT = "assistant"
19
+ SYSTEM = "system"
20
+ TOOL = "tool"
21
+
22
+
23
+ @dataclass
24
+ class ConversationTurn:
25
+ """Represents an individual message turn in a conversation."""
26
+ turn_index: int
27
+ role: TurnRole
28
+ content: str
29
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
30
+ author_model: Optional[str] = None
31
+ tool_calls: List[Dict[str, Any]] = field(default_factory=list)
32
+ metadata: Dict[str, Any] = field(default_factory=dict)
33
+
34
+ def to_dict(self) -> Dict[str, Any]:
35
+ d = asdict(self)
36
+ d["role"] = self.role.value
37
+ return d
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: Dict[str, Any]) -> "ConversationTurn":
41
+ d = dict(data)
42
+ d["role"] = TurnRole(d.get("role", "user"))
43
+ if "turn_index" not in d:
44
+ d["turn_index"] = int(d.pop("turn_id", "1").replace("t", "")) if isinstance(d.get("turn_id"), str) and d.get("turn_id", "").replace("t", "").isdigit() else 1
45
+ d.pop("turn_id", None)
46
+ return cls(**d)
47
+
48
+
49
+ @dataclass
50
+ class ConversationTranscript:
51
+ """Vendor-neutral aggregated conversation transcript."""
52
+ session_id: str = field(default_factory=lambda: f"sess_{uuid.uuid4().hex[:8]}")
53
+ platform: str = "generic" # claude, openai, gemini, cursor, custom
54
+ model_name: Optional[str] = None
55
+ turns: List[ConversationTurn] = field(default_factory=list)
56
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
57
+ metadata: Dict[str, Any] = field(default_factory=dict)
58
+
59
+ def add_turn(self, role: TurnRole, content: str, author_model: Optional[str] = None, **kwargs) -> ConversationTurn:
60
+ turn = ConversationTurn(
61
+ turn_index=len(self.turns) + 1,
62
+ role=role,
63
+ content=content,
64
+ author_model=author_model or self.model_name,
65
+ **kwargs
66
+ )
67
+ self.turns.append(turn)
68
+ return turn
69
+
70
+ def to_dict(self) -> Dict[str, Any]:
71
+ return {
72
+ "session_id": self.session_id,
73
+ "platform": self.platform,
74
+ "model_name": self.model_name,
75
+ "created_at": self.created_at,
76
+ "turns": [t.to_dict() for t in self.turns],
77
+ "metadata": self.metadata
78
+ }
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: Dict[str, Any]) -> "ConversationTranscript":
82
+ return cls(
83
+ session_id=data.get("session_id", f"sess_{uuid.uuid4().hex[:8]}"),
84
+ platform=data.get("platform", "generic"),
85
+ model_name=data.get("model_name"),
86
+ created_at=data.get("created_at", datetime.now(timezone.utc).isoformat()),
87
+ turns=[ConversationTurn.from_dict(t) for t in data.get("turns", [])],
88
+ metadata=data.get("metadata", {})
89
+ )
90
+
91
+ @classmethod
92
+ def from_json(cls, json_str: str) -> "ConversationTranscript":
93
+ data = json.loads(json_str)
94
+ if isinstance(data, list): # Array of messages format
95
+ transcript = cls()
96
+ for idx, msg in enumerate(data, start=1):
97
+ role_str = msg.get("role", "user").lower()
98
+ role = TurnRole(role_str) if role_str in [r.value for r in TurnRole] else TurnRole.USER
99
+ transcript.turns.append(ConversationTurn(
100
+ turn_index=idx,
101
+ role=role,
102
+ content=msg.get("content", ""),
103
+ author_model=msg.get("model")
104
+ ))
105
+ return transcript
106
+ return cls.from_dict(data)
107
+
108
+ @classmethod
109
+ def from_plain_text(cls, text: str, default_role: TurnRole = TurnRole.USER) -> "ConversationTranscript":
110
+ """Parses plain text transcripts (e.g., 'User: ...\nAssistant: ...')."""
111
+ transcript = cls()
112
+ lines = text.splitlines()
113
+ current_role = default_role
114
+ current_buffer: List[str] = []
115
+ turn_counter = 1
116
+
117
+ def flush_turn():
118
+ nonlocal turn_counter, current_buffer
119
+ if current_buffer:
120
+ content = "\n".join(current_buffer).strip()
121
+ if content:
122
+ transcript.turns.append(ConversationTurn(
123
+ turn_index=turn_counter,
124
+ role=current_role,
125
+ content=content
126
+ ))
127
+ turn_counter += 1
128
+ current_buffer = []
129
+
130
+ for line in lines:
131
+ lower = line.lower().strip()
132
+ if lower.startswith("user:") or lower.startswith("human:"):
133
+ flush_turn()
134
+ current_role = TurnRole.USER
135
+ current_buffer.append(line.split(":", 1)[1].strip())
136
+ elif lower.startswith("assistant:") or lower.startswith("ai:") or lower.startswith("bot:"):
137
+ flush_turn()
138
+ current_role = TurnRole.ASSISTANT
139
+ current_buffer.append(line.split(":", 1)[1].strip())
140
+ elif lower.startswith("system:"):
141
+ flush_turn()
142
+ current_role = TurnRole.SYSTEM
143
+ current_buffer.append(line.split(":", 1)[1].strip())
144
+ else:
145
+ current_buffer.append(line)
146
+
147
+ flush_turn()
148
+ return transcript