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,209 @@
1
+ """
2
+ thinkstack_core.reasoning_plus.learning.embeddings
3
+ ================================================
4
+ Pluggable embedding backends for DRPL relevance.
5
+
6
+ The default backend is keyword-based (no extra dependencies). Users can opt
7
+ into dense semantic embeddings by installing the optional ``embeddings`` extra:
8
+
9
+ pip install thinkstack-core[embeddings]
10
+
11
+ and then configuring the backend:
12
+
13
+ thinkstack reasoning-plus config \
14
+ --learning-embedding-backend sentence_transformers \
15
+ --learning-embedding-model all-MiniLM-L6-v2
16
+
17
+ Supported backends:
18
+ - ``keyword`` — Jaccard similarity over extracted keywords (default)
19
+ - ``sentence_transformers`` — cosine similarity over dense embeddings (local)
20
+ - ``openai`` — cosine similarity over OpenAI embeddings (remote)
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import math
26
+ from abc import ABC, abstractmethod
27
+ from typing import Any
28
+
29
+ from thinkstack_core.reasoning_plus.context import extract_keywords
30
+
31
+ logger = logging.getLogger("thinkstack.reasoning_plus.learning")
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Base class
36
+ # ---------------------------------------------------------------------------
37
+
38
+ class EmbeddingBackend(ABC):
39
+ """Abstract embedding backend for DRPL relevance scoring."""
40
+
41
+ @abstractmethod
42
+ def encode(self, text: str) -> Any:
43
+ """Encode *text* into a backend-specific representation."""
44
+ ...
45
+
46
+ @abstractmethod
47
+ def similarity(self, a: Any, b: Any) -> float:
48
+ """Return a similarity score in [0, 1] for two encodings."""
49
+ ...
50
+
51
+ @property
52
+ @abstractmethod
53
+ def name(self) -> str:
54
+ """Short identifier for this backend."""
55
+ ...
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Keyword backend (default, no dependencies)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ class KeywordEmbeddingBackend(EmbeddingBackend):
63
+ """Keyword-overlap backend that uses extracted concepts as a sparse vector."""
64
+
65
+ def __init__(self, max_keywords: int = 15) -> None:
66
+ self.max_keywords = max_keywords
67
+
68
+ def encode(self, text: str) -> set[str]:
69
+ return set(extract_keywords(text, max_keywords=self.max_keywords))
70
+
71
+ def similarity(self, a: set[str], b: set[str]) -> float:
72
+ if not a and not b:
73
+ return 0.0
74
+ overlap = len(a & b)
75
+ total = len(a | b)
76
+ return overlap / total if total else 0.0
77
+
78
+ @property
79
+ def name(self) -> str:
80
+ return "keyword"
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Dense-vector helpers
85
+ # ---------------------------------------------------------------------------
86
+
87
+ def _cosine_similarity(a: list[float], b: list[float]) -> float:
88
+ dot = sum(x * y for x, y in zip(a, b))
89
+ norm_a = math.sqrt(sum(x * x for x in a))
90
+ norm_b = math.sqrt(sum(x * x for x in b))
91
+ if norm_a == 0.0 or norm_b == 0.0:
92
+ return 0.0
93
+ return max(0.0, min(1.0, dot / (norm_a * norm_b)))
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Sentence-transformers backend (optional)
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class SentenceTransformerBackend(EmbeddingBackend):
101
+ """Local dense embedding backend via sentence-transformers."""
102
+
103
+ def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
104
+ try:
105
+ from sentence_transformers import SentenceTransformer
106
+ except ImportError as exc:
107
+ raise ImportError(
108
+ "sentence-transformers is not installed. "
109
+ "Install the embeddings extra: pip install thinkstack-core[embeddings]"
110
+ ) from exc
111
+ self._model = SentenceTransformer(model_name)
112
+ self._model_name = model_name
113
+
114
+ def encode(self, text: str) -> list[float]:
115
+ import numpy as np
116
+
117
+ vector = self._model.encode(text)
118
+ return vector.tolist()
119
+
120
+ def similarity(self, a: list[float], b: list[float]) -> float:
121
+ return _cosine_similarity(a, b)
122
+
123
+ @property
124
+ def name(self) -> str:
125
+ return "sentence_transformers"
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # OpenAI backend (optional)
130
+ # ---------------------------------------------------------------------------
131
+
132
+ class OpenAIEmbeddingBackend(EmbeddingBackend):
133
+ """Remote dense embedding backend via OpenAI embeddings API."""
134
+
135
+ def __init__(
136
+ self,
137
+ model_name: str = "text-embedding-3-small",
138
+ api_key: str | None = None,
139
+ ) -> None:
140
+ try:
141
+ import openai
142
+ except ImportError as exc:
143
+ raise ImportError(
144
+ "openai is not installed. "
145
+ "Install the wrapper extra: pip install thinkstack-core[wrapper]"
146
+ ) from exc
147
+ self._client = openai.OpenAI(api_key=api_key)
148
+ self._model_name = model_name
149
+
150
+ def encode(self, text: str) -> list[float]:
151
+ response = self._client.embeddings.create(
152
+ input=text,
153
+ model=self._model_name,
154
+ )
155
+ return response.data[0].embedding
156
+
157
+ def similarity(self, a: list[float], b: list[float]) -> float:
158
+ return _cosine_similarity(a, b)
159
+
160
+ @property
161
+ def name(self) -> str:
162
+ return "openai"
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Fake backend (for tests)
167
+ # ---------------------------------------------------------------------------
168
+
169
+ class FakeEmbeddingBackend(EmbeddingBackend):
170
+ """Deterministic fake backend for unit tests."""
171
+
172
+ def __init__(self, embeddings: dict[str, list[float]] | None = None, dim: int = 8) -> None:
173
+ self._embeddings: dict[str, list[float]] = embeddings or {}
174
+ self._dim = dim
175
+
176
+ def encode(self, text: str) -> list[float]:
177
+ return self._embeddings.get(text, [0.0] * self._dim)
178
+
179
+ def similarity(self, a: list[float], b: list[float]) -> float:
180
+ return _cosine_similarity(a, b)
181
+
182
+ @property
183
+ def name(self) -> str:
184
+ return "fake"
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Factory
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def get_embedding_backend(
192
+ name: str | None = None,
193
+ model_name: str | None = None,
194
+ max_keywords: int = 15,
195
+ ) -> EmbeddingBackend:
196
+ """
197
+ Return an embedding backend by name.
198
+
199
+ Supported names: ``keyword`` (default), ``sentence_transformers``,
200
+ ``sentence-transformers``, ``st``, ``openai``.
201
+ """
202
+ name = (name or "keyword").lower().strip().replace("-", "_")
203
+ if name in ("keyword", "keywords"):
204
+ return KeywordEmbeddingBackend(max_keywords=max_keywords)
205
+ if name in ("sentence_transformers", "sentence_transformer", "st"):
206
+ return SentenceTransformerBackend(model_name or "all-MiniLM-L6-v2")
207
+ if name == "openai":
208
+ return OpenAIEmbeddingBackend(model_name or "text-embedding-3-small")
209
+ raise ValueError(f"Unknown embedding backend: {name}")
@@ -0,0 +1,119 @@
1
+ """
2
+ thinkstack_core.reasoning_plus.learning.evolution
3
+ ================================================
4
+ Learning evolution helpers: goal-change detection and confidence decay.
5
+
6
+ Learnings change over time. A learning from week 1 may be superseded by a
7
+ learning from week 3. When the project goal shifts, an older high-confidence
8
+ learning may no longer be valid. And learnings that receive no feedback for
9
+ extended periods should decay in confidence and eventually be marked stale.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from thinkstack_core.reasoning_plus.learning.models import Learning
19
+
20
+ logger = logging.getLogger("thinkstack.reasoning_plus.learning")
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Goal change detection
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _GOAL_PATTERN = "GOAL:"
28
+
29
+
30
+ def goal_changed_since(learning_id: str, gcc_dir: Path | str) -> bool:
31
+ """Return True if a goal-setting commit appears in the GCC event log.
32
+
33
+ Goal-setting commits are detected by the ``GOAL:`` prefix in the commit
34
+ message (the convention is ``thinkstack commit -m "GOAL: ..."``). The
35
+ ``learning_id`` is accepted for forward-compatibility (per-learning goal
36
+ tracking) but the current implementation checks for *any* goal commit in
37
+ the event log, which is sufficient for the resolution matrix.
38
+
39
+ If the event log exists but cannot be read (permissions, lock contention),
40
+ the function returns ``True`` as a conservative default — this ensures
41
+ conflicts are flagged for human review rather than auto-resolved with
42
+ stale information.
43
+ """
44
+ gcc_dir = Path(gcc_dir)
45
+ events_path = gcc_dir / "events.log.jsonl"
46
+ if not events_path.exists():
47
+ return False
48
+ try:
49
+ for line in events_path.read_text(encoding="utf-8").splitlines():
50
+ line = line.strip()
51
+ if not line:
52
+ continue
53
+ try:
54
+ event = json.loads(line)
55
+ except json.JSONDecodeError:
56
+ continue
57
+ message = event.get("message", "") or ""
58
+ if isinstance(message, str) and message.upper().startswith(_GOAL_PATTERN):
59
+ return True
60
+ except Exception as exc:
61
+ logger.warning("thinkstack: failed to read events log for goal change — %s", exc)
62
+ return True
63
+ return False
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Confidence decay
68
+ # ---------------------------------------------------------------------------
69
+
70
+ _DECAY_WEEKLY_AMOUNT = 0.05 # confidence -= 0.05 per week without feedback
71
+ _DECAY_THRESHOLD_DAYS = 7 # start decaying after 7 days
72
+ _STALE_THRESHOLD_DAYS = 30 # mark stale after 30 days
73
+
74
+
75
+ def _days_since(ts: str) -> float:
76
+ """Return days elapsed since an ISO timestamp string."""
77
+ try:
78
+ dt = datetime.fromisoformat(ts)
79
+ except (TypeError, ValueError):
80
+ return 0.0
81
+ if dt.tzinfo is None:
82
+ dt = dt.replace(tzinfo=timezone.utc)
83
+ return (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0
84
+
85
+
86
+ def apply_confidence_decay(
87
+ learning: Learning,
88
+ *,
89
+ now: datetime | None = None,
90
+ ) -> Learning:
91
+ """Apply time-based confidence decay to a learning in-place and return it.
92
+
93
+ Rules:
94
+ - ``"canon"`` learnings are immune (human-endorsed canonical rules).
95
+ - Already deprecated learnings are left as-is.
96
+ - After ``_STALE_THRESHOLD_DAYS`` (30) days without feedback: validity
97
+ becomes ``"stale"``.
98
+ - After ``_DECAY_THRESHOLD_DAYS`` (7) days: confidence decays by
99
+ ``_DECAY_WEEKLY_AMOUNT`` (0.05) per elapsed week, floored at 0.0.
100
+ """
101
+ if learning.type == "canon":
102
+ return learning
103
+ if learning.validity == "deprecated":
104
+ return learning
105
+
106
+ now = now or datetime.now(timezone.utc)
107
+ days = _days_since(learning.updated_at)
108
+
109
+ if days < _DECAY_THRESHOLD_DAYS:
110
+ return learning
111
+
112
+ weeks = int(days // 7)
113
+ learning.confidence = max(0.0, learning.confidence - weeks * _DECAY_WEEKLY_AMOUNT)
114
+
115
+ if days > _STALE_THRESHOLD_DAYS:
116
+ learning.validity = "stale"
117
+
118
+ learning.updated_at = now.isoformat()
119
+ return learning
@@ -0,0 +1,271 @@
1
+ """
2
+ thinkstack_core.reasoning_plus.learning.extractor
3
+ ===============================================
4
+ Distill ReasoningCall records into reusable Learning objects.
5
+
6
+ Two strategies are supported:
7
+
8
+ 1. **Rule-based** (default, no dependencies) — pair captured reasoning with the
9
+ observed outcome and synthesize a compact learning sentence.
10
+ 2. **LLM-based** (optional) — pass the reasoning, input, output, outcome, and
11
+ enriched extraction context to a small LLM prompt that returns a concise
12
+ learning summary. The caller supplies the LLM client, so the core library
13
+ remains dependency-free.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Callable
19
+
20
+ from thinkstack_core.reasoning_plus.context import extract_keywords
21
+ from thinkstack_core.reasoning_plus.learning.context_collector import ExtractionContext
22
+ from thinkstack_core.reasoning_plus.learning.models import Learning, ReasoningCall
23
+
24
+ logger = logging.getLogger("thinkstack.reasoning_plus.learning")
25
+
26
+ DEFAULT_EXTRACTION_PROMPT = """\
27
+ You are distilling a previous reasoning step into a short, reusable insight.
28
+ Given the reasoning, the action's input, its output, and the observed outcome,
29
+ write one concise sentence that another AI agent could reuse when facing a
30
+ similar situation.
31
+
32
+ Outcome: {outcome}
33
+ Name: {name}
34
+ Input: {input}
35
+ Output: {output}
36
+ Reasoning: {reasoning}
37
+
38
+ Insight:"""
39
+
40
+ CONTEXT_AWARE_EXTRACTION_PROMPT = """\
41
+ You are extracting learnings from an AI agent's reasoning step. The agent
42
+ may have changed files, added or removed imports, and used specific libraries.
43
+
44
+ {context_section}
45
+
46
+ Agent reasoning:
47
+ {reasoning}
48
+
49
+ Extract what another AI agent should learn from this step. Classify as:
50
+ - "prescription": Concrete, actionable recommendation (use library X, version Y, configure Z)
51
+ - "pattern": General pattern that emerged (structuring code, error handling, etc.)
52
+ - "avoid": Something that should NOT be done
53
+ - "insight": Non-obvious observation about the codebase, architecture, or approach
54
+ - "correction": A mistake that was made and then fixed
55
+ - "confirm": A previously uncertain approach that proved correct
56
+
57
+ Return a JSON object with the following structure:
58
+ {{
59
+ "type": "<type>",
60
+ "content": "<one concise, actionable sentence>",
61
+ "trigger_concepts": ["<concept1>", "<concept2>"],
62
+ "confidence": <0.0-1.0>
63
+ }}
64
+
65
+ If the context shows a concrete library, framework, or tool being used,
66
+ include "prescription" as the type and specify the exact library, version,
67
+ and usage pattern in the content."""
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Helpers
72
+ # ---------------------------------------------------------------------------
73
+
74
+ def _type_for_outcome(outcome: str, context: ExtractionContext | None = None) -> str:
75
+ if context and context.is_significant():
76
+ if context.imports_added or context.libraries_used:
77
+ return "prescription"
78
+ if outcome == "success":
79
+ return "confirm"
80
+ if outcome == "failure":
81
+ return "correction"
82
+ if outcome == "partial":
83
+ return "insight"
84
+ return "insight"
85
+
86
+
87
+ def _extract_concepts(call: ReasoningCall, context: ExtractionContext | None = None) -> list[str]:
88
+ concepts = set(extract_keywords(call.reasoning, max_keywords=10))
89
+ # Add import symbols as concepts for better cross-task matching.
90
+ # File names are intentionally NOT added — they cause near-identical
91
+ # learnings to diverge on file path alone, defeating deduplication.
92
+ if context:
93
+ for imp in context.imports_added[:3]:
94
+ concept = imp.rsplit(".", 1)[-1]
95
+ concepts.add(concept)
96
+ # Ensure we always have at least some concepts
97
+ if not concepts:
98
+ concepts = set(extract_keywords(call.input + " " + call.output, max_keywords=5))
99
+ return sorted(concepts)
100
+
101
+
102
+ def _fallback_reasoning(call: ReasoningCall) -> str:
103
+ """When the call has no captured reasoning, synthesize a short summary."""
104
+ if call.outcome not in ("success", "failure"):
105
+ return ""
106
+ name = call.name or call.call_type
107
+ if call.outcome == "failure":
108
+ return f"{name} failed. Input: {call.input}. Output: {call.output}."
109
+ return f"{name} succeeded. Input: {call.input}. Output: {call.output}."
110
+
111
+
112
+ def _compose_content_from_reasoning(call: ReasoningCall, reasoning: str, context: ExtractionContext | None = None) -> str:
113
+ reasoning = reasoning.strip().replace("\n", " ")
114
+ # Truncate very long reasoning: keep first 200 + last 200 chars
115
+ if len(reasoning) > 400:
116
+ reasoning = reasoning[:197].rstrip() + " ... " + reasoning[-200:].lstrip()
117
+
118
+ name = call.name or call.call_type
119
+
120
+ # Return clean reasoning content — no boilerplate prefixes.
121
+ # Context metadata (files, imports, libraries) goes into trigger_concepts.
122
+ return reasoning
123
+
124
+
125
+ def _build_learning(call: ReasoningCall, reasoning: str, context: ExtractionContext | None = None) -> Learning:
126
+ """Build a single Learning object from a reasoning summary and optional extraction context."""
127
+ learning_type = _type_for_outcome(call.outcome, context)
128
+ content = _compose_content_from_reasoning(call, reasoning, context)
129
+ concepts = _extract_concepts(call, context)
130
+
131
+ # Start with moderate confidence so feedback can raise or lower it.
132
+ base_confidence = min(call.confidence, 0.8)
133
+
134
+ meta: dict[str, str] = {"call_type": call.call_type, "name": call.name, "outcome": call.outcome}
135
+ if context and context.is_significant():
136
+ meta["context_files"] = ",".join(context.files_changed[:5])
137
+ meta["context_imports"] = ",".join(context.imports_added[:5])
138
+
139
+ return Learning(
140
+ content=content,
141
+ type=learning_type,
142
+ trigger_concepts=concepts,
143
+ state_hash=call.state_hash,
144
+ confidence=base_confidence,
145
+ source_reasoning_ids=[call.id],
146
+ sensitivity=call.sensitivity,
147
+ meta=meta,
148
+ )
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Rule-based extractor
153
+ # ---------------------------------------------------------------------------
154
+
155
+ class LearningExtractor:
156
+ """Extract learnings from a single reasoning call using lightweight rules."""
157
+
158
+ def extract(self, call: ReasoningCall, context: ExtractionContext | None = None) -> list[Learning]:
159
+ """
160
+ Return one or more Learning objects derived from the call.
161
+
162
+ If extraction context is provided, the rule-based extractor uses it to
163
+ determine whether the learning should be classified as a "prescription"
164
+ (when concrete imports, libraries, or file changes are detected).
165
+ """
166
+ reasoning = call.reasoning.strip()
167
+ if not reasoning:
168
+ reasoning = _fallback_reasoning(call)
169
+
170
+ if not reasoning:
171
+ return []
172
+
173
+ return [_build_learning(call, reasoning, context)]
174
+
175
+
176
+ # Backwards-compatible alias
177
+ RuleBasedLearningExtractor = LearningExtractor
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # LLM-based extractor
182
+ # ---------------------------------------------------------------------------
183
+
184
+ class LLMBasedLearningExtractor(LearningExtractor):
185
+ """
186
+ Extract learnings by asking an LLM to summarize a reasoning call.
187
+
188
+ The caller supplies ``client``, a callable that takes a prompt string and
189
+ returns a completion string. This keeps the core library free of any LLM
190
+ SDK dependency.
191
+
192
+ When ``context`` is provided (an ``ExtractionContext`` with files, imports,
193
+ and libraries), the extractor uses a richer prompt that generates concrete,
194
+ prescriptive learnings.
195
+ """
196
+
197
+ def __init__(
198
+ self,
199
+ client: Callable[[str], str],
200
+ prompt_template: str | None = None,
201
+ context_prompt_template: str | None = None,
202
+ ) -> None:
203
+ self._client = client
204
+ self._prompt_template = prompt_template or DEFAULT_EXTRACTION_PROMPT
205
+ self._context_prompt_template = context_prompt_template or CONTEXT_AWARE_EXTRACTION_PROMPT
206
+
207
+ def extract(self, call: ReasoningCall, context: ExtractionContext | None = None) -> list[Learning]:
208
+ """Use an LLM to summarize the reasoning; fall back to rule-based if the LLM fails."""
209
+ reasoning = call.reasoning.strip()
210
+ if not reasoning:
211
+ reasoning = _fallback_reasoning(call)
212
+
213
+ if not reasoning:
214
+ return []
215
+
216
+ prompt = self._build_prompt(call, reasoning, context)
217
+
218
+ try:
219
+ summary = self._client(prompt).strip()
220
+ except Exception as exc:
221
+ logger.warning("thinkstack: LLM-based extraction failed — %s", exc)
222
+ return [_build_learning(call, reasoning, context)]
223
+
224
+ if not summary:
225
+ return []
226
+
227
+ return [_build_learning(call, summary, context)]
228
+
229
+ def _build_prompt(self, call: ReasoningCall, reasoning: str, context: ExtractionContext | None) -> str:
230
+ """Build the extraction prompt, selecting context-aware or default template."""
231
+ if context and context.is_significant():
232
+ template = self._context_prompt_template
233
+ context_section = context.to_prompt_context()
234
+ return template.format(
235
+ context_section=context_section,
236
+ reasoning=reasoning,
237
+ )
238
+
239
+ return self._prompt_template.format(
240
+ reasoning=reasoning,
241
+ input=call.input,
242
+ output=call.output,
243
+ outcome=call.outcome,
244
+ name=call.name or call.call_type,
245
+ )
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Factory
250
+ # ---------------------------------------------------------------------------
251
+
252
+ def make_extractor(
253
+ strategy: str = "rule",
254
+ llm_client: Callable[[str], str] | None = None,
255
+ context_prompt_template: str | None = None,
256
+ ) -> LearningExtractor:
257
+ """
258
+ Build a learning extractor for the given strategy.
259
+
260
+ Strategies:
261
+ - ``rule`` (default): lightweight rule-based extraction.
262
+ - ``llm``: LLM-based extraction; requires a ``llm_client`` callable.
263
+
264
+ If ``llm`` is requested but no client is provided, falls back to rule-based.
265
+ """
266
+ if strategy == "llm" and llm_client is not None:
267
+ return LLMBasedLearningExtractor(
268
+ llm_client,
269
+ context_prompt_template=context_prompt_template or CONTEXT_AWARE_EXTRACTION_PROMPT,
270
+ )
271
+ return LearningExtractor()
@@ -0,0 +1,95 @@
1
+ """
2
+ thinkstack_core.reasoning_plus.learning.filter_five_layer
3
+ ========================================================
4
+ Five-layer cross-project learning relevance filter.
5
+
6
+ Only learnings that pass all five layers are eligible for injection
7
+ into a target project's agent workflow:
8
+
9
+ 1. **Concept mismatch** — the target project must share at least one
10
+ trigger concept with the learning.
11
+ 2. **Scope gate** — ``scope: "project"`` and ``scope: "branch"``
12
+ learnings are excluded from cross-project injection.
13
+ 3. **Deny list** — per-project ``.GCC/learning_config.json`` can
14
+ exclude specific concepts or learning IDs.
15
+ 4. **Relevance score** — similarity between the learning's content and
16
+ the current prompt must exceed ``learning_relevance_threshold``.
17
+ 5. **Human promotion** — auto-applied learnings must have ``scope: "org"``
18
+ and ``type: "canon"``; others require explicit opt-in.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ from pathlib import Path
25
+
26
+ from thinkstack_core.reasoning_plus.learning.deny_list import (
27
+ DenyList,
28
+ load_deny_list,
29
+ )
30
+ from thinkstack_core.reasoning_plus.learning.models import Learning
31
+
32
+ logger = logging.getLogger("thinkstack.reasoning_plus.learning")
33
+
34
+
35
+ def five_layer_filter(
36
+ learning: Learning,
37
+ *,
38
+ target_project_root: Path | str,
39
+ prompt_context: str = "",
40
+ relevance_score: float = 0.0,
41
+ deny_list: DenyList | None = None,
42
+ min_relevance: float = 0.3,
43
+ ) -> tuple[bool, str]:
44
+ """Return (allowed, reason) after evaluating all five layers.
45
+
46
+ Returns ``(True, "ALLOWED")`` if the learning passes every layer.
47
+ Returns ``(False, reason_string)`` identifying the first failing layer.
48
+ """
49
+ target = Path(target_project_root)
50
+
51
+ # Layer 1: Concept mismatch
52
+ target_concepts = _load_project_concepts(target)
53
+ if target_concepts:
54
+ overlap = set(c.lower() for c in learning.trigger_concepts) & set(c.lower() for c in target_concepts)
55
+
56
+ has_any_concept = len(overlap) > 0
57
+ else:
58
+ has_any_concept = len(learning.trigger_concepts) > 0
59
+ # No target concepts defined — allow through rather than block.
60
+
61
+ if not has_any_concept:
62
+ return False, "CONCEPT_MISMATCH"
63
+
64
+ # Layer 2: Scope gate — only org-scope learnings cross projects
65
+ if learning.scope in ("project", "branch"):
66
+ return False, f"SCOPE_GATE:{learning.scope}"
67
+
68
+ # Layer 3: Deny list
69
+ dl = deny_list or load_deny_list(target)
70
+ if learning.id in dl.excluded_learnings:
71
+ return False, "DENY_LIST:learning_id"
72
+ for concept in learning.trigger_concepts:
73
+ if concept.lower() in (c.lower() for c in dl.excluded_concepts):
74
+ return False, f"DENY_LIST:concept:{concept}"
75
+
76
+ # Layer 4: Relevance threshold
77
+ if relevance_score < min_relevance:
78
+ return False, f"RELEVANCE_THRESHOLD:{relevance_score:.2f}<{min_relevance:.2f}"
79
+
80
+ # Layer 5: Human promotion gate
81
+ # Auto-apply only if org-scope + canon type; everything else needs explicit
82
+ if learning.scope == "org" and learning.type == "canon":
83
+ return True, "ALLOWED"
84
+ return False, "PROMOTION_REQUIRED"
85
+
86
+
87
+ def _load_project_concepts(project_root: Path) -> list[str]:
88
+ concepts_dir = project_root / ".GCC" / "concepts"
89
+ if not concepts_dir.exists():
90
+ return []
91
+ concepts: list[str] = []
92
+ for f in concepts_dir.iterdir():
93
+ if f.suffix in (".md", ".json") and f.is_file():
94
+ concepts.append(f.stem)
95
+ return concepts