thinkstack-core 4.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 (205) hide show
  1. thinkstack_core/__init__.py +158 -0
  2. thinkstack_core/aggphi_textual.py +275 -0
  3. thinkstack_core/alerts/__init__.py +23 -0
  4. thinkstack_core/alerts/base.py +46 -0
  5. thinkstack_core/alerts/config.py +60 -0
  6. thinkstack_core/alerts/dispatcher.py +110 -0
  7. thinkstack_core/alerts/jira.py +96 -0
  8. thinkstack_core/alerts/linear.py +72 -0
  9. thinkstack_core/alerts/pagerduty.py +66 -0
  10. thinkstack_core/alerts/slack.py +81 -0
  11. thinkstack_core/alerts/teams.py +70 -0
  12. thinkstack_core/audit/__init__.py +43 -0
  13. thinkstack_core/audit/exporter.py +297 -0
  14. thinkstack_core/audit/privacy.py +101 -0
  15. thinkstack_core/audit/scrubber.py +149 -0
  16. thinkstack_core/audit/service.py +67 -0
  17. thinkstack_core/audit/signing.py +127 -0
  18. thinkstack_core/broadcast/__init__.py +4 -0
  19. thinkstack_core/broadcast/broadcaster.py +100 -0
  20. thinkstack_core/broadcast/watcher.py +71 -0
  21. thinkstack_core/capability.py +639 -0
  22. thinkstack_core/cloud/__init__.py +1 -0
  23. thinkstack_core/cloud/client_config.py +472 -0
  24. thinkstack_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. thinkstack_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. thinkstack_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. thinkstack_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. thinkstack_core/cloud/client_configs/.opencode.json +15 -0
  29. thinkstack_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. thinkstack_core/cloud/mcp_client.py +229 -0
  31. thinkstack_core/cloud/setup.py +144 -0
  32. thinkstack_core/cloud/sync.py +143 -0
  33. thinkstack_core/cloud/sync_bundle.py +639 -0
  34. thinkstack_core/cloud/sync_conflicts.py +183 -0
  35. thinkstack_core/cloud/sync_state.py +159 -0
  36. thinkstack_core/cloud/team_sync.py +337 -0
  37. thinkstack_core/cloud/thinkstack-mcp-bridge.js +357 -0
  38. thinkstack_core/codex/__init__.py +9 -0
  39. thinkstack_core/codex/__main__.py +97 -0
  40. thinkstack_core/codex/capture.py +208 -0
  41. thinkstack_core/codex/proxy.py +412 -0
  42. thinkstack_core/compat.py +103 -0
  43. thinkstack_core/concept_catalog.py +209 -0
  44. thinkstack_core/consolidation/__init__.py +3 -0
  45. thinkstack_core/consolidation/synthesizer.py +87 -0
  46. thinkstack_core/consolidation/workflow.py +175 -0
  47. thinkstack_core/daemon/__init__.py +27 -0
  48. thinkstack_core/daemon/supervisor.py +293 -0
  49. thinkstack_core/daemon/watcher.py +244 -0
  50. thinkstack_core/dashboard_api.py +2012 -0
  51. thinkstack_core/deltaf.py +97 -0
  52. thinkstack_core/disclosure.py +50 -0
  53. thinkstack_core/divergence/__init__.py +3 -0
  54. thinkstack_core/divergence/detector.py +166 -0
  55. thinkstack_core/gateway/__init__.py +32 -0
  56. thinkstack_core/gateway/key_manager.py +124 -0
  57. thinkstack_core/gateway/metrics_webhook.py +252 -0
  58. thinkstack_core/gateway/policy.py +262 -0
  59. thinkstack_core/gateway/server.py +727 -0
  60. thinkstack_core/gateway/sso.py +233 -0
  61. thinkstack_core/gcc.py +1246 -0
  62. thinkstack_core/github/__init__.py +35 -0
  63. thinkstack_core/github/app.py +240 -0
  64. thinkstack_core/github/comment_builder.py +113 -0
  65. thinkstack_core/github/pat.py +76 -0
  66. thinkstack_core/github/pr_parser.py +82 -0
  67. thinkstack_core/github/pr_reporter.py +555 -0
  68. thinkstack_core/gitlab/__init__.py +177 -0
  69. thinkstack_core/hitl/__init__.py +4 -0
  70. thinkstack_core/hitl/channels.py +129 -0
  71. thinkstack_core/hitl/orchestrator.py +95 -0
  72. thinkstack_core/hooks/__init__.py +17 -0
  73. thinkstack_core/hooks/claude_code.py +228 -0
  74. thinkstack_core/hooks/git_capture.py +341 -0
  75. thinkstack_core/hooks/git_commit.py +182 -0
  76. thinkstack_core/hooks/installer.py +850 -0
  77. thinkstack_core/hooks/pre_commit.py +157 -0
  78. thinkstack_core/hooks/runner.py +386 -0
  79. thinkstack_core/identity/__init__.py +4 -0
  80. thinkstack_core/identity/agent.py +86 -0
  81. thinkstack_core/identity/providers.py +85 -0
  82. thinkstack_core/invariants.py +182 -0
  83. thinkstack_core/mcp/__init__.py +10 -0
  84. thinkstack_core/mcp/auth.py +177 -0
  85. thinkstack_core/mcp/server.py +1215 -0
  86. thinkstack_core/metrics/__init__.py +35 -0
  87. thinkstack_core/metrics/aggregate.py +215 -0
  88. thinkstack_core/metrics/calibrate.py +198 -0
  89. thinkstack_core/metrics/calibration.py +125 -0
  90. thinkstack_core/metrics/credibility.py +288 -0
  91. thinkstack_core/metrics/delivery_time.py +70 -0
  92. thinkstack_core/metrics/dhs.py +126 -0
  93. thinkstack_core/metrics/mcs.py +96 -0
  94. thinkstack_core/metrics/roi.py +88 -0
  95. thinkstack_core/metrics/session_writer.py +81 -0
  96. thinkstack_core/metrics/shadow_ai.py +117 -0
  97. thinkstack_core/metrics/sprint_writer.py +243 -0
  98. thinkstack_core/observability/__init__.py +78 -0
  99. thinkstack_core/observability/datadog.py +157 -0
  100. thinkstack_core/observability/formatter.py +119 -0
  101. thinkstack_core/observability/report.py +264 -0
  102. thinkstack_core/observability/servicenow.py +147 -0
  103. thinkstack_core/observability/splunk.py +218 -0
  104. thinkstack_core/observability/webhook.py +227 -0
  105. thinkstack_core/parser/__init__.py +30 -0
  106. thinkstack_core/parser/blocks.py +216 -0
  107. thinkstack_core/parser/inference.py +159 -0
  108. thinkstack_core/parser/thinking.py +112 -0
  109. thinkstack_core/projects.py +169 -0
  110. thinkstack_core/prompt_artifact.py +76 -0
  111. thinkstack_core/proxy/__init__.py +9 -0
  112. thinkstack_core/proxy/routes/__init__.py +1 -0
  113. thinkstack_core/proxy/routes/anthropic.py +264 -0
  114. thinkstack_core/proxy/routes/azure_openai.py +336 -0
  115. thinkstack_core/proxy/routes/gemini.py +331 -0
  116. thinkstack_core/proxy/routes/groq.py +284 -0
  117. thinkstack_core/proxy/routes/ollama.py +279 -0
  118. thinkstack_core/proxy/routes/openai.py +287 -0
  119. thinkstack_core/proxy/server.py +356 -0
  120. thinkstack_core/query/__init__.py +15 -0
  121. thinkstack_core/query/grep.py +181 -0
  122. thinkstack_core/query/hybrid.py +86 -0
  123. thinkstack_core/query/semantic.py +157 -0
  124. thinkstack_core/rdp.py +105 -0
  125. thinkstack_core/reasoning/__init__.py +4 -0
  126. thinkstack_core/reasoning/entry.py +31 -0
  127. thinkstack_core/reasoning/store.py +122 -0
  128. thinkstack_core/reasoning_plus/__init__.py +70 -0
  129. thinkstack_core/reasoning_plus/augmenter.py +337 -0
  130. thinkstack_core/reasoning_plus/capture.py +51 -0
  131. thinkstack_core/reasoning_plus/config.py +313 -0
  132. thinkstack_core/reasoning_plus/context.py +262 -0
  133. thinkstack_core/reasoning_plus/learning/__init__.py +125 -0
  134. thinkstack_core/reasoning_plus/learning/analytics.py +141 -0
  135. thinkstack_core/reasoning_plus/learning/api.py +784 -0
  136. thinkstack_core/reasoning_plus/learning/chain.py +285 -0
  137. thinkstack_core/reasoning_plus/learning/composer.py +141 -0
  138. thinkstack_core/reasoning_plus/learning/conflicts.py +184 -0
  139. thinkstack_core/reasoning_plus/learning/context_collector.py +194 -0
  140. thinkstack_core/reasoning_plus/learning/cross_project.py +234 -0
  141. thinkstack_core/reasoning_plus/learning/deny_list.py +108 -0
  142. thinkstack_core/reasoning_plus/learning/embeddings.py +209 -0
  143. thinkstack_core/reasoning_plus/learning/evolution.py +119 -0
  144. thinkstack_core/reasoning_plus/learning/extractor.py +271 -0
  145. thinkstack_core/reasoning_plus/learning/filter_five_layer.py +95 -0
  146. thinkstack_core/reasoning_plus/learning/models.py +149 -0
  147. thinkstack_core/reasoning_plus/learning/org_store.py +156 -0
  148. thinkstack_core/reasoning_plus/learning/pii.py +142 -0
  149. thinkstack_core/reasoning_plus/learning/promotion.py +58 -0
  150. thinkstack_core/reasoning_plus/learning/provenance.py +126 -0
  151. thinkstack_core/reasoning_plus/learning/recorder.py +81 -0
  152. thinkstack_core/reasoning_plus/learning/relevance.py +122 -0
  153. thinkstack_core/reasoning_plus/learning/state.py +86 -0
  154. thinkstack_core/reasoning_plus/learning/store.py +178 -0
  155. thinkstack_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  156. thinkstack_core/reasoning_plus/prompt.py +90 -0
  157. thinkstack_core/rep.py +134 -0
  158. thinkstack_core/rep_network/__init__.py +25 -0
  159. thinkstack_core/rep_network/merge.py +70 -0
  160. thinkstack_core/rep_network/node.py +137 -0
  161. thinkstack_core/rep_network/server.py +140 -0
  162. thinkstack_core/rep_network/sync.py +207 -0
  163. thinkstack_core/sensitivity.py +182 -0
  164. thinkstack_core/serve.py +258 -0
  165. thinkstack_core/session/__init__.py +39 -0
  166. thinkstack_core/session/disagreement.py +188 -0
  167. thinkstack_core/session/models.py +114 -0
  168. thinkstack_core/session/orchestrator.py +182 -0
  169. thinkstack_core/session/planner.py +169 -0
  170. thinkstack_core/session/simulator.py +132 -0
  171. thinkstack_core/signing.py +290 -0
  172. thinkstack_core/sis.py +197 -0
  173. thinkstack_core/skills/pr-reviewer/SKILL.md +204 -0
  174. thinkstack_core/skills/thinkstack-auto-sync/SKILL.md +175 -0
  175. thinkstack_core/skills/thinkstack-session-start/SKILL.md +136 -0
  176. thinkstack_core/storage.py +308 -0
  177. thinkstack_core/templates/__init__.py +6 -0
  178. thinkstack_core/templates/engine.py +122 -0
  179. thinkstack_core/templates/go.py +18 -0
  180. thinkstack_core/templates/infra.py +19 -0
  181. thinkstack_core/templates/library/__init__.py +18 -0
  182. thinkstack_core/templates/library/api_design.md +27 -0
  183. thinkstack_core/templates/library/bug_fix.md +27 -0
  184. thinkstack_core/templates/library/decision_record.md +27 -0
  185. thinkstack_core/templates/library/engine.py +228 -0
  186. thinkstack_core/templates/library/security_review.md +30 -0
  187. thinkstack_core/templates/python.py +19 -0
  188. thinkstack_core/templates/react.py +18 -0
  189. thinkstack_core/templates/typescript.py +18 -0
  190. thinkstack_core/theta.py +221 -0
  191. thinkstack_core/theta_synthesis.py +268 -0
  192. thinkstack_core/topics.py +320 -0
  193. thinkstack_core/variance.py +219 -0
  194. thinkstack_core/wrapper/__init__.py +52 -0
  195. thinkstack_core/wrapper/anthropic.py +487 -0
  196. thinkstack_core/wrapper/base.py +562 -0
  197. thinkstack_core/wrapper/bedrock.py +342 -0
  198. thinkstack_core/wrapper/gemini.py +422 -0
  199. thinkstack_core/wrapper/ollama.py +527 -0
  200. thinkstack_core/wrapper/openai.py +461 -0
  201. thinkstack_core-4.0.0.dist-info/METADATA +868 -0
  202. thinkstack_core-4.0.0.dist-info/RECORD +205 -0
  203. thinkstack_core-4.0.0.dist-info/WHEEL +5 -0
  204. thinkstack_core-4.0.0.dist-info/entry_points.txt +2 -0
  205. thinkstack_core-4.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,285 @@
1
+ """
2
+ thinkstack_core.reasoning_plus.learning.chain
3
+ ============================================
4
+ Reasoning chain: trace the full developer-agent handoff path.
5
+
6
+ Traverses: human commit -> agent session -> agent calls -> learnings -> outcomes.
7
+
8
+ The chain is built by linking:
9
+ 1. Commit records (with concepts or session references)
10
+ 2. Session records (from .GCC/sessions/)
11
+ 3. ReasoningCall records (from .GCC/reasoning_learnings/calls/)
12
+ 4. Provenance records (from .GCC/reasoning_learnings/provenance/)
13
+ 5. Learning records (from .GCC/reasoning_learnings/learnings/)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ logger = logging.getLogger("thinkstack.reasoning_plus.learning")
23
+
24
+
25
+ def build_chain(
26
+ gcc_dir: Path | str,
27
+ concept: str | None = None,
28
+ max_chains: int = 5,
29
+ ) -> list[dict[str, Any]]:
30
+ """Build reasoning chains by linking commits to sessions to calls to learnings.
31
+
32
+ Each chain is a list of nodes:
33
+ commit -> session -> [call -> learning -> outcome]*
34
+
35
+ Chains are filtered to those whose commits or sessions mention *concept*,
36
+ or all chains if *concept* is None.
37
+
38
+ Args:
39
+ gcc_dir: Path to .GCC/ directory.
40
+ concept: Optional concept filter.
41
+ max_chains: Maximum number of chains to return.
42
+
43
+ Returns:
44
+ List of chain dicts, each with keys:
45
+ - commit_id: the commit ID
46
+ - commit_message: the commit message
47
+ - commit_concepts: list of concepts
48
+ - timestamp: commit timestamp
49
+ - session: session dict or None
50
+ - calls: list of call dicts (with learning + outcome)
51
+ """
52
+ chains: list[dict] = []
53
+ commits = _load_commits(gcc_dir)
54
+
55
+ for commit in commits:
56
+ if concept and not _matches_concept(commit, concept):
57
+ continue
58
+
59
+ chain = {
60
+ "commit_id": commit.get("id", "?"),
61
+ "commit_message": commit.get("message", ""),
62
+ "commit_concepts": commit.get("concepts", []),
63
+ "timestamp": commit.get("timestamp", ""),
64
+ "session": None,
65
+ "calls": [],
66
+ }
67
+
68
+ session_id = _extract_session_id(commit)
69
+ if session_id:
70
+ session = _load_session(gcc_dir, session_id)
71
+ if session:
72
+ chain["session"] = {
73
+ "session_id": session.get("session_id"),
74
+ "name": session.get("name"),
75
+ "problem": session.get("problem"),
76
+ "status": session.get("status"),
77
+ "subtasks": session.get("subtasks", []),
78
+ }
79
+ calls = _load_calls_for_session(gcc_dir, session_id)
80
+ for call in calls:
81
+ call_entry = _build_call_entry(gcc_dir, call)
82
+ if call_entry:
83
+ chain["calls"].append(call_entry)
84
+
85
+ chains.append(chain)
86
+ if len(chains) >= max_chains:
87
+ break
88
+
89
+ return chains
90
+
91
+
92
+ def _load_commits(gcc_dir: Path) -> list[dict]:
93
+ """Load all commits from .GCC/commits/, sorted newest-first."""
94
+ commits_dir = Path(gcc_dir) / "commits"
95
+ if not commits_dir.exists():
96
+ return []
97
+ commits: list[dict] = []
98
+ for path in sorted(commits_dir.glob("*.json"), reverse=True):
99
+ try:
100
+ data = json.loads(path.read_text(encoding="utf-8"))
101
+ commits.append(data)
102
+ except (json.JSONDecodeError, OSError):
103
+ continue
104
+ return commits
105
+
106
+
107
+ def _matches_concept(commit: dict, concept: str) -> bool:
108
+ """Check if a commit matches a concept (via concepts field or message)."""
109
+ cname = concept.lower()
110
+ for c in commit.get("concepts", []):
111
+ if c.lower() == cname:
112
+ return True
113
+ if cname in commit.get("message", "").lower():
114
+ return True
115
+ return False
116
+
117
+
118
+ def _extract_session_id(commit: dict) -> str | None:
119
+ """Extract session_id from commit message or a stored session_id field."""
120
+ msg = commit.get("message", "")
121
+ # Check for [session=xxx] pattern in message
122
+ import re
123
+ m = re.search(r"\[session=([^\]]+)\]", msg)
124
+ if m:
125
+ return m.group(1)
126
+ return None
127
+
128
+
129
+ def _load_session(gcc_dir: Path, session_id: str) -> dict | None:
130
+ """Load a session record from .GCC/sessions/."""
131
+ session_path = Path(gcc_dir) / "sessions" / f"{session_id}.json"
132
+ if not session_path.exists():
133
+ # Try matching by prefix or name
134
+ sessions_dir = Path(gcc_dir) / "sessions"
135
+ if sessions_dir.exists():
136
+ for sp in sessions_dir.glob("*.json"):
137
+ try:
138
+ data = json.loads(sp.read_text(encoding="utf-8"))
139
+ if data.get("session_id") == session_id or data.get("name") == session_id:
140
+ return data
141
+ except (json.JSONDecodeError, OSError):
142
+ continue
143
+ return None
144
+ try:
145
+ return json.loads(session_path.read_text(encoding="utf-8"))
146
+ except (json.JSONDecodeError, OSError):
147
+ return None
148
+
149
+
150
+ def _load_calls_for_session(gcc_dir: Path, session_id: str) -> list[dict]:
151
+ """Load all ReasoningCall records for a given session."""
152
+ calls_dir = Path(gcc_dir) / "reasoning_learnings" / "calls"
153
+ if not calls_dir.exists():
154
+ return []
155
+ calls: list[dict] = []
156
+ for path in sorted(calls_dir.glob("*.json")):
157
+ try:
158
+ data = json.loads(path.read_text(encoding="utf-8"))
159
+ if data.get("session_id") == session_id:
160
+ calls.append(data)
161
+ except (json.JSONDecodeError, OSError):
162
+ continue
163
+ return calls
164
+
165
+
166
+ def _build_call_entry(gcc_dir: Path, call: dict) -> dict | None:
167
+ """Build a chain entry for a call, linking to its learnings and outcomes."""
168
+ call_id = call.get("id", "")
169
+ if not call_id:
170
+ return None
171
+
172
+ entry: dict = {
173
+ "call_id": call_id,
174
+ "call_type": call.get("call_type", ""),
175
+ "name": call.get("name", ""),
176
+ "reasoning": call.get("reasoning", ""),
177
+ "outcome": call.get("outcome", "unknown"),
178
+ "concepts": call.get("concepts", []),
179
+ "learnings": [],
180
+ }
181
+
182
+ # Find provenance records for this call
183
+ provenance = _load_provenance_for_call(gcc_dir, call_id)
184
+ if provenance:
185
+ entry["provenance_outcome"] = provenance.get("outcome")
186
+ for lid in provenance.get("learning_ids", []):
187
+ learning = _load_learning(gcc_dir, lid)
188
+ if learning:
189
+ entry["learnings"].append({
190
+ "learning_id": lid,
191
+ "content": learning.get("content", "")[:120],
192
+ "type": learning.get("type", ""),
193
+ "confidence": learning.get("confidence", 0),
194
+ "validity": learning.get("validity", ""),
195
+ "trigger_concepts": learning.get("trigger_concepts", []),
196
+ })
197
+
198
+ return entry
199
+
200
+
201
+ def _load_provenance_for_call(gcc_dir: Path, call_id: str) -> dict | None:
202
+ """Find the provenance record for a specific call."""
203
+ prov_dir = Path(gcc_dir) / "reasoning_learnings" / "provenance"
204
+ if not prov_dir.exists():
205
+ return None
206
+ for path in prov_dir.glob("*.json"):
207
+ try:
208
+ data = json.loads(path.read_text(encoding="utf-8"))
209
+ if data.get("call_id") == call_id:
210
+ return data
211
+ except (json.JSONDecodeError, OSError):
212
+ continue
213
+ return None
214
+
215
+
216
+ def _load_learning(gcc_dir: Path, learning_id: str) -> dict | None:
217
+ """Load a single learning record by ID."""
218
+ path = Path(gcc_dir) / "reasoning_learnings" / "learnings" / f"{learning_id}.json"
219
+ if not path.exists():
220
+ return None
221
+ try:
222
+ return json.loads(path.read_text(encoding="utf-8"))
223
+ except (json.JSONDecodeError, OSError):
224
+ return None
225
+
226
+
227
+ def format_chain(chain: dict, indent: str = "") -> str:
228
+ """Format a single chain as readable text."""
229
+ lines = []
230
+
231
+ # Commit node
232
+ cid = chain.get("commit_id", "?")
233
+ msg = chain.get("commit_message", "")
234
+ concepts = chain.get("commit_concepts", [])
235
+ ts = chain.get("timestamp", "")
236
+ lines.append(f"{indent}Commit {cid} [{ts}]")
237
+ lines.append(f"{indent} Message: {msg[:80]}")
238
+ if concepts:
239
+ lines.append(f"{indent} Concepts: {', '.join(concepts)}")
240
+
241
+ # Session node
242
+ session = chain.get("session")
243
+ if session:
244
+ lines.append(f"{indent} Session: {session.get('name', '?')} ({session.get('status', '?')})")
245
+ lines.append(f"{indent} Problem: {session.get('problem', '')[:80]}")
246
+ subtasks = session.get("subtasks", [])
247
+ if subtasks:
248
+ completed = sum(1 for s in subtasks if s.get("status") == "completed")
249
+ failed = sum(1 for s in subtasks if s.get("status") == "failed")
250
+ total = len(subtasks)
251
+ lines.append(f"{indent} Subtasks: {completed}/{total} completed, {failed} failed")
252
+
253
+ # Call + learning nodes
254
+ calls = chain.get("calls", [])
255
+ if calls:
256
+ lines.append(f"{indent} Agent calls ({len(calls)}):")
257
+ success = sum(1 for c in calls if c.get("outcome") == "success")
258
+ failure = sum(1 for c in calls if c.get("outcome") == "failure")
259
+ lines.append(f"{indent} Outcomes: {success} success, {failure} failure")
260
+ for call in calls[:3]: # Show first 3 calls
261
+ lines.append(f"{indent} [{call.get('call_type','')}] {call.get('name','')}: {call.get('reasoning','')[:60]}")
262
+ lines.append(f"{indent} Outcome: {call.get('outcome', '?')}")
263
+ for lr in call.get("learnings", [])[:2]: # Show first 2 learnings per call
264
+ lines.append(f"{indent} Learning: {lr.get('content','')[:60]}")
265
+ lines.append(f"{indent} [{lr.get('type','')}] conf={lr.get('confidence',0)} concepts={lr.get('trigger_concepts',[])}")
266
+ if len(calls) > 3:
267
+ lines.append(f"{indent} ... and {len(calls) - 3} more calls")
268
+ else:
269
+ lines.append(f"{indent} No agent calls linked to this commit.")
270
+
271
+ return "\n".join(lines)
272
+
273
+
274
+ def format_chains(chains: list[dict]) -> str:
275
+ """Format multiple chains for CLI output."""
276
+ if not chains:
277
+ return "No reasoning chains found."
278
+
279
+ lines = ["═══ Reasoning chains ═══", ""]
280
+ for i, chain in enumerate(chains, 1):
281
+ lines.append(f"Chain #{i}")
282
+ lines.append(format_chain(chain, indent=" "))
283
+ lines.append("")
284
+ lines.append("═══ End ═══")
285
+ return "\n".join(lines)
@@ -0,0 +1,141 @@
1
+ """
2
+ thinkstack_core.reasoning_plus.learning.composer
3
+ ==============================================
4
+ Format selected learnings into a compact prompt block.
5
+
6
+ Token budget: caps the learning block at a configurable number of tokens
7
+ so injection never overshoots the call budget.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from thinkstack_core.reasoning_plus.learning.models import Learning
12
+
13
+
14
+ def approx_tokens(text: str) -> int:
15
+ """Rough token estimate (4 chars ~= 1 token for English text)."""
16
+ return len(text) // 4 + 1
17
+
18
+
19
+ class PromptComposer:
20
+ """Compose a <learnings> block for injection into a user prompt.
21
+
22
+ By default only `PUBLIC` learnings are injected. Callers can override
23
+ ``allowed_sensitivity`` to include PROTECTED or PRIVATE learnings, but the
24
+ default prevents accidentally leaking sensitive reasoning into prompts.
25
+
26
+ When ``token_budget`` is provided to :meth:`compose`, the output is capped
27
+ so that ``approx_tokens(output) <= token_budget``. Learnings are added in
28
+ rank order (first items in the list are most relevant). The ``<learnings>``
29
+ and ``</learnings>`` tags are always preserved.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ max_lines: int = 100,
35
+ allowed_sensitivity: list[str] | None = None,
36
+ ) -> None:
37
+ self.max_lines = max_lines
38
+ self.allowed_sensitivity = set(
39
+ allowed_sensitivity if allowed_sensitivity is not None else ["PUBLIC"]
40
+ )
41
+
42
+ _HEADER_LINES: int = 4
43
+ _TRAILER_LINES: int = 2
44
+
45
+ @staticmethod
46
+ def _build_header() -> list[str]:
47
+ return [
48
+ "<learnings>",
49
+ "The following are relevant insights from previous steps in this session.",
50
+ "Evaluate them against the current evidence; they may be outdated.",
51
+ "",
52
+ ]
53
+
54
+ @staticmethod
55
+ def _build_trailer(truncated: bool) -> list[str]:
56
+ if truncated:
57
+ return ["", "...</learnings>"]
58
+ return ["", "</learnings>"]
59
+
60
+ @staticmethod
61
+ def _build_learning_lines(learnings: list[Learning]) -> list[str]:
62
+ lines: list[str] = []
63
+ for i, learning in enumerate(learnings, start=1):
64
+ text = learning.short_form(max_chars=240)
65
+ concepts_tag = ""
66
+ if learning.trigger_concepts:
67
+ concepts_tag = f" [concepts: {', '.join(learning.trigger_concepts)}]"
68
+ lines.append(f"{i}. {text}{concepts_tag}")
69
+ return lines
70
+
71
+ def _filter(self, learnings: list[Learning]) -> list[Learning]:
72
+ return [
73
+ learning
74
+ for learning in learnings
75
+ if learning.sensitivity in self.allowed_sensitivity
76
+ ]
77
+
78
+ def _build_block(
79
+ self,
80
+ learnings: list[Learning],
81
+ ) -> tuple[list[str], list[str], list[str]]:
82
+ header = self._build_header()
83
+ body = self._build_learning_lines(learnings)
84
+ truncated = False
85
+ if len(body) + self._HEADER_LINES + self._TRAILER_LINES > self.max_lines:
86
+ max_body = self.max_lines - self._HEADER_LINES - self._TRAILER_LINES + 1
87
+ body = body[:max_body]
88
+ truncated = True
89
+ trailer = self._build_trailer(truncated)
90
+ return header, body, trailer
91
+
92
+ def compose(
93
+ self,
94
+ learnings: list[Learning],
95
+ token_budget: int | None = None,
96
+ relevance_threshold: float | None = None,
97
+ ) -> str:
98
+ if token_budget is not None and token_budget <= 0:
99
+ return ""
100
+
101
+ learnings = self._filter(learnings)
102
+ if not learnings:
103
+ return ""
104
+
105
+ if relevance_threshold is not None:
106
+ learnings = [l for l in learnings if l.confidence >= relevance_threshold]
107
+ if not learnings:
108
+ return ""
109
+
110
+ header, body, trailer = self._build_block(learnings)
111
+
112
+ def join_all(b: list[str]) -> str:
113
+ return "\n".join(header + b + trailer)
114
+
115
+ block = join_all(body)
116
+
117
+ # Token budget: trim learning lines from the end while preserving
118
+ # header and trailer structure.
119
+ if token_budget is not None:
120
+ while approx_tokens(block) > token_budget:
121
+ if not body:
122
+ return ""
123
+ body = body[:-1]
124
+ if not body:
125
+ return ""
126
+ block = join_all(body)
127
+
128
+ return block
129
+
130
+ def compose_system_prompt(self, learnings: list[Learning]) -> str:
131
+ """
132
+ Alternative: format learnings as a short system-prompt appendix.
133
+ Kept for callers that cannot modify the user prompt.
134
+ """
135
+ learnings = self._filter(learnings)
136
+ if not learnings:
137
+ return ""
138
+ parts = ["Relevant context from previous reasoning steps:"]
139
+ for i, learning in enumerate(learnings, start=1):
140
+ parts.append(f"{i}. {learning.short_form(max_chars=200)}")
141
+ return "\n".join(parts)
@@ -0,0 +1,184 @@
1
+ """
2
+ thinkstack_core.reasoning_plus.learning.conflicts
3
+ ================================================
4
+ Conflict detection and resolution for evolving learnings.
5
+
6
+ A learning from week 1 may be superseded by a learning from week 3. The
7
+ ``ConflictDetector`` finds existing learnings that overlap with an incoming
8
+ learning (by trigger-concept overlap and content embedding similarity).
9
+ ``resolve_conflict`` applies the agreed resolution matrix to decide whether
10
+ the newer or older learning wins, or whether a human must decide.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from thinkstack_core.reasoning_plus.learning.embeddings import (
18
+ EmbeddingBackend,
19
+ KeywordEmbeddingBackend,
20
+ )
21
+ from thinkstack_core.reasoning_plus.learning.models import Learning
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Data classes
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ @dataclass
30
+ class Conflict:
31
+ """A potential conflict between two learnings that share trigger concepts."""
32
+
33
+ learning_a: Learning # existing (older) learning
34
+ learning_b: Learning # incoming (newer) learning
35
+ concept_overlap: list[str] # shared trigger_concepts
36
+ embedding_similarity: float # cosine/keyword similarity between contents
37
+ timestamp_a: str # ISO timestamp of learning_a
38
+ timestamp_b: str # ISO timestamp of learning_b
39
+ confidence_a: float
40
+ confidence_b: float
41
+ goal_changed: bool = False # whether the project goal changed since learning_a
42
+
43
+
44
+ @dataclass
45
+ class ConflictResolution:
46
+ """The outcome of resolving a :class:`Conflict`."""
47
+
48
+ winner: str # learning ID that wins
49
+ loser: str # learning ID that is deprecated/stale
50
+ action: str # "NEWER_WINS" | "OLDER_WINS" | "FLAG_FOR_HUMAN"
51
+ reason: str # explanation of the decision
52
+ auto_resolved: bool # True if no human needed
53
+ conflict: Conflict | None = None
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Conflict detection
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ class ConflictDetector:
62
+ """Find existing learnings that conflict with an incoming learning.
63
+
64
+ A conflict is flagged when two learnings share at least one trigger
65
+ concept **and** their contents are not identical. The embedding
66
+ similarity is computed and stored on the :class:`Conflict` so the
67
+ resolver can weigh it if needed.
68
+ """
69
+
70
+ def __init__(self, backend: EmbeddingBackend | None = None) -> None:
71
+ self._backend = backend or KeywordEmbeddingBackend()
72
+
73
+ def detect_conflicts(
74
+ self,
75
+ learning: Learning,
76
+ existing_learnings: list[Learning],
77
+ ) -> list[Conflict]:
78
+ incoming_concepts = {c.lower() for c in learning.trigger_concepts}
79
+ incoming_emb = self._backend.encode(learning.content)
80
+ conflicts: list[Conflict] = []
81
+
82
+ for existing in existing_learnings:
83
+ if existing.id == learning.id:
84
+ continue
85
+ existing_concepts = {c.lower() for c in existing.trigger_concepts}
86
+ overlap = sorted(incoming_concepts & existing_concepts)
87
+ if not overlap:
88
+ continue
89
+ # Identical content is a duplicate, not a conflict.
90
+ if existing.content.strip().lower() == learning.content.strip().lower():
91
+ continue
92
+ existing_emb = self._backend.encode(existing.content)
93
+ sim = self._backend.similarity(incoming_emb, existing_emb)
94
+ conflicts.append(
95
+ Conflict(
96
+ learning_a=existing,
97
+ learning_b=learning,
98
+ concept_overlap=overlap,
99
+ embedding_similarity=sim,
100
+ timestamp_a=existing.created_at,
101
+ timestamp_b=learning.created_at,
102
+ confidence_a=existing.confidence,
103
+ confidence_b=learning.confidence,
104
+ )
105
+ )
106
+ return conflicts
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Conflict resolution matrix
111
+ # ---------------------------------------------------------------------------
112
+
113
+ _CONF_DIFF_THRESHOLD = 0.1
114
+
115
+
116
+ def resolve_conflict(conflict: Conflict) -> ConflictResolution:
117
+ r"""Resolve a conflict per the agreed matrix.
118
+
119
+ | Condition | Action |
120
+ |---------------------------------------------|-----------------|
121
+ | \|conf_new - conf_old\| <= 0.1 | FLAG_FOR_HUMAN |
122
+ | conf_new > conf_old + 0.1 | NEWER_WINS |
123
+ | conf_old > conf_new + 0.1, goal unchanged | OLDER_WINS |
124
+ | conf_old > conf_new + 0.1, goal changed | FLAG_FOR_HUMAN |
125
+
126
+ ``learning_a`` is the existing (older) learning; ``learning_b`` is the
127
+ incoming (newer) learning.
128
+ """
129
+ conf_old = conflict.confidence_a
130
+ conf_new = conflict.confidence_b
131
+ diff = abs(conf_new - conf_old)
132
+
133
+ if diff <= _CONF_DIFF_THRESHOLD:
134
+ return ConflictResolution(
135
+ winner="",
136
+ loser="",
137
+ action="FLAG_FOR_HUMAN",
138
+ reason=(
139
+ f"Confidence difference ({diff:.2f}) is within "
140
+ f"±{_CONF_DIFF_THRESHOLD}; cannot auto-resolve."
141
+ ),
142
+ auto_resolved=False,
143
+ conflict=conflict,
144
+ )
145
+
146
+ if conf_new > conf_old + _CONF_DIFF_THRESHOLD:
147
+ return ConflictResolution(
148
+ winner=conflict.learning_b.id,
149
+ loser=conflict.learning_a.id,
150
+ action="NEWER_WINS",
151
+ reason=(
152
+ f"Newer learning confidence ({conf_new:.2f}) exceeds older "
153
+ f"({conf_old:.2f}) by more than {_CONF_DIFF_THRESHOLD}."
154
+ ),
155
+ auto_resolved=True,
156
+ conflict=conflict,
157
+ )
158
+
159
+ # conf_old > conf_new + threshold
160
+ if conflict.goal_changed:
161
+ return ConflictResolution(
162
+ winner="",
163
+ loser="",
164
+ action="FLAG_FOR_HUMAN",
165
+ reason=(
166
+ "Older learning has higher confidence but the project goal "
167
+ "changed; older learning may no longer be valid."
168
+ ),
169
+ auto_resolved=False,
170
+ conflict=conflict,
171
+ )
172
+
173
+ return ConflictResolution(
174
+ winner=conflict.learning_a.id,
175
+ loser=conflict.learning_b.id,
176
+ action="OLDER_WINS",
177
+ reason=(
178
+ f"Older learning confidence ({conf_old:.2f}) exceeds newer "
179
+ f"({conf_new:.2f}) by more than {_CONF_DIFF_THRESHOLD} and goal "
180
+ f"is unchanged."
181
+ ),
182
+ auto_resolved=True,
183
+ conflict=conflict,
184
+ )