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,639 @@
1
+ """
2
+ thinkstack_core.cloud.sync_bundle
3
+ ================================
4
+ Build, compress, chunk, merge, and apply sync bundles for bidirectional
5
+ sync between local .GCC/ and the MCP server.
6
+
7
+ A sync bundle is a JSON dict containing all 5 data types (commits, theta,
8
+ learnings, sensitivities, topics). Bundles are gzip-compressed and
9
+ base64-encoded for transport through JSON-RPC.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import gzip
15
+ import hashlib
16
+ import json
17
+ import logging
18
+ from collections import defaultdict
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from thinkstack_core.cloud.team_sync import _merge_learnings as _ts_merge_learnings
23
+
24
+ logger = logging.getLogger("thinkstack.cloud.sync_bundle")
25
+
26
+ DEFAULT_CHUNK_SIZE = 500
27
+ DEFAULT_CHUNK_MAX_BYTES = 1 * 1024 * 1024
28
+ _MAX_SYNCED_IDS_FOR_HASH = 10000
29
+
30
+
31
+ def estimate_bundle_bytes(items: list | dict | None) -> int:
32
+ """Rough byte estimate for chunking decisions (avoids full serialization cost)."""
33
+ if not items:
34
+ return 0
35
+ raw = json.dumps(items, separators=(",", ":"), default=str)
36
+ return len(raw)
37
+
38
+
39
+ def compress(bundle: dict) -> str:
40
+ """Gzip + base64 compress a bundle for transport."""
41
+ raw = json.dumps(bundle, separators=(",", ":")).encode("utf-8")
42
+ compressed = gzip.compress(raw, compresslevel=6)
43
+ return base64.b64encode(compressed).decode("ascii")
44
+
45
+
46
+ def decompress(encoded: str) -> dict:
47
+ """Decompress a bundle from transport."""
48
+ compressed = base64.b64decode(encoded)
49
+ raw = gzip.decompress(compressed)
50
+ return json.loads(raw.decode("utf-8"))
51
+
52
+
53
+ def chunk(
54
+ bundle: dict,
55
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
56
+ max_bytes: int = DEFAULT_CHUNK_MAX_BYTES,
57
+ ) -> list[dict]:
58
+ """Split a bundle into chunks bounded by *max_bytes* (approx) or *chunk_size* items.
59
+
60
+ Each chunk tries to stay under *max_bytes* by splitting large data-type lists
61
+ into smaller pieces. If a single item exceeds *max_bytes*, it gets its own chunk.
62
+ The *chunk_size* parameter caps items per data type per chunk as a secondary bound.
63
+ """
64
+ if chunk_size <= 0 and max_bytes <= 0:
65
+ return [bundle]
66
+
67
+ chunks: list[dict] = []
68
+ for data_type, items in bundle.items():
69
+ if data_type in ("version", "synced_at"):
70
+ continue
71
+ if not isinstance(items, (list, dict)):
72
+ continue
73
+ if isinstance(items, list):
74
+ start = 0
75
+ while start < len(items):
76
+ end = min(start + chunk_size, len(items)) if chunk_size > 0 else len(items)
77
+ slice_items = items[start:end]
78
+ if max_bytes > 0:
79
+ while estimate_bundle_bytes(slice_items) > max_bytes and len(slice_items) > 1:
80
+ end = start + (end - start) // 2
81
+ slice_items = items[start:end]
82
+ chunk_idx = len(chunks)
83
+ while chunk_idx >= len(chunks):
84
+ chunks.append({"version": "1"})
85
+ chunks[chunk_idx][data_type] = slice_items
86
+ start = end
87
+ elif isinstance(items, dict):
88
+ if len(chunks) == 0:
89
+ chunks.append({"version": "1"})
90
+ chunks[0][data_type] = items
91
+
92
+ return chunks if chunks else [bundle]
93
+
94
+
95
+ def build_sync_status(gcc_dir: Path) -> dict:
96
+ """Return a summary of syncable items for status reporting."""
97
+ return {
98
+ "commits": _get_max_commit_index(gcc_dir),
99
+ "learnings": {"count": _count_learnings(gcc_dir)},
100
+ "theta": len(_get_theta_per_concept_timestamps(gcc_dir)),
101
+ }
102
+
103
+
104
+ def build_local_delta(gcc_dir: Path, remote_state: dict) -> dict:
105
+ """Build a delta bundle of items the server doesn't have (for push)."""
106
+ bundle: dict = {"version": "1"}
107
+ _add_commit_delta(gcc_dir, remote_state, bundle)
108
+ _add_theta_delta(gcc_dir, remote_state, bundle)
109
+ _add_learning_delta(gcc_dir, remote_state, bundle)
110
+ _add_sensitivity_delta(gcc_dir, remote_state, bundle)
111
+ _add_topic_delta(gcc_dir, remote_state, bundle)
112
+ return bundle
113
+
114
+
115
+ def build_remote_delta(gcc_dir: Path, client_state: dict) -> dict:
116
+ """Build a delta bundle of items the client doesn't have (for pull, server-side)."""
117
+ bundle: dict = {"version": "1"}
118
+ _add_pull_commit_delta(gcc_dir, client_state, bundle)
119
+ _add_pull_theta_delta(gcc_dir, client_state, bundle)
120
+ _add_pull_learning_delta(gcc_dir, client_state, bundle)
121
+ _add_pull_sensitivity_delta(gcc_dir, client_state, bundle)
122
+ _add_pull_topic_delta(gcc_dir, client_state, bundle)
123
+ return bundle
124
+
125
+
126
+ def build_server_state(gcc_dir: Path) -> dict:
127
+ """Return the server's current sync state (what it has)."""
128
+ return {
129
+ "commits": {"head": _get_max_commit_index(gcc_dir)},
130
+ "theta": _get_theta_per_concept_timestamps(gcc_dir),
131
+ "learnings": {"synced_ids": _get_learning_ids(gcc_dir), "count": _count_learnings(gcc_dir)},
132
+ "sensitivities": {"last_synced_ts": _get_latest_sensitivity_ts(gcc_dir)},
133
+ "topics": {"synced_custom_names": _get_custom_topic_names(gcc_dir)},
134
+ }
135
+
136
+
137
+ def merge_pull(gcc_dir: Path, remote_delta: dict, conflict_log: Any) -> dict:
138
+ """Merge a remote delta into local .GCC/. Returns counts + conflicts.
139
+
140
+ *conflict_log* is a ``ConflictLog`` instance (or duck-typed equivalent).
141
+ """
142
+ counts: dict[str, int] = {}
143
+ conflicts: list[dict] = []
144
+
145
+ if "commits" in remote_delta:
146
+ c, cf = _merge_commits(gcc_dir, remote_delta["commits"], conflict_log)
147
+ counts["commits"] = c
148
+ conflicts.extend(cf)
149
+
150
+ if "theta" in remote_delta:
151
+ c, cf = _merge_theta(gcc_dir, remote_delta["theta"], conflict_log)
152
+ counts["theta"] = c
153
+ conflicts.extend(cf)
154
+
155
+ if "learnings" in remote_delta:
156
+ c, cf = _merge_learnings(gcc_dir, remote_delta["learnings"], conflict_log)
157
+ counts["learnings"] = c
158
+ conflicts.extend(cf)
159
+
160
+ if "sensitivities" in remote_delta:
161
+ c, cf = _merge_sensitivities(gcc_dir, remote_delta["sensitivities"], conflict_log)
162
+ counts["sensitivities"] = c
163
+ conflicts.extend(cf)
164
+
165
+ if "topics" in remote_delta:
166
+ c, cf = _merge_topics(gcc_dir, remote_delta["topics"], conflict_log)
167
+ counts["topics"] = c
168
+ conflicts.extend(cf)
169
+
170
+ return {"counts": counts, "conflicts": conflicts}
171
+
172
+
173
+ def merge_push(gcc_dir: Path, local_delta: dict, conflict_log: Any) -> dict:
174
+ """Merge a local delta into server .GCC/. Returns counts + conflicts.
175
+
176
+ Same logic as merge_pull — the merge rules are symmetric.
177
+ """
178
+ return merge_pull(gcc_dir, local_delta, conflict_log)
179
+
180
+
181
+ def _merge_commits(gcc_dir: Path, remote_commits: list[dict], conflict_log: Any) -> tuple[int, list[dict]]:
182
+ """Add new commits (skip if ID exists). Log ID collisions."""
183
+ commits_dir = gcc_dir / "commits"
184
+ commits_dir.mkdir(parents=True, exist_ok=True)
185
+ added = 0
186
+ conflicts = []
187
+ for commit in remote_commits:
188
+ cid = commit.get("id")
189
+ if not cid:
190
+ continue
191
+ path = commits_dir / f"{cid}.json"
192
+ if path.exists():
193
+ try:
194
+ existing = json.loads(path.read_text(encoding="utf-8"))
195
+ if existing.get("message") != commit.get("message") or existing.get("concepts") != commit.get("concepts"):
196
+ conflict = _make_conflict("commit_id_collision", "high", "commits", cid,
197
+ {"message": existing.get("message"), "concepts": existing.get("concepts")},
198
+ {"message": commit.get("message"), "concepts": commit.get("concepts")},
199
+ "kept_local")
200
+ if conflict_log:
201
+ conflict["conflict_id"] = conflict_log.append(conflict)
202
+ conflicts.append(conflict)
203
+ except (json.JSONDecodeError, OSError):
204
+ pass
205
+ continue
206
+ try:
207
+ _atomic_write(path, json.dumps(commit, indent=2) + "\n")
208
+ added += 1
209
+ except OSError as exc:
210
+ logger.warning("thinkstack: failed to write commit %s — %s", cid, exc)
211
+ return added, conflicts
212
+
213
+
214
+ def _merge_theta(gcc_dir: Path, remote_theta: dict, conflict_log: Any) -> tuple[int, list[dict]]:
215
+ """Merge remote theta using merge_theta rules. Log disclosure downgrades."""
216
+ from thinkstack_core.theta import make_theta_store
217
+ theta_store = make_theta_store(gcc_dir)
218
+ theta = theta_store.load()
219
+ cv = theta.setdefault("coordination_vector", {})
220
+ conflicts = []
221
+
222
+ remote_cv = remote_theta.get("coordination_vector", {})
223
+ for concept, entry in remote_cv.items():
224
+ existing = cv.get(concept, {})
225
+ old_count = int(existing.get("event_count", 0))
226
+ new_count = int(entry.get("event_count", 0))
227
+ total = old_count + new_count
228
+
229
+ if total:
230
+ old_conf = float(existing.get("mean_confidence", entry["mean_confidence"]))
231
+ new_conf = float(entry["mean_confidence"])
232
+ blended = (old_conf * old_count + new_conf * new_count) / total
233
+ else:
234
+ blended = float(entry["mean_confidence"])
235
+
236
+ _LEVEL_RANK = {"PUBLIC": 0, "PROTECTED": 1, "PRIVATE": 2}
237
+ _RANK_LEVEL = {0: "PUBLIC", 1: "PROTECTED", 2: "PRIVATE"}
238
+ existing_rank = _LEVEL_RANK.get(existing.get("disclosure_level", "PUBLIC"), 0)
239
+ new_rank = _LEVEL_RANK.get(entry.get("disclosure_level", "PUBLIC"), 0)
240
+
241
+ if existing and existing_rank != new_rank and existing_rank > new_rank:
242
+ conflict = _make_conflict("theta_disclosure_downgrade", "low", "theta", concept,
243
+ {"disclosure_level": existing.get("disclosure_level")},
244
+ {"disclosure_level": entry.get("disclosure_level")},
245
+ "kept_max")
246
+ if conflict_log:
247
+ conflict["conflict_id"] = conflict_log.append(conflict)
248
+ conflicts.append(conflict)
249
+
250
+ merged_level = _RANK_LEVEL[max(existing_rank, new_rank)]
251
+ from datetime import datetime, timezone
252
+ cv[concept] = {
253
+ "mean_confidence": round(blended, 6),
254
+ "disclosure_level": merged_level,
255
+ "event_count": total,
256
+ "last_updated": datetime.now(timezone.utc).isoformat(),
257
+ }
258
+
259
+ theta_store.save(theta)
260
+ return len(remote_cv), conflicts
261
+
262
+
263
+ def _merge_learnings(gcc_dir: Path, remote_learnings: list[dict], conflict_log: Any) -> tuple[int, list[dict]]:
264
+ """Add new learnings. For existing IDs: max confidence, union concepts, most restrictive validity. Log content divergence."""
265
+ from thinkstack_core.reasoning_plus.learning.models import Learning
266
+ from thinkstack_core.reasoning_plus.learning.store import LearningStore
267
+ store = LearningStore(gcc_dir)
268
+ added = 0
269
+ conflicts = []
270
+ for rl in remote_learnings:
271
+ rid = rl.get("id")
272
+ if not rid:
273
+ continue
274
+ existing_learning = store.get(rid)
275
+ if existing_learning:
276
+ if existing_learning.content != rl.get("content", ""):
277
+ conflict = _make_conflict("learning_content_divergence", "medium", "learnings", rid,
278
+ {"content": existing_learning.content, "confidence": existing_learning.confidence},
279
+ {"content": rl.get("content"), "confidence": rl.get("confidence")},
280
+ "kept_local")
281
+ if conflict_log:
282
+ conflict["conflict_id"] = conflict_log.append(conflict)
283
+ conflicts.append(conflict)
284
+ existing_learning.confidence = max(existing_learning.confidence, float(rl.get("confidence", 0)))
285
+ existing_learning.trigger_concepts = list(set(existing_learning.trigger_concepts) | set(rl.get("trigger_concepts", [])))
286
+ valid_rank = {"active": 0, "stale": 1, "deprecated": 2}
287
+ if valid_rank.get(rl.get("validity", "active"), 0) > valid_rank.get(existing_learning.validity, 0):
288
+ existing_learning.validity = rl["validity"]
289
+ store.save(existing_learning, deduplicate=False)
290
+ else:
291
+ try:
292
+ learning = Learning.from_dict(rl)
293
+ store.save(learning, deduplicate=False)
294
+ added += 1
295
+ except Exception as exc:
296
+ logger.warning("thinkstack: failed to save remote learning %s — %s", rid, exc)
297
+ return added, conflicts
298
+
299
+
300
+ def _merge_sensitivities(gcc_dir: Path, remote_events: list[dict], conflict_log: Any) -> tuple[int, list[dict]]:
301
+ """Append new sensitivity events (dedup by SHA-256 hash)."""
302
+ path = gcc_dir / "sensitivities" / "events.jsonl"
303
+ path.parent.mkdir(parents=True, exist_ok=path.parent != gcc_dir and True or True)
304
+ path.parent.mkdir(parents=True, exist_ok=True)
305
+
306
+ existing_hashes = set()
307
+ if path.exists():
308
+ try:
309
+ for line in path.read_text(encoding="utf-8").splitlines():
310
+ if line.strip():
311
+ existing_hashes.add(hashlib.sha256(line.encode("utf-8")).hexdigest())
312
+ except OSError:
313
+ pass
314
+
315
+ added = 0
316
+ with open(str(path), "a", encoding="utf-8") as f:
317
+ for event in remote_events:
318
+ line = json.dumps(event, separators=(",", ":"))
319
+ h = hashlib.sha256(line.encode("utf-8")).hexdigest()
320
+ if h in existing_hashes:
321
+ continue
322
+ existing_hashes.add(h)
323
+ f.write(line + "\n")
324
+ added += 1
325
+ return added, []
326
+
327
+
328
+ def _merge_topics(gcc_dir: Path, remote_topics: dict, conflict_log: Any) -> tuple[int, list[dict]]:
329
+ """Merge custom topics (union by name, union concepts). Log delete vs update."""
330
+ from thinkstack_core.topics import TopicStore
331
+ store = TopicStore(gcc_dir)
332
+ remote_custom = remote_topics.get("custom_topics", {})
333
+ added = 0
334
+ conflicts = []
335
+
336
+ local_full = store._read_full()
337
+ local_custom = local_full.setdefault("custom_topics", {})
338
+
339
+ for name, concepts in remote_custom.items():
340
+ if name in local_custom:
341
+ if set(local_custom[name]) != set(concepts):
342
+ conflict = _make_conflict("topic_delete_vs_update", "medium", "topics", name,
343
+ {"concepts": local_custom[name]},
344
+ {"concepts": concepts},
345
+ "kept_updated")
346
+ if conflict_log:
347
+ conflict["conflict_id"] = conflict_log.append(conflict)
348
+ conflicts.append(conflict)
349
+ local_custom[name] = sorted(set(local_custom[name]) | set(concepts))
350
+ else:
351
+ local_custom[name] = sorted(set(concepts))
352
+ added += 1
353
+
354
+ store._write_full(local_full)
355
+ return added, conflicts
356
+
357
+
358
+ def _make_conflict(
359
+ type_: str, severity: str, data_type: str, item_id: str,
360
+ local_value: dict, remote_value: dict, resolution: str,
361
+ ) -> dict:
362
+ from datetime import datetime, timezone
363
+ return {
364
+ "type": type_,
365
+ "severity": severity,
366
+ "data_type": data_type,
367
+ "item_id": item_id,
368
+ "local_value": local_value,
369
+ "remote_value": remote_value,
370
+ "resolution": resolution,
371
+ "resolved": False,
372
+ "timestamp": datetime.now(timezone.utc).isoformat(),
373
+ }
374
+
375
+
376
+ def _add_commit_delta(gcc_dir: Path, remote_state: dict, bundle: dict) -> None:
377
+ commits_dir = gcc_dir / "commits"
378
+ if not commits_dir.exists():
379
+ return
380
+ remote_commits = remote_state.get("commits", {})
381
+ # Server state exposes commit progress via "head" (max commit id). Fall back
382
+ # to synced_ids for compatibility with older server states.
383
+ remote_head = remote_commits.get("head")
384
+ remote_synced = set(remote_commits.get("synced_ids", []))
385
+ delta = []
386
+ for path in sorted(commits_dir.glob("*.json")):
387
+ try:
388
+ data = json.loads(path.read_text(encoding="utf-8"))
389
+ cid = data.get("id")
390
+ if remote_head is not None and cid is not None:
391
+ if cid > remote_head:
392
+ delta.append(data)
393
+ elif cid not in remote_synced:
394
+ delta.append(data)
395
+ except (json.JSONDecodeError, OSError):
396
+ continue
397
+ if delta:
398
+ bundle["commits"] = delta
399
+
400
+
401
+ def _add_theta_delta(gcc_dir: Path, remote_state: dict, bundle: dict) -> None:
402
+ path = gcc_dir / "theta.json"
403
+ if not path.exists():
404
+ return
405
+ try:
406
+ theta = json.loads(path.read_text(encoding="utf-8"))
407
+ except (json.JSONDecodeError, OSError):
408
+ return
409
+ cv = theta.get("coordination_vector", {})
410
+ remote_concepts = remote_state.get("theta", {})
411
+ delta_cv = {}
412
+ for concept, entry in cv.items():
413
+ remote_ts = remote_concepts.get(concept, {}).get("last_updated", "")
414
+ local_ts = entry.get("last_updated", "")
415
+ if local_ts > remote_ts:
416
+ delta_cv[concept] = entry
417
+ if delta_cv:
418
+ bundle["theta"] = {"version": 1, "coordination_vector": delta_cv}
419
+
420
+
421
+ def _add_learning_delta(gcc_dir: Path, remote_state: dict, bundle: dict) -> None:
422
+ learn_dir = gcc_dir / "reasoning_learnings" / "learnings"
423
+ if not learn_dir.exists():
424
+ return
425
+ remote_ids = set(remote_state.get("learnings", {}).get("synced_ids", []))
426
+ delta = []
427
+ for path in learn_dir.glob("*.json"):
428
+ try:
429
+ data = json.loads(path.read_text(encoding="utf-8"))
430
+ if data.get("id") not in remote_ids:
431
+ delta.append(data)
432
+ except (json.JSONDecodeError, OSError):
433
+ continue
434
+ if delta:
435
+ bundle["learnings"] = delta
436
+
437
+
438
+ def _add_sensitivity_delta(gcc_dir: Path, remote_state: dict, bundle: dict) -> None:
439
+ path = gcc_dir / "sensitivities" / "events.jsonl"
440
+ if not path.exists():
441
+ return
442
+ remote_ts = remote_state.get("sensitivities", {}).get("last_synced_ts", "")
443
+ delta = []
444
+ try:
445
+ for line in path.read_text(encoding="utf-8").splitlines():
446
+ if not line.strip():
447
+ continue
448
+ ev = json.loads(line)
449
+ payload = ev if "target_concept" in ev else ev.get("payload", {})
450
+ created = payload.get("created_at", "") or payload.get("timestamp", "")
451
+ if created > remote_ts:
452
+ delta.append(payload)
453
+ except (OSError, json.JSONDecodeError):
454
+ pass
455
+ if delta:
456
+ bundle["sensitivities"] = delta
457
+
458
+
459
+ def _add_topic_delta(gcc_dir: Path, remote_state: dict, bundle: dict) -> None:
460
+ from thinkstack_core.topics import TopicStore
461
+ store = TopicStore(gcc_dir)
462
+ remote_names = set(remote_state.get("topics", {}).get("synced_custom_names", []))
463
+ local_custom = store._read_custom()
464
+ delta = {name: concepts for name, concepts in local_custom.items() if name not in remote_names}
465
+ if delta:
466
+ bundle["topics"] = {"custom_topics": delta}
467
+
468
+
469
+ def _add_pull_commit_delta(gcc_dir: Path, client_state: dict, bundle: dict) -> None:
470
+ commits_dir = gcc_dir / "commits"
471
+ if not commits_dir.exists():
472
+ return
473
+ client_commits = client_state.get("commits", {})
474
+ client_head = client_commits.get("remote_head")
475
+ client_ids = set(client_commits.get("synced_ids", []))
476
+ delta = []
477
+ for path in sorted(commits_dir.glob("*.json")):
478
+ try:
479
+ data = json.loads(path.read_text(encoding="utf-8"))
480
+ cid = data.get("id")
481
+ if client_head is not None and cid is not None:
482
+ if cid > client_head:
483
+ delta.append(data)
484
+ elif cid not in client_ids:
485
+ delta.append(data)
486
+ except (json.JSONDecodeError, OSError):
487
+ continue
488
+ if delta:
489
+ bundle["commits"] = delta
490
+
491
+
492
+ def _add_pull_theta_delta(gcc_dir: Path, client_state: dict, bundle: dict) -> None:
493
+ path = gcc_dir / "theta.json"
494
+ if not path.exists():
495
+ return
496
+ try:
497
+ theta = json.loads(path.read_text(encoding="utf-8"))
498
+ except (json.JSONDecodeError, OSError):
499
+ return
500
+ cv = theta.get("coordination_vector", {})
501
+ client_concepts = client_state.get("theta", {})
502
+ delta_cv = {}
503
+ for concept, entry in cv.items():
504
+ client_ts = client_concepts.get(concept, {}).get("last_updated", "")
505
+ local_ts = entry.get("last_updated", "")
506
+ if local_ts > client_ts:
507
+ delta_cv[concept] = entry
508
+ if delta_cv:
509
+ bundle["theta"] = {"version": 1, "coordination_vector": delta_cv}
510
+
511
+
512
+ def _add_pull_learning_delta(gcc_dir: Path, client_state: dict, bundle: dict) -> None:
513
+ learn_dir = gcc_dir / "reasoning_learnings" / "learnings"
514
+ if not learn_dir.exists():
515
+ return
516
+ client_ids = set(client_state.get("learnings", {}).get("synced_ids", []))
517
+ delta = []
518
+ for path in learn_dir.glob("*.json"):
519
+ try:
520
+ data = json.loads(path.read_text(encoding="utf-8"))
521
+ if data.get("id") not in client_ids:
522
+ delta.append(data)
523
+ except (json.JSONDecodeError, OSError):
524
+ continue
525
+ if delta:
526
+ bundle["learnings"] = delta
527
+
528
+
529
+ def _add_pull_sensitivity_delta(gcc_dir: Path, client_state: dict, bundle: dict) -> None:
530
+ path = gcc_dir / "sensitivities" / "events.jsonl"
531
+ if not path.exists():
532
+ return
533
+ client_ts = client_state.get("sensitivities", {}).get("last_synced_ts", "")
534
+ delta = []
535
+ try:
536
+ for line in path.read_text(encoding="utf-8").splitlines():
537
+ if not line.strip():
538
+ continue
539
+ ev = json.loads(line)
540
+ payload = ev if "target_concept" in ev else ev.get("payload", {})
541
+ created = payload.get("created_at", "") or payload.get("timestamp", "")
542
+ if created > client_ts:
543
+ delta.append(payload)
544
+ except (OSError, json.JSONDecodeError):
545
+ pass
546
+ if delta:
547
+ bundle["sensitivities"] = delta
548
+
549
+
550
+ def _add_pull_topic_delta(gcc_dir: Path, client_state: dict, bundle: dict) -> None:
551
+ from thinkstack_core.topics import TopicStore
552
+ store = TopicStore(gcc_dir)
553
+ client_names = set(client_state.get("topics", {}).get("synced_custom_names", []))
554
+ local_custom = store._read_custom()
555
+ delta = {name: concepts for name, concepts in local_custom.items() if name not in client_names}
556
+ if delta:
557
+ bundle["topics"] = {"custom_topics": delta}
558
+
559
+
560
+ def _get_max_commit_index(gcc_dir: Path) -> str:
561
+ commits_dir = gcc_dir / "commits"
562
+ if not commits_dir.exists():
563
+ return "0"
564
+ max_id = "0"
565
+ for path in commits_dir.glob("*.json"):
566
+ stem = path.stem
567
+ if stem > max_id:
568
+ max_id = stem
569
+ return max_id
570
+
571
+
572
+ def _get_theta_per_concept_timestamps(gcc_dir: Path) -> dict[str, dict[str, str]]:
573
+ path = gcc_dir / "theta.json"
574
+ if not path.exists():
575
+ return {}
576
+ try:
577
+ theta = json.loads(path.read_text(encoding="utf-8"))
578
+ except (json.JSONDecodeError, OSError):
579
+ return {}
580
+ cv = theta.get("coordination_vector", {})
581
+ return {c: {"last_updated": e.get("last_updated", "")} for c, e in cv.items()}
582
+
583
+
584
+ def _get_learning_ids(gcc_dir: Path) -> list[str]:
585
+ learn_dir = gcc_dir / "reasoning_learnings" / "learnings"
586
+ if not learn_dir.exists():
587
+ return []
588
+ ids = []
589
+ for path in learn_dir.glob("*.json"):
590
+ try:
591
+ data = json.loads(path.read_text(encoding="utf-8"))
592
+ if data.get("id"):
593
+ ids.append(data["id"])
594
+ except (json.JSONDecodeError, OSError):
595
+ continue
596
+ return ids
597
+
598
+
599
+ def _count_learnings(gcc_dir: Path) -> int:
600
+ learn_dir = gcc_dir / "reasoning_learnings" / "learnings"
601
+ if not learn_dir.exists():
602
+ return 0
603
+ return len(list(learn_dir.glob("*.json")))
604
+
605
+
606
+ def _get_latest_sensitivity_ts(gcc_dir: Path) -> str:
607
+ path = gcc_dir / "sensitivities" / "events.jsonl"
608
+ if not path.exists():
609
+ return ""
610
+ latest = ""
611
+ try:
612
+ for line in path.read_text(encoding="utf-8").splitlines():
613
+ if not line.strip():
614
+ continue
615
+ ev = json.loads(line)
616
+ ts = ev.get("created_at", "") or ev.get("timestamp", "")
617
+ if ts > latest:
618
+ latest = ts
619
+ except (OSError, json.JSONDecodeError):
620
+ pass
621
+ return latest
622
+
623
+
624
+ def _get_custom_topic_names(gcc_dir: Path) -> list[str]:
625
+ from thinkstack_core.topics import TopicStore
626
+ return list(TopicStore(gcc_dir)._read_custom().keys())
627
+
628
+
629
+ def _atomic_write(path: Path, content: str) -> None:
630
+ tmp = path.with_suffix(path.suffix + ".tmp")
631
+ try:
632
+ tmp.write_text(content, encoding="utf-8")
633
+ tmp.replace(path)
634
+ finally:
635
+ if tmp.exists():
636
+ try:
637
+ tmp.unlink()
638
+ except Exception:
639
+ pass