delimit-cli 3.14.28 → 3.14.29
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.
- package/gateway/ai/backends/deploy_bridge.py +56 -2
- package/gateway/ai/backends/gateway_core.py +212 -1
- package/gateway/ai/backends/generate_bridge.py +84 -13
- package/gateway/ai/backends/governance_bridge.py +63 -16
- package/gateway/ai/backends/memory_bridge.py +77 -76
- package/gateway/ai/backends/ops_bridge.py +76 -6
- package/gateway/ai/backends/os_bridge.py +23 -3
- package/gateway/ai/backends/repo_bridge.py +156 -17
- package/gateway/ai/backends/tools_design.py +116 -9
- package/gateway/ai/backends/tools_infra.py +200 -72
- package/gateway/ai/backends/tools_real.py +8 -0
- package/gateway/ai/backends/ui_bridge.py +115 -5
- package/gateway/ai/backends/vault_bridge.py +69 -114
- package/gateway/ai/content_engine.py +1276 -0
- package/gateway/ai/context_fs.py +193 -0
- package/gateway/ai/daemon.py +500 -0
- package/gateway/ai/data_plane.py +291 -0
- package/gateway/ai/deliberation.py +1033 -6
- package/gateway/ai/events.py +39 -0
- package/gateway/ai/founding_users.py +162 -0
- package/gateway/ai/governance.py +698 -4
- package/gateway/ai/inbox_daemon.py +78 -17
- package/gateway/ai/integrations/__init__.py +1 -0
- package/gateway/ai/integrations/opensage_wrapper.py +288 -0
- package/gateway/ai/key_resolver.py +95 -0
- package/gateway/ai/ledger_manager.py +289 -1
- package/gateway/ai/license.py +62 -4
- package/gateway/ai/license_core.py +208 -7
- package/gateway/ai/local_server.py +215 -0
- package/gateway/ai/loop_engine.py +408 -0
- package/gateway/ai/mcp_bridge.py +178 -0
- package/gateway/ai/release_sync.py +2 -2
- package/gateway/ai/screen_record.py +374 -0
- package/gateway/ai/secrets_broker.py +235 -0
- package/gateway/ai/social.py +189 -27
- package/gateway/ai/social_target.py +1635 -0
- package/gateway/ai/supabase_sync.py +190 -0
- package/gateway/ai/tracing.py +195 -0
- package/gateway/core/contract_ledger.py +1 -1
- package/gateway/core/dependency_graph.py +1 -1
- package/gateway/core/dependency_manifest.py +1 -1
- package/gateway/core/diff_engine_v2.py +272 -78
- package/gateway/core/event_backbone.py +2 -2
- package/gateway/core/event_schema.py +1 -1
- package/gateway/core/impact_analyzer.py +1 -1
- package/gateway/core/policy_engine.py +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Secrets broker — JIT credential access with audit (STR-049).
|
|
2
|
+
|
|
3
|
+
Agents request credentials through this broker instead of accessing API keys
|
|
4
|
+
directly. The broker validates scope, issues time-limited access, and logs
|
|
5
|
+
every request in the audit trail.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import base64
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
SECRETS_DIR = Path.home() / ".delimit" / "secrets"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def store_secret(
|
|
18
|
+
name: str,
|
|
19
|
+
value: str,
|
|
20
|
+
scope: str = "all",
|
|
21
|
+
description: str = "",
|
|
22
|
+
created_by: str = "",
|
|
23
|
+
) -> Dict:
|
|
24
|
+
"""Store a secret locally.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
name: Unique identifier for the secret.
|
|
28
|
+
value: The secret value (will be base64-encoded at rest).
|
|
29
|
+
scope: Comma-separated list of tools/agents allowed access, or 'all'.
|
|
30
|
+
description: Human-readable description.
|
|
31
|
+
created_by: Identity of the creator.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Confirmation dict with the stored secret name.
|
|
35
|
+
"""
|
|
36
|
+
if not name or not name.strip():
|
|
37
|
+
return {"error": "Secret name is required"}
|
|
38
|
+
if not value:
|
|
39
|
+
return {"error": "Secret value is required"}
|
|
40
|
+
|
|
41
|
+
# Sanitise name for filesystem safety
|
|
42
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
43
|
+
|
|
44
|
+
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
encoded = base64.b64encode(value.encode()).decode()
|
|
46
|
+
secret = {
|
|
47
|
+
"name": safe_name,
|
|
48
|
+
"encrypted_value": encoded,
|
|
49
|
+
"scope": scope,
|
|
50
|
+
"description": description,
|
|
51
|
+
"created_by": created_by,
|
|
52
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
53
|
+
"last_accessed_at": None,
|
|
54
|
+
"access_count": 0,
|
|
55
|
+
"revoked": False,
|
|
56
|
+
}
|
|
57
|
+
(SECRETS_DIR / f"{safe_name}.json").write_text(json.dumps(secret, indent=2))
|
|
58
|
+
return {"stored": safe_name}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_secret(
|
|
62
|
+
name: str,
|
|
63
|
+
agent_type: str = "",
|
|
64
|
+
tool: str = "",
|
|
65
|
+
) -> Dict:
|
|
66
|
+
"""Request access to a secret. Returns value if authorised.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
name: The secret name to retrieve.
|
|
70
|
+
agent_type: Identity of the requesting agent (e.g. 'claude', 'codex').
|
|
71
|
+
tool: Which MCP tool is requesting access.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Dict with 'value' and 'granted': True on success, or 'error' and
|
|
75
|
+
'granted': False on failure.
|
|
76
|
+
"""
|
|
77
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
78
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
79
|
+
if not path.exists():
|
|
80
|
+
_log_access(safe_name, agent_type, tool, granted=False, reason="not_found")
|
|
81
|
+
return {"error": f"Secret '{safe_name}' not found", "granted": False}
|
|
82
|
+
|
|
83
|
+
secret = json.loads(path.read_text())
|
|
84
|
+
|
|
85
|
+
if secret.get("revoked"):
|
|
86
|
+
_log_access(safe_name, agent_type, tool, granted=False, reason="revoked")
|
|
87
|
+
return {"error": f"Secret '{safe_name}' has been revoked", "granted": False}
|
|
88
|
+
|
|
89
|
+
# Scope check — 'all' allows any requester, otherwise match tool or agent_type
|
|
90
|
+
scope = secret.get("scope", "all")
|
|
91
|
+
if scope != "all":
|
|
92
|
+
allowed = {s.strip().lower() for s in scope.split(",")}
|
|
93
|
+
requester_ids = {agent_type.lower(), tool.lower()} - {""}
|
|
94
|
+
if requester_ids and not requester_ids & allowed:
|
|
95
|
+
_log_access(
|
|
96
|
+
safe_name, agent_type, tool,
|
|
97
|
+
granted=False,
|
|
98
|
+
reason=f"scope_denied: required={scope}, got agent_type={agent_type}, tool={tool}",
|
|
99
|
+
)
|
|
100
|
+
return {
|
|
101
|
+
"error": f"Access denied: scope '{scope}' does not include '{agent_type or tool}'",
|
|
102
|
+
"granted": False,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Log successful access
|
|
106
|
+
_log_access(safe_name, agent_type, tool, granted=True, reason="")
|
|
107
|
+
|
|
108
|
+
# Update access metadata
|
|
109
|
+
secret["access_count"] = secret.get("access_count", 0) + 1
|
|
110
|
+
secret["last_accessed_at"] = datetime.now(timezone.utc).isoformat()
|
|
111
|
+
path.write_text(json.dumps(secret, indent=2))
|
|
112
|
+
|
|
113
|
+
value = base64.b64decode(secret["encrypted_value"]).decode()
|
|
114
|
+
return {"value": value, "granted": True, "name": safe_name}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def list_secrets() -> List[Dict]:
|
|
118
|
+
"""List all secrets (metadata only, never values).
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
List of dicts with name, scope, description, access_count, etc.
|
|
122
|
+
"""
|
|
123
|
+
if not SECRETS_DIR.exists():
|
|
124
|
+
return []
|
|
125
|
+
secrets = []
|
|
126
|
+
for f in sorted(SECRETS_DIR.glob("*.json")):
|
|
127
|
+
try:
|
|
128
|
+
s = json.loads(f.read_text())
|
|
129
|
+
secrets.append({
|
|
130
|
+
"name": s["name"],
|
|
131
|
+
"scope": s.get("scope", "all"),
|
|
132
|
+
"description": s.get("description", ""),
|
|
133
|
+
"created_by": s.get("created_by", ""),
|
|
134
|
+
"access_count": s.get("access_count", 0),
|
|
135
|
+
"last_accessed_at": s.get("last_accessed_at"),
|
|
136
|
+
"revoked": s.get("revoked", False),
|
|
137
|
+
"created_at": s.get("created_at", ""),
|
|
138
|
+
})
|
|
139
|
+
except (json.JSONDecodeError, KeyError):
|
|
140
|
+
pass
|
|
141
|
+
return secrets
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def revoke_secret(name: str) -> Dict:
|
|
145
|
+
"""Revoke a secret, preventing future access.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
name: The secret name to revoke.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Confirmation dict or error.
|
|
152
|
+
"""
|
|
153
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
154
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return {"error": f"Secret '{safe_name}' not found"}
|
|
157
|
+
secret = json.loads(path.read_text())
|
|
158
|
+
secret["revoked"] = True
|
|
159
|
+
secret["revoked_at"] = datetime.now(timezone.utc).isoformat()
|
|
160
|
+
path.write_text(json.dumps(secret, indent=2))
|
|
161
|
+
_log_access(safe_name, "", "", granted=True, reason="revoked_by_user")
|
|
162
|
+
return {"revoked": safe_name}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_access_log(name: Optional[str] = None) -> List[Dict]:
|
|
166
|
+
"""Return access log entries, optionally filtered by secret name.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
name: If provided, only return entries for this secret.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of access log entries (newest first).
|
|
173
|
+
"""
|
|
174
|
+
log_path = SECRETS_DIR / "access_log" / "log.jsonl"
|
|
175
|
+
if not log_path.exists():
|
|
176
|
+
return []
|
|
177
|
+
entries = []
|
|
178
|
+
for line in log_path.read_text().strip().split("\n"):
|
|
179
|
+
if not line.strip():
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
entry = json.loads(line)
|
|
183
|
+
if name and entry.get("secret_name") != name:
|
|
184
|
+
continue
|
|
185
|
+
entries.append(entry)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
pass
|
|
188
|
+
# Newest first
|
|
189
|
+
entries.reverse()
|
|
190
|
+
return entries
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def delete_secret(name: str) -> Dict:
|
|
194
|
+
"""Permanently delete a secret file.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
name: The secret name to delete.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Confirmation dict or error.
|
|
201
|
+
"""
|
|
202
|
+
safe_name = name.strip().replace("/", "_").replace("\\", "_")
|
|
203
|
+
path = SECRETS_DIR / f"{safe_name}.json"
|
|
204
|
+
if not path.exists():
|
|
205
|
+
return {"error": f"Secret '{safe_name}' not found"}
|
|
206
|
+
path.unlink()
|
|
207
|
+
_log_access(safe_name, "", "", granted=True, reason="deleted_by_user")
|
|
208
|
+
return {"deleted": safe_name}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Internal helpers
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _log_access(
|
|
217
|
+
secret_name: str,
|
|
218
|
+
agent_type: str,
|
|
219
|
+
tool: str,
|
|
220
|
+
granted: bool,
|
|
221
|
+
reason: str,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Append an entry to the JSONL access log."""
|
|
224
|
+
log_dir = SECRETS_DIR / "access_log"
|
|
225
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
entry = {
|
|
227
|
+
"secret_name": secret_name,
|
|
228
|
+
"agent_type": agent_type,
|
|
229
|
+
"tool": tool,
|
|
230
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
231
|
+
"granted": granted,
|
|
232
|
+
"reason": reason,
|
|
233
|
+
}
|
|
234
|
+
with open(log_dir / "log.jsonl", "a") as f:
|
|
235
|
+
f.write(json.dumps(entry) + "\n")
|
package/gateway/ai/social.py
CHANGED
|
@@ -28,25 +28,82 @@ ACCOUNTS_FILE = SECRETS_DIR / "twitter-accounts.json"
|
|
|
28
28
|
PLATFORM_TONE = {
|
|
29
29
|
"twitter": {
|
|
30
30
|
"max_length": 280,
|
|
31
|
-
"tone": "
|
|
31
|
+
"tone": "confident technical brand, direct, professional",
|
|
32
|
+
"identity": "@delimit_ai — verified, professional account",
|
|
33
|
+
"conversion_targets": [
|
|
34
|
+
"action: delimit-ai/delimit-action (GitHub Marketplace)",
|
|
35
|
+
"mcp: delimit-mcp-server (Claude Code, Cursor, Gemini CLI)",
|
|
36
|
+
"vscode: Delimit VS Code extension (Marketplace)",
|
|
37
|
+
"install: npx delimit-cli setup",
|
|
38
|
+
],
|
|
32
39
|
"rules": [
|
|
40
|
+
"ALWAYS POSITIVE — celebrate wins, share progress, show momentum. Never complain, never air gaps or insecurities publicly",
|
|
41
|
+
"Frame every metric as a win: '400 clones in 2 weeks' NOT '400 clones but 0 stars'",
|
|
42
|
+
"If there's a problem to solve, handle it internally — don't tweet about it",
|
|
43
|
+
"Confident technical brand voice — direct, clear, authoritative",
|
|
44
|
+
"NOT a Reddit voice — no rough edges, no typos, no 'typing on phone' energy",
|
|
45
|
+
"Proper capitalization and punctuation — we're a product account",
|
|
33
46
|
"Lead with the insight, not the product",
|
|
34
47
|
"One clear point per tweet",
|
|
48
|
+
"No em dashes or en dashes — most people can't type them, so they look AI-generated. Use commas, periods, or hyphens instead",
|
|
49
|
+
"Short punchy lines with line breaks for rhythm",
|
|
35
50
|
"Include a link or install command when relevant",
|
|
51
|
+
"Quote tweets: position Delimit relative to the quoted content, not as a generic response",
|
|
52
|
+
"Replies: be genuinely helpful or insightful, not just 'cool project!'",
|
|
36
53
|
],
|
|
37
54
|
},
|
|
38
55
|
"reddit": {
|
|
39
56
|
"max_length": 500,
|
|
40
|
-
"tone": "
|
|
57
|
+
"tone": "proud builder, genuinely helpful, never salesy",
|
|
58
|
+
"identity": "u/delimitdev — we're openly building Delimit, not hiding it",
|
|
59
|
+
"conversion_targets": [
|
|
60
|
+
"action: delimit-ai/delimit-action (GitHub Marketplace)",
|
|
61
|
+
"mcp: delimit-mcp-server (Claude Code, Cursor, Gemini CLI)",
|
|
62
|
+
"vscode: Delimit VS Code extension (Marketplace)",
|
|
63
|
+
],
|
|
64
|
+
"subreddit_angles": {
|
|
65
|
+
"r/vibecoding": {
|
|
66
|
+
"angle": "founder was a non-technical vibe coder who kept breaking things — built Delimit as the safety net",
|
|
67
|
+
"narrative": "'I was in your shoes' — fellow builder sharing what they made, not selling",
|
|
68
|
+
"best_for": "threads about stability, breaking things, fear of AI-introduced bugs, QA loops",
|
|
69
|
+
"conversion": "action or vscode",
|
|
70
|
+
},
|
|
71
|
+
"r/ClaudeAI": {
|
|
72
|
+
"angle": "MCP power user — shared context across models, agent governance",
|
|
73
|
+
"narrative": "deep practitioner who's built tooling on top of Claude's ecosystem",
|
|
74
|
+
"best_for": "threads about MCP, multi-model workflows, context loss, agent tooling",
|
|
75
|
+
"conversion": "mcp",
|
|
76
|
+
},
|
|
77
|
+
"r/devops": {
|
|
78
|
+
"angle": "CI/CD governance — catch breaking API changes before merge",
|
|
79
|
+
"narrative": "team that ships a GitHub Action for API compatibility checks",
|
|
80
|
+
"best_for": "threads about API versioning, breaking changes, CI pipelines, OpenAPI",
|
|
81
|
+
"conversion": "action",
|
|
82
|
+
},
|
|
83
|
+
"r/programming": {
|
|
84
|
+
"angle": "developer tooling builder — 27 breaking change types, one YAML line",
|
|
85
|
+
"narrative": "technical founder sharing what the engine detects and why",
|
|
86
|
+
"best_for": "threads about API design, backward compatibility, schema evolution",
|
|
87
|
+
"conversion": "action",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
41
90
|
"rules": [
|
|
91
|
+
"We post as u/delimitdev — no need to hide that we build Delimit",
|
|
92
|
+
"Mention Delimit ONLY when it genuinely helps the person's problem",
|
|
93
|
+
"Be proud of our work, not promotional — 'we built X to solve this' not 'check out X!'",
|
|
94
|
+
"NEVER post unsolicited promos — only mention our tools in direct response to a real problem",
|
|
95
|
+
"If Delimit isn't relevant to the thread, just be helpful with no mention at all",
|
|
96
|
+
"Confident builder energy — proud of what we've shipped, not cocky or boastful",
|
|
42
97
|
"NEVER use bullet points or numbered lists — instant AI tell",
|
|
43
98
|
"Keep it to 2-3 sentences max, not multi-paragraph essays",
|
|
44
99
|
"Write like you're typing on your phone, not writing a blog post",
|
|
45
|
-
"Be opinionated — 'honestly X drives me nuts' not 'X is a known issue'",
|
|
46
100
|
"Use contractions, sentence fragments, rough edges",
|
|
47
101
|
"One sharp point per comment — don't cover three topics",
|
|
48
|
-
"
|
|
102
|
+
"ALWAYS POSITIVE — celebrate wins, share progress, show momentum. Never complain or air gaps publicly",
|
|
103
|
+
"NEVER commiserate or self-deprecate — sound like someone who's figured it out",
|
|
49
104
|
"A typo or two is fine — too polished = flagged as AI by mods",
|
|
105
|
+
"NEVER use em dashes (—), en dashes (–), or fancy punctuation — use commas, 'and', or hyphens instead",
|
|
106
|
+
"NEVER use semicolons, ellipsis (…), or curly quotes — type like a human on a phone keyboard",
|
|
50
107
|
"NEVER structure responses with headers, bold text, or formatted lists",
|
|
51
108
|
"r/devops mods actively flag LLM-generated content (learned 2026-03-27)",
|
|
52
109
|
],
|
|
@@ -278,8 +335,16 @@ def generate_post(category: str = "", custom: str = "") -> dict:
|
|
|
278
335
|
return {"text": text, "category": category}
|
|
279
336
|
|
|
280
337
|
|
|
281
|
-
def get_post_history(limit: int = 20
|
|
282
|
-
|
|
338
|
+
def get_post_history(limit: int = 20, platform: str = "",
|
|
339
|
+
user: str = "", subreddit: str = "") -> list:
|
|
340
|
+
"""Get recent post history from the JSONL log.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
limit: Max entries to return.
|
|
344
|
+
platform: Filter by platform (e.g. "twitter", "reddit").
|
|
345
|
+
user: Filter by Reddit user we replied to (replying_to_user field).
|
|
346
|
+
subreddit: Filter by subreddit (e.g. "r/vibecoding").
|
|
347
|
+
"""
|
|
283
348
|
if not SOCIAL_LOG.exists():
|
|
284
349
|
return []
|
|
285
350
|
posts = []
|
|
@@ -287,29 +352,55 @@ def get_post_history(limit: int = 20) -> list:
|
|
|
287
352
|
if not line.strip():
|
|
288
353
|
continue
|
|
289
354
|
try:
|
|
290
|
-
|
|
355
|
+
entry = json.loads(line)
|
|
291
356
|
except (json.JSONDecodeError, ValueError):
|
|
292
|
-
|
|
357
|
+
continue
|
|
358
|
+
# Apply filters
|
|
359
|
+
if platform and entry.get("platform") != platform:
|
|
360
|
+
continue
|
|
361
|
+
if user and user.lower() not in (entry.get("replying_to_user") or "").lower():
|
|
362
|
+
continue
|
|
363
|
+
if subreddit and subreddit.lower() not in (entry.get("subreddit") or "").lower():
|
|
364
|
+
continue
|
|
365
|
+
posts.append(entry)
|
|
293
366
|
if len(posts) >= limit:
|
|
294
367
|
break
|
|
295
368
|
return posts
|
|
296
369
|
|
|
297
370
|
|
|
298
371
|
def log_post(platform: str, text: str, post_id: str = "", handle: str = "",
|
|
299
|
-
quote_tweet_id: str = "", reply_to_id: str = ""
|
|
300
|
-
|
|
372
|
+
quote_tweet_id: str = "", reply_to_id: str = "",
|
|
373
|
+
subreddit: str = "", thread_url: str = "",
|
|
374
|
+
thread_title: str = "", replying_to_user: str = "",
|
|
375
|
+
conversion_target: str = ""):
|
|
376
|
+
"""Log a social media post to the JSONL log.
|
|
377
|
+
|
|
378
|
+
For Reddit comments, include subreddit, thread context, and the user
|
|
379
|
+
being replied to so we can recall full conversation threads later.
|
|
380
|
+
"""
|
|
301
381
|
SOCIAL_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
302
382
|
entry = {
|
|
303
383
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
304
384
|
"platform": platform,
|
|
305
385
|
"handle": handle,
|
|
306
|
-
"text": text[:200],
|
|
386
|
+
"text": text[:500] if platform == "reddit" else text[:200],
|
|
307
387
|
"post_id": post_id,
|
|
308
388
|
}
|
|
309
389
|
if quote_tweet_id:
|
|
310
390
|
entry["quote_tweet_id"] = quote_tweet_id
|
|
311
391
|
if reply_to_id:
|
|
312
392
|
entry["reply_to_id"] = reply_to_id
|
|
393
|
+
# Reddit-specific fields
|
|
394
|
+
if subreddit:
|
|
395
|
+
entry["subreddit"] = subreddit
|
|
396
|
+
if thread_url:
|
|
397
|
+
entry["thread_url"] = thread_url
|
|
398
|
+
if thread_title:
|
|
399
|
+
entry["thread_title"] = thread_title
|
|
400
|
+
if replying_to_user:
|
|
401
|
+
entry["replying_to_user"] = replying_to_user
|
|
402
|
+
if conversion_target:
|
|
403
|
+
entry["conversion_target"] = conversion_target
|
|
313
404
|
with open(SOCIAL_LOG, "a") as f:
|
|
314
405
|
f.write(json.dumps(entry) + "\n")
|
|
315
406
|
|
|
@@ -355,10 +446,17 @@ def get_platform_tone(platform: str = "twitter") -> dict:
|
|
|
355
446
|
|
|
356
447
|
|
|
357
448
|
def save_draft(text: str, platform: str = "twitter", account: str = "",
|
|
358
|
-
quote_tweet_id: str = "", reply_to_id: str = ""
|
|
449
|
+
quote_tweet_id: str = "", reply_to_id: str = "",
|
|
450
|
+
conversion_target: str = "", thread_url: str = "",
|
|
451
|
+
context: str = "") -> dict:
|
|
359
452
|
"""Save a social media post as a draft for later approval.
|
|
360
453
|
|
|
361
454
|
Returns the draft entry with a unique draft_id and platform tone guidelines.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
conversion_target: For Reddit — "action", "mcp", "vscode", or "" (no promo, just helpful).
|
|
458
|
+
thread_url: URL of the Reddit thread being replied to.
|
|
459
|
+
context: WHY this post should be made — strategic reasoning shown in the email.
|
|
362
460
|
"""
|
|
363
461
|
DRAFTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
364
462
|
draft_id = uuid.uuid4().hex[:12]
|
|
@@ -370,6 +468,9 @@ def save_draft(text: str, platform: str = "twitter", account: str = "",
|
|
|
370
468
|
"account": account,
|
|
371
469
|
"quote_tweet_id": quote_tweet_id,
|
|
372
470
|
"reply_to_id": reply_to_id,
|
|
471
|
+
"conversion_target": conversion_target,
|
|
472
|
+
"thread_url": thread_url,
|
|
473
|
+
"context": context,
|
|
373
474
|
"status": "pending",
|
|
374
475
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
375
476
|
}
|
|
@@ -377,6 +478,32 @@ def save_draft(text: str, platform: str = "twitter", account: str = "",
|
|
|
377
478
|
warnings = []
|
|
378
479
|
if tone.get("max_length") and len(text) > tone["max_length"]:
|
|
379
480
|
warnings.append(f"Text exceeds {platform} max length ({len(text)}/{tone['max_length']})")
|
|
481
|
+
# Fancy AI punctuation checks — applies to ALL platforms
|
|
482
|
+
# Most people can't type em dashes, curly quotes, etc. on a keyboard, so they look AI-generated
|
|
483
|
+
_fancy_chars = {
|
|
484
|
+
"\u2014": "em dash",
|
|
485
|
+
"\u2013": "en dash",
|
|
486
|
+
"\u2026": "ellipsis (...)",
|
|
487
|
+
"\u201c": "curly left quote",
|
|
488
|
+
"\u201d": "curly right quote",
|
|
489
|
+
"\u2018": "curly left single quote",
|
|
490
|
+
"\u2019": "curly right single quote",
|
|
491
|
+
}
|
|
492
|
+
_found_fancy = [name for char, name in _fancy_chars.items() if char in text]
|
|
493
|
+
if _found_fancy:
|
|
494
|
+
warnings.append(f"AI TELL WARNING: Fancy punctuation detected: {', '.join(_found_fancy)} — use plain keyboard characters only")
|
|
495
|
+
# Negativity check — applies to ALL platforms
|
|
496
|
+
_lower_text = text.lower()
|
|
497
|
+
_negative_patterns = [
|
|
498
|
+
"zero stars", "no stars", "0 stars", "nobody cared",
|
|
499
|
+
"no one noticed", "nobody noticed", "crickets",
|
|
500
|
+
"the challenge is", "the problem is", "the hard part is",
|
|
501
|
+
"but zero", "but no one", "but nobody",
|
|
502
|
+
"struggling to", "failing to", "can't seem to",
|
|
503
|
+
"not working", "isn't working",
|
|
504
|
+
]
|
|
505
|
+
if any(p in _lower_text for p in _negative_patterns):
|
|
506
|
+
warnings.append("NEGATIVITY WARNING: Post sounds negative or self-defeating. Reframe as a win or celebration. If there's a problem, handle it internally — don't tweet about it.")
|
|
380
507
|
if platform == "reddit":
|
|
381
508
|
if any(line.strip().startswith(("- ", "* ", "1.", "2.", "3.")) for line in text.split("\n")):
|
|
382
509
|
warnings.append("REDDIT WARNING: Contains bullet/numbered lists — high risk of mod removal as AI content")
|
|
@@ -384,6 +511,30 @@ def save_draft(text: str, platform: str = "twitter", account: str = "",
|
|
|
384
511
|
warnings.append("REDDIT WARNING: Multi-paragraph essay format — shorten to 2-3 sentences")
|
|
385
512
|
if "**" in text:
|
|
386
513
|
warnings.append("REDDIT WARNING: Contains bold formatting — too polished for Reddit")
|
|
514
|
+
if ";" in text:
|
|
515
|
+
warnings.append("REDDIT WARNING: Contains semicolon — too formal, use a comma or period instead")
|
|
516
|
+
# Self-deprecating / commiserating tone check
|
|
517
|
+
_lower = text.lower()
|
|
518
|
+
_commiserate_patterns = [
|
|
519
|
+
"same issue here", "i've been hitting", "i struggle with",
|
|
520
|
+
"yeah i have this", "me too", "same problem",
|
|
521
|
+
"i've been dealing with", "drives me nuts too",
|
|
522
|
+
"i'm stuck on", "can't figure out", "been struggling",
|
|
523
|
+
]
|
|
524
|
+
if any(p in _lower for p in _commiserate_patterns):
|
|
525
|
+
warnings.append("REDDIT WARNING: Self-deprecating/commiserating tone detected — rewrite with confident practitioner voice")
|
|
526
|
+
# Unsolicited promo check — mention Delimit only when genuinely helpful
|
|
527
|
+
_promo_patterns = [
|
|
528
|
+
"check out", "you should try", "give it a try",
|
|
529
|
+
"we just launched", "just shipped", "shameless plug",
|
|
530
|
+
"i'd recommend delimit", "you need delimit",
|
|
531
|
+
]
|
|
532
|
+
_mentions_delimit = "delimit" in _lower
|
|
533
|
+
_is_salesy = any(p in _lower for p in _promo_patterns)
|
|
534
|
+
if _mentions_delimit and _is_salesy:
|
|
535
|
+
warnings.append("REDDIT WARNING: Looks like an unsolicited promo — mention Delimit only in direct response to a real problem, never as a pitch")
|
|
536
|
+
if _mentions_delimit and not conversion_target:
|
|
537
|
+
warnings.append("REDDIT NOTE: Mentions Delimit but no conversion_target set — specify 'action', 'mcp', or 'vscode' so the email shows the funnel intent")
|
|
387
538
|
if warnings:
|
|
388
539
|
entry["tone_warnings"] = warnings
|
|
389
540
|
with open(DRAFTS_FILE, "a") as f:
|
|
@@ -454,7 +605,10 @@ def _load_all_drafts() -> list[dict]:
|
|
|
454
605
|
|
|
455
606
|
|
|
456
607
|
def approve_draft(draft_id: str) -> dict:
|
|
457
|
-
"""Approve a draft
|
|
608
|
+
"""Approve a draft — marks it approved and emails the final text to the founder.
|
|
609
|
+
|
|
610
|
+
Auto-posting via Twitter API is disabled. Founder posts manually from their device.
|
|
611
|
+
"""
|
|
458
612
|
all_entries = _load_all_drafts()
|
|
459
613
|
target = None
|
|
460
614
|
for entry in all_entries:
|
|
@@ -466,23 +620,31 @@ def approve_draft(draft_id: str) -> dict:
|
|
|
466
620
|
if target.get("status") != "pending":
|
|
467
621
|
return {"error": f"Draft '{draft_id}' is already {target.get('status')}"}
|
|
468
622
|
|
|
469
|
-
#
|
|
470
|
-
result = post_tweet(
|
|
471
|
-
target["text"],
|
|
472
|
-
account=target.get("account", ""),
|
|
473
|
-
quote_tweet_id=target.get("quote_tweet_id", ""),
|
|
474
|
-
reply_to_id=target.get("reply_to_id", ""),
|
|
475
|
-
)
|
|
476
|
-
|
|
477
|
-
if "error" in result:
|
|
478
|
-
return result
|
|
479
|
-
|
|
480
|
-
# Update status
|
|
623
|
+
# Mark approved but do NOT auto-post — email to founder for manual posting
|
|
481
624
|
target["status"] = "approved"
|
|
482
625
|
target["approved_at"] = datetime.now(timezone.utc).isoformat()
|
|
483
|
-
target["post_result"] = result
|
|
484
626
|
_rewrite_drafts(all_entries)
|
|
485
|
-
|
|
627
|
+
|
|
628
|
+
# Email the approved text for manual posting
|
|
629
|
+
try:
|
|
630
|
+
from ai.notify import send_email
|
|
631
|
+
qt = target.get("quote_tweet_id", "")
|
|
632
|
+
rt = target.get("reply_to_id", "")
|
|
633
|
+
context_lines = []
|
|
634
|
+
if qt:
|
|
635
|
+
context_lines.append(f"Quote tweet: https://x.com/i/status/{qt}")
|
|
636
|
+
if rt:
|
|
637
|
+
context_lines.append(f"Reply to: https://x.com/i/status/{rt}")
|
|
638
|
+
context = "\n".join(context_lines)
|
|
639
|
+
body = f"APPROVED — post this manually:\n\n---\n{target['text']}\n---\n\n{context}"
|
|
640
|
+
send_email(
|
|
641
|
+
subject=f"APPROVED X Post: {draft_id}",
|
|
642
|
+
body=body,
|
|
643
|
+
)
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
|
|
647
|
+
return {"draft_id": draft_id, "status": "approved", "mode": "manual_post", "message": "Emailed to founder for manual posting. Auto-posting is disabled."}
|
|
486
648
|
|
|
487
649
|
|
|
488
650
|
def reject_draft(draft_id: str) -> dict:
|