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.
- ai/__init__.py +0 -0
- ai/formatter.py +59 -0
- ai/generator.py +604 -0
- ai/prompts.py +197 -0
- ai/viral_patterns.py +75 -0
- api/__init__.py +0 -0
- api/analytics.py +48 -0
- api/auth.py +49 -0
- api/auth_middleware.py +129 -0
- api/auth_routes.py +117 -0
- api/monitoring.py +56 -0
- api/payload.py +253 -0
- api/ratelimit.py +9 -0
- api/routes.py +1565 -0
- api/server.py +162 -0
- api/validators.py +101 -0
- assets/__init__.py +1 -0
- assets/favicon-16x16.png +0 -0
- assets/favicon-32x32.png +0 -0
- assets/favicon-64x64.png +0 -0
- assets/favicon.ico +0 -0
- assets/icon.png +0 -0
- cli/.env.example +26 -0
- cli/__init__.py +1 -0
- cli/gitcast.py +79 -0
- config/__init__.py +0 -0
- config/settings.py +213 -0
- core/__init__.py +0 -0
- core/capture.py +258 -0
- core/codebase_reader.py +90 -0
- core/framing.py +86 -0
- core/hotkey.py +21 -0
- core/log_stream.py +50 -0
- core/ocr.py +173 -0
- core/screenshot_session.py +274 -0
- core/security.py +126 -0
- core/tray.py +54 -0
- gitcast-1.0.0.dist-info/LICENSE +21 -0
- gitcast-1.0.0.dist-info/METADATA +67 -0
- gitcast-1.0.0.dist-info/RECORD +61 -0
- gitcast-1.0.0.dist-info/WHEEL +5 -0
- gitcast-1.0.0.dist-info/entry_points.txt +2 -0
- gitcast-1.0.0.dist-info/top_level.txt +10 -0
- publisher/__init__.py +0 -0
- publisher/clipboard.py +44 -0
- publisher/twitter.py +100 -0
- storage/__init__.py +0 -0
- storage/cleanup.py +60 -0
- storage/engagement.py +114 -0
- storage/insights.py +203 -0
- storage/key_manager.py +45 -0
- storage/logger.py +208 -0
- storage/metrics.py +119 -0
- storage/sprint.py +40 -0
- storage/streak.py +0 -0
- storage/supabase_client.py +25 -0
- storage/tone_memory.py +139 -0
- ui/__init__.py +0 -0
- web/__init__.py +1 -0
- web/index.html +4994 -0
- web/landing.html +925 -0
api/server.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
from fastapi import FastAPI, Request, HTTPException
|
|
3
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
4
|
+
from fastapi.staticfiles import StaticFiles
|
|
5
|
+
from fastapi.responses import FileResponse, Response
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
from slowapi.errors import RateLimitExceeded
|
|
8
|
+
from slowapi.middleware import SlowAPIMiddleware
|
|
9
|
+
from api.routes import router
|
|
10
|
+
from api.auth_routes import router as auth_router
|
|
11
|
+
from config.settings import missing_api_keys, BASE_DIR, STORAGE_DIR, CONFIG_DIR
|
|
12
|
+
from api.monitoring import init_sentry
|
|
13
|
+
from api.ratelimit import limiter
|
|
14
|
+
from api.auth import get_token
|
|
15
|
+
|
|
16
|
+
# ── App setup ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
init_sentry()
|
|
19
|
+
|
|
20
|
+
app = FastAPI(
|
|
21
|
+
title="Gitcast",
|
|
22
|
+
description="Local AI server for build-in-public post generation",
|
|
23
|
+
version="0.1.0",
|
|
24
|
+
docs_url="/docs",
|
|
25
|
+
redoc_url=None,
|
|
26
|
+
)
|
|
27
|
+
app.state.limiter = limiter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.exception_handler(RateLimitExceeded)
|
|
31
|
+
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
|
32
|
+
retry_after = 60
|
|
33
|
+
headers = getattr(exc, "headers", None) or {}
|
|
34
|
+
try:
|
|
35
|
+
retry_after = int(headers.get("Retry-After", retry_after))
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
retry_after = 60
|
|
38
|
+
headers["Retry-After"] = str(retry_after)
|
|
39
|
+
return JSONResponse(
|
|
40
|
+
status_code=429,
|
|
41
|
+
content={
|
|
42
|
+
"success": False,
|
|
43
|
+
"error": "rate limit exceeded",
|
|
44
|
+
"retry_after": retry_after,
|
|
45
|
+
"message": f"[!!] slow down. retry in {retry_after}s.",
|
|
46
|
+
},
|
|
47
|
+
headers=headers,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# allow the UI layer to call the API from localhost
|
|
51
|
+
app.add_middleware(
|
|
52
|
+
CORSMiddleware,
|
|
53
|
+
allow_origins=["*"], # Relax for local dev
|
|
54
|
+
allow_methods=["*"],
|
|
55
|
+
allow_headers=["*"],
|
|
56
|
+
)
|
|
57
|
+
app.add_middleware(SlowAPIMiddleware)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
|
|
61
|
+
|
|
62
|
+
app.add_middleware(SentryAsgiMiddleware)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# Serve captured images without exposing local data files or Python modules.
|
|
67
|
+
app.mount(
|
|
68
|
+
"/storage/data/screenshots",
|
|
69
|
+
StaticFiles(directory=str(STORAGE_DIR / "screenshots")),
|
|
70
|
+
name="screenshots",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
app.mount(
|
|
74
|
+
"/assets",
|
|
75
|
+
StaticFiles(directory=str(BASE_DIR / "assets")),
|
|
76
|
+
name="assets",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# register routes
|
|
81
|
+
app.include_router(router, prefix="/api")
|
|
82
|
+
app.include_router(auth_router, prefix="/auth")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Startup Event ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
@app.on_event("startup")
|
|
88
|
+
def startup_event():
|
|
89
|
+
print(f"\n[Auth] Session Token: {get_token()}")
|
|
90
|
+
print("[Server] Starting Gitcast API on http://127.0.0.1:8000")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Serve Frontend ────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
@app.get("/")
|
|
96
|
+
async def read_landing():
|
|
97
|
+
return FileResponse(BASE_DIR / "web" / "landing.html")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.get("/landing")
|
|
101
|
+
async def read_landing_alias():
|
|
102
|
+
return FileResponse(BASE_DIR / "web" / "landing.html")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.get("/app")
|
|
106
|
+
async def read_app():
|
|
107
|
+
return FileResponse(BASE_DIR / "web" / "index.html")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.get("/favicon.ico")
|
|
111
|
+
async def favicon():
|
|
112
|
+
return FileResponse(BASE_DIR / "assets" / "favicon.ico")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Health check ──────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
@app.get("/health")
|
|
118
|
+
def health_check():
|
|
119
|
+
missing = missing_api_keys()
|
|
120
|
+
return {
|
|
121
|
+
"status": "ok",
|
|
122
|
+
"missing_api_keys": missing,
|
|
123
|
+
"ready": len(missing) == 0,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ── Auth token retrieval (Localhost only) ───────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
@app.get("/api/token")
|
|
130
|
+
def get_session_token(request: Request):
|
|
131
|
+
client_host = request.client.host if request.client else None
|
|
132
|
+
if client_host not in ["127.0.0.1", "localhost", "::1"]:
|
|
133
|
+
raise HTTPException(status_code=403, detail="Forbidden: Access allowed only from localhost")
|
|
134
|
+
return {"token": get_token()}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def start_server():
|
|
140
|
+
"""Starts the FastAPI server. Called from main.py in a background thread."""
|
|
141
|
+
# Write session token to config/session_token.txt
|
|
142
|
+
try:
|
|
143
|
+
token_file = CONFIG_DIR / "session_token.txt"
|
|
144
|
+
token_file.write_text(get_token(), encoding="utf-8")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
print(f"[Server] Failed to write session token to file: {e}")
|
|
147
|
+
|
|
148
|
+
print(f"\n[Auth] Session Token: {get_token()}")
|
|
149
|
+
print("[Server] Starting Gitcast API on http://127.0.0.1:8000")
|
|
150
|
+
uvicorn.run(
|
|
151
|
+
"api.server:app",
|
|
152
|
+
host="127.0.0.1",
|
|
153
|
+
port=8000,
|
|
154
|
+
log_level="warning", # keep console clean — only show warnings and errors
|
|
155
|
+
reload=False,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
print("[Server] Starting Gitcast API on http://127.0.0.1:8000")
|
|
161
|
+
print("[Server] Docs available at http://127.0.0.1:8000/docs")
|
|
162
|
+
start_server()
|
api/validators.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
|
|
7
|
+
from config.settings import BASE_DIR, STORAGE_DIR
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
11
|
+
HTML_TAGS = re.compile(r"<[^>]*>")
|
|
12
|
+
EMAIL_REGEX = re.compile(r"^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$", re.IGNORECASE)
|
|
13
|
+
INJECTION_PATTERNS = [
|
|
14
|
+
"ignore previous instructions",
|
|
15
|
+
"ignore all instructions",
|
|
16
|
+
"system prompt",
|
|
17
|
+
"you are now",
|
|
18
|
+
"disregard",
|
|
19
|
+
"forget everything",
|
|
20
|
+
"new instructions",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sanitize_text(text: str) -> str:
|
|
25
|
+
if text is None:
|
|
26
|
+
return ""
|
|
27
|
+
clean = str(text).strip()
|
|
28
|
+
clean = CONTROL_CHARS.sub("", clean)
|
|
29
|
+
clean = HTML_TAGS.sub("", clean)
|
|
30
|
+
return clean[:2000]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sanitize_path(path: str) -> str:
|
|
34
|
+
if not path:
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
raw = str(path).strip()
|
|
38
|
+
if re.search(r"(^|[\\/])\.\.([\\/]|$)", raw) or raw in {".", ".."}:
|
|
39
|
+
raise HTTPException(status_code=400, detail="Invalid path traversal attempt")
|
|
40
|
+
|
|
41
|
+
storage_root = STORAGE_DIR.resolve()
|
|
42
|
+
candidate = Path(raw)
|
|
43
|
+
if not candidate.is_absolute():
|
|
44
|
+
if raw.replace("\\", "/").startswith("storage/"):
|
|
45
|
+
candidate = (BASE_DIR / raw).resolve()
|
|
46
|
+
else:
|
|
47
|
+
candidate = (storage_root / raw).resolve()
|
|
48
|
+
else:
|
|
49
|
+
candidate = candidate.resolve()
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
candidate.relative_to(storage_root)
|
|
53
|
+
except ValueError:
|
|
54
|
+
raise HTTPException(status_code=400, detail="Path must stay within storage directory")
|
|
55
|
+
|
|
56
|
+
return str(candidate)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_email(email: str) -> bool:
|
|
60
|
+
if not email or len(email) > 254:
|
|
61
|
+
return False
|
|
62
|
+
return bool(EMAIL_REGEX.match(email.strip()))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_api_key(key: str, provider: str) -> bool:
|
|
66
|
+
value = (key or "").strip()
|
|
67
|
+
name = (provider or "").strip().lower()
|
|
68
|
+
if not value:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
if name == "groq":
|
|
72
|
+
return value.startswith("gsk_") and len(value) >= 40
|
|
73
|
+
if name == "gemini":
|
|
74
|
+
return len(value) >= 30
|
|
75
|
+
if name == "openai":
|
|
76
|
+
return value.startswith("sk-") and len(value) >= 40
|
|
77
|
+
return len(value) >= 20
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def check_prompt_injection(text: str) -> dict:
|
|
81
|
+
raw = text or ""
|
|
82
|
+
lowered = raw.lower()
|
|
83
|
+
flagged = [phrase for phrase in INJECTION_PATTERNS if phrase in lowered]
|
|
84
|
+
if not flagged:
|
|
85
|
+
return {"safe": True, "reason": ""}
|
|
86
|
+
|
|
87
|
+
logging.warning("[Validators] prompt injection phrase(s) removed: %s", ", ".join(flagged))
|
|
88
|
+
sanitized = raw
|
|
89
|
+
for phrase in flagged:
|
|
90
|
+
sanitized = re.sub(re.escape(phrase), "", sanitized, flags=re.IGNORECASE)
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"safe": False,
|
|
94
|
+
"reason": f"Removed prompt injection phrase(s): {', '.join(flagged)}",
|
|
95
|
+
"sanitized": sanitize_text(sanitized),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
print("[Validators] email valid:", validate_email("founder@example.com"))
|
|
101
|
+
print("[Validators] clean:", sanitize_text(" <b>hello</b>\x00 "))
|
assets/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Make assets directory a Python package so it gets packaged and installed to site-packages.
|
assets/favicon-16x16.png
ADDED
|
Binary file
|
assets/favicon-32x32.png
ADDED
|
Binary file
|
assets/favicon-64x64.png
ADDED
|
Binary file
|
assets/favicon.ico
ADDED
|
Binary file
|
assets/icon.png
ADDED
|
File without changes
|
cli/.env.example
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Groq — primary AI inference
|
|
2
|
+
GROQ_API_KEY=your_groq_api_key_here
|
|
3
|
+
|
|
4
|
+
# DeepSeek & Moonshot — specialized/free-tier fallbacks
|
|
5
|
+
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
|
6
|
+
MOONSHOT_API_KEY=your_moonshot_api_key_here
|
|
7
|
+
CEREBRAS_API_KEY=your_cerebras_api_key_here
|
|
8
|
+
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
|
9
|
+
|
|
10
|
+
# Gemini — vision fallback (when OCR confidence is too low)
|
|
11
|
+
GEMINI_API_KEY=your_gemini_api_key_here
|
|
12
|
+
|
|
13
|
+
# Optional model overrides
|
|
14
|
+
GROQ_MODEL=llama-3.3-70b-versatile
|
|
15
|
+
DEEPSEEK_MODEL=deepseek-chat
|
|
16
|
+
MOONSHOT_MODEL=moonshot-v1-8k
|
|
17
|
+
GEMINI_MODEL=gemini-2.0-flash
|
|
18
|
+
CEREBRAS_MODEL=llama-3.3-70b
|
|
19
|
+
OPENROUTER_MODEL=meta-llama/llama-3.3-70b-instruct:free
|
|
20
|
+
|
|
21
|
+
# X (Twitter) API v2 — all five are required for posting
|
|
22
|
+
TWITTER_API_KEY=your_twitter_api_key_here
|
|
23
|
+
TWITTER_API_SECRET=your_twitter_api_secret_here
|
|
24
|
+
TWITTER_ACCESS_TOKEN=your_twitter_access_token_here
|
|
25
|
+
TWITTER_ACCESS_SECRET=your_twitter_access_secret_here
|
|
26
|
+
TWITTER_BEARER_TOKEN=your_twitter_bearer_token_here
|
cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# cli package
|
cli/gitcast.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import httpx
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
if "--setup" in sys.argv:
|
|
10
|
+
env_path = os.path.join(
|
|
11
|
+
os.path.dirname(__file__), '..', '.env')
|
|
12
|
+
example_path = os.path.join(
|
|
13
|
+
os.path.dirname(__file__), '..', '.env.example')
|
|
14
|
+
if not os.path.exists(example_path):
|
|
15
|
+
example_path = os.path.join(
|
|
16
|
+
os.path.dirname(__file__), '.env.example')
|
|
17
|
+
|
|
18
|
+
if not os.path.exists(env_path):
|
|
19
|
+
if os.path.exists(example_path):
|
|
20
|
+
shutil.copy(example_path, env_path)
|
|
21
|
+
else:
|
|
22
|
+
# If neither is found, create an empty or minimal env file
|
|
23
|
+
with open(env_path, 'w') as f:
|
|
24
|
+
f.write("# Gitcast Environment Variables\nGROQ_API_KEY=\nGEMINI_API_KEY=\n")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
if sys.platform == "win32":
|
|
28
|
+
subprocess.run(["notepad", env_path])
|
|
29
|
+
elif sys.platform == "darwin":
|
|
30
|
+
subprocess.run(["open", "-t", env_path])
|
|
31
|
+
else:
|
|
32
|
+
try:
|
|
33
|
+
subprocess.run(["xdg-open", env_path])
|
|
34
|
+
except Exception:
|
|
35
|
+
print(f"[Gitcast] Created/configured .env at: {env_path}")
|
|
36
|
+
print("Please open it in an editor to add your API keys.")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
print(f"[Gitcast] Created/configured .env at: {env_path}")
|
|
39
|
+
print(f"Could not automatically open editor: {e}")
|
|
40
|
+
sys.exit(0)
|
|
41
|
+
|
|
42
|
+
if len(sys.argv) < 2:
|
|
43
|
+
print("Usage:")
|
|
44
|
+
print(" gitcast \"your thought here\" -> Quick single capture")
|
|
45
|
+
print(" gitcast capture -> Start interactive multi-shot session")
|
|
46
|
+
print(" gitcast --setup -> Setup environment variables")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
command = sys.argv[1]
|
|
50
|
+
|
|
51
|
+
if command == "capture":
|
|
52
|
+
from core.screenshot_session import ScreenshotSession
|
|
53
|
+
session = ScreenshotSession()
|
|
54
|
+
session.run()
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
thought = " ".join(sys.argv[1:])
|
|
58
|
+
print(f"[Gitcast] Initializing capture with thought: '{thought}'")
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
# Call the internal trigger endpoint
|
|
62
|
+
response = httpx.post(
|
|
63
|
+
"http://127.0.0.1:8000/api/cli/trigger",
|
|
64
|
+
json={"thought": thought},
|
|
65
|
+
timeout=30
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if response.status_code == 200:
|
|
69
|
+
print("[Gitcast] Context captured. Opening Draft Room...")
|
|
70
|
+
webbrowser.open("http://127.0.0.1:8000")
|
|
71
|
+
else:
|
|
72
|
+
print(f"[Gitcast Error] Failed to trigger: {response.text}")
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f"[Gitcast Error] Connection failed: {e}")
|
|
76
|
+
print("Is Gitcast running? (python -m gitcast or python main.py)")
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
config/__init__.py
ADDED
|
File without changes
|
config/settings.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
9
|
+
CONFIG_DIR = BASE_DIR / "config"
|
|
10
|
+
load_dotenv(BASE_DIR / ".env")
|
|
11
|
+
STORAGE_DIR = BASE_DIR / "storage" / "data"
|
|
12
|
+
POSTHOG_API_KEY = os.getenv("POSTHOG_API_KEY", "")
|
|
13
|
+
SENTRY_DSN = os.getenv("SENTRY_DSN", "")
|
|
14
|
+
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
|
|
15
|
+
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY", "")
|
|
16
|
+
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
|
|
17
|
+
SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", "")
|
|
18
|
+
SUPABASE_JWT_AUDIENCE = os.getenv("SUPABASE_JWT_AUDIENCE", "authenticated")
|
|
19
|
+
APP_BASE_URL = os.getenv("APP_BASE_URL", "http://127.0.0.1:8000")
|
|
20
|
+
WAITLIST_FILE = STORAGE_DIR / "waitlist.txt"
|
|
21
|
+
METRICS_LOG = STORAGE_DIR / "metrics_log.json"
|
|
22
|
+
PROMPTS_FILE = STORAGE_DIR / "prompts.json"
|
|
23
|
+
CURRENT_DRAFT = STORAGE_DIR / "current_draft.json"
|
|
24
|
+
SPRINT_LOG = STORAGE_DIR / "sprint_log.txt"
|
|
25
|
+
POST_LOG = STORAGE_DIR / "post_log.json"
|
|
26
|
+
TONE_LOG = STORAGE_DIR / "tone_log.json"
|
|
27
|
+
ENGAGEMENT_LOG = STORAGE_DIR / "engagement_log.json"
|
|
28
|
+
ENCRYPTION_KEY_PATH = CONFIG_DIR / ".secret_key"
|
|
29
|
+
SETTINGS_FILE = CONFIG_DIR / "user_settings.json"
|
|
30
|
+
screenshot_retention_hours = 24
|
|
31
|
+
|
|
32
|
+
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
API_KEY_ENV_MAP = {
|
|
36
|
+
"groq": "GROQ_API_KEY",
|
|
37
|
+
"gemini": "GEMINI_API_KEY",
|
|
38
|
+
"kimi": "MOONSHOT_API_KEY",
|
|
39
|
+
"cerebras": "CEREBRAS_API_KEY",
|
|
40
|
+
"openrouter": "OPENROUTER_API_KEY",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
GROQ_API_KEY = ""
|
|
44
|
+
MOONSHOT_API_KEY = ""
|
|
45
|
+
GEMINI_API_KEY = ""
|
|
46
|
+
CEREBRAS_API_KEY = ""
|
|
47
|
+
OPENROUTER_API_KEY = ""
|
|
48
|
+
GROQ_MODEL = ""
|
|
49
|
+
MOONSHOT_MODEL = ""
|
|
50
|
+
GEMINI_MODEL = ""
|
|
51
|
+
CEREBRAS_MODEL = ""
|
|
52
|
+
OPENROUTER_MODEL = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reload_api_keys() -> None:
|
|
56
|
+
global GROQ_API_KEY
|
|
57
|
+
global MOONSHOT_API_KEY
|
|
58
|
+
global GEMINI_API_KEY
|
|
59
|
+
global CEREBRAS_API_KEY
|
|
60
|
+
global OPENROUTER_API_KEY
|
|
61
|
+
global GROQ_MODEL
|
|
62
|
+
global MOONSHOT_MODEL
|
|
63
|
+
global GEMINI_MODEL
|
|
64
|
+
global CEREBRAS_MODEL
|
|
65
|
+
global OPENROUTER_MODEL
|
|
66
|
+
|
|
67
|
+
load_dotenv(BASE_DIR / ".env", override=True)
|
|
68
|
+
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
|
|
69
|
+
MOONSHOT_API_KEY = os.getenv("MOONSHOT_API_KEY", "")
|
|
70
|
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
|
71
|
+
CEREBRAS_API_KEY = os.getenv("CEREBRAS_API_KEY", "")
|
|
72
|
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
|
73
|
+
GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
|
|
74
|
+
MOONSHOT_MODEL = os.getenv("MOONSHOT_MODEL", "moonshot-v1-8k")
|
|
75
|
+
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash")
|
|
76
|
+
CEREBRAS_MODEL = os.getenv("CEREBRAS_MODEL", "gpt-oss-120b")
|
|
77
|
+
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "qwen/qwen3-coder:free")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
reload_api_keys()
|
|
81
|
+
|
|
82
|
+
# ── AI Provider Routing ───────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
# Maps format_key -> preferred provider alias
|
|
85
|
+
# Providers: 'groq', 'kimi', 'gemini', 'cerebras', 'openrouter'
|
|
86
|
+
AI_ROUTING_MAP = {
|
|
87
|
+
"article": "kimi", # Better for long context
|
|
88
|
+
"linkedin": "groq", # Fast
|
|
89
|
+
"deep_tech": "groq", # Fast
|
|
90
|
+
"shitpost": "groq", # Fast
|
|
91
|
+
"sprint_summary": "kimi",
|
|
92
|
+
"default": "groq"
|
|
93
|
+
}
|
|
94
|
+
TWITTER_API_KEY = os.getenv("TWITTER_API_KEY", "")
|
|
95
|
+
TWITTER_API_SECRET = os.getenv("TWITTER_API_SECRET", "")
|
|
96
|
+
TWITTER_ACCESS_TOKEN = os.getenv("TWITTER_ACCESS_TOKEN", "")
|
|
97
|
+
TWITTER_ACCESS_SECRET = os.getenv("TWITTER_ACCESS_SECRET", "")
|
|
98
|
+
TWITTER_BEARER_TOKEN = os.getenv("TWITTER_BEARER_TOKEN", "")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
DEFAULTS = {
|
|
102
|
+
"project_narrative": "",
|
|
103
|
+
"sprint_mode": False,
|
|
104
|
+
"tone_memory_enabled": True,
|
|
105
|
+
"ocr_confidence_threshold": 60,
|
|
106
|
+
"post_char_limit": 280,
|
|
107
|
+
"twitter_plan": "free",
|
|
108
|
+
"onboarding_complete": False,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_settings() -> dict:
|
|
113
|
+
if not SETTINGS_FILE.exists():
|
|
114
|
+
save_settings(DEFAULTS)
|
|
115
|
+
return DEFAULTS.copy()
|
|
116
|
+
try:
|
|
117
|
+
with open(SETTINGS_FILE, "r", encoding="utf-8") as file:
|
|
118
|
+
stored = json.load(file)
|
|
119
|
+
return {**DEFAULTS, **stored}
|
|
120
|
+
except (json.JSONDecodeError, OSError):
|
|
121
|
+
return DEFAULTS.copy()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def save_settings(settings: dict) -> None:
|
|
125
|
+
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
with open(SETTINGS_FILE, "w", encoding="utf-8") as file:
|
|
127
|
+
json.dump(settings, file, indent=2)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get(key: str):
|
|
131
|
+
return load_settings().get(key, DEFAULTS.get(key))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def set(key: str, value) -> None:
|
|
135
|
+
settings = load_settings()
|
|
136
|
+
settings[key] = value
|
|
137
|
+
save_settings(settings)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_project_narrative() -> str:
|
|
141
|
+
return str(get("project_narrative") or "")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def set_project_narrative(narrative: str) -> None:
|
|
145
|
+
set("project_narrative", narrative.strip())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_sprint_mode() -> bool:
|
|
149
|
+
return bool(get("sprint_mode"))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def toggle_sprint_mode() -> bool:
|
|
153
|
+
current = is_sprint_mode()
|
|
154
|
+
set("sprint_mode", not current)
|
|
155
|
+
return not current
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def is_tone_memory_enabled() -> bool:
|
|
159
|
+
return bool(get("tone_memory_enabled"))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_ocr_threshold() -> int:
|
|
163
|
+
return int(get("ocr_confidence_threshold") or DEFAULTS["ocr_confidence_threshold"])
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_tesseract_cmd() -> str:
|
|
167
|
+
configured = os.getenv("TESSERACT_CMD", "").strip()
|
|
168
|
+
if configured:
|
|
169
|
+
return configured
|
|
170
|
+
return "tesseract"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_onboarding_complete() -> bool:
|
|
174
|
+
return bool(get("onboarding_complete"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def complete_onboarding() -> None:
|
|
178
|
+
set("onboarding_complete", True)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_twitter_plan() -> str:
|
|
182
|
+
return str(get("twitter_plan") or "free")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def set_twitter_plan(plan: str) -> None:
|
|
186
|
+
if plan.lower() in ["free", "basic", "premium"]:
|
|
187
|
+
set("twitter_plan", plan.lower())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def validate_api_keys() -> dict:
|
|
191
|
+
return {
|
|
192
|
+
"groq": bool(GROQ_API_KEY),
|
|
193
|
+
"kimi": bool(MOONSHOT_API_KEY),
|
|
194
|
+
"gemini": bool(GEMINI_API_KEY),
|
|
195
|
+
"cerebras": bool(CEREBRAS_API_KEY),
|
|
196
|
+
"openrouter": bool(OPENROUTER_API_KEY),
|
|
197
|
+
"twitter_api_key": bool(TWITTER_API_KEY),
|
|
198
|
+
"twitter_api_secret": bool(TWITTER_API_SECRET),
|
|
199
|
+
"twitter_access_token": bool(TWITTER_ACCESS_TOKEN),
|
|
200
|
+
"twitter_access_secret": bool(TWITTER_ACCESS_SECRET),
|
|
201
|
+
"twitter_bearer_token": bool(TWITTER_BEARER_TOKEN),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def missing_api_keys() -> list:
|
|
206
|
+
return [key for key, present in validate_api_keys().items() if not present]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def ai_provider_key_status() -> dict:
|
|
210
|
+
return {
|
|
211
|
+
provider: bool(os.getenv(env_name, "").strip())
|
|
212
|
+
for provider, env_name in API_KEY_ENV_MAP.items()
|
|
213
|
+
}
|
core/__init__.py
ADDED
|
File without changes
|