gitcast 1.0.18__tar.gz → 1.0.20__tar.gz

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 (90) hide show
  1. {gitcast-1.0.18/gitcast.egg-info → gitcast-1.0.20}/PKG-INFO +2 -1
  2. {gitcast-1.0.18 → gitcast-1.0.20}/ai/generator.py +9 -1
  3. {gitcast-1.0.18 → gitcast-1.0.20}/ai/prompts.py +1 -0
  4. {gitcast-1.0.18 → gitcast-1.0.20}/api/routes.py +53 -45
  5. {gitcast-1.0.18 → gitcast-1.0.20}/cli/gitcast.py +6 -2
  6. {gitcast-1.0.18 → gitcast-1.0.20}/core/hotkey.py +9 -0
  7. {gitcast-1.0.18 → gitcast-1.0.20}/core/screenshot_session.py +0 -1
  8. {gitcast-1.0.18 → gitcast-1.0.20}/core/tray.py +1 -1
  9. gitcast-1.0.20/core/trigger.py +158 -0
  10. {gitcast-1.0.18 → gitcast-1.0.20/gitcast.egg-info}/PKG-INFO +2 -1
  11. {gitcast-1.0.18 → gitcast-1.0.20}/gitcast.egg-info/SOURCES.txt +1 -0
  12. {gitcast-1.0.18 → gitcast-1.0.20}/gitcast.egg-info/requires.txt +1 -0
  13. {gitcast-1.0.18 → gitcast-1.0.20}/requirements.txt +1 -0
  14. {gitcast-1.0.18 → gitcast-1.0.20}/setup.py +1 -1
  15. gitcast-1.0.20/tests/test_validation.py +50 -0
  16. {gitcast-1.0.18 → gitcast-1.0.20}/ui/popup.py +6 -4
  17. {gitcast-1.0.18 → gitcast-1.0.20}/ui/review.py +0 -1
  18. {gitcast-1.0.18 → gitcast-1.0.20}/web/index.html +364 -63
  19. gitcast-1.0.18/core/trigger.py +0 -122
  20. {gitcast-1.0.18 → gitcast-1.0.20}/.env.example +0 -0
  21. {gitcast-1.0.18 → gitcast-1.0.20}/LICENSE +0 -0
  22. {gitcast-1.0.18 → gitcast-1.0.20}/MANIFEST.in +0 -0
  23. {gitcast-1.0.18 → gitcast-1.0.20}/README.md +0 -0
  24. {gitcast-1.0.18 → gitcast-1.0.20}/ai/__init__.py +0 -0
  25. {gitcast-1.0.18 → gitcast-1.0.20}/ai/formatter.py +0 -0
  26. {gitcast-1.0.18 → gitcast-1.0.20}/ai/viral_patterns.py +0 -0
  27. {gitcast-1.0.18 → gitcast-1.0.20}/api/__init__.py +0 -0
  28. {gitcast-1.0.18 → gitcast-1.0.20}/api/analytics.py +0 -0
  29. {gitcast-1.0.18 → gitcast-1.0.20}/api/auth.py +0 -0
  30. {gitcast-1.0.18 → gitcast-1.0.20}/api/auth_middleware.py +0 -0
  31. {gitcast-1.0.18 → gitcast-1.0.20}/api/auth_routes.py +0 -0
  32. {gitcast-1.0.18 → gitcast-1.0.20}/api/monitoring.py +0 -0
  33. {gitcast-1.0.18 → gitcast-1.0.20}/api/payload.py +0 -0
  34. {gitcast-1.0.18 → gitcast-1.0.20}/api/ratelimit.py +0 -0
  35. {gitcast-1.0.18 → gitcast-1.0.20}/api/server.py +0 -0
  36. {gitcast-1.0.18 → gitcast-1.0.20}/api/validators.py +0 -0
  37. {gitcast-1.0.18 → gitcast-1.0.20}/assets/__init__.py +0 -0
  38. {gitcast-1.0.18 → gitcast-1.0.20}/assets/favicon-16x16.png +0 -0
  39. {gitcast-1.0.18 → gitcast-1.0.20}/assets/favicon-32x32.png +0 -0
  40. {gitcast-1.0.18 → gitcast-1.0.20}/assets/favicon-64x64.png +0 -0
  41. {gitcast-1.0.18 → gitcast-1.0.20}/assets/favicon.ico +0 -0
  42. {gitcast-1.0.18 → gitcast-1.0.20}/assets/icon.png +0 -0
  43. {gitcast-1.0.18 → gitcast-1.0.20}/cli/.env.example +0 -0
  44. {gitcast-1.0.18 → gitcast-1.0.20}/cli/__init__.py +0 -0
  45. {gitcast-1.0.18 → gitcast-1.0.20}/config/__init__.py +0 -0
  46. {gitcast-1.0.18 → gitcast-1.0.20}/config/settings.py +0 -0
  47. {gitcast-1.0.18 → gitcast-1.0.20}/core/__init__.py +0 -0
  48. {gitcast-1.0.18 → gitcast-1.0.20}/core/capture.py +0 -0
  49. {gitcast-1.0.18 → gitcast-1.0.20}/core/codebase_reader.py +0 -0
  50. {gitcast-1.0.18 → gitcast-1.0.20}/core/framing.py +0 -0
  51. {gitcast-1.0.18 → gitcast-1.0.20}/core/log_stream.py +0 -0
  52. {gitcast-1.0.18 → gitcast-1.0.20}/core/ocr.py +0 -0
  53. {gitcast-1.0.18 → gitcast-1.0.20}/core/security.py +0 -0
  54. {gitcast-1.0.18 → gitcast-1.0.20}/docs/api-keys.md +0 -0
  55. {gitcast-1.0.18 → gitcast-1.0.20}/docs/article-generator.md +0 -0
  56. {gitcast-1.0.18 → gitcast-1.0.20}/docs/capture-flow.md +0 -0
  57. {gitcast-1.0.18 → gitcast-1.0.20}/docs/changelog.md +0 -0
  58. {gitcast-1.0.18 → gitcast-1.0.20}/docs/contributing.md +0 -0
  59. {gitcast-1.0.18 → gitcast-1.0.20}/docs/dashboard.md +0 -0
  60. {gitcast-1.0.18 → gitcast-1.0.20}/docs/insights.md +0 -0
  61. {gitcast-1.0.18 → gitcast-1.0.20}/docs/introduction.md +0 -0
  62. {gitcast-1.0.18 → gitcast-1.0.20}/docs/post-formats.md +0 -0
  63. {gitcast-1.0.18 → gitcast-1.0.20}/docs/providers.md +0 -0
  64. {gitcast-1.0.18 → gitcast-1.0.20}/docs/pypi-errors.md +0 -0
  65. {gitcast-1.0.18 → gitcast-1.0.20}/docs/quickstart.md +0 -0
  66. {gitcast-1.0.18 → gitcast-1.0.20}/docs/sprint-mode.md +0 -0
  67. {gitcast-1.0.18 → gitcast-1.0.20}/gitcast.egg-info/dependency_links.txt +0 -0
  68. {gitcast-1.0.18 → gitcast-1.0.20}/gitcast.egg-info/entry_points.txt +0 -0
  69. {gitcast-1.0.18 → gitcast-1.0.20}/gitcast.egg-info/top_level.txt +0 -0
  70. {gitcast-1.0.18 → gitcast-1.0.20}/publisher/__init__.py +0 -0
  71. {gitcast-1.0.18 → gitcast-1.0.20}/publisher/clipboard.py +0 -0
  72. {gitcast-1.0.18 → gitcast-1.0.20}/publisher/twitter.py +0 -0
  73. {gitcast-1.0.18 → gitcast-1.0.20}/pyproject.toml +0 -0
  74. {gitcast-1.0.18 → gitcast-1.0.20}/setup.cfg +0 -0
  75. {gitcast-1.0.18 → gitcast-1.0.20}/storage/__init__.py +0 -0
  76. {gitcast-1.0.18 → gitcast-1.0.20}/storage/cleanup.py +0 -0
  77. {gitcast-1.0.18 → gitcast-1.0.20}/storage/engagement.py +0 -0
  78. {gitcast-1.0.18 → gitcast-1.0.20}/storage/insights.py +0 -0
  79. {gitcast-1.0.18 → gitcast-1.0.20}/storage/key_manager.py +0 -0
  80. {gitcast-1.0.18 → gitcast-1.0.20}/storage/logger.py +0 -0
  81. {gitcast-1.0.18 → gitcast-1.0.20}/storage/metrics.py +0 -0
  82. {gitcast-1.0.18 → gitcast-1.0.20}/storage/sprint.py +0 -0
  83. {gitcast-1.0.18 → gitcast-1.0.20}/storage/streak.py +0 -0
  84. {gitcast-1.0.18 → gitcast-1.0.20}/storage/supabase_client.py +0 -0
  85. {gitcast-1.0.18 → gitcast-1.0.20}/storage/tone_memory.py +0 -0
  86. {gitcast-1.0.18 → gitcast-1.0.20}/tests/test_keys.py +0 -0
  87. {gitcast-1.0.18 → gitcast-1.0.20}/tests/test_ocr_fix.py +0 -0
  88. {gitcast-1.0.18 → gitcast-1.0.20}/ui/__init__.py +0 -0
  89. {gitcast-1.0.18 → gitcast-1.0.20}/web/__init__.py +0 -0
  90. {gitcast-1.0.18 → gitcast-1.0.20}/web/landing.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitcast
3
- Version: 1.0.18
3
+ Version: 1.0.20
4
4
  Summary: git diff → published post. under 60 seconds.
5
5
  Home-page: https://github.com/drizzy765/gitcast
6
6
  Author: Timilehin Agoro
@@ -29,6 +29,7 @@ Requires-Dist: slowapi
29
29
  Requires-Dist: limits
30
30
  Requires-Dist: sentry-sdk[fastapi]
31
31
  Requires-Dist: posthog
32
+ Requires-Dist: plyer
32
33
  Dynamic: author
33
34
  Dynamic: author-email
34
35
  Dynamic: description
@@ -1,3 +1,4 @@
1
+ import os
1
2
  import asyncio
2
3
  import httpx
3
4
  import uuid
@@ -11,6 +12,7 @@ from config.settings import (
11
12
  MOONSHOT_API_KEY,
12
13
  CEREBRAS_API_KEY,
13
14
  OPENROUTER_API_KEY,
15
+ DEEPSEEK_API_KEY,
14
16
  GROQ_MODEL,
15
17
  MOONSHOT_MODEL,
16
18
  GEMINI_MODEL,
@@ -25,7 +27,7 @@ PROVIDERS = {
25
27
  "base_url": "https://api.groq.com/openai/v1",
26
28
  "api_key": GROQ_API_KEY,
27
29
  "model": GROQ_MODEL,
28
- "tasks": ["quick_win", "struggle", "linkedin", "deep_tech", "pr_generator"]
30
+ "tasks": ["quick_win", "struggle", "linkedin", "deep_tech", "pr_generator", "x_post"]
29
31
  },
30
32
  "kimi": {
31
33
  "base_url": "https://api.moonshot.cn/v1",
@@ -33,6 +35,12 @@ PROVIDERS = {
33
35
  "model": MOONSHOT_MODEL,
34
36
  "tasks": ["article", "sprint_summary"]
35
37
  },
38
+ "deepseek": {
39
+ "base_url": os.getenv("DEEPSEEK_API_BASE", "https://api.deepseek.com/v1"),
40
+ "api_key": DEEPSEEK_API_KEY,
41
+ "model": os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
42
+ "tasks": ["pr_desc"]
43
+ },
36
44
  "cerebras": {
37
45
  "base_url": "https://api.cerebras.ai/v1",
38
46
  "api_key": CEREBRAS_API_KEY,
@@ -65,6 +65,7 @@ Rules:
65
65
  - No generic filler: "excited to share", "game changer",
66
66
  "cutting-edge", "pushing boundaries", "unparalleled",
67
67
  "the intersection of X and Y", "delving into".
68
+ - NEVER use marketing-heavy, hype-filled, or clickbait phrases like "Revolutionizing Snake Game Development", "unprecedented performance gains", "Join the conversation", or similar corporate/AI clichés.
68
69
  - Keep it grounded and specific. Vague posts get ignored.
69
70
  - If there is a code snippet in the context, reference
70
71
  the actual function name, variable, or error.
@@ -132,8 +132,10 @@ class ProviderKeyRemove(BaseModel):
132
132
 
133
133
 
134
134
  class ChatRequest(BaseModel):
135
- message: str
135
+ instruction: str
136
+ current_post: str
136
137
  format_key: str
138
+ tab: Optional[str] = None
137
139
 
138
140
 
139
141
  class CliTriggerRequest(BaseModel):
@@ -859,62 +861,68 @@ def get_current_draft():
859
861
  raise HTTPException(status_code=500, detail=f"Error reading draft: {e}")
860
862
 
861
863
 
864
+ REFINE_PROVIDER = {
865
+ "article": "kimi",
866
+ "x_post": "groq",
867
+ "quick_win": "groq",
868
+ "linkedin": "groq",
869
+ "pr_desc": "deepseek",
870
+ }
871
+
872
+
862
873
  @router.post("/chat")
863
874
  @limiter.limit("20/minute")
864
875
  async def chat_refine(request: Request, body: ChatRequest, user_id: str = Depends(get_current_user)):
865
876
  """Refines a post variation using AI chat."""
866
- if not CURRENT_DRAFT.exists():
867
- raise HTTPException(status_code=400, detail="No active draft to refine.")
877
+ instruction = sanitize_text(body.instruction)
878
+ current_post = body.current_post
879
+ format_key = sanitize_text(body.format_key)
868
880
 
869
- try:
870
- with open(CURRENT_DRAFT, "r", encoding="utf-8") as f:
871
- draft = json.load(f)
872
- except Exception as e:
873
- raise HTTPException(status_code=500, detail=f"Error reading draft: {e}")
881
+ refinement_system = f"""You are refining a
882
+ {format_key} post for a developer.
883
+ The user's instruction is: {instruction}
884
+ Apply it to the post below. Return ONLY the
885
+ refined post text no explanation, no preamble,
886
+ no markdown wrapper. Just the improved post."""
874
887
 
875
- message = sanitize_text(body.message)
876
- injection = check_prompt_injection(message)
877
- if not injection["safe"]:
878
- message = injection.get("sanitized", "")
879
- format_key = sanitize_text(body.format_key)
880
- current_text = draft["variations"].get(format_key, "")
881
-
882
- try:
883
- platform_prompt = get_prompt(format_key)
884
- except Exception as e:
885
- platform_prompt = f"Format/Platform key: {format_key}"
886
-
887
- refinement_system_prompt = (
888
- "You are a social media manager helping a developer refine a post.\n"
889
- f"The post format/platform is: '{format_key}'.\n"
890
- "Adhere to the platform rules and guidelines below:\n"
891
- "--- START PLATFORM RULES ---\n"
892
- f"{platform_prompt}\n"
893
- "--- END PLATFORM RULES ---\n\n"
894
- "The user will provide instructions on how to change the existing draft. "
895
- "Adhere strictly to the platform rules above while applying the user's changes."
896
- )
897
-
898
- refinement_user_message = (
899
- f"Original Context:\n{draft['payload']['user_message']}\n\n"
900
- f"Current Draft for '{format_key}':\n{current_text}\n\n"
901
- f"User Instruction: {message}\n\n"
902
- "Output ONLY the revised post text. No preamble."
903
- )
888
+ refinement_user = f"""Current post:
889
+ {current_post}
890
+
891
+ Instruction: {instruction}
892
+
893
+ Return the refined post now:"""
904
894
 
905
895
  try:
906
- new_text = await _ai_call(
896
+ refined_text = await _ai_call(
907
897
  format_key,
908
- refinement_system_prompt,
909
- refinement_user_message,
898
+ refinement_system,
899
+ refinement_user,
910
900
  user_id=user_id,
911
901
  )
912
902
 
913
- draft["variations"][format_key] = new_text
914
- with open(CURRENT_DRAFT, "w", encoding="utf-8") as f:
915
- json.dump(draft, f, indent=4)
916
-
917
- return {"success": True, "new_text": new_text}
903
+ if CURRENT_DRAFT.exists():
904
+ try:
905
+ with open(CURRENT_DRAFT, "r", encoding="utf-8") as f:
906
+ draft = json.load(f)
907
+
908
+ if "variations" not in draft:
909
+ draft["variations"] = {}
910
+
911
+ # Map incoming public format_key to backend variation key
912
+ variation_key = format_key
913
+ if format_key == "x_post":
914
+ variation_key = "deep_tech"
915
+ elif format_key == "pr_desc":
916
+ variation_key = "pr_generator"
917
+
918
+ draft["variations"][variation_key] = refined_text
919
+
920
+ with open(CURRENT_DRAFT, "w", encoding="utf-8") as f:
921
+ json.dump(draft, f, indent=4)
922
+ except Exception as e:
923
+ print(f"Error updating draft file: {e}")
924
+
925
+ return {"success": True, "new_text": refined_text, "refined_post": refined_text}
918
926
  except Exception as e:
919
927
  raise HTTPException(status_code=500, detail=f"AI refinement failed: {e}")
920
928
 
@@ -5,6 +5,8 @@ import subprocess
5
5
  import httpx
6
6
  import webbrowser
7
7
 
8
+ _browser_opened = False
9
+
8
10
  def main():
9
11
  if "--setup" in sys.argv:
10
12
  from config.settings import get_active_env_path
@@ -65,7 +67,10 @@ def main():
65
67
  time.sleep(0.5)
66
68
 
67
69
  # open browser
68
- webbrowser.open("http://localhost:8000")
70
+ global _browser_opened
71
+ if not _browser_opened:
72
+ webbrowser.open("http://localhost:8000")
73
+ _browser_opened = True
69
74
 
70
75
  # ASCII Logo
71
76
  print(" ██████╗ ██╗████████╗ ██████╗ █████╗ ███████╗████████╗")
@@ -116,7 +121,6 @@ def main():
116
121
 
117
122
  if response.status_code == 200:
118
123
  print("[Gitcast] Context captured. Opening Draft Room...")
119
- webbrowser.open("http://localhost:8000")
120
124
  else:
121
125
  print(f"[Gitcast Error] Failed to trigger: {response.text}")
122
126
 
@@ -1,10 +1,19 @@
1
1
  from pynput import keyboard
2
+ import time
2
3
 
3
4
  _listener = None
5
+ _last_trigger_time = 0
6
+ COOLDOWN_SECONDS = 3
4
7
 
5
8
  def start_hotkey_listener(callback):
6
9
  global _listener
7
10
  def on_activate():
11
+ global _last_trigger_time
12
+ now = time.time()
13
+ if now - _last_trigger_time < COOLDOWN_SECONDS:
14
+ print("[Hotkey] cooldown — ignoring rapid repeat")
15
+ return
16
+ _last_trigger_time = now
8
17
  callback()
9
18
 
10
19
  _listener = keyboard.GlobalHotKeys({
@@ -230,7 +230,6 @@ class ScreenshotSession:
230
230
  ))
231
231
 
232
232
  self.save_session_and_generate()
233
- webbrowser.open("http://localhost:8000")
234
233
 
235
234
  def save_session_and_generate(self):
236
235
  """Background generation of post variations."""
@@ -34,7 +34,7 @@ def run_tray(trigger_callback):
34
34
  trigger_callback()
35
35
 
36
36
  def open_dashboard(icon, item):
37
- webbrowser.open("http://127.0.0.1:8000")
37
+ pass
38
38
 
39
39
  image = create_image()
40
40
  menu = pystray.Menu(
@@ -0,0 +1,158 @@
1
+ import threading
2
+ from core.capture import run_capture
3
+ from core.ocr import run_ocr
4
+ from api.payload import build_payload
5
+ from ui.popup import show_popup
6
+ from config.settings import is_sprint_mode, get_project_narrative, set_project_narrative
7
+ from storage.sprint import log_sprint_capture
8
+ from ai.generator import generate_posts
9
+ from ui.review import show_review
10
+ from publisher.twitter import publish_post
11
+ from storage.logger import log_post
12
+ import asyncio
13
+
14
+ _capture_lock = threading.Event()
15
+ _capture_lock.set() # start as "not capturing"
16
+
17
+
18
+ def on_trigger():
19
+ # prevent concurrent captures
20
+ if not _capture_lock.is_set():
21
+ print("[Trigger] capture already in progress — ignoring")
22
+ return
23
+
24
+ _capture_lock.clear() # mark as capturing
25
+ try:
26
+ _run_trigger()
27
+ finally:
28
+ _capture_lock.set() # release lock
29
+
30
+
31
+ def _show_tray_notification(title, message):
32
+ try:
33
+ from plyer import notification
34
+ notification.notify(
35
+ title=title,
36
+ message=message,
37
+ app_name="Gitcast",
38
+ timeout=4,
39
+ )
40
+ except Exception:
41
+ print(f"[Trigger] {title}: {message}")
42
+
43
+
44
+ def _generate_and_open(raw_thought, capture, ocr):
45
+ payload = build_payload(
46
+ raw_thought=raw_thought,
47
+ ocr_result=ocr,
48
+ capture_result=capture,
49
+ )
50
+ payload["use_vision_fallback"] = False
51
+ payload["screenshot_b64"] = None
52
+
53
+ def generate_and_show():
54
+ try:
55
+ print('[Trigger] calling generate_posts...')
56
+ print("[Trigger] generating posts...")
57
+ variations = asyncio.run(generate_posts(payload))
58
+ print(f'[Trigger] got {len(variations)} variations')
59
+ print(f'[Trigger] keys: {list(variations.keys())}')
60
+ for k, v in variations.items():
61
+ print(f'[Trigger] {k}: {v[:50]}...')
62
+
63
+ def on_publish(post_text, format_key, screenshot_path):
64
+ print(f"[Gitcast] Publishing: {format_key}")
65
+ result = publish_post(post_text, screenshot_path)
66
+ if result.get("success"):
67
+ if result.get("fallback"):
68
+ print("[Gitcast] Copied to clipboard")
69
+ else:
70
+ print(f"[Gitcast] Published: {result.get('tweet_url')}")
71
+ log_post(
72
+ post_text=post_text,
73
+ format_key=format_key,
74
+ screenshot_path=screenshot_path,
75
+ tweet_url=result.get("tweet_url", ""),
76
+ tweet_id=result.get("tweet_id", ""),
77
+ fallback=result.get("fallback", False),
78
+ )
79
+ else:
80
+ print(f"[Gitcast] Error: {result.get('error')}")
81
+
82
+ def on_close():
83
+ print("[Gitcast] Review closed.")
84
+
85
+ show_review(
86
+ payload=payload,
87
+ variations=variations,
88
+ on_publish=on_publish,
89
+ on_close=on_close,
90
+ )
91
+
92
+ _show_tray_notification(
93
+ "Gitcast",
94
+ "Post ready — check dashboard"
95
+ )
96
+
97
+ except Exception as e:
98
+ print(f'[Trigger] generation error: {e}')
99
+ import traceback
100
+ traceback.print_exc()
101
+
102
+ threading.Thread(
103
+ target=generate_and_show,
104
+ daemon=True
105
+ ).start()
106
+
107
+
108
+ def _run_trigger():
109
+ """The actual trigger logic — moved from on_trigger."""
110
+ print("[Trigger] on_trigger fired successfully")
111
+ print("[Gitcast] Hotkey fired — starting capture...")
112
+
113
+ capture = run_capture()
114
+ print("[Trigger] capture complete")
115
+ ocr = run_ocr(capture["screenshot"]["path"])
116
+ print("[Trigger] OCR complete")
117
+
118
+ if is_sprint_mode():
119
+ log_sprint_capture(
120
+ git_diff=capture["git_diff"].get("diff", ""),
121
+ ocr_text=ocr.get("text", ""),
122
+ raw_thought="",
123
+ timestamp=capture["screenshot"]["timestamp"],
124
+ )
125
+ print("[Gitcast] Sprint Mode — capture logged silently.")
126
+ return
127
+
128
+ narrative = get_project_narrative()
129
+
130
+ if narrative:
131
+ # silent mode — no popup
132
+ # use narrative as the raw thought
133
+ raw_thought = narrative
134
+ print("[Trigger] using project narrative — skipping popup")
135
+ _generate_and_open(raw_thought, capture, ocr)
136
+
137
+ else:
138
+ # first time or no narrative — show popup once
139
+ # but frame it as "what are you building?"
140
+ # not "what was the struggle or win?"
141
+ print("[Trigger] no narrative set — showing setup popup")
142
+
143
+ def on_submit(thought):
144
+ from config.settings import set_project_narrative
145
+ # save as project narrative for future
146
+ set_project_narrative(thought)
147
+ print(f"[Trigger] narrative saved: '{thought}'")
148
+ _generate_and_open(thought, capture, ocr)
149
+
150
+ def on_dismiss():
151
+ print("[Trigger] popup dismissed")
152
+
153
+ # update popup text to ask for narrative
154
+ show_popup(
155
+ on_submit=on_submit,
156
+ on_dismiss=on_dismiss,
157
+ prompt_text="What are you building? (saved for future captures)"
158
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitcast
3
- Version: 1.0.18
3
+ Version: 1.0.20
4
4
  Summary: git diff → published post. under 60 seconds.
5
5
  Home-page: https://github.com/drizzy765/gitcast
6
6
  Author: Timilehin Agoro
@@ -29,6 +29,7 @@ Requires-Dist: slowapi
29
29
  Requires-Dist: limits
30
30
  Requires-Dist: sentry-sdk[fastapi]
31
31
  Requires-Dist: posthog
32
+ Requires-Dist: plyer
32
33
  Dynamic: author
33
34
  Dynamic: author-email
34
35
  Dynamic: description
@@ -78,6 +78,7 @@ storage/supabase_client.py
78
78
  storage/tone_memory.py
79
79
  tests/test_keys.py
80
80
  tests/test_ocr_fix.py
81
+ tests/test_validation.py
81
82
  ui/__init__.py
82
83
  ui/popup.py
83
84
  ui/review.py
@@ -18,3 +18,4 @@ slowapi
18
18
  limits
19
19
  sentry-sdk[fastapi]
20
20
  posthog
21
+ plyer
@@ -18,3 +18,4 @@ slowapi
18
18
  limits
19
19
  sentry-sdk[fastapi]
20
20
  posthog
21
+ plyer
@@ -51,7 +51,7 @@ if os.path.exists(requirements_path):
51
51
 
52
52
  setup(
53
53
  name="gitcast",
54
- version="1.0.18",
54
+ version="1.0.20",
55
55
  description="git diff → published post. under 60 seconds.",
56
56
  long_description=long_description,
57
57
  long_description_content_type="text/markdown",
@@ -0,0 +1,50 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ # Add project root to sys.path
5
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
6
+
7
+ from api.payload import validate_payload
8
+
9
+ def test_validation():
10
+ # Test 1 — thin context (should BLOCK)
11
+ payload_thin = {
12
+ "raw_thought": "Captured via hotkey trigger",
13
+ "ocr_text": "noisy ocr text", # length < 80
14
+ "git_diff": "",
15
+ "git_diff_available": False
16
+ }
17
+ is_valid, warnings = validate_payload(payload_thin)
18
+ print("Thin context result:", is_valid, warnings)
19
+ assert not is_valid
20
+ assert len(warnings) == 1
21
+ assert "Not enough context" in warnings[0]
22
+
23
+ # Test 2 — real context (should GENERATE)
24
+ payload_real = {
25
+ "raw_thought": "fixed score not resetting on game over",
26
+ "ocr_text": "game snake over score resetting",
27
+ "git_diff": "diff --git a/snake.py b/snake.py\n+score = 0",
28
+ "git_diff_available": True
29
+ }
30
+ is_valid, warnings = validate_payload(payload_real)
31
+ print("Real context result:", is_valid, warnings)
32
+ assert is_valid
33
+ assert not warnings or "Not enough context" not in warnings[0]
34
+
35
+ # Test 3 - real thought but missing diff (should WARN but allow)
36
+ payload_warn = {
37
+ "raw_thought": "fixed score not resetting on game over",
38
+ "ocr_text": "game snake over score resetting",
39
+ "git_diff": "",
40
+ "git_diff_available": False
41
+ }
42
+ is_valid, warnings = validate_payload(payload_warn)
43
+ print("Warn context result:", is_valid, warnings)
44
+ assert is_valid
45
+ assert any("No git diff" in w for w in warnings)
46
+
47
+ print("✅ All validation tests passed!")
48
+
49
+ if __name__ == "__main__":
50
+ test_validation()
@@ -19,14 +19,16 @@ FONT_SIZE = 13
19
19
  # ── Popup window ──────────────────────────────────────────────────────────────
20
20
 
21
21
  class CapturePopup:
22
- def __init__(self, on_submit, on_dismiss):
22
+ def __init__(self, on_submit, on_dismiss, prompt_text=None):
23
23
  """
24
24
  Args:
25
25
  on_submit: Callback called with the user's raw thought string.
26
26
  on_dismiss: Callback called when the user hits Esc or closes.
27
+ prompt_text: Optional custom prompt text.
27
28
  """
28
29
  self.on_submit = on_submit
29
30
  self.on_dismiss = on_dismiss
31
+ self.prompt_text = prompt_text or "What was the struggle or win?"
30
32
  self.result = None
31
33
  self.root = None
32
34
 
@@ -64,7 +66,7 @@ class CapturePopup:
64
66
  label_font = tkfont.Font(family=FONT_FAMILY, size=10)
65
67
  label = tk.Label(
66
68
  main_frame,
67
- text="What was the struggle or win?",
69
+ text=self.prompt_text,
68
70
  bg=BG_COLOR,
69
71
  fg=PLACEHOLDER_COLOR,
70
72
  font=label_font,
@@ -147,14 +149,14 @@ class CapturePopup:
147
149
 
148
150
  # ── Public interface ──────────────────────────────────────────────────────────
149
151
 
150
- def show_popup(on_submit, on_dismiss):
152
+ def show_popup(on_submit, on_dismiss, prompt_text=None):
151
153
  """
152
154
  Shows the capture popup in a new thread so it doesn't block
153
155
  the hotkey listener or the tray process.
154
156
  """
155
157
  def run():
156
158
  try:
157
- popup = CapturePopup(on_submit=on_submit, on_dismiss=on_dismiss)
159
+ popup = CapturePopup(on_submit=on_submit, on_dismiss=on_dismiss, prompt_text=prompt_text)
158
160
  popup.show()
159
161
  except Exception as e:
160
162
  print(f"[Gitcast] Popup display error: {e}")
@@ -21,5 +21,4 @@ def show_review(payload, variations, on_publish, on_close):
21
21
  except Exception as e:
22
22
  print(f"[Gitcast] Failed to save draft: {e}")
23
23
 
24
- webbrowser.open("http://localhost:8000")
25
24
  on_close()