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.
- cli/__init__.py +12 -0
- cli/main.py +415 -0
- confidence/__init__.py +13 -0
- confidence/calculator.py +308 -0
- confidence/models.py +40 -0
- context/__init__.py +17 -0
- context/models.py +108 -0
- context/pruner.py +228 -0
- context/selector.py +193 -0
- continuum_toolkit-1.0.0.dist-info/METADATA +511 -0
- continuum_toolkit-1.0.0.dist-info/RECORD +72 -0
- continuum_toolkit-1.0.0.dist-info/WHEEL +5 -0
- continuum_toolkit-1.0.0.dist-info/entry_points.txt +2 -0
- continuum_toolkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- continuum_toolkit-1.0.0.dist-info/top_level.txt +13 -0
- contradictions/__init__.py +19 -0
- contradictions/detector.py +442 -0
- contradictions/models.py +75 -0
- core/__init__.py +97 -0
- core/enums.py +130 -0
- core/evidence.py +117 -0
- core/interfaces.py +209 -0
- core/schema.py +391 -0
- core/serializer.py +84 -0
- core/state_models.py +530 -0
- daemon/__init__.py +12 -0
- daemon/service.py +170 -0
- extractors/__init__.py +51 -0
- extractors/base.py +117 -0
- extractors/config/parsers.py +288 -0
- extractors/config/secret_sanitizer.py +91 -0
- extractors/config_extractor.py +172 -0
- extractors/conversation/analyzers.py +193 -0
- extractors/conversation/models.py +148 -0
- extractors/conversation_extractor.py +166 -0
- extractors/git_extractor.py +305 -0
- extractors/parsers/base.py +91 -0
- extractors/parsers/comment_parser.py +51 -0
- extractors/parsers/js_ts_parser.py +171 -0
- extractors/parsers/python_parser.py +180 -0
- extractors/verification/runners.py +278 -0
- extractors/verification_extractor.py +263 -0
- extractors/workspace_extractor.py +221 -0
- graph/__init__.py +24 -0
- graph/diff.py +109 -0
- graph/manager.py +473 -0
- graph/models.py +62 -0
- graph/propagator.py +194 -0
- graph/query.py +86 -0
- graph/snapshot.py +85 -0
- handoff/__init__.py +28 -0
- handoff/adapters/__init__.py +45 -0
- handoff/adapters/base.py +90 -0
- handoff/adapters/claude_adapter.py +176 -0
- handoff/adapters/codex_gpt_adapter.py +151 -0
- handoff/adapters/gemini_adapter.py +151 -0
- handoff/adapters/local_model_adapter.py +130 -0
- handoff/models.py +88 -0
- handoff/packager.py +288 -0
- pipeline/__init__.py +9 -0
- pipeline/orchestrator.py +260 -0
- resolution/__init__.py +15 -0
- resolution/resolver.py +311 -0
- storage/__init__.py +18 -0
- storage/hooks.py +125 -0
- storage/manager.py +127 -0
- storage/models.py +39 -0
- storage/recovery.py +98 -0
- watcher/__init__.py +17 -0
- watcher/detector.py +136 -0
- watcher/models.py +62 -0
- 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
|