mcp-sequential-thinking 0.6.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.
File without changes
@@ -0,0 +1,289 @@
1
+ from collections import Counter
2
+ from typing import Any, Dict, List
3
+
4
+ from .logging_conf import configure_logging
5
+ from .models import ThoughtData, ThoughtStage
6
+
7
+ logger = configure_logging("sequential-thinking.analysis")
8
+
9
+
10
+ class ThoughtAnalyzer:
11
+ """Analyzer for thought data to extract insights and patterns."""
12
+
13
+ @staticmethod
14
+ def _is_mainline(thought: ThoughtData) -> bool:
15
+ """Whether a thought belongs to the main line of reasoning.
16
+
17
+ Revisions and branch thoughts are excluded from progress metrics:
18
+ counting them would report e.g. 160% for a 5-thought session with
19
+ 3 revisions.
20
+ """
21
+ return not thought.is_revision and thought.branch_id is None
22
+
23
+ @staticmethod
24
+ def find_related_thoughts(
25
+ current_thought: ThoughtData, all_thoughts: List[ThoughtData], max_results: int = 3
26
+ ) -> List[ThoughtData]:
27
+ """Find thoughts related to the current thought.
28
+
29
+ Args:
30
+ current_thought: The current thought to find related thoughts for
31
+ all_thoughts: All available thoughts to search through
32
+ max_results: Maximum number of related thoughts to return
33
+
34
+ Returns:
35
+ List[ThoughtData]: Related thoughts, sorted by relevance
36
+ """
37
+ # First, find thoughts in the same stage
38
+ same_stage = [
39
+ t
40
+ for t in all_thoughts
41
+ if t.stage == current_thought.stage and t.id != current_thought.id
42
+ ]
43
+
44
+ # Then, find thoughts with similar tags
45
+ if current_thought.tags:
46
+ tag_matches = []
47
+ for thought in all_thoughts:
48
+ if thought.id == current_thought.id:
49
+ continue
50
+
51
+ # Count matching tags
52
+ matching_tags = set(current_thought.tags) & set(thought.tags)
53
+ if matching_tags:
54
+ tag_matches.append((thought, len(matching_tags)))
55
+
56
+ # Sort by number of matching tags (descending)
57
+ tag_matches.sort(key=lambda x: x[1], reverse=True)
58
+ tag_related = [t[0] for t in tag_matches]
59
+ else:
60
+ tag_related = []
61
+
62
+ # Combine and deduplicate results
63
+ combined = []
64
+ seen_ids = set()
65
+
66
+ # First add same stage thoughts
67
+ for thought in same_stage:
68
+ if thought.id not in seen_ids:
69
+ combined.append(thought)
70
+ seen_ids.add(thought.id)
71
+
72
+ if len(combined) >= max_results:
73
+ break
74
+
75
+ # Then add tag-related thoughts
76
+ if len(combined) < max_results:
77
+ for thought in tag_related:
78
+ if thought.id not in seen_ids:
79
+ combined.append(thought)
80
+ seen_ids.add(thought.id)
81
+
82
+ if len(combined) >= max_results:
83
+ break
84
+
85
+ return combined
86
+
87
+ @staticmethod
88
+ def generate_summary(thoughts: List[ThoughtData]) -> Dict[str, Any]:
89
+ """Generate a summary of the thinking process.
90
+
91
+ Args:
92
+ thoughts: List of thoughts to summarize
93
+
94
+ Returns:
95
+ Dict[str, Any]: Summary data
96
+ """
97
+ if not thoughts:
98
+ return {"summary": "No thoughts recorded yet"}
99
+
100
+ # Group thoughts by stage
101
+ stages: Dict[str, List[ThoughtData]] = {}
102
+ for thought in thoughts:
103
+ if thought.stage.value not in stages:
104
+ stages[thought.stage.value] = []
105
+ stages[thought.stage.value].append(thought)
106
+
107
+ # Count tags - using a more readable approach with explicit steps
108
+ # Collect all tags from all thoughts
109
+ all_tags = []
110
+ for thought in thoughts:
111
+ all_tags.extend(thought.tags)
112
+
113
+ # Count occurrences of each tag
114
+ tag_counts = Counter(all_tags)
115
+
116
+ # Get the 5 most common tags
117
+ top_tags = tag_counts.most_common(5)
118
+
119
+ # Create summary
120
+ try:
121
+ # Progress is based on mainline thoughts only; revisions and
122
+ # branch thoughts don't advance the sequence.
123
+ mainline_thoughts = [t for t in thoughts if ThoughtAnalyzer._is_mainline(t)]
124
+
125
+ # Safely calculate max total thoughts to avoid division by zero
126
+ max_total = max((t.total_thoughts for t in mainline_thoughts), default=0)
127
+
128
+ # Calculate percent complete safely
129
+ percent_complete: float = 0.0
130
+ if max_total > 0:
131
+ percent_complete = (len(mainline_thoughts) / max_total) * 100
132
+
133
+ logger.debug(
134
+ f"Calculating completion: {len(mainline_thoughts)}/{max_total} "
135
+ f"= {percent_complete}%"
136
+ )
137
+
138
+ # Build the summary dictionary with more readable and
139
+ # maintainable list comprehensions
140
+
141
+ # Count thoughts by stage
142
+ stage_counts = {stage: len(thoughts_list) for stage, thoughts_list in stages.items()}
143
+
144
+ # Create timeline entries
145
+ sorted_thoughts = sorted(thoughts, key=lambda x: x.thought_number)
146
+ timeline_entries = []
147
+ for t in sorted_thoughts:
148
+ entry: Dict[str, Any] = {"number": t.thought_number, "stage": t.stage.value}
149
+ if t.is_revision:
150
+ entry["isRevision"] = True
151
+ if t.branch_id is not None:
152
+ entry["branchId"] = t.branch_id
153
+ timeline_entries.append(entry)
154
+
155
+ # Aggregate branches: first occurrence defines the fork point.
156
+ branches: Dict[str, Dict[str, Any]] = {}
157
+ for t in sorted_thoughts:
158
+ if t.branch_id is None:
159
+ continue
160
+ if t.branch_id not in branches:
161
+ branches[t.branch_id] = {
162
+ "fromThought": t.branch_from_thought,
163
+ "thoughtCount": 0,
164
+ }
165
+ branches[t.branch_id]["thoughtCount"] += 1
166
+
167
+ revision_count = sum(1 for t in thoughts if t.is_revision)
168
+
169
+ # Create top tags entries
170
+ top_tags_entries = []
171
+ for tag, count in top_tags:
172
+ top_tags_entries.append({"tag": tag, "count": count})
173
+
174
+ # Check if all stages are represented
175
+ all_stages_present = all(stage.value in stages for stage in ThoughtStage)
176
+
177
+ # Assemble the final summary
178
+ summary = {
179
+ "totalThoughts": len(thoughts),
180
+ "stages": stage_counts,
181
+ "timeline": timeline_entries,
182
+ "branches": branches,
183
+ "revisionCount": revision_count,
184
+ "topTags": top_tags_entries,
185
+ "completionStatus": {
186
+ "hasAllStages": all_stages_present,
187
+ "percentComplete": percent_complete,
188
+ },
189
+ }
190
+ except Exception as e:
191
+ logger.error(f"Error generating summary: {e}")
192
+ summary = {"totalThoughts": len(thoughts), "error": str(e)}
193
+
194
+ return {"summary": summary}
195
+
196
+ @staticmethod
197
+ def analyze_thought(thought: ThoughtData, all_thoughts: List[ThoughtData]) -> Dict[str, Any]:
198
+ """Analyze a single thought in the context of all thoughts.
199
+
200
+ Args:
201
+ thought: The thought to analyze
202
+ all_thoughts: All available thoughts for context
203
+
204
+ Returns:
205
+ Dict[str, Any]: Analysis results
206
+ """
207
+ # Find related thoughts
208
+ related_thoughts = ThoughtAnalyzer.find_related_thoughts(thought, all_thoughts)
209
+
210
+ # Check if this is the first thought in its stage (lowest thought_number)
211
+ same_stage_thoughts = [t for t in all_thoughts if t.stage == thought.stage]
212
+ is_first_in_stage = all(
213
+ t.thought_number >= thought.thought_number for t in same_stage_thoughts
214
+ )
215
+
216
+ # Calculate progress. Revisions and branch thoughts don't advance the
217
+ # sequence, so for them progress reflects the mainline position instead
218
+ # of their own number (which may exceed total_thoughts).
219
+ if ThoughtAnalyzer._is_mainline(thought):
220
+ effective_number = thought.thought_number
221
+ else:
222
+ effective_number = max(
223
+ (t.thought_number for t in all_thoughts if ThoughtAnalyzer._is_mainline(t)),
224
+ default=0,
225
+ )
226
+ progress = (effective_number / thought.total_thoughts) * 100
227
+
228
+ # For a revision, surface a snippet of the mainline thought it revises.
229
+ revision_of = None
230
+ if thought.is_revision and thought.revises_thought_number is not None:
231
+ revised = next(
232
+ (
233
+ t
234
+ for t in all_thoughts
235
+ if ThoughtAnalyzer._is_mainline(t)
236
+ and t.thought_number == thought.revises_thought_number
237
+ ),
238
+ None,
239
+ )
240
+ if revised is not None:
241
+ revision_of = {
242
+ "thoughtNumber": revised.thought_number,
243
+ "stage": revised.stage.value,
244
+ "snippet": (
245
+ revised.thought[:100] + "..."
246
+ if len(revised.thought) > 100
247
+ else revised.thought
248
+ ),
249
+ }
250
+
251
+ # Create analysis
252
+ analysis_block: Dict[str, Any] = {
253
+ "relatedThoughtsCount": len(related_thoughts),
254
+ "relatedThoughtSummaries": [
255
+ {
256
+ "thoughtNumber": t.thought_number,
257
+ "stage": t.stage.value,
258
+ "snippet": (
259
+ t.thought[:100] + "..." if len(t.thought) > 100 else t.thought
260
+ ),
261
+ }
262
+ for t in related_thoughts
263
+ ],
264
+ "progress": progress,
265
+ "isFirstInStage": is_first_in_stage,
266
+ "isRevision": thought.is_revision,
267
+ "revisedThought": thought.revises_thought_number,
268
+ "branchId": thought.branch_id,
269
+ }
270
+ if revision_of is not None:
271
+ analysis_block["revisionOf"] = revision_of
272
+
273
+ return {
274
+ "thoughtAnalysis": {
275
+ "currentThought": {
276
+ "thoughtNumber": thought.thought_number,
277
+ "totalThoughts": thought.total_thoughts,
278
+ "nextThoughtNeeded": thought.next_thought_needed,
279
+ "stage": thought.stage.value,
280
+ "tags": thought.tags,
281
+ "timestamp": thought.timestamp,
282
+ },
283
+ "analysis": analysis_block,
284
+ "context": {
285
+ "thoughtHistoryLength": len(all_thoughts),
286
+ "currentStage": thought.stage.value,
287
+ },
288
+ }
289
+ }
@@ -0,0 +1,24 @@
1
+ import logging
2
+ import sys
3
+
4
+
5
+ def configure_logging(name: str = "sequential-thinking") -> logging.Logger:
6
+ """Configure and return a logger with standardized settings.
7
+
8
+ Args:
9
+ name: The name for the logger
10
+
11
+ Returns:
12
+ logging.Logger: Configured logger instance
13
+ """
14
+ # Configure root logger
15
+ logging.basicConfig(
16
+ level=logging.INFO,
17
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
18
+ handlers=[
19
+ logging.StreamHandler(sys.stderr)
20
+ ]
21
+ )
22
+
23
+ # Get and return the named logger
24
+ return logging.getLogger(name)
@@ -0,0 +1,226 @@
1
+ import re
2
+ from typing import List, Optional
3
+ from enum import Enum
4
+ from datetime import datetime
5
+ from uuid import uuid4, UUID
6
+ from pydantic import BaseModel, Field, field_validator, model_validator, ValidationInfo
7
+
8
+ # branch_id ends up in files and tool output, so it is restricted to a short,
9
+ # filesystem- and log-safe alphabet.
10
+ BRANCH_ID_MAX_LENGTH = 64
11
+ BRANCH_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
12
+
13
+
14
+ class ThoughtStage(Enum):
15
+ """Basic thinking stages for structured sequential thinking."""
16
+ PROBLEM_DEFINITION = "Problem Definition"
17
+ RESEARCH = "Research"
18
+ ANALYSIS = "Analysis"
19
+ SYNTHESIS = "Synthesis"
20
+ CONCLUSION = "Conclusion"
21
+
22
+ @classmethod
23
+ def from_string(cls, value: str) -> 'ThoughtStage':
24
+ """Convert a string to a thinking stage.
25
+
26
+ Args:
27
+ value: The string representation of the thinking stage
28
+
29
+ Returns:
30
+ ThoughtStage: The corresponding ThoughtStage enum value
31
+
32
+ Raises:
33
+ ValueError: If the string does not match any valid thinking stage
34
+ """
35
+ # Case-insensitive comparison
36
+ for stage in cls:
37
+ if stage.value.casefold() == value.casefold():
38
+ return stage
39
+
40
+ # If no match found
41
+ valid_stages = ", ".join(stage.value for stage in cls)
42
+ raise ValueError(f"Invalid thinking stage: '{value}'. Valid stages are: {valid_stages}")
43
+
44
+
45
+ class ThoughtData(BaseModel):
46
+ """Data structure for a single thought in the sequential thinking process."""
47
+ thought: str
48
+ thought_number: int
49
+ total_thoughts: int
50
+ next_thought_needed: bool
51
+ stage: ThoughtStage
52
+ tags: List[str] = Field(default_factory=list)
53
+ axioms_used: List[str] = Field(default_factory=list)
54
+ assumptions_challenged: List[str] = Field(default_factory=list)
55
+ is_revision: bool = False
56
+ revises_thought_number: Optional[int] = None
57
+ branch_from_thought: Optional[int] = None
58
+ branch_id: Optional[str] = None
59
+ timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
60
+ id: UUID = Field(default_factory=uuid4)
61
+
62
+ def __hash__(self) -> int:
63
+ """Make ThoughtData hashable based on its ID."""
64
+ return hash(self.id)
65
+
66
+ def __eq__(self, other: object) -> bool:
67
+ """Compare ThoughtData objects based on their ID."""
68
+ if not isinstance(other, ThoughtData):
69
+ return False
70
+ return self.id == other.id
71
+
72
+ @field_validator('thought')
73
+ @classmethod
74
+ def thought_not_empty(cls, v: str) -> str:
75
+ """Validate that thought content is not empty."""
76
+ if not v or not v.strip():
77
+ raise ValueError("Thought content cannot be empty")
78
+ return v
79
+
80
+ @field_validator('thought_number')
81
+ @classmethod
82
+ def thought_number_positive(cls, v: int) -> int:
83
+ """Validate that thought number is positive."""
84
+ if v < 1:
85
+ raise ValueError("Thought number must be positive")
86
+ return v
87
+
88
+ @field_validator('total_thoughts')
89
+ @classmethod
90
+ def total_thoughts_valid(cls, v: int, info: ValidationInfo) -> int:
91
+ """Validate that total thoughts is valid."""
92
+ thought_number = info.data.get('thought_number')
93
+ if thought_number is not None and v < thought_number:
94
+ raise ValueError("Total thoughts must be greater or equal to current thought number")
95
+ return v
96
+
97
+ @model_validator(mode='after')
98
+ def validate_revision_and_branch(self) -> 'ThoughtData':
99
+ """Validate the cross-field rules for revisions and branches."""
100
+ if self.is_revision and self.revises_thought_number is None:
101
+ raise ValueError("is_revision=True requires revises_thought_number to be set")
102
+ if self.revises_thought_number is not None and not self.is_revision:
103
+ raise ValueError("revises_thought_number requires is_revision=True")
104
+ if self.is_revision and self.branch_from_thought is not None:
105
+ raise ValueError(
106
+ "A thought cannot be a revision and a branch start at the same time"
107
+ )
108
+ if self.branch_id is not None and self.branch_from_thought is None:
109
+ raise ValueError("branch_id requires branch_from_thought to be set")
110
+
111
+ for field_name, value in (
112
+ ("revises_thought_number", self.revises_thought_number),
113
+ ("branch_from_thought", self.branch_from_thought),
114
+ ):
115
+ if value is not None and not 1 <= value < self.thought_number:
116
+ raise ValueError(
117
+ f"{field_name} must be >= 1 and < thought_number "
118
+ f"({self.thought_number}), got {value}"
119
+ )
120
+
121
+ if self.branch_id is not None and (
122
+ len(self.branch_id) > BRANCH_ID_MAX_LENGTH
123
+ or not BRANCH_ID_PATTERN.match(self.branch_id)
124
+ ):
125
+ raise ValueError(
126
+ f"branch_id must be 1-{BRANCH_ID_MAX_LENGTH} characters from "
127
+ "[A-Za-z0-9_-]"
128
+ )
129
+
130
+ return self
131
+
132
+ def to_dict(self, include_id: bool = False) -> dict:
133
+ """Convert the thought data to a dictionary representation.
134
+
135
+ Args:
136
+ include_id: Whether to include the ID in the dictionary representation.
137
+ Default is False to omit it from external representations.
138
+
139
+ Returns:
140
+ dict: Dictionary representation of the thought data, with camelCase
141
+ keys for API consistency.
142
+ """
143
+ result = {
144
+ "thought": self.thought,
145
+ "thoughtNumber": self.thought_number,
146
+ "totalThoughts": self.total_thoughts,
147
+ "nextThoughtNeeded": self.next_thought_needed,
148
+ "stage": self.stage.value,
149
+ "tags": self.tags,
150
+ "axiomsUsed": self.axioms_used,
151
+ "assumptionsChallenged": self.assumptions_challenged,
152
+ "timestamp": self.timestamp,
153
+ }
154
+
155
+ # Revision/branch fields are only emitted when set, keeping records
156
+ # compact and older v2 files readable without them.
157
+ if self.is_revision:
158
+ result["isRevision"] = self.is_revision
159
+ if self.revises_thought_number is not None:
160
+ result["revisesThoughtNumber"] = self.revises_thought_number
161
+ if self.branch_from_thought is not None:
162
+ result["branchFromThought"] = self.branch_from_thought
163
+ if self.branch_id is not None:
164
+ result["branchId"] = self.branch_id
165
+
166
+ if include_id:
167
+ result["id"] = str(self.id)
168
+
169
+ return result
170
+
171
+ @classmethod
172
+ def from_dict(cls, data: dict) -> 'ThoughtData':
173
+ """Create a ThoughtData instance from a dictionary.
174
+
175
+ Args:
176
+ data: Dictionary containing thought data
177
+
178
+ Returns:
179
+ ThoughtData: A new ThoughtData instance
180
+ """
181
+ # Convert any camelCase keys to snake_case
182
+ snake_data = {}
183
+ mappings = {
184
+ "thoughtNumber": "thought_number",
185
+ "totalThoughts": "total_thoughts",
186
+ "nextThoughtNeeded": "next_thought_needed",
187
+ "axiomsUsed": "axioms_used",
188
+ "assumptionsChallenged": "assumptions_challenged",
189
+ "isRevision": "is_revision",
190
+ "revisesThoughtNumber": "revises_thought_number",
191
+ "branchFromThought": "branch_from_thought",
192
+ "branchId": "branch_id"
193
+ }
194
+
195
+ # Process known direct mappings
196
+ for camel_key, snake_key in mappings.items():
197
+ if camel_key in data:
198
+ snake_data[snake_key] = data[camel_key]
199
+
200
+ # Copy fields that don't need conversion
201
+ for key in ["thought", "tags", "timestamp"]:
202
+ if key in data:
203
+ snake_data[key] = data[key]
204
+
205
+ # Handle special fields
206
+ if "stage" in data:
207
+ snake_data["stage"] = ThoughtStage.from_string(data["stage"])
208
+
209
+ # Set default values for missing fields
210
+ snake_data.setdefault("tags", [])
211
+ snake_data.setdefault("axioms_used", data.get("axiomsUsed", []))
212
+ snake_data.setdefault("assumptions_challenged", data.get("assumptionsChallenged", []))
213
+ snake_data.setdefault("timestamp", datetime.now().isoformat())
214
+
215
+ # Add ID if present, otherwise generate a new one
216
+ if "id" in data:
217
+ try:
218
+ snake_data["id"] = UUID(data["id"])
219
+ except (ValueError, TypeError):
220
+ snake_data["id"] = uuid4()
221
+
222
+ return cls(**snake_data)
223
+
224
+ model_config = {
225
+ "arbitrary_types_allowed": True
226
+ }