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