gitcast 1.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 (61) hide show
  1. ai/__init__.py +0 -0
  2. ai/formatter.py +59 -0
  3. ai/generator.py +604 -0
  4. ai/prompts.py +197 -0
  5. ai/viral_patterns.py +75 -0
  6. api/__init__.py +0 -0
  7. api/analytics.py +48 -0
  8. api/auth.py +49 -0
  9. api/auth_middleware.py +129 -0
  10. api/auth_routes.py +117 -0
  11. api/monitoring.py +56 -0
  12. api/payload.py +253 -0
  13. api/ratelimit.py +9 -0
  14. api/routes.py +1565 -0
  15. api/server.py +162 -0
  16. api/validators.py +101 -0
  17. assets/__init__.py +1 -0
  18. assets/favicon-16x16.png +0 -0
  19. assets/favicon-32x32.png +0 -0
  20. assets/favicon-64x64.png +0 -0
  21. assets/favicon.ico +0 -0
  22. assets/icon.png +0 -0
  23. cli/.env.example +26 -0
  24. cli/__init__.py +1 -0
  25. cli/gitcast.py +79 -0
  26. config/__init__.py +0 -0
  27. config/settings.py +213 -0
  28. core/__init__.py +0 -0
  29. core/capture.py +258 -0
  30. core/codebase_reader.py +90 -0
  31. core/framing.py +86 -0
  32. core/hotkey.py +21 -0
  33. core/log_stream.py +50 -0
  34. core/ocr.py +173 -0
  35. core/screenshot_session.py +274 -0
  36. core/security.py +126 -0
  37. core/tray.py +54 -0
  38. gitcast-1.0.0.dist-info/LICENSE +21 -0
  39. gitcast-1.0.0.dist-info/METADATA +67 -0
  40. gitcast-1.0.0.dist-info/RECORD +61 -0
  41. gitcast-1.0.0.dist-info/WHEEL +5 -0
  42. gitcast-1.0.0.dist-info/entry_points.txt +2 -0
  43. gitcast-1.0.0.dist-info/top_level.txt +10 -0
  44. publisher/__init__.py +0 -0
  45. publisher/clipboard.py +44 -0
  46. publisher/twitter.py +100 -0
  47. storage/__init__.py +0 -0
  48. storage/cleanup.py +60 -0
  49. storage/engagement.py +114 -0
  50. storage/insights.py +203 -0
  51. storage/key_manager.py +45 -0
  52. storage/logger.py +208 -0
  53. storage/metrics.py +119 -0
  54. storage/sprint.py +40 -0
  55. storage/streak.py +0 -0
  56. storage/supabase_client.py +25 -0
  57. storage/tone_memory.py +139 -0
  58. ui/__init__.py +0 -0
  59. web/__init__.py +1 -0
  60. web/index.html +4994 -0
  61. web/landing.html +925 -0
storage/logger.py ADDED
@@ -0,0 +1,208 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import Optional
3
+ from uuid import UUID
4
+
5
+ from storage.supabase_client import get_client
6
+
7
+
8
+ def _is_supabase_user(user_id: Optional[str]) -> bool:
9
+ try:
10
+ UUID(str(user_id))
11
+ return True
12
+ except (TypeError, ValueError):
13
+ return False
14
+
15
+
16
+ def _normalize_entry(entry: dict) -> dict:
17
+ timestamp = entry.get("timestamp") or entry.get("created_at") or ""
18
+ tweet_url = entry.get("tweet_url", "") or ""
19
+ return {
20
+ **entry,
21
+ "id": str(entry.get("id", "")),
22
+ "timestamp": timestamp,
23
+ "posted_verified": bool(entry.get("posted_verified", False)),
24
+ "posted_declined": bool(entry.get("declined", False)),
25
+ "post_url": tweet_url,
26
+ "verified_at": entry.get("verified_at", "") or "",
27
+ "metrics": {
28
+ "impressions": int(entry.get("impressions") or 0),
29
+ "likes": int(entry.get("likes") or 0),
30
+ "comments": int(entry.get("comments") or 0),
31
+ "reposts": int(entry.get("reposts") or 0),
32
+ "hashtags": entry.get("hashtags") or [],
33
+ "platform": entry.get("platform") or "",
34
+ "days_after_post": int(entry.get("days_after_post") or 0),
35
+ "measured_at": entry.get("metrics_saved_at") or "",
36
+ } if entry.get("metrics_saved") else {},
37
+ }
38
+
39
+
40
+ def save_posts(posts: list, user_id: Optional[str] = None) -> None:
41
+ print("[Logger] save_posts is deprecated for Supabase storage")
42
+
43
+
44
+ def log_post(
45
+ post_text: str,
46
+ format_key: str,
47
+ screenshot_path: str,
48
+ tweet_url: str = "",
49
+ tweet_id: str = "",
50
+ fallback: bool = False,
51
+ timestamp: str = "",
52
+ user_id: Optional[str] = None,
53
+ provider_used: str = "",
54
+ ) -> Optional[str]:
55
+ if not user_id:
56
+ raise ValueError("user_id is required")
57
+ if not _is_supabase_user(user_id):
58
+ print("[Logger] Local user detected; skipping Supabase post log")
59
+ return None
60
+
61
+ payload = {
62
+ "user_id": user_id,
63
+ "post_text": post_text,
64
+ "format_key": format_key,
65
+ "provider_used": provider_used or ("fallback" if fallback else ""),
66
+ "platform": "twitter",
67
+ "tweet_url": tweet_url,
68
+ "tweet_id": tweet_id,
69
+ }
70
+ if timestamp:
71
+ payload["timestamp"] = timestamp
72
+
73
+ try:
74
+ response = get_client().table("posts").insert(payload).execute()
75
+ row = response.data[0] if response.data else {}
76
+ print("[Logger] Post metadata logged to Supabase")
77
+ return row.get("id")
78
+ except Exception as e:
79
+ print(f"[Logger] Failed to log post metadata: {e}")
80
+ return None
81
+
82
+
83
+ def load_posts(user_id: str) -> list:
84
+ if not _is_supabase_user(user_id):
85
+ return []
86
+
87
+ try:
88
+ response = (
89
+ get_client()
90
+ .table("posts")
91
+ .select("*")
92
+ .eq("user_id", user_id)
93
+ .order("timestamp", desc=True)
94
+ .execute()
95
+ )
96
+ return [_normalize_entry(entry) for entry in (response.data or [])]
97
+ except Exception as e:
98
+ print(f"[Logger] Failed to load posts: {e}")
99
+ return []
100
+
101
+
102
+ def verify_post(post_id: str, post_url: str = "", user_id: Optional[str] = None) -> dict:
103
+ if not user_id:
104
+ return {"success": False, "error": "user_id is required"}
105
+ if not _is_supabase_user(user_id):
106
+ return {"success": False, "error": "local history is not backed by Supabase"}
107
+
108
+ payload = {
109
+ "posted_verified": True,
110
+ "declined": False,
111
+ "verified_at": datetime.now().isoformat(),
112
+ }
113
+ if post_url:
114
+ payload["tweet_url"] = post_url
115
+
116
+ response = (
117
+ get_client()
118
+ .table("posts")
119
+ .update(payload)
120
+ .eq("id", post_id)
121
+ .eq("user_id", user_id)
122
+ .execute()
123
+ )
124
+ if response.data:
125
+ return {"success": True, "post_id": post_id}
126
+ return {"success": False, "error": "post not found"}
127
+
128
+
129
+ def decline_post(post_id: str, user_id: Optional[str] = None) -> dict:
130
+ if not user_id:
131
+ return {"success": False, "error": "user_id is required"}
132
+ if not _is_supabase_user(user_id):
133
+ return {"success": False, "error": "local history is not backed by Supabase"}
134
+
135
+ response = (
136
+ get_client()
137
+ .table("posts")
138
+ .update({"declined": True})
139
+ .eq("id", post_id)
140
+ .eq("user_id", user_id)
141
+ .execute()
142
+ )
143
+ if response.data:
144
+ return {"success": True, "post_id": post_id}
145
+ return {"success": False, "error": "post not found"}
146
+
147
+
148
+ def get_unverified_posts(user_id: str) -> list:
149
+ posts = [
150
+ entry for entry in load_posts(user_id)
151
+ if not entry.get("posted_verified") and not entry.get("posted_declined")
152
+ ]
153
+ return sorted(posts, key=lambda item: item.get("timestamp", ""), reverse=True)
154
+
155
+
156
+ def get_streak(user_id: str) -> dict:
157
+ posts = [post for post in load_posts(user_id) if not post.get("posted_declined")]
158
+ if not posts:
159
+ return {"current_streak": 0, "best_streak": 0, "total_posts": 0, "last_post_date": ""}
160
+
161
+ post_dates = set()
162
+ for entry in posts:
163
+ try:
164
+ post_dates.add(datetime.fromisoformat(str(entry["timestamp"]).replace("Z", "+00:00")).date())
165
+ except (KeyError, ValueError):
166
+ continue
167
+
168
+ if not post_dates:
169
+ return {"current_streak": 0, "best_streak": 0, "total_posts": len(posts), "last_post_date": ""}
170
+
171
+ sorted_dates = sorted(post_dates, reverse=True)
172
+ last_post_date = sorted_dates[0]
173
+ today = datetime.now().date()
174
+ if last_post_date < today - timedelta(days=1):
175
+ return {
176
+ "current_streak": 0,
177
+ "best_streak": 0,
178
+ "total_posts": len(posts),
179
+ "last_post_date": str(last_post_date),
180
+ }
181
+
182
+ streak = 1
183
+ for i in range(1, len(sorted_dates)):
184
+ if sorted_dates[i] == sorted_dates[i - 1] - timedelta(days=1):
185
+ streak += 1
186
+ else:
187
+ break
188
+
189
+ best_streak = 1
190
+ running = 1
191
+ for i in range(1, len(sorted_dates)):
192
+ if sorted_dates[i] == sorted_dates[i - 1] - timedelta(days=1):
193
+ running += 1
194
+ else:
195
+ best_streak = max(best_streak, running)
196
+ running = 1
197
+ best_streak = max(best_streak, running)
198
+
199
+ return {
200
+ "current_streak": streak,
201
+ "best_streak": best_streak,
202
+ "total_posts": len(posts),
203
+ "last_post_date": str(last_post_date),
204
+ }
205
+
206
+
207
+ if __name__ == "__main__":
208
+ print("[Logger] Supabase logger module loaded")
storage/metrics.py ADDED
@@ -0,0 +1,119 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+ from uuid import UUID
4
+
5
+ from storage.supabase_client import get_client
6
+
7
+
8
+ def _is_supabase_user(user_id: Optional[str]) -> bool:
9
+ try:
10
+ UUID(str(user_id))
11
+ return True
12
+ except (TypeError, ValueError):
13
+ return False
14
+
15
+
16
+ def _normalise_metrics(post_id: str, metrics: dict) -> dict:
17
+ return {
18
+ "post_id": post_id,
19
+ "impressions": int(metrics.get("impressions", 0)),
20
+ "likes": int(metrics.get("likes", 0)),
21
+ "comments": int(metrics.get("comments", 0)),
22
+ "reposts": int(metrics.get("reposts", 0)),
23
+ "hashtags": [str(tag).strip()[:40] for tag in metrics.get("hashtags", []) if str(tag).strip()][:10],
24
+ "platform": str(metrics.get("platform", "")).strip()[:40],
25
+ "measured_at": metrics.get("measured_at") or datetime.now().isoformat(),
26
+ "days_after_post": int(metrics.get("days_after_post", 1)),
27
+ }
28
+
29
+
30
+ def save_metrics(post_id: str, metrics: dict, user_id: Optional[str] = None) -> dict:
31
+ if not user_id:
32
+ return {"success": False, "error": "user_id is required"}
33
+ if not _is_supabase_user(user_id):
34
+ return {"success": False, "error": "local metrics are not backed by Supabase"}
35
+
36
+ entry = _normalise_metrics(post_id, metrics)
37
+ payload = {
38
+ "impressions": entry["impressions"],
39
+ "likes": entry["likes"],
40
+ "comments": entry["comments"],
41
+ "reposts": entry["reposts"],
42
+ "hashtags": entry["hashtags"],
43
+ "platform": entry["platform"] or "twitter",
44
+ "days_after_post": entry["days_after_post"],
45
+ "metrics_saved": True,
46
+ "metrics_saved_at": entry["measured_at"],
47
+ }
48
+ response = (
49
+ get_client()
50
+ .table("posts")
51
+ .update(payload)
52
+ .eq("id", post_id)
53
+ .eq("user_id", user_id)
54
+ .execute()
55
+ )
56
+ if not response.data:
57
+ return {"success": False, "error": "post not found"}
58
+ return {"success": True}
59
+
60
+
61
+ def get_all_metrics(user_id: Optional[str] = None) -> list:
62
+ if not user_id or not _is_supabase_user(user_id):
63
+ return []
64
+
65
+ response = (
66
+ get_client()
67
+ .table("posts")
68
+ .select("id,impressions,likes,comments,reposts,hashtags,platform,metrics_saved_at,days_after_post")
69
+ .eq("user_id", user_id)
70
+ .eq("metrics_saved", True)
71
+ .execute()
72
+ )
73
+ entries = []
74
+ for row in response.data or []:
75
+ entries.append({
76
+ "post_id": row["id"],
77
+ "impressions": row.get("impressions") or 0,
78
+ "likes": row.get("likes") or 0,
79
+ "comments": row.get("comments") or 0,
80
+ "reposts": row.get("reposts") or 0,
81
+ "hashtags": row.get("hashtags") or [],
82
+ "platform": row.get("platform") or "",
83
+ "measured_at": row.get("metrics_saved_at") or "",
84
+ "days_after_post": row.get("days_after_post") or 0,
85
+ })
86
+ return entries
87
+
88
+
89
+ def get_metrics(post_id: str, user_id: Optional[str] = None) -> dict:
90
+ if not user_id or not _is_supabase_user(user_id):
91
+ return {}
92
+
93
+ response = (
94
+ get_client()
95
+ .table("posts")
96
+ .select("id,impressions,likes,comments,reposts,hashtags,platform,metrics_saved_at,days_after_post")
97
+ .eq("id", post_id)
98
+ .eq("user_id", user_id)
99
+ .eq("metrics_saved", True)
100
+ .execute()
101
+ )
102
+ if not response.data:
103
+ return {}
104
+ row = response.data[0]
105
+ return {
106
+ "post_id": row["id"],
107
+ "impressions": row.get("impressions") or 0,
108
+ "likes": row.get("likes") or 0,
109
+ "comments": row.get("comments") or 0,
110
+ "reposts": row.get("reposts") or 0,
111
+ "hashtags": row.get("hashtags") or [],
112
+ "platform": row.get("platform") or "",
113
+ "measured_at": row.get("metrics_saved_at") or "",
114
+ "days_after_post": row.get("days_after_post") or 0,
115
+ }
116
+
117
+
118
+ if __name__ == "__main__":
119
+ print("[Metrics] Supabase metrics module loaded")
storage/sprint.py ADDED
@@ -0,0 +1,40 @@
1
+ import json
2
+ from config.settings import SPRINT_LOG
3
+
4
+
5
+ def log_sprint_capture(
6
+ git_diff: str,
7
+ ocr_text: str,
8
+ raw_thought: str,
9
+ timestamp: str,
10
+ ) -> None:
11
+ entry = {
12
+ "timestamp": timestamp,
13
+ "raw_thought": raw_thought,
14
+ "git_diff": git_diff[:1000],
15
+ "ocr_text": ocr_text[:500],
16
+ }
17
+ with open(SPRINT_LOG, "a", encoding="utf-8") as f:
18
+ f.write(json.dumps(entry) + "\n")
19
+ print(f"[Sprint] Capture logged to {SPRINT_LOG}")
20
+
21
+
22
+ def load_sprint_log() -> list[dict]:
23
+ if not SPRINT_LOG.exists():
24
+ return []
25
+ entries = []
26
+ with open(SPRINT_LOG, "r", encoding="utf-8") as f:
27
+ for line in f:
28
+ line = line.strip()
29
+ if line:
30
+ try:
31
+ entries.append(json.loads(line))
32
+ except json.JSONDecodeError:
33
+ continue
34
+ return entries
35
+
36
+
37
+ def clear_sprint_log() -> None:
38
+ if SPRINT_LOG.exists():
39
+ SPRINT_LOG.unlink()
40
+ print("[Sprint] Sprint log cleared.")
storage/streak.py ADDED
File without changes
@@ -0,0 +1,25 @@
1
+ import threading
2
+ from supabase import Client, create_client, ClientOptions
3
+ from config.settings import SUPABASE_SERVICE_KEY, SUPABASE_URL
4
+
5
+ _thread_local = threading.local()
6
+
7
+ def get_client() -> Client:
8
+ if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
9
+ raise RuntimeError("SUPABASE_URL and SUPABASE_SERVICE_KEY must be configured")
10
+
11
+ if not hasattr(_thread_local, "client"):
12
+ _thread_local.client = create_client(
13
+ SUPABASE_URL,
14
+ SUPABASE_SERVICE_KEY,
15
+ options=ClientOptions(
16
+ postgrest_client_timeout=10,
17
+ storage_client_timeout=10
18
+ )
19
+ )
20
+ return _thread_local.client
21
+
22
+
23
+ if __name__ == "__main__":
24
+ client = get_client()
25
+ print(f"[Supabase] Client initialized: {bool(client)}")
storage/tone_memory.py ADDED
@@ -0,0 +1,139 @@
1
+ import json
2
+ import time
3
+ from pathlib import Path
4
+ from config.settings import TONE_LOG
5
+
6
+ # [ToneMemory] module for RLHF-style feedback and few-shot example selection
7
+
8
+ def save_rating(post_text: str, format_key: str, rating: int, timestamp: str = ""):
9
+ """Appends a post rating to TONE_LOG as newline-delimited JSON."""
10
+ if not timestamp:
11
+ timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
12
+
13
+ entry = {
14
+ "timestamp": timestamp,
15
+ "format_key": format_key,
16
+ "post_text": post_text,
17
+ "rating": rating,
18
+ "engagement": {
19
+ "likes": 0,
20
+ "retweets": 0,
21
+ "replies": 0,
22
+ "impressions": 0
23
+ }
24
+ }
25
+
26
+ try:
27
+ with open(TONE_LOG, "a", encoding="utf-8") as f:
28
+ f.write(json.dumps(entry) + "\n")
29
+ print(f"[ToneMemory] Saved rating {rating} for {format_key}")
30
+ except Exception as e:
31
+ print(f"[ToneMemory] Error saving rating: {e}")
32
+
33
+ def load_ratings():
34
+ """Returns all entries from TONE_LOG."""
35
+ if not TONE_LOG.exists():
36
+ return []
37
+
38
+ entries = []
39
+ try:
40
+ with open(TONE_LOG, "r", encoding="utf-8") as f:
41
+ for line in f:
42
+ if line.strip():
43
+ entries.append(json.loads(line))
44
+ except Exception as e:
45
+ print(f"[ToneMemory] Error loading ratings: {e}")
46
+
47
+ return entries
48
+
49
+ def get_few_shot_examples(format_key: str = None, top_n: int = 3) -> list[str]:
50
+ """
51
+ Returns top_n post_text strings with highest rating for format_key.
52
+ If format_key is None, returns top examples across all formats.
53
+ """
54
+ entries = load_ratings()
55
+ if not entries:
56
+ return []
57
+
58
+ if format_key:
59
+ filtered = [e for e in entries if e.get("format_key") == format_key]
60
+ else:
61
+ filtered = entries
62
+
63
+ # Sort by rating descending
64
+ sorted_entries = sorted(filtered, key=lambda x: x.get("rating", 0), reverse=True)
65
+ return [e["post_text"] for e in sorted_entries[:top_n]]
66
+
67
+ def update_engagement(post_text: str, likes: int, retweets: int, replies: int):
68
+ """
69
+ Finds matching entry in TONE_LOG, updates with engagement metrics.
70
+ Higher engagement boosts effective rating weight.
71
+ """
72
+ entries = load_ratings()
73
+ updated = False
74
+
75
+ # We use post_text as a loose identifier; in a real DB we'd use a UUID
76
+ for e in entries:
77
+ if e["post_text"] == post_text:
78
+ e["engagement"]["likes"] = likes
79
+ e["engagement"]["retweets"] = retweets
80
+ e["engagement"]["replies"] = replies
81
+ updated = True
82
+ break
83
+
84
+ if updated:
85
+ try:
86
+ with open(TONE_LOG, "w", encoding="utf-8") as f:
87
+ for e in entries:
88
+ f.write(json.dumps(e) + "\n")
89
+ print("[ToneMemory] Updated engagement metrics for post")
90
+ except Exception as e:
91
+ print(f"[ToneMemory] Error updating engagement: {e}")
92
+
93
+ def get_weighted_examples(format_key: str, top_n: int = 3) -> list[str]:
94
+ """
95
+ Combines explicit rating + engagement metrics into weighted score.
96
+ Returns top_n examples by weighted score.
97
+ Score = rating + (likes * 0.5) + (retweets * 1.0) + (replies * 2.0)
98
+ """
99
+ entries = load_ratings()
100
+ if not entries:
101
+ return []
102
+
103
+ filtered = [e for e in entries if e.get("format_key") == format_key]
104
+
105
+ def calculate_score(entry):
106
+ r = entry.get("rating", 0)
107
+ eng = entry.get("engagement", {})
108
+ score = r + (eng.get("likes", 0) * 0.5) + (eng.get("retweets", 0) * 1.0) + (eng.get("replies", 0) * 2.0)
109
+ return score
110
+
111
+ sorted_entries = sorted(filtered, key=calculate_score, reverse=True)
112
+ return [e["post_text"] for e in sorted_entries[:top_n]]
113
+
114
+ if __name__ == "__main__":
115
+ print("=== TONE MEMORY TEST ===")
116
+
117
+ # Clear and test
118
+ if TONE_LOG.exists():
119
+ TONE_LOG.unlink()
120
+
121
+ save_rating("Example post 1", "deep_tech", 5)
122
+ save_rating("Example post 2", "deep_tech", 3)
123
+ save_rating("Example post 3", "deep_tech", 4)
124
+ save_rating("Example post 4", "struggle", 5)
125
+
126
+ examples = get_few_shot_examples("deep_tech", top_n=2)
127
+ print(f"Top 2 Deep Tech examples: {examples}")
128
+ assert len(examples) == 2
129
+ assert examples[0] == "Example post 1"
130
+
131
+ # Test engagement boost
132
+ update_engagement("Example post 3", likes=10, retweets=5, replies=2)
133
+ weighted = get_weighted_examples("deep_tech", top_n=1)
134
+ print(f"Top weighted Deep Tech: {weighted}")
135
+ # Score for post 1: 5
136
+ # Score for post 3: 4 + (10*0.5) + (5*1.0) + (2*2.0) = 4 + 5 + 5 + 4 = 18
137
+ assert weighted[0] == "Example post 3"
138
+
139
+ print("Tone Memory tests: PASSED")
ui/__init__.py ADDED
File without changes
web/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # Make web directory a Python package so it gets packaged and installed to site-packages.