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.
- thinkstack_core/__init__.py +158 -0
- thinkstack_core/aggphi_textual.py +275 -0
- thinkstack_core/alerts/__init__.py +23 -0
- thinkstack_core/alerts/base.py +46 -0
- thinkstack_core/alerts/config.py +60 -0
- thinkstack_core/alerts/dispatcher.py +110 -0
- thinkstack_core/alerts/jira.py +96 -0
- thinkstack_core/alerts/linear.py +72 -0
- thinkstack_core/alerts/pagerduty.py +66 -0
- thinkstack_core/alerts/slack.py +81 -0
- thinkstack_core/alerts/teams.py +70 -0
- thinkstack_core/audit/__init__.py +43 -0
- thinkstack_core/audit/exporter.py +297 -0
- thinkstack_core/audit/privacy.py +101 -0
- thinkstack_core/audit/scrubber.py +149 -0
- thinkstack_core/audit/service.py +67 -0
- thinkstack_core/audit/signing.py +127 -0
- thinkstack_core/broadcast/__init__.py +4 -0
- thinkstack_core/broadcast/broadcaster.py +100 -0
- thinkstack_core/broadcast/watcher.py +71 -0
- thinkstack_core/capability.py +639 -0
- thinkstack_core/cloud/__init__.py +1 -0
- thinkstack_core/cloud/client_config.py +472 -0
- thinkstack_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- thinkstack_core/cloud/client_configs/.claude-stdio.json +13 -0
- thinkstack_core/cloud/client_configs/.cursor-mcp.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode-bridge.json +13 -0
- thinkstack_core/cloud/client_configs/.opencode.json +15 -0
- thinkstack_core/cloud/client_configs/.vscode-mcp.json +13 -0
- thinkstack_core/cloud/mcp_client.py +229 -0
- thinkstack_core/cloud/setup.py +144 -0
- thinkstack_core/cloud/sync.py +143 -0
- thinkstack_core/cloud/sync_bundle.py +639 -0
- thinkstack_core/cloud/sync_conflicts.py +183 -0
- thinkstack_core/cloud/sync_state.py +159 -0
- thinkstack_core/cloud/team_sync.py +337 -0
- thinkstack_core/cloud/thinkstack-mcp-bridge.js +357 -0
- thinkstack_core/codex/__init__.py +9 -0
- thinkstack_core/codex/__main__.py +97 -0
- thinkstack_core/codex/capture.py +208 -0
- thinkstack_core/codex/proxy.py +412 -0
- thinkstack_core/compat.py +103 -0
- thinkstack_core/concept_catalog.py +209 -0
- thinkstack_core/consolidation/__init__.py +3 -0
- thinkstack_core/consolidation/synthesizer.py +87 -0
- thinkstack_core/consolidation/workflow.py +175 -0
- thinkstack_core/daemon/__init__.py +27 -0
- thinkstack_core/daemon/supervisor.py +293 -0
- thinkstack_core/daemon/watcher.py +244 -0
- thinkstack_core/dashboard_api.py +2012 -0
- thinkstack_core/deltaf.py +97 -0
- thinkstack_core/disclosure.py +50 -0
- thinkstack_core/divergence/__init__.py +3 -0
- thinkstack_core/divergence/detector.py +166 -0
- thinkstack_core/gateway/__init__.py +32 -0
- thinkstack_core/gateway/key_manager.py +124 -0
- thinkstack_core/gateway/metrics_webhook.py +252 -0
- thinkstack_core/gateway/policy.py +262 -0
- thinkstack_core/gateway/server.py +727 -0
- thinkstack_core/gateway/sso.py +233 -0
- thinkstack_core/gcc.py +1246 -0
- thinkstack_core/github/__init__.py +35 -0
- thinkstack_core/github/app.py +240 -0
- thinkstack_core/github/comment_builder.py +113 -0
- thinkstack_core/github/pat.py +76 -0
- thinkstack_core/github/pr_parser.py +82 -0
- thinkstack_core/github/pr_reporter.py +555 -0
- thinkstack_core/gitlab/__init__.py +177 -0
- thinkstack_core/hitl/__init__.py +4 -0
- thinkstack_core/hitl/channels.py +129 -0
- thinkstack_core/hitl/orchestrator.py +95 -0
- thinkstack_core/hooks/__init__.py +17 -0
- thinkstack_core/hooks/claude_code.py +228 -0
- thinkstack_core/hooks/git_capture.py +341 -0
- thinkstack_core/hooks/git_commit.py +182 -0
- thinkstack_core/hooks/installer.py +850 -0
- thinkstack_core/hooks/pre_commit.py +157 -0
- thinkstack_core/hooks/runner.py +386 -0
- thinkstack_core/identity/__init__.py +4 -0
- thinkstack_core/identity/agent.py +86 -0
- thinkstack_core/identity/providers.py +85 -0
- thinkstack_core/invariants.py +182 -0
- thinkstack_core/mcp/__init__.py +10 -0
- thinkstack_core/mcp/auth.py +177 -0
- thinkstack_core/mcp/server.py +1215 -0
- thinkstack_core/metrics/__init__.py +35 -0
- thinkstack_core/metrics/aggregate.py +215 -0
- thinkstack_core/metrics/calibrate.py +198 -0
- thinkstack_core/metrics/calibration.py +125 -0
- thinkstack_core/metrics/credibility.py +288 -0
- thinkstack_core/metrics/delivery_time.py +70 -0
- thinkstack_core/metrics/dhs.py +126 -0
- thinkstack_core/metrics/mcs.py +96 -0
- thinkstack_core/metrics/roi.py +88 -0
- thinkstack_core/metrics/session_writer.py +81 -0
- thinkstack_core/metrics/shadow_ai.py +117 -0
- thinkstack_core/metrics/sprint_writer.py +243 -0
- thinkstack_core/observability/__init__.py +78 -0
- thinkstack_core/observability/datadog.py +157 -0
- thinkstack_core/observability/formatter.py +119 -0
- thinkstack_core/observability/report.py +264 -0
- thinkstack_core/observability/servicenow.py +147 -0
- thinkstack_core/observability/splunk.py +218 -0
- thinkstack_core/observability/webhook.py +227 -0
- thinkstack_core/parser/__init__.py +30 -0
- thinkstack_core/parser/blocks.py +216 -0
- thinkstack_core/parser/inference.py +159 -0
- thinkstack_core/parser/thinking.py +112 -0
- thinkstack_core/projects.py +169 -0
- thinkstack_core/prompt_artifact.py +76 -0
- thinkstack_core/proxy/__init__.py +9 -0
- thinkstack_core/proxy/routes/__init__.py +1 -0
- thinkstack_core/proxy/routes/anthropic.py +264 -0
- thinkstack_core/proxy/routes/azure_openai.py +336 -0
- thinkstack_core/proxy/routes/gemini.py +331 -0
- thinkstack_core/proxy/routes/groq.py +284 -0
- thinkstack_core/proxy/routes/ollama.py +279 -0
- thinkstack_core/proxy/routes/openai.py +287 -0
- thinkstack_core/proxy/server.py +356 -0
- thinkstack_core/query/__init__.py +15 -0
- thinkstack_core/query/grep.py +181 -0
- thinkstack_core/query/hybrid.py +86 -0
- thinkstack_core/query/semantic.py +157 -0
- thinkstack_core/rdp.py +105 -0
- thinkstack_core/reasoning/__init__.py +4 -0
- thinkstack_core/reasoning/entry.py +31 -0
- thinkstack_core/reasoning/store.py +122 -0
- thinkstack_core/reasoning_plus/__init__.py +70 -0
- thinkstack_core/reasoning_plus/augmenter.py +337 -0
- thinkstack_core/reasoning_plus/capture.py +51 -0
- thinkstack_core/reasoning_plus/config.py +313 -0
- thinkstack_core/reasoning_plus/context.py +262 -0
- thinkstack_core/reasoning_plus/learning/__init__.py +125 -0
- thinkstack_core/reasoning_plus/learning/analytics.py +141 -0
- thinkstack_core/reasoning_plus/learning/api.py +784 -0
- thinkstack_core/reasoning_plus/learning/chain.py +285 -0
- thinkstack_core/reasoning_plus/learning/composer.py +141 -0
- thinkstack_core/reasoning_plus/learning/conflicts.py +184 -0
- thinkstack_core/reasoning_plus/learning/context_collector.py +194 -0
- thinkstack_core/reasoning_plus/learning/cross_project.py +234 -0
- thinkstack_core/reasoning_plus/learning/deny_list.py +108 -0
- thinkstack_core/reasoning_plus/learning/embeddings.py +209 -0
- thinkstack_core/reasoning_plus/learning/evolution.py +119 -0
- thinkstack_core/reasoning_plus/learning/extractor.py +271 -0
- thinkstack_core/reasoning_plus/learning/filter_five_layer.py +95 -0
- thinkstack_core/reasoning_plus/learning/models.py +149 -0
- thinkstack_core/reasoning_plus/learning/org_store.py +156 -0
- thinkstack_core/reasoning_plus/learning/pii.py +142 -0
- thinkstack_core/reasoning_plus/learning/promotion.py +58 -0
- thinkstack_core/reasoning_plus/learning/provenance.py +126 -0
- thinkstack_core/reasoning_plus/learning/recorder.py +81 -0
- thinkstack_core/reasoning_plus/learning/relevance.py +122 -0
- thinkstack_core/reasoning_plus/learning/state.py +86 -0
- thinkstack_core/reasoning_plus/learning/store.py +178 -0
- thinkstack_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- thinkstack_core/reasoning_plus/prompt.py +90 -0
- thinkstack_core/rep.py +134 -0
- thinkstack_core/rep_network/__init__.py +25 -0
- thinkstack_core/rep_network/merge.py +70 -0
- thinkstack_core/rep_network/node.py +137 -0
- thinkstack_core/rep_network/server.py +140 -0
- thinkstack_core/rep_network/sync.py +207 -0
- thinkstack_core/sensitivity.py +182 -0
- thinkstack_core/serve.py +258 -0
- thinkstack_core/session/__init__.py +39 -0
- thinkstack_core/session/disagreement.py +188 -0
- thinkstack_core/session/models.py +114 -0
- thinkstack_core/session/orchestrator.py +182 -0
- thinkstack_core/session/planner.py +169 -0
- thinkstack_core/session/simulator.py +132 -0
- thinkstack_core/signing.py +290 -0
- thinkstack_core/sis.py +197 -0
- thinkstack_core/skills/pr-reviewer/SKILL.md +204 -0
- thinkstack_core/skills/thinkstack-auto-sync/SKILL.md +175 -0
- thinkstack_core/skills/thinkstack-session-start/SKILL.md +136 -0
- thinkstack_core/storage.py +308 -0
- thinkstack_core/templates/__init__.py +6 -0
- thinkstack_core/templates/engine.py +122 -0
- thinkstack_core/templates/go.py +18 -0
- thinkstack_core/templates/infra.py +19 -0
- thinkstack_core/templates/library/__init__.py +18 -0
- thinkstack_core/templates/library/api_design.md +27 -0
- thinkstack_core/templates/library/bug_fix.md +27 -0
- thinkstack_core/templates/library/decision_record.md +27 -0
- thinkstack_core/templates/library/engine.py +228 -0
- thinkstack_core/templates/library/security_review.md +30 -0
- thinkstack_core/templates/python.py +19 -0
- thinkstack_core/templates/react.py +18 -0
- thinkstack_core/templates/typescript.py +18 -0
- thinkstack_core/theta.py +221 -0
- thinkstack_core/theta_synthesis.py +268 -0
- thinkstack_core/topics.py +320 -0
- thinkstack_core/variance.py +219 -0
- thinkstack_core/wrapper/__init__.py +52 -0
- thinkstack_core/wrapper/anthropic.py +487 -0
- thinkstack_core/wrapper/base.py +562 -0
- thinkstack_core/wrapper/bedrock.py +342 -0
- thinkstack_core/wrapper/gemini.py +422 -0
- thinkstack_core/wrapper/ollama.py +527 -0
- thinkstack_core/wrapper/openai.py +461 -0
- thinkstack_core-4.0.0.dist-info/METADATA +868 -0
- thinkstack_core-4.0.0.dist-info/RECORD +205 -0
- thinkstack_core-4.0.0.dist-info/WHEEL +5 -0
- thinkstack_core-4.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|