social-autoposter 1.0.7 → 1.0.9

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/bin/cli.js CHANGED
@@ -12,7 +12,7 @@ const PKG_ROOT = path.join(__dirname, '..');
12
12
  // Files/dirs to copy from npm package to ~/social-autoposter
13
13
  const COPY_TARGETS = [
14
14
  'scripts',
15
- 'schema.sql',
15
+ 'schema-postgres.sql',
16
16
  'config.example.json',
17
17
  '.env.example',
18
18
  'SKILL.md',
@@ -23,7 +23,7 @@ const COPY_TARGETS = [
23
23
  ];
24
24
 
25
25
  // Never overwrite these user files during update
26
- const USER_FILES = new Set(['config.json', 'social_posts.db', '.env']);
26
+ const USER_FILES = new Set(['config.json', '.env']);
27
27
 
28
28
  function copyDir(src, dest) {
29
29
  fs.mkdirSync(dest, { recursive: true });
@@ -79,22 +79,19 @@ function init() {
79
79
  console.log(' .env exists — skipping');
80
80
  }
81
81
 
82
- // Create DB from schema if missing
83
- const dbPath = path.join(DEST, 'social_posts.db');
84
- if (!fs.existsSync(dbPath)) {
85
- const schemaPath = path.join(DEST, 'schema.sql');
86
- const result = spawnSync('sqlite3', [dbPath], {
87
- input: fs.readFileSync(schemaPath),
88
- stdio: ['pipe', 'inherit', 'inherit'],
89
- });
90
- if (result.status === 0) {
91
- console.log(' created social_posts.db');
82
+ // Check psycopg2-binary (required to connect to Neon DB)
83
+ const pip3Check = spawnSync('pip3', ['show', 'psycopg2-binary'], { stdio: 'pipe' });
84
+ if (pip3Check.status !== 0) {
85
+ console.log(' installing psycopg2-binary (required for Neon DB)...');
86
+ const pipInstall = spawnSync('pip3', ['install', 'psycopg2-binary', '-q'], { stdio: 'inherit' });
87
+ if (pipInstall.status !== 0) {
88
+ console.warn(' WARNING: psycopg2-binary install failed — run manually:');
89
+ console.warn(' pip3 install psycopg2-binary');
92
90
  } else {
93
- console.warn(' WARNING: sqlite3 failed — create DB manually:');
94
- console.warn(' sqlite3 ~/social-autoposter/social_posts.db < ~/social-autoposter/schema.sql');
91
+ console.log(' psycopg2-binary installed');
95
92
  }
96
93
  } else {
97
- console.log(' social_posts.db exists — skipping');
94
+ console.log(' psycopg2-binary already installed');
98
95
  }
99
96
 
100
97
  // Skill symlinks
@@ -105,19 +102,12 @@ function init() {
105
102
  linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
106
103
  console.log(' ~/.claude/skills/social-autoposter-setup ->', path.join(DEST, 'setup'));
107
104
 
108
- // DB symlink: ~/.claude/social_posts.db -> ~/social-autoposter/social_posts.db
109
- const claudeDir = path.join(os.homedir(), '.claude');
110
- try {
111
- linkOrRelink(dbPath, path.join(claudeDir, 'social_posts.db'));
112
- console.log(' ~/.claude/social_posts.db ->', dbPath);
113
- } catch {}
114
-
115
105
  console.log('');
116
106
  console.log('Done! Next steps:');
117
107
  console.log(' 1. Edit ~/social-autoposter/config.json with your accounts');
118
108
  console.log(' 2. Tell your Claude agent: "set up social autoposter"');
119
109
  console.log(' (uses the setup/SKILL.md wizard for browser login verification)');
120
- console.log(' 3. Or configure manually and run: bash ~/social-autoposter/setup.sh');
110
+ console.log(' 3. Posts are logged to the shared Neon DB (DATABASE_URL in .env)');
121
111
  }
122
112
 
123
113
  function update() {
@@ -145,9 +135,8 @@ function update() {
145
135
  console.log(' updated', f);
146
136
  }
147
137
 
148
- // Re-symlink skill, setup skill, and DB in case they broke
138
+ // Re-symlink skill and setup skill in case they broke
149
139
  const skillsDir = path.join(os.homedir(), '.claude', 'skills');
150
- const claudeDir = path.join(os.homedir(), '.claude');
151
140
  try {
152
141
  linkOrRelink(path.join(DEST, 'skill'), path.join(skillsDir, 'social-autoposter'));
153
142
  console.log(' re-linked ~/.claude/skills/social-autoposter');
@@ -156,10 +145,6 @@ function update() {
156
145
  linkOrRelink(path.join(DEST, 'setup'), path.join(skillsDir, 'social-autoposter-setup'));
157
146
  console.log(' re-linked ~/.claude/skills/social-autoposter-setup');
158
147
  } catch {}
159
- try {
160
- linkOrRelink(path.join(DEST, 'social_posts.db'), path.join(claudeDir, 'social_posts.db'));
161
- console.log(' re-linked ~/.claude/social_posts.db');
162
- } catch {}
163
148
 
164
149
  console.log('');
165
150
  console.log('Update complete. config.json and social_posts.db were preserved.');
@@ -1,7 +1,5 @@
1
1
  {
2
- "database": "~/social-autoposter/social_posts.db",
3
2
  "prompt_db": "~/claude-prompt-db/prompts.db",
4
- "sync_script": "~/social-autoposter/syncfield.sh",
5
3
  "stats_script": "~/.claude/skills/social-autoposter/stats.sh",
6
4
 
7
5
  "accounts": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -8,7 +8,7 @@
8
8
  "files": [
9
9
  "bin/",
10
10
  "scripts/*.py",
11
- "schema.sql",
11
+ "schema-postgres.sql",
12
12
  "config.example.json",
13
13
  ".env.example",
14
14
  "SKILL.md",
@@ -1,6 +1,9 @@
1
+ -- schema-postgres.sql — Neon Postgres schema mirroring SQLite for SyncField
2
+ -- Run once: psql "$DATABASE_URL" -f schema-postgres.sql
3
+
1
4
  CREATE TABLE IF NOT EXISTS posts (
2
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3
- platform TEXT NOT NULL CHECK(platform IN ('reddit', 'x', 'linkedin')),
5
+ id INTEGER PRIMARY KEY,
6
+ platform TEXT NOT NULL,
4
7
  thread_url TEXT NOT NULL,
5
8
  thread_author TEXT,
6
9
  thread_author_handle TEXT,
@@ -12,7 +15,7 @@ CREATE TABLE IF NOT EXISTS posts (
12
15
  our_account TEXT NOT NULL,
13
16
  posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
14
17
  discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
15
- status TEXT DEFAULT 'active' CHECK(status IN ('active', 'inactive', 'deleted', 'removed')),
18
+ status TEXT DEFAULT 'active',
16
19
  status_checked_at TIMESTAMP,
17
20
  engagement_updated_at TIMESTAMP,
18
21
  upvotes INTEGER,
@@ -28,40 +31,20 @@ CREATE TABLE IF NOT EXISTS posts (
28
31
 
29
32
  CREATE INDEX IF NOT EXISTS idx_posts_platform ON posts(platform);
30
33
 
31
- CREATE TABLE IF NOT EXISTS threads (
32
- id INTEGER PRIMARY KEY AUTOINCREMENT,
33
- platform TEXT NOT NULL,
34
- url TEXT NOT NULL UNIQUE,
35
- author TEXT,
36
- author_handle TEXT,
37
- title TEXT,
38
- content TEXT,
39
- engagement TEXT,
40
- discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP
41
- );
42
-
43
- CREATE TABLE IF NOT EXISTS our_posts (
44
- id INTEGER PRIMARY KEY AUTOINCREMENT,
45
- thread_id INTEGER REFERENCES threads(id),
46
- platform TEXT NOT NULL,
47
- url TEXT,
48
- content TEXT NOT NULL,
49
- account TEXT,
50
- posted_at DATETIME DEFAULT CURRENT_TIMESTAMP
51
- );
52
-
53
- CREATE TABLE IF NOT EXISTS thread_comments (
54
- id INTEGER PRIMARY KEY AUTOINCREMENT,
55
- thread_id INTEGER REFERENCES threads(id),
56
- author TEXT,
57
- author_handle TEXT,
58
- content TEXT,
59
- engagement TEXT,
60
- discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP
34
+ CREATE TABLE IF NOT EXISTS campaigns (
35
+ id INTEGER PRIMARY KEY,
36
+ name TEXT NOT NULL,
37
+ prompt TEXT NOT NULL,
38
+ platforms TEXT DEFAULT 'x,reddit,moltbook',
39
+ status TEXT DEFAULT 'active',
40
+ max_posts_per_day INTEGER DEFAULT 4,
41
+ posts_made INTEGER DEFAULT 0,
42
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
43
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
61
44
  );
62
45
 
63
46
  CREATE TABLE IF NOT EXISTS replies (
64
- id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ id INTEGER PRIMARY KEY,
65
48
  post_id INTEGER REFERENCES posts(id),
66
49
  platform TEXT NOT NULL,
67
50
  their_comment_id TEXT NOT NULL,
@@ -75,8 +58,26 @@ CREATE TABLE IF NOT EXISTS replies (
75
58
  moltbook_post_uuid TEXT,
76
59
  moltbook_parent_comment_uuid TEXT,
77
60
  depth INTEGER DEFAULT 1,
78
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','replied','skipped','error')),
61
+ status TEXT DEFAULT 'pending',
79
62
  skip_reason TEXT,
80
63
  discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
81
64
  replied_at TIMESTAMP
82
65
  );
66
+
67
+ CREATE TABLE IF NOT EXISTS thread_comments (
68
+ id INTEGER PRIMARY KEY,
69
+ thread_id INTEGER,
70
+ author TEXT,
71
+ author_handle TEXT,
72
+ content TEXT,
73
+ engagement TEXT,
74
+ discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS _syncfield_meta (
78
+ key TEXT PRIMARY KEY,
79
+ value TEXT
80
+ );
81
+
82
+ INSERT INTO _syncfield_meta (key, value) VALUES ('last_sync', '1970-01-01T00:00:00Z')
83
+ ON CONFLICT (key) DO NOTHING;
package/scripts/db.py ADDED
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """Shared Neon Postgres connection for social-autoposter.
3
+
4
+ Provides a thin psycopg2 wrapper with a sqlite3-compatible API so all
5
+ scripts can use the same SQL without changes to query logic.
6
+
7
+ DATABASE_URL is read from ~/social-autoposter/.env (pre-filled on install).
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import sys
13
+
14
+ ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
15
+
16
+
17
+ def load_env():
18
+ if os.path.exists(ENV_PATH):
19
+ with open(ENV_PATH) as f:
20
+ for line in f:
21
+ line = line.strip()
22
+ if line and not line.startswith('#') and '=' in line:
23
+ k, v = line.split('=', 1)
24
+ os.environ.setdefault(k.strip(), v.strip())
25
+
26
+
27
+ def _translate_sql(sql):
28
+ """Translate SQLite-specific SQL syntax to PostgreSQL."""
29
+ # ? placeholders -> %s
30
+ sql = sql.replace('?', '%s')
31
+ # datetime('now', '-N hours') -> NOW() - INTERVAL 'N hours'
32
+ sql = re.sub(r"datetime\('now',\s*'-(\d+) hours'\)", r"NOW() - INTERVAL '\1 hours'", sql)
33
+ # datetime('now', '-N days') -> NOW() - INTERVAL 'N days'
34
+ sql = re.sub(r"datetime\('now',\s*'-(\d+) days'\)", r"NOW() - INTERVAL '\1 days'", sql)
35
+ # datetime('now') -> NOW()
36
+ sql = re.sub(r"datetime\('now'\)", 'NOW()', sql)
37
+ # status_checked_at=datetime('now') already handled above
38
+ return sql
39
+
40
+
41
+ class PGConn:
42
+ """Thin psycopg2 wrapper with a sqlite3-compatible execute/commit/close API."""
43
+
44
+ def __init__(self, conn):
45
+ import psycopg2.extras
46
+ self._conn = conn
47
+ self._cursor_factory = psycopg2.extras.DictCursor
48
+
49
+ def execute(self, sql, params=None):
50
+ cur = self._conn.cursor(cursor_factory=self._cursor_factory)
51
+ sql = _translate_sql(sql)
52
+ if params is not None:
53
+ cur.execute(sql, list(params))
54
+ else:
55
+ cur.execute(sql)
56
+ return cur
57
+
58
+ def commit(self):
59
+ self._conn.commit()
60
+
61
+ def close(self):
62
+ self._conn.close()
63
+
64
+ # No-op to absorb sqlite3.Row assignments
65
+ @property
66
+ def row_factory(self):
67
+ return None
68
+
69
+ @row_factory.setter
70
+ def row_factory(self, val):
71
+ pass
72
+
73
+
74
+ def get_conn():
75
+ """Return a PGConn connected to the central Neon database."""
76
+ load_env()
77
+ url = os.environ.get('DATABASE_URL')
78
+ if not url:
79
+ print("ERROR: DATABASE_URL not set in ~/social-autoposter/.env", file=sys.stderr)
80
+ print(" Re-run: npx social-autoposter init", file=sys.stderr)
81
+ sys.exit(1)
82
+ try:
83
+ import psycopg2
84
+ except ImportError:
85
+ print("ERROR: psycopg2-binary not installed.", file=sys.stderr)
86
+ print(" Run: pip3 install psycopg2-binary", file=sys.stderr)
87
+ sys.exit(1)
88
+ conn = psycopg2.connect(url)
89
+ return PGConn(conn)
@@ -13,13 +13,14 @@ import argparse
13
13
  import json
14
14
  import os
15
15
  import re
16
- import sqlite3
17
16
  import sys
18
17
  import time
19
18
  import urllib.request
20
19
  from datetime import datetime, timezone
21
20
 
22
- DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
21
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
+ import db as dbmod
23
+
23
24
  CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
24
25
 
25
26
 
@@ -43,29 +44,29 @@ def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
43
44
  return None
44
45
 
45
46
 
46
- def get_already_posted(db_path):
47
+ def get_already_posted():
47
48
  """Return set of thread URLs we've already posted in."""
48
- db = sqlite3.connect(db_path)
49
- rows = db.execute("SELECT thread_url FROM posts WHERE thread_url IS NOT NULL").fetchall()
50
- db.close()
49
+ conn = dbmod.get_conn()
50
+ rows = conn.execute("SELECT thread_url FROM posts WHERE thread_url IS NOT NULL").fetchall()
51
+ conn.close()
51
52
  return {row[0] for row in rows}
52
53
 
53
54
 
54
- def get_recent_posts(db_path, limit=5):
55
+ def get_recent_posts(limit=5):
55
56
  """Return our last N post contents for repetition checking."""
56
- db = sqlite3.connect(db_path)
57
- rows = db.execute("SELECT our_content FROM posts ORDER BY id DESC LIMIT ?", [limit]).fetchall()
58
- db.close()
57
+ conn = dbmod.get_conn()
58
+ rows = conn.execute("SELECT our_content FROM posts ORDER BY id DESC LIMIT %s", [limit]).fetchall()
59
+ conn.close()
59
60
  return [row[0] for row in rows]
60
61
 
61
62
 
62
- def check_rate_limit(db_path, max_per_day=40):
63
+ def check_rate_limit(max_per_day=40):
63
64
  """Return (posts_today, can_post)."""
64
- db = sqlite3.connect(db_path)
65
- row = db.execute(
66
- "SELECT COUNT(*) FROM posts WHERE posted_at >= datetime('now', '-24 hours')"
65
+ conn = dbmod.get_conn()
66
+ row = conn.execute(
67
+ "SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'"
67
68
  ).fetchone()
68
- db.close()
69
+ conn.close()
69
70
  count = row[0]
70
71
  return count, count < max_per_day
71
72
 
@@ -145,7 +146,6 @@ def filter_threads(threads, already_posted, topic=None):
145
146
 
146
147
  def main():
147
148
  parser = argparse.ArgumentParser(description="Find candidate threads to comment on")
148
- parser.add_argument("--db", default=None, help="Path to SQLite database")
149
149
  parser.add_argument("--subreddits", default=None, help="Comma-separated subreddits (e.g. ClaudeAI,programming)")
150
150
  parser.add_argument("--topic", default=None, help="Filter threads by topic keyword")
151
151
  parser.add_argument("--sort", default="new", choices=["new", "hot", "top"], help="Reddit sort order")
@@ -154,19 +154,18 @@ def main():
154
154
  args = parser.parse_args()
155
155
 
156
156
  config = load_config()
157
- db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
158
157
  subreddits = args.subreddits.split(",") if args.subreddits else config.get("subreddits", [])
159
158
  reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
160
159
  user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
161
160
 
162
161
  # Rate limit check
163
- posts_today, can_post = check_rate_limit(db_path)
162
+ posts_today, can_post = check_rate_limit()
164
163
  if not can_post:
165
164
  print(json.dumps({"error": "rate_limit", "posts_today": posts_today, "threads": []}))
166
165
  sys.exit(1)
167
166
 
168
- already_posted = get_already_posted(db_path)
169
- recent_posts = get_recent_posts(db_path)
167
+ already_posted = get_already_posted()
168
+ recent_posts = get_recent_posts()
170
169
 
171
170
  # Fetch threads
172
171
  threads = fetch_reddit_threads(subreddits, sort=args.sort, limit=args.limit, user_agent=user_agent)
@@ -14,15 +14,16 @@ import argparse
14
14
  import json
15
15
  import os
16
16
  import re
17
- import sqlite3
18
17
  import sys
19
18
  import time
20
19
  import urllib.request
21
20
  from datetime import datetime, timedelta, timezone
22
21
 
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+ import db as dbmod
24
+
23
25
  STALENESS_DAYS = 30
24
26
  MIN_WORDS = 5
25
- DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
26
27
  CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
27
28
 
28
29
 
@@ -70,9 +71,8 @@ def fetch_json(url, headers=None, user_agent="social-autoposter/1.0", retries=5)
70
71
 
71
72
 
72
73
  class ReplyScanner:
73
- def __init__(self, db_path, reddit_account, user_agent="social-autoposter/1.0"):
74
- self.db = sqlite3.connect(db_path, timeout=30)
75
- self.db.row_factory = sqlite3.Row
74
+ def __init__(self, reddit_account, user_agent="social-autoposter/1.0"):
75
+ self.db = dbmod.get_conn()
76
76
  self.reddit_account = reddit_account
77
77
  self.user_agent = user_agent
78
78
  self.skip_authors = {"AutoModerator", "[deleted]", reddit_account}
@@ -80,31 +80,9 @@ class ReplyScanner:
80
80
  self.skipped = 0
81
81
  self.errors = 0
82
82
 
83
- # Ensure replies table exists
84
- self.db.execute("""CREATE TABLE IF NOT EXISTS replies (
85
- id INTEGER PRIMARY KEY AUTOINCREMENT,
86
- post_id INTEGER REFERENCES posts(id),
87
- platform TEXT NOT NULL,
88
- their_comment_id TEXT NOT NULL,
89
- their_author TEXT,
90
- their_content TEXT,
91
- their_comment_url TEXT,
92
- our_reply_id TEXT,
93
- our_reply_content TEXT,
94
- our_reply_url TEXT,
95
- parent_reply_id INTEGER REFERENCES replies(id),
96
- moltbook_post_uuid TEXT,
97
- moltbook_parent_comment_uuid TEXT,
98
- depth INTEGER DEFAULT 1,
99
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','replied','skipped','error')),
100
- skip_reason TEXT,
101
- discovered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
102
- replied_at TIMESTAMP
103
- )""")
104
-
105
83
  def already_tracked(self, platform, comment_id):
106
84
  row = self.db.execute(
107
- "SELECT COUNT(*) FROM replies WHERE platform=? AND their_comment_id=?",
85
+ "SELECT COUNT(*) FROM replies WHERE platform=%s AND their_comment_id=%s",
108
86
  (platform, str(comment_id)),
109
87
  ).fetchone()
110
88
  return row[0] > 0
@@ -120,7 +98,7 @@ class ReplyScanner:
120
98
  """INSERT INTO replies
121
99
  (post_id, platform, their_comment_id, their_author, their_content, their_comment_url,
122
100
  parent_reply_id, depth, status, skip_reason, moltbook_post_uuid, moltbook_parent_comment_uuid)
123
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
101
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
124
102
  (post_id, platform, comment_id, author, content, comment_url,
125
103
  parent_reply_id, depth, status, skip_reason, moltbook_post_uuid, moltbook_parent_comment_uuid),
126
104
  )
@@ -218,7 +196,7 @@ class ReplyScanner:
218
196
  print("Scanning Reddit posts for replies...")
219
197
  posts = self.db.execute(
220
198
  "SELECT id, our_url, thread_url, thread_title, thread_author FROM posts "
221
- "WHERE platform='reddit' AND status='active' AND our_url IS NOT NULL AND our_url != '' AND our_url LIKE 'http%'"
199
+ "WHERE platform='reddit' AND status='active' AND our_url IS NOT NULL AND our_url != '' AND our_url LIKE 'http%%'"
222
200
  ).fetchall()
223
201
 
224
202
  for post in posts:
@@ -264,7 +242,7 @@ class ReplyScanner:
264
242
  print("\nScanning replies to our previous replies...")
265
243
  replied_rows = self.db.execute(
266
244
  "SELECT id, platform, our_reply_url, post_id, depth "
267
- "FROM replies WHERE status='replied' AND our_reply_url IS NOT NULL"
245
+ "FROM replies WHERE status='replied' AND our_reply_url IS NOT NULL",
268
246
  ).fetchall()
269
247
 
270
248
  for row in replied_rows:
@@ -299,7 +277,7 @@ class ReplyScanner:
299
277
  print("\nScanning Moltbook posts for replies...")
300
278
  posts = self.db.execute(
301
279
  "SELECT id, our_url FROM posts "
302
- "WHERE platform='moltbook' AND status='active' AND our_url IS NOT NULL"
280
+ "WHERE platform='moltbook' AND status='active' AND our_url IS NOT NULL",
303
281
  ).fetchall()
304
282
 
305
283
  for post in posts:
@@ -342,20 +320,19 @@ class ReplyScanner:
342
320
 
343
321
  def main():
344
322
  parser = argparse.ArgumentParser(description="Scan for replies to our social posts")
345
- parser.add_argument("--db", default=None, help="Path to SQLite database")
346
323
  parser.add_argument("--reddit-account", default=None, help="Reddit username")
347
324
  args = parser.parse_args()
348
325
 
349
326
  config = load_config()
350
- db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
351
327
  reddit_account = args.reddit_account or config.get("accounts", {}).get("reddit", {}).get("username", "")
352
328
 
353
329
  if not reddit_account:
354
330
  print("ERROR: Reddit account not configured. Set it in config.json or pass --reddit-account")
355
331
  sys.exit(1)
356
332
 
333
+ dbmod.load_env()
357
334
  user_agent = f"social-autoposter/1.0 (u/{reddit_account})"
358
- scanner = ReplyScanner(db_path, reddit_account, user_agent)
335
+ scanner = ReplyScanner(reddit_account, user_agent)
359
336
  scanner.scan_reddit()
360
337
 
361
338
  moltbook_key = os.environ.get("MOLTBOOK_API_KEY", "")
@@ -12,12 +12,13 @@ import argparse
12
12
  import json
13
13
  import os
14
14
  import re
15
- import sqlite3
16
15
  import sys
17
16
  import time
18
17
  import urllib.request
19
18
 
20
- DEFAULT_DB = os.path.expanduser("~/social-autoposter/social_posts.db")
19
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
+ import db as dbmod
21
+
21
22
  CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
22
23
 
23
24
 
@@ -80,14 +81,14 @@ def update_reddit(db, user_agent, quiet=False):
80
81
  score = comment_data.get("score", 0)
81
82
 
82
83
  if body in ("[deleted]",) or author == "[deleted]":
83
- db.execute("UPDATE posts SET status='deleted', status_checked_at=datetime('now') WHERE id=?", [post_id])
84
+ db.execute("UPDATE posts SET status='deleted', status_checked_at=NOW() WHERE id=%s", [post_id])
84
85
  deleted += 1
85
86
  if not quiet:
86
87
  print(f"DELETED [{post_id}]")
87
88
  continue
88
89
 
89
90
  if body == "[removed]":
90
- db.execute("UPDATE posts SET status='removed', status_checked_at=datetime('now') WHERE id=?", [post_id])
91
+ db.execute("UPDATE posts SET status='removed', status_checked_at=NOW() WHERE id=%s", [post_id])
91
92
  removed += 1
92
93
  if not quiet:
93
94
  print(f"REMOVED [{post_id}]")
@@ -99,8 +100,8 @@ def update_reddit(db, user_agent, quiet=False):
99
100
  engagement = json.dumps({"thread_score": thread_score, "thread_comments": thread_comments})
100
101
 
101
102
  db.execute(
102
- "UPDATE posts SET upvotes=?, comments_count=?, thread_engagement=?, "
103
- "engagement_updated_at=datetime('now'), status_checked_at=datetime('now') WHERE id=?",
103
+ "UPDATE posts SET upvotes=%s, comments_count=%s, thread_engagement=%s, "
104
+ "engagement_updated_at=NOW(), status_checked_at=NOW() WHERE id=%s",
104
105
  [score, thread_comments, engagement, post_id],
105
106
  )
106
107
  updated += 1
@@ -142,7 +143,7 @@ def update_moltbook(db, api_key, quiet=False):
142
143
 
143
144
  post_data = data.get("post", {})
144
145
  if post_data.get("is_deleted"):
145
- db.execute("UPDATE posts SET status='deleted', status_checked_at=datetime('now') WHERE id=?", [post_id])
146
+ db.execute("UPDATE posts SET status='deleted', status_checked_at=NOW() WHERE id=%s", [post_id])
146
147
  deleted += 1
147
148
  continue
148
149
 
@@ -153,8 +154,8 @@ def update_moltbook(db, api_key, quiet=False):
153
154
  engagement = json.dumps({"score": score, "upvotes": upvotes, "comment_count": comment_count})
154
155
 
155
156
  db.execute(
156
- "UPDATE posts SET upvotes=?, comments_count=?, thread_engagement=?, "
157
- "engagement_updated_at=datetime('now'), status_checked_at=datetime('now') WHERE id=?",
157
+ "UPDATE posts SET upvotes=%s, comments_count=%s, thread_engagement=%s, "
158
+ "engagement_updated_at=NOW(), status_checked_at=NOW() WHERE id=%s",
158
159
  [upvotes, comment_count, engagement, post_id],
159
160
  )
160
161
  updated += 1
@@ -167,17 +168,16 @@ def update_moltbook(db, api_key, quiet=False):
167
168
 
168
169
  def main():
169
170
  parser = argparse.ArgumentParser(description="Update engagement stats for social posts")
170
- parser.add_argument("--db", default=None, help="Path to SQLite database")
171
171
  parser.add_argument("--quiet", action="store_true", help="Minimal output")
172
172
  parser.add_argument("--json", action="store_true", help="Output as JSON")
173
173
  args = parser.parse_args()
174
174
 
175
175
  config = load_config()
176
- db_path = args.db or os.path.expanduser(config.get("database", DEFAULT_DB))
177
176
  reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
178
177
  user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
179
178
 
180
- db = sqlite3.connect(db_path, timeout=30)
179
+ dbmod.load_env()
180
+ db = dbmod.get_conn()
181
181
 
182
182
  reddit_stats = update_reddit(db, user_agent, quiet=args.quiet)
183
183
  moltbook_stats = update_moltbook(db, os.environ.get("MOLTBOOK_API_KEY", ""), quiet=args.quiet)
package/setup/SKILL.md CHANGED
@@ -39,10 +39,10 @@ If NOT_FOUND, install:
39
39
  npx social-autoposter init
40
40
  ```
41
41
 
42
- This copies all scripts, schema, skill files, and config templates to `~/social-autoposter/`. It also:
42
+ This copies all scripts, skill files, and config templates to `~/social-autoposter/`. It also:
43
43
  - Creates `config.json` from `config.example.json` (if missing)
44
- - Creates `.env` from `.env.example` (if missing) — includes pre-filled Neon DATABASE_URL
45
- - Creates `social_posts.db` from `schema.sql` (if missing)
44
+ - Creates `.env` from `.env.example` (if missing) — includes pre-filled Neon `DATABASE_URL`
45
+ - Installs `psycopg2-binary` (Python driver for Neon)
46
46
  - Symlinks `~/.claude/skills/social-autoposter` → `~/social-autoposter/skill`
47
47
 
48
48
  To update scripts later without touching config/data:
@@ -52,18 +52,27 @@ npx social-autoposter update
52
52
 
53
53
  Set `SKILL_DIR=~/social-autoposter` for the rest of this wizard.
54
54
 
55
- ### Step 2: Verify the database
55
+ ### Step 2: Verify the Neon database connection
56
+
57
+ Load the env and test the connection:
56
58
 
57
59
  ```bash
58
- sqlite3 "$SKILL_DIR/social_posts.db" "SELECT name FROM sqlite_master WHERE type='table';"
60
+ source "$SKILL_DIR/.env"
61
+ python3 -c "
62
+ import psycopg2, os
63
+ conn = psycopg2.connect(os.environ['DATABASE_URL'])
64
+ cur = conn.cursor()
65
+ cur.execute(\"SELECT COUNT(*) FROM posts\")
66
+ print('Connected. Posts in DB:', cur.fetchone()[0])
67
+ conn.close()
68
+ "
59
69
  ```
60
70
 
61
- Expected tables: `posts`, `threads`, `our_posts`, `thread_comments`, `replies`.
71
+ Expected: `Connected. Posts in DB: <number>` (any number is fine, including 0).
62
72
 
63
- If missing, create it:
64
- ```bash
65
- sqlite3 "$SKILL_DIR/social_posts.db" < "$SKILL_DIR/schema.sql"
66
- ```
73
+ If psycopg2 is missing: `pip3 install psycopg2-binary`
74
+
75
+ If the connection fails, check that `DATABASE_URL` is set in `$SKILL_DIR/.env`.
67
76
 
68
77
  ### Step 3: Configure accounts
69
78
 
@@ -232,8 +241,8 @@ Print a summary:
232
241
  ```
233
242
  Social Autoposter Setup Complete
234
243
 
235
- Installed: ~/social-autoposter (v1.0.3 via npm)
236
- Database: ~/social-autoposter/social_posts.db
244
+ Installed: ~/social-autoposter (v1.0.8 via npm)
245
+ Database: Neon Postgres (DATABASE_URL in .env)
237
246
  Config: ~/social-autoposter/config.json
238
247
  Env: ~/social-autoposter/.env
239
248
  Skill: ~/.claude/skills/social-autoposter
package/skill/SKILL.md CHANGED
@@ -18,36 +18,37 @@ Automates finding, posting, and tracking social media comments and original post
18
18
  | `/social-autoposter engage` | Scan and reply to responses on our posts |
19
19
  | `/social-autoposter audit` | Full browser audit of all posts |
20
20
 
21
- ## Accounts
21
+ ---
22
22
 
23
- - **Reddit**: u/Deep_Ad1959 (logged in via Google with matt@mediar.ai). Use old.reddit.com.
24
- - **X/Twitter**: @m13v_
25
- - **LinkedIn**: Matthew Diakonov
26
- - **Moltbook**: matthew-autoposter (API key in `~/social-autoposter/.env`)
23
+ ## FIRST: Read config
27
24
 
28
- ## Our Projects & Links
25
+ Before doing anything, read `~/social-autoposter/config.json`. Everything — accounts, projects, subreddits, content angle — comes from there.
29
26
 
30
- | Project | What it does | Website | GitHub |
31
- |---------|-------------|---------|--------|
32
- | Fazm | AI computer agent for macOS | https://fazm.ai | — |
33
- | Terminator | Desktop automation framework | https://t8r.tech | https://github.com/mediar-ai/terminator |
34
- | macOS MCP | MCP server for macOS automation | — | https://github.com/mediar-ai/mcp-server-macos-use |
35
- | Vipassana | Resource site for meditators | https://vipassana.cool | https://github.com/m13v/vipassana-cool |
36
- | S4L | Social media autoposter (this tool) | https://s4l.ai | https://github.com/m13v/social-autoposter |
27
+ ```bash
28
+ cat ~/social-autoposter/config.json
29
+ ```
37
30
 
38
- Prefer website links when one exists (drives signups). Use GitHub for open source tools without a website.
31
+ Key fields you'll use throughout every workflow:
39
32
 
40
- ## Database
33
+ - `accounts.reddit.username` — Reddit handle to post as
34
+ - `accounts.twitter.handle` — X/Twitter handle
35
+ - `accounts.linkedin.name` — LinkedIn display name
36
+ - `accounts.moltbook.username` — Moltbook username
37
+ - `subreddits` — list of subreddits to monitor and post in
38
+ - `content_angle` — the user's unique perspective for writing authentic comments
39
+ - `projects` — products/repos to mention naturally when relevant (each has `name`, `description`, `website`, `github`, `topics`)
40
+ - `database` — unused (DB is Neon Postgres via `DATABASE_URL` in `.env`)
41
41
 
42
- - **Path**: `~/social-autoposter/social_posts.db` (also symlinked at `~/.claude/social_posts.db`)
43
- - **Prompt DB**: `~/claude-prompt-db/prompts.db`
42
+ Use these values everywhere below instead of any hardcoded names or links.
43
+
44
+ ---
44
45
 
45
46
  ## Helper Scripts
46
47
 
47
48
  Standalone Python scripts — no LLM needed.
48
49
 
49
50
  ```bash
50
- python3 ~/social-autoposter/scripts/find_threads.py --topic "macOS automation"
51
+ python3 ~/social-autoposter/scripts/find_threads.py --include-moltbook
51
52
  python3 ~/social-autoposter/scripts/scan_replies.py
52
53
  python3 ~/social-autoposter/scripts/update_stats.py --quiet
53
54
  ```
@@ -59,7 +60,7 @@ python3 ~/social-autoposter/scripts/update_stats.py --quiet
59
60
  ### 1. Rate limit check
60
61
 
61
62
  ```sql
62
- SELECT COUNT(*) FROM posts WHERE posted_at >= datetime('now', '-24 hours')
63
+ SELECT COUNT(*) FROM posts WHERE posted_at >= NOW() - INTERVAL '24 hours'
63
64
  ```
64
65
  Max 40 posts per 24 hours. Stop if at limit.
65
66
 
@@ -71,30 +72,42 @@ python3 ~/social-autoposter/scripts/find_threads.py --include-moltbook
71
72
  ```
72
73
 
73
74
  **Option B — Browse manually:**
74
- Browse `/new` and `/hot` on: r/ClaudeAI, r/ClaudeCode, r/AI_Agents, r/ExperiencedDevs, r/macapps, r/vipassana.
75
- Also check Moltbook via API.
75
+ Browse `/new` and `/hot` on the subreddits from `config.json`. Also check Moltbook via API.
76
76
 
77
77
  ### 3. Pick the best thread
78
78
 
79
- - Must have a genuine angle from Matthew's work: building desktop AI agents, running 5 Claude agents in parallel on Swift/Rust/Flutter, CLAUDE.md specs, Playwright MCP, token costs, rate limits, vipassana practice
79
+ - You have a genuine angle from `content_angle` in config.json
80
80
  - Not already posted in: `SELECT thread_url FROM posts`
81
- - Last 5 comments don't repeat: `SELECT our_content FROM posts ORDER BY id DESC LIMIT 5`
82
- - If nothing fits, **stop**
81
+ - Last 5 comments don't repeat the same talking points:
82
+ ```sql
83
+ SELECT our_content FROM posts ORDER BY id DESC LIMIT 5
84
+ ```
85
+ - If nothing fits naturally, **stop**. Better to skip than force a bad comment.
83
86
 
84
87
  ### 4. Read the thread + top comments
85
88
 
86
- Check tone, length cues, thread age. Find best comment to reply to (50+ upvotes = more visibility).
89
+ Check tone, length cues, thread age. Find best comment to reply to (high-upvote comments get more visibility).
87
90
 
88
91
  ### 5. Draft the comment
89
92
 
90
- Follow Content Rules below. 2-3 sentences, first person, specific. No product links in top-level comments.
93
+ Follow Content Rules below. 2-3 sentences, first person, specific details from `content_angle`. No product links in top-level comments.
91
94
 
92
95
  ### 6. Post it
93
96
 
94
- **Reddit**: old.reddit.com → reply box → type → submit → verify → capture permalink → close tab.
95
- **X/Twitter**: tweet reply box → type → Reply → verify → capture URL → close tab.
96
- **LinkedIn**: post → comment boxtypePost → close tab.
97
- **Moltbook** (API, no browser):
97
+ **Reddit** (browser automation):
98
+ - Navigate to `old.reddit.com` thread URL
99
+ - Reply box type comment submit wait 2-3s verify comment appeared capture permalink → close tab
100
+ - Post as the username in `config.json → accounts.reddit.username`
101
+
102
+ **X/Twitter** (browser automation):
103
+ - Navigate to tweet → reply box → type → Reply → verify → capture URL
104
+ - Post as the handle in `config.json → accounts.twitter.handle`
105
+
106
+ **LinkedIn** (browser automation):
107
+ - Navigate to post → comment box → type → Post → close tab
108
+ - Post as the name in `config.json → accounts.linkedin.name`
109
+
110
+ **Moltbook** (API — no browser needed):
98
111
  ```bash
99
112
  source ~/social-autoposter/.env
100
113
  curl -s -X POST -H "Authorization: Bearer $MOLTBOOK_API_KEY" -H "Content-Type: application/json" \
@@ -102,6 +115,7 @@ curl -s -X POST -H "Authorization: Bearer $MOLTBOOK_API_KEY" -H "Content-Type: a
102
115
  "https://www.moltbook.com/api/v1/posts"
103
116
  ```
104
117
  On Moltbook: write as agent ("my human" not "I"). Max 1 post per 30 min.
118
+ Verify: fetch post by UUID, check `verification_status` is `"verified"`.
105
119
 
106
120
  ### 7. Log + sync
107
121
 
@@ -109,10 +123,12 @@ On Moltbook: write as agent ("my human" not "I"). Max 1 post per 30 min.
109
123
  INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
110
124
  thread_title, thread_content, our_url, our_content, our_account,
111
125
  source_summary, status, posted_at)
112
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', datetime('now'));
126
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
113
127
  ```
114
128
 
115
- Then sync: `bash ~/social-autoposter/syncfield.sh`
129
+ Use the account value from `config.json` for `our_account`.
130
+
131
+ If `sync_script` is set in config.json, run it after logging.
116
132
 
117
133
  ---
118
134
 
@@ -122,44 +138,34 @@ Then sync: `bash ~/social-autoposter/syncfield.sh`
122
138
 
123
139
  ### 1. Rate limit check
124
140
 
125
- ```sql
126
- SELECT COUNT(*) FROM posts WHERE posted_at >= datetime('now', '-24 hours') AND thread_author = 'Deep_Ad1959';
127
- ```
128
141
  Max 1 original post per 24 hours. Max 3 per week.
129
142
 
130
143
  ### 2. Cross-posting check
131
144
 
132
145
  ```sql
133
146
  SELECT platform, thread_title, posted_at FROM posts
134
- WHERE source_summary LIKE '%' || ? || '%' AND posted_at >= datetime('now', '-30 days')
147
+ WHERE source_summary LIKE '%' || %s || '%' AND posted_at >= NOW() - INTERVAL '30 days'
135
148
  ORDER BY posted_at DESC;
136
149
  ```
137
- **NEVER post the same or similar content to multiple subreddits.** This is the #1 AI detection red flag. Each post must be unique to its community. If you posted about vipassana in r/vipassana this week, do NOT post about vipassana in r/meditation or r/streamentry.
138
150
 
139
- ### 3. Pick one target community
151
+ **NEVER post the same or similar content to multiple subreddits.** This is the #1 AI detection red flag. Each post must be unique to its community.
140
152
 
141
- Choose the single best subreddit for this topic. Tailor the post to that community's culture:
153
+ ### 3. Pick one target community
142
154
 
143
- | Community | Tone | What works |
144
- |-----------|------|------------|
145
- | r/vipassana | Earnest, practical | Course experiences, daily practice struggles, specific technique questions |
146
- | r/meditation | Casual, broad | General insights, beginner-friendly, "what worked for me" |
147
- | r/streamentry | Technical, experienced | Practice milestones, specific meditation phenomena, dharma discussion |
148
- | r/TheMindIlluminated | Structured, stage-based | TMI stage references, attention/awareness balance |
149
- | r/ClaudeAI, r/ClaudeCode | Dev-casual, memes OK | Tool tips, workflow hacks, cost/rate-limit gripes |
155
+ Choose the single best subreddit from `config.json → subreddits` for this topic. Tailor the post to that community's culture and tone.
150
156
 
151
157
  ### 4. Draft the post
152
158
 
153
159
  **Anti-AI-detection checklist** (must pass ALL before posting):
154
160
 
155
161
  - [ ] No em dashes (—). Use regular dashes (-) or commas instead
156
- - [ ] No markdown headers (##) or bold (**) in Reddit posts — Reddit users don't format like that
157
- - [ ] No numbered/bulleted lists — write in paragraphs like a normal person
162
+ - [ ] No markdown headers (##) or bold (**) in Reddit posts
163
+ - [ ] No numbered/bulleted lists — write in paragraphs
158
164
  - [ ] No "Hi everyone" or "Hey r/subreddit" openings
159
- - [ ] Title doesn't use clickbait patterns ("What I wish I'd known", "What actually changed", "A guide to")
160
- - [ ] Contains at least one imperfection: incomplete thought, casual aside, typo-level informality
165
+ - [ ] Title doesn't use clickbait patterns ("What I wish I'd known", "A guide to")
166
+ - [ ] Contains at least one imperfection: incomplete thought, casual aside, informality
161
167
  - [ ] Reads like a real person writing on their phone, not an essay
162
- - [ ] Does NOT link to vipassana.cool or any project in the post body — earn attention first
168
+ - [ ] Does NOT link to any project in the post body — earn attention first
163
169
  - [ ] Not too long — 2-4 short paragraphs max for Reddit
164
170
 
165
171
  **Read it out loud.** If it sounds like a blog post or a ChatGPT response, rewrite it.
@@ -174,10 +180,10 @@ Choose the single best subreddit for this topic. Tailor the post to that communi
174
180
  INSERT INTO posts (platform, thread_url, thread_author, thread_author_handle,
175
181
  thread_title, thread_content, our_url, our_content, our_account,
176
182
  source_summary, status, posted_at)
177
- VALUES (?, ?, 'Deep_Ad1959', 'u/Deep_Ad1959', ?, ?, ?, ?, 'u/Deep_Ad1959', ?, 'active', datetime('now'));
183
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'active', NOW());
178
184
  ```
179
185
 
180
- For original posts: `thread_url` = `our_url` (same thing), `thread_author` = our account.
186
+ For original posts: `thread_url` = `our_url`, `thread_author` = our account from config.json.
181
187
 
182
188
  ### 7. Mandatory engagement plan
183
189
 
@@ -185,7 +191,7 @@ After posting, you MUST:
185
191
  - Check for comments within 2-4 hours
186
192
  - Reply to every substantive comment within 24 hours
187
193
  - Replies should be casual, conversational, expand the topic — NOT polished paragraphs
188
- - If someone accuses the post of being AI: respond genuinely, don't get defensive, mention a specific personal detail
194
+ - If someone accuses the post of being AI: respond genuinely, mention a specific personal detail
189
195
 
190
196
  ---
191
197
 
@@ -195,8 +201,6 @@ After posting, you MUST:
195
201
  python3 ~/social-autoposter/scripts/update_stats.py
196
202
  ```
197
203
 
198
- Or the legacy bash version: `bash ~/social-autoposter/skill/stats.sh`
199
-
200
204
  ---
201
205
 
202
206
  ## Workflow: Engage (`/social-autoposter engage`)
@@ -208,21 +212,30 @@ python3 ~/social-autoposter/scripts/scan_replies.py
208
212
 
209
213
  ### Phase B: Respond to pending replies
210
214
 
211
- Query pending: `SELECT * FROM replies WHERE status='pending' ORDER BY discovered_at LIMIT 10`
215
+ ```sql
216
+ SELECT r.id, r.platform, r.their_author, r.their_content, r.their_comment_url,
217
+ r.depth, p.thread_title, p.our_content
218
+ FROM replies r JOIN posts p ON r.post_id = p.id
219
+ WHERE r.status='pending' ORDER BY r.discovered_at ASC LIMIT 10
220
+ ```
212
221
 
213
- Draft replies: 2-4 sentences, casual, expand the topic. Apply Tiered Reply Strategy. Process all pending replies skip (with reason) those that don't warrant a response.
222
+ Draft replies: 2-4 sentences, casual, expand the topic. Apply Tiered Reply Strategy. Max 5 replies per run.
214
223
 
215
- Post via browser (Reddit) or API (Moltbook). Update: `UPDATE replies SET status='replied', our_reply_content=?, replied_at=datetime('now') WHERE id=?`
224
+ Post via browser (Reddit/X) or API (Moltbook). Update:
225
+ ```sql
226
+ UPDATE replies SET status='replied', our_reply_content=%s, our_reply_url=%s,
227
+ replied_at=NOW() WHERE id=%s
228
+ ```
216
229
 
217
230
  ### Phase C: X/Twitter replies (browser required)
218
231
 
219
- Navigate to `https://x.com/notifications/mentions`. Extract mentions replying to @m13v_. Respond to all substantive ones. Log to `replies` table.
232
+ Navigate to `https://x.com/notifications/mentions`. Find replies to the handle in config.json. Respond to substantive ones (max 5). Log to `replies` table.
220
233
 
221
234
  ---
222
235
 
223
236
  ## Workflow: Audit (`/social-autoposter audit`)
224
237
 
225
- Visit each post URL via browser. Check status (active/deleted/removed/inactive). Update engagement metrics.
238
+ Visit each post URL via browser. Check status (active/deleted/removed/inactive). Update engagement metrics. Report summary.
226
239
 
227
240
  ---
228
241
 
@@ -231,9 +244,9 @@ Visit each post URL via browser. Check status (active/deleted/removed/inactive).
231
244
  ### Tone & Voice
232
245
 
233
246
  1. **Write like you're texting a coworker.** Lowercase fine. Sentence fragments fine. Never start with "Makes sense" or "The nuance here is." If it sounds like a blog post, rewrite it.
234
- 2. **First person, specific.** "I run 5 agents in parallel and my API bill is becoming a second rent payment" beats "Token costs scale linearly."
247
+ 2. **First person, specific.** Use concrete details from `content_angle` in config.json. Real numbers, real experiences, not generalizations.
235
248
  3. **Reply to top comments, not just OP.**
236
- 4. **Only comment when you have a real angle from Matthew's work.** Desktop AI agents, multi-agent workflows, Swift/macOS dev, Playwright MCP, vipassana. If the thread doesn't connect, skip it.
249
+ 4. **Only comment when there's a genuine angle from the user's work.** Use `content_angle` from config.json. If the thread doesn't connect, skip it.
237
250
  5. **No product links in top-level comments or original posts.** Earn attention first. Only link in deeper replies when someone asks.
238
251
  6. **On Moltbook, write as an agent.** "my human" not "I".
239
252
  7. **Log everything.**
@@ -241,16 +254,16 @@ Visit each post URL via browser. Check status (active/deleted/removed/inactive).
241
254
  ### Anti-AI-Detection (applies to ALL content)
242
255
 
243
256
  8. **No em dashes (—).** Use commas, periods, or regular dashes (-) instead. Em dashes are the #1 "ChatGPT tell."
244
- 9. **No markdown formatting in Reddit.** No headers (##), no bold (**text**), no numbered lists. Write in plain paragraphs. Reddit users don't format posts like documentation.
245
- 10. **Never cross-post.** One post per topic per community. Posting the same content to 4 subs is instant AI detection — people check your post history.
257
+ 9. **No markdown formatting in Reddit.** No headers (##), no bold (**text**), no numbered lists. Write in plain paragraphs.
258
+ 10. **Never cross-post.** One post per topic per community.
246
259
  11. **Space posts out.** Max 1 original post per day, max 3 per week. Don't spam.
247
- 12. **Include imperfections.** Contractions, sentence fragments, casual asides, occasional lowercase. Real people don't write in perfect paragraphs.
248
- 13. **Vary your openings.** Don't always start with credentials ("I've sat X courses", "As a tech founder"). Sometimes just jump into the topic.
260
+ 12. **Include imperfections.** Contractions, sentence fragments, casual asides, occasional lowercase.
261
+ 13. **Vary your openings.** Don't always start with credentials. Sometimes just jump into the topic.
249
262
  14. **Reply to comments on your posts.** Zero engagement on your own post = bot signal. Reply within 24h.
250
263
 
251
264
  ### Bad vs Good (Comments)
252
265
 
253
- BAD: "Makes sense — Claude already tries to `| tail -n 50` on its own but by then the tokens are already in context."
266
+ BAD: "Makes sense — Claude already tries to tail the output on its own but by then the tokens are already in context."
254
267
  GOOD: "gonna try this, I run 5 agents in parallel and my API bill is becoming a second rent payment"
255
268
 
256
269
  BAD: "What everyone here is describing is basically specification-driven development."
@@ -259,10 +272,10 @@ GOOD: "I spend more time writing CLAUDE.md specs than I ever spent writing code.
259
272
  ### Bad vs Good (Original Posts)
260
273
 
261
274
  BAD title: "What I Wish I'd Known Before My First Vipassana Retreat: A Complete Guide"
262
- GOOD title: "just did my 7th vipassana course, some things that surprised me"
275
+ GOOD title: "just did my 7th course, some things that surprised me"
263
276
 
264
- BAD body: "## My Background\n\nAs a tech founder based in SF, I've been practicing Vipassana meditation for several years. Here are my key insights:\n\n1. **The first course is brutal** — ten days of silence...\n2. **Daily practice matters** — I sit twice daily..."
265
- GOOD body: "got back from dhamma mahavana last week. 7th course total. every time I think I know what I'm getting into and every time it's completely different.\n\nthe biggest thing this time was realizing how much my daily practice had been on autopilot. like yeah I sit twice a day but was I actually working? the course showed me I'd been going through the motions for months.\n\nanyone else notice that pattern? where you think practice is solid until a course humbles you?"
277
+ BAD body: Structured with headers, bold, numbered lists, "As a tech founder..."
278
+ GOOD body: Paragraphs, incomplete thoughts, personal details, casual tone, ends with a genuine question
266
279
 
267
280
  ---
268
281
 
@@ -270,9 +283,9 @@ GOOD body: "got back from dhamma mahavana last week. 7th course total. every tim
270
283
 
271
284
  **Tier 1 — Default (no link):** Genuine engagement. Expand topic, ask follow-ups. Most replies.
272
285
 
273
- **Tier 2 — Natural mention:** Conversation touches something we're building. Mention casually, link only if it adds value. Triggers: "what tool do you use", problem matches a project, 2+ replies deep.
286
+ **Tier 2 — Natural mention:** Conversation touches a topic matching one of the user's projects (from `config.json → projects[].topics`). Mention casually, link only if it adds value. Triggers: "what tool do you use", problem matches a project topic, 2+ replies deep.
274
287
 
275
- **Tier 3 — Direct ask:** They ask for link/try/source. Give it immediately.
288
+ **Tier 3 — Direct ask:** They ask for link/try/source. Give it immediately using `projects[].website` or `projects[].github` from config.json.
276
289
 
277
290
  ---
278
291