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
thinkstack_core/rep.py ADDED
@@ -0,0 +1,134 @@
1
+ """
2
+ Sprint 5 – REP (Reasoning Exchange Protocol) envelope schema and local transport.
3
+
4
+ Message envelope: agent_id, round, trust, payload hashes. Optional signatures.
5
+ Local transport: file-based ledger with deterministic ordering for record/replay.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ REP_VERSION = "rep-v1"
17
+ REP_LEDGER_NAME = "rep_ledger.jsonl"
18
+ REP_DIR_NAME = "rep"
19
+
20
+
21
+ def _hash_payload(payload: Any) -> str:
22
+ """SHA-256 hex digest of JSON-serialized payload."""
23
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
24
+
25
+
26
+ @dataclass
27
+ class REPEnvelope:
28
+ """REP message envelope for sensitivity exchange."""
29
+ agent_id: str
30
+ round_id: int
31
+ trust: float # [0, 1] optional trust score
32
+ payload_hash: str
33
+ payload_type: str = "sensitivity" # sensitivity | control | ack
34
+ payload_ref: Optional[str] = None # optional pointer to full payload
35
+ signature: Optional[str] = None # optional crypto signature
36
+
37
+ def to_dict(self) -> dict:
38
+ d = {
39
+ "version": REP_VERSION,
40
+ "agent_id": self.agent_id,
41
+ "round_id": self.round_id,
42
+ "trust": self.trust,
43
+ "payload_hash": self.payload_hash,
44
+ "payload_type": self.payload_type,
45
+ }
46
+ if self.payload_ref is not None:
47
+ d["payload_ref"] = self.payload_ref
48
+ if self.signature is not None:
49
+ d["signature"] = self.signature
50
+ return d
51
+
52
+ @classmethod
53
+ def from_dict(cls, d: dict) -> "REPEnvelope":
54
+ return cls(
55
+ agent_id=str(d["agent_id"]),
56
+ round_id=int(d["round_id"]),
57
+ trust=float(d.get("trust", 0.5)),
58
+ payload_hash=str(d["payload_hash"]),
59
+ payload_type=str(d.get("payload_type", "sensitivity")),
60
+ payload_ref=d.get("payload_ref"),
61
+ signature=d.get("signature"),
62
+ )
63
+
64
+
65
+ class REPLedger:
66
+ """
67
+ Append-only REP ledger for local transport. Deterministic ordering by
68
+ sequence id and timestamp for record/replay.
69
+ """
70
+ def __init__(self, gcc_dir: Path) -> None:
71
+ self.rep_dir = gcc_dir / REP_DIR_NAME
72
+ self.ledger_path = self.rep_dir / REP_LEDGER_NAME
73
+
74
+ def ensure_dir(self) -> None:
75
+ self.rep_dir.mkdir(parents=True, exist_ok=True)
76
+
77
+ def append(self, envelope: REPEnvelope, timestamp: str, sequence_id: Optional[int] = None) -> int:
78
+ """Append envelope to ledger; return sequence id (1-based)."""
79
+ self.ensure_dir()
80
+ if sequence_id is None:
81
+ sequence_id = self._next_sequence_id()
82
+ record = {
83
+ "sequence_id": sequence_id,
84
+ "timestamp": timestamp,
85
+ "envelope": envelope.to_dict(),
86
+ }
87
+ with self.ledger_path.open("a", encoding="utf-8") as f:
88
+ f.write(json.dumps(record, sort_keys=True) + "\n")
89
+ return sequence_id
90
+
91
+ def _next_sequence_id(self) -> int:
92
+ if not self.ledger_path.exists():
93
+ return 1
94
+ last = 0
95
+ with self.ledger_path.open("r", encoding="utf-8") as f:
96
+ for line in f:
97
+ line = line.strip()
98
+ if not line:
99
+ continue
100
+ try:
101
+ rec = json.loads(line)
102
+ last = max(last, rec.get("sequence_id", 0))
103
+ except json.JSONDecodeError:
104
+ continue
105
+ return last + 1
106
+
107
+ def read_all(self) -> List[dict]:
108
+ """Read all records in deterministic order (sequence_id, timestamp)."""
109
+ if not self.ledger_path.exists():
110
+ return []
111
+ records = []
112
+ with self.ledger_path.open("r", encoding="utf-8") as f:
113
+ for line in f:
114
+ line = line.strip()
115
+ if not line:
116
+ continue
117
+ try:
118
+ records.append(json.loads(line))
119
+ except json.JSONDecodeError:
120
+ continue
121
+ records.sort(key=lambda r: (r.get("sequence_id", 0), r.get("timestamp", "")))
122
+ return records
123
+
124
+ def replay_checksum(self) -> str:
125
+ """Deterministic checksum of ledger for replay verification."""
126
+ records = self.read_all()
127
+ content = json.dumps(records, sort_keys=True)
128
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
129
+
130
+
131
+ def create_envelope(agent_id: str, round_id: int, payload: Any, trust: float = 0.5) -> REPEnvelope:
132
+ """Build REP envelope from payload (hash stored; payload not stored in envelope)."""
133
+ payload_hash = _hash_payload(payload)
134
+ return REPEnvelope(agent_id=agent_id, round_id=round_id, trust=trust, payload_hash=payload_hash)
@@ -0,0 +1,25 @@
1
+ """
2
+ thinkstack_core.rep_network — S15 multi-node REP ledger synchronization.
3
+ """
4
+
5
+ from thinkstack_core.rep_network.node import NodeIdentity, NodeRegistry, PeerEntry
6
+ from thinkstack_core.rep_network.sync import (
7
+ SyncResult,
8
+ pull_from_peer,
9
+ push_to_peer,
10
+ sync_all_peers,
11
+ sync_with_peer,
12
+ )
13
+ from thinkstack_core.rep_network.merge import merge_theta
14
+
15
+ __all__ = [
16
+ "NodeIdentity",
17
+ "PeerEntry",
18
+ "NodeRegistry",
19
+ "SyncResult",
20
+ "push_to_peer",
21
+ "pull_from_peer",
22
+ "sync_with_peer",
23
+ "sync_all_peers",
24
+ "merge_theta",
25
+ ]
@@ -0,0 +1,70 @@
1
+ """
2
+ theta.json merge function for REP network synchronisation.
3
+ """
4
+ from __future__ import annotations
5
+ from typing import Dict
6
+
7
+ _LEVEL_RANK = {"PUBLIC": 0, "PROTECTED": 1, "PRIVATE": 2}
8
+ _RANK_LEVEL = {0: "PUBLIC", 1: "PROTECTED", 2: "PRIVATE"}
9
+
10
+
11
+ def merge_theta(local: dict, remote: dict) -> dict:
12
+ """
13
+ Merge two theta.json dicts (remote into local) and return the merged result.
14
+
15
+ Merge rules per concept:
16
+ - event_count: sum of both
17
+ - mean_confidence: weighted mean (weighted by event_count from each side)
18
+ - disclosure_level: max (most restrictive) of both sides
19
+ - last_updated: take the more recent of both (ISO string comparison)
20
+
21
+ The returned dict has the same shape as ThetaStore.load() output:
22
+ {
23
+ "version": 1,
24
+ "updated_at": <now ISO UTC>,
25
+ "coordination_vector": { concept: { ... } }
26
+ }
27
+ """
28
+ import datetime as _dt
29
+
30
+ local_cv = local.get("coordination_vector", {})
31
+ remote_cv = remote.get("coordination_vector", {})
32
+ all_concepts = set(local_cv) | set(remote_cv)
33
+
34
+ merged_cv: Dict[str, dict] = {}
35
+ for concept in all_concepts:
36
+ l = local_cv.get(concept, {})
37
+ r = remote_cv.get(concept, {})
38
+
39
+ l_count = int(l.get("event_count", 0))
40
+ r_count = int(r.get("event_count", 0))
41
+ total = l_count + r_count
42
+
43
+ if total == 0:
44
+ merged_conf = 0.0
45
+ else:
46
+ l_conf = float(l.get("mean_confidence", 0.0))
47
+ r_conf = float(r.get("mean_confidence", 0.0))
48
+ merged_conf = round((l_conf * l_count + r_conf * r_count) / total, 6)
49
+
50
+ l_rank = _LEVEL_RANK.get(l.get("disclosure_level", "PUBLIC"), 0)
51
+ r_rank = _LEVEL_RANK.get(r.get("disclosure_level", "PUBLIC"), 0)
52
+ merged_level = _RANK_LEVEL[max(l_rank, r_rank)]
53
+
54
+ l_ts = l.get("last_updated", "")
55
+ r_ts = r.get("last_updated", "")
56
+ merged_ts = l_ts if l_ts >= r_ts else r_ts
57
+
58
+ merged_cv[concept] = {
59
+ "event_count": total,
60
+ "mean_confidence": merged_conf,
61
+ "disclosure_level": merged_level,
62
+ "last_updated": merged_ts,
63
+ }
64
+
65
+ now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat()
66
+ return {
67
+ "version": local.get("version", 1),
68
+ "updated_at": now,
69
+ "coordination_vector": merged_cv,
70
+ }
@@ -0,0 +1,137 @@
1
+ """
2
+ REP network node identity and peer discovery.
3
+
4
+ Nodes are identified by a UUID generated once and stored in .GCC/rep/node_id.
5
+ Peer discovery is file-based (no mDNS/DNS-SD in S15): peers are listed in
6
+ .GCC/rep/peers.json as [{id: str, url: str, name: str}].
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import socket
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import List
18
+
19
+
20
+ @dataclass
21
+ class NodeIdentity:
22
+ """Identity of this node."""
23
+ node_id: str
24
+ name: str
25
+ url: str
26
+
27
+
28
+ @dataclass
29
+ class PeerEntry:
30
+ """A known remote peer."""
31
+ node_id: str
32
+ name: str
33
+ url: str
34
+ last_seen: str = ""
35
+
36
+
37
+ class NodeRegistry:
38
+ """Manages node identity and peer list backed by .GCC/rep/ files."""
39
+
40
+ def __init__(self, gcc_root: str) -> None:
41
+ self._rep_dir = Path(gcc_root) / "rep"
42
+
43
+ # ------------------------------------------------------------------
44
+ # Identity
45
+ # ------------------------------------------------------------------
46
+
47
+ def get_or_create_identity(self, url: str = "") -> NodeIdentity:
48
+ """Read .GCC/rep/node_id; create and persist one if missing."""
49
+ self._rep_dir.mkdir(parents=True, exist_ok=True)
50
+ node_id_path = self._rep_dir / "node_id"
51
+ if node_id_path.exists():
52
+ try:
53
+ node_id = node_id_path.read_text(encoding="utf-8").strip()
54
+ except OSError:
55
+ node_id = str(uuid.uuid4())
56
+ node_id_path.write_text(node_id, encoding="utf-8")
57
+ else:
58
+ node_id = str(uuid.uuid4())
59
+ node_id_path.write_text(node_id, encoding="utf-8")
60
+
61
+ name = socket.gethostname()
62
+ return NodeIdentity(node_id=node_id, name=name, url=url)
63
+
64
+ # ------------------------------------------------------------------
65
+ # Peers
66
+ # ------------------------------------------------------------------
67
+
68
+ def _peers_path(self) -> Path:
69
+ return self._rep_dir / "peers.json"
70
+
71
+ def list_peers(self) -> List[PeerEntry]:
72
+ """Read .GCC/rep/peers.json; return empty list if missing or corrupt."""
73
+ path = self._peers_path()
74
+ if not path.exists():
75
+ return []
76
+ try:
77
+ raw = json.loads(path.read_text(encoding="utf-8"))
78
+ if not isinstance(raw, list):
79
+ return []
80
+ peers = []
81
+ for item in raw:
82
+ if not isinstance(item, dict):
83
+ continue
84
+ try:
85
+ peers.append(PeerEntry(
86
+ node_id=str(item["node_id"]),
87
+ name=str(item.get("name", "")),
88
+ url=str(item.get("url", "")),
89
+ last_seen=str(item.get("last_seen", "")),
90
+ ))
91
+ except (KeyError, TypeError):
92
+ continue
93
+ return peers
94
+ except (json.JSONDecodeError, OSError):
95
+ return []
96
+
97
+ def _save_peers(self, peers: List[PeerEntry]) -> None:
98
+ self._rep_dir.mkdir(parents=True, exist_ok=True)
99
+ data = [
100
+ {
101
+ "node_id": p.node_id,
102
+ "name": p.name,
103
+ "url": p.url,
104
+ "last_seen": p.last_seen,
105
+ }
106
+ for p in peers
107
+ ]
108
+ self._peers_path().write_text(json.dumps(data, indent=2), encoding="utf-8")
109
+
110
+ def add_peer(self, peer: PeerEntry) -> None:
111
+ """Append peer; replace existing entry with the same node_id."""
112
+ peers = self.list_peers()
113
+ peers = [p for p in peers if p.node_id != peer.node_id]
114
+ peers.append(peer)
115
+ self._save_peers(peers)
116
+
117
+ def remove_peer(self, node_id: str) -> None:
118
+ """Remove peer by node_id; no-op if not found."""
119
+ peers = [p for p in self.list_peers() if p.node_id != node_id]
120
+ self._save_peers(peers)
121
+
122
+ def update_peer_last_seen(self, node_id: str) -> None:
123
+ """Set last_seen to current UTC ISO timestamp for the given peer."""
124
+ peers = self.list_peers()
125
+ ts = datetime.now(timezone.utc).isoformat()
126
+ updated = []
127
+ for p in peers:
128
+ if p.node_id == node_id:
129
+ updated.append(PeerEntry(
130
+ node_id=p.node_id,
131
+ name=p.name,
132
+ url=p.url,
133
+ last_seen=ts,
134
+ ))
135
+ else:
136
+ updated.append(p)
137
+ self._save_peers(updated)
@@ -0,0 +1,140 @@
1
+ """
2
+ FastAPI server exposing REP ledger to peers.
3
+ Runs on a different port from the proxy (default 8767).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict, List
9
+
10
+ from thinkstack_core.rep import REPEnvelope, REPLedger
11
+ from thinkstack_core.rep_network.node import NodeRegistry, PeerEntry
12
+
13
+ try:
14
+ from fastapi import Body, FastAPI
15
+ from pydantic import BaseModel
16
+
17
+ HAS_FASTAPI = True
18
+
19
+ class PushEntriesRequest(BaseModel):
20
+ entries: List[Dict[str, Any]]
21
+ sender_id: str = ""
22
+
23
+ class PeerEntryModel(BaseModel):
24
+ node_id: str
25
+ name: str = ""
26
+ url: str = ""
27
+ last_seen: str = ""
28
+
29
+ except ImportError:
30
+ HAS_FASTAPI = False
31
+
32
+
33
+ def create_rep_server_app(ledger: REPLedger, registry: NodeRegistry) -> "FastAPI":
34
+ """Build and return a FastAPI application exposing the REP ledger.
35
+
36
+ Raises RuntimeError if FastAPI is not installed.
37
+ """
38
+ if not HAS_FASTAPI:
39
+ raise RuntimeError("FastAPI is not installed; cannot create REP server app.")
40
+
41
+ app = FastAPI(title="ThinkStack REP Network Node", version="0.1.0")
42
+
43
+ # ------------------------------------------------------------------
44
+ # Helpers
45
+ # ------------------------------------------------------------------
46
+
47
+ from thinkstack_core.rep_network.sync import _envelope_ids_in_ledger, _persist_pulled_id
48
+
49
+ def _existing_envelope_ids() -> set:
50
+ return _envelope_ids_in_ledger(ledger)
51
+
52
+ def _get_node_id() -> str:
53
+ try:
54
+ identity = registry.get_or_create_identity()
55
+ return identity.node_id
56
+ except Exception: # noqa: BLE001
57
+ return ""
58
+
59
+ # ------------------------------------------------------------------
60
+ # Routes
61
+ # ------------------------------------------------------------------
62
+
63
+ @app.get("/rep/entries")
64
+ def get_entries(since: int = 0) -> Dict[str, Any]:
65
+ """Return ledger entries with sequence_id > since."""
66
+ all_records = ledger.read_all()
67
+ filtered = [r for r in all_records if r.get("sequence_id", 0) > since]
68
+ entries = [r.get("envelope", {}) for r in filtered]
69
+ return {
70
+ "node_id": _get_node_id(),
71
+ "entries": entries,
72
+ "count": len(entries),
73
+ }
74
+
75
+ @app.post("/rep/entries")
76
+ def post_entries(request: PushEntriesRequest = Body(...)) -> Dict[str, Any]:
77
+ """Accept pushed entries; skip duplicates by envelope_id."""
78
+ existing_ids = _existing_envelope_ids()
79
+ accepted = 0
80
+ duplicate = 0
81
+ import datetime as _dt
82
+
83
+ for entry_dict in request.entries:
84
+ eid = entry_dict.get("envelope_id")
85
+ if eid and eid in existing_ids:
86
+ duplicate += 1
87
+ continue
88
+ try:
89
+ envelope = REPEnvelope.from_dict(entry_dict)
90
+ except Exception: # noqa: BLE001
91
+ continue
92
+ ts = entry_dict.get("timestamp", _dt.datetime.now(_dt.timezone.utc).isoformat())
93
+ ledger.append(envelope, timestamp=ts)
94
+ if eid:
95
+ existing_ids.add(eid)
96
+ _persist_pulled_id(ledger, eid)
97
+ accepted += 1
98
+
99
+ # Update last_seen for sender
100
+ if request.sender_id:
101
+ try:
102
+ registry.update_peer_last_seen(request.sender_id)
103
+ except Exception: # noqa: BLE001
104
+ pass
105
+
106
+ return {"accepted": accepted, "duplicate": duplicate}
107
+
108
+ @app.get("/rep/peers")
109
+ def get_peers() -> List[Dict[str, Any]]:
110
+ peers = registry.list_peers()
111
+ return [
112
+ {
113
+ "node_id": p.node_id,
114
+ "name": p.name,
115
+ "url": p.url,
116
+ "last_seen": p.last_seen,
117
+ }
118
+ for p in peers
119
+ ]
120
+
121
+ @app.post("/rep/peers")
122
+ def post_peer(body: PeerEntryModel = Body(...)) -> Dict[str, str]:
123
+ peer = PeerEntry(
124
+ node_id=body.node_id,
125
+ name=body.name,
126
+ url=body.url,
127
+ last_seen=body.last_seen,
128
+ )
129
+ registry.add_peer(peer)
130
+ return {"status": "ok"}
131
+
132
+ @app.get("/health")
133
+ def health() -> Dict[str, Any]:
134
+ return {
135
+ "status": "ok",
136
+ "node_id": _get_node_id(),
137
+ "entry_count": len(ledger.read_all()),
138
+ }
139
+
140
+ return app