social-autoposter 1.6.112 → 1.6.113

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.112",
3
- "installedAt": "2026-06-25T05:09:23.776Z"
2
+ "version": "1.6.113",
3
+ "installedAt": "2026-06-25T19:38:00.335Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.6.112",
5
+ "version": "1.6.113",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "The disclaimer above is generic Claude boilerplate. S4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L end to end**\n\n2\\. Quit fully with CMD+Q, restart Claude, and paste the prompt into a new chat.",
8
8
  "author": {
@@ -241,6 +241,30 @@ class _ReviewController(NSObject):
241
241
  else:
242
242
  self._render()
243
243
 
244
+ @objc.python_method
245
+ def extend_drafts(self, drafts):
246
+ """Append newly-queued drafts to an OPEN card. Without this, a card built
247
+ when N drafts were pending froze at "of N": every draft that arrived after
248
+ the card opened was stranded behind it (the menu bar bailed while a panel
249
+ was up). Dedups by plan index `n`, never disturbs the card on screen, and
250
+ refreshes the title-bar counter live so the backlog is honest."""
251
+ if self._panel is None:
252
+ return 0
253
+ have = {d.get("n") for d in self._drafts}
254
+ added = [d for d in drafts if d.get("n") not in have]
255
+ if not added:
256
+ return 0
257
+ self._drafts.extend(added)
258
+ # Update only the "X of N" counter; do NOT re-render the body (that would
259
+ # reset the caret / clobber an in-progress edit on the current card).
260
+ try:
261
+ self._panel.setTitle_(
262
+ f"Review draft {self._idx + 1} of {len(self._drafts)}"
263
+ )
264
+ except Exception:
265
+ pass
266
+ return len(added)
267
+
244
268
  @objc.python_method
245
269
  def _fire_decision(self):
246
270
  # Fire the per-card callback the instant a decision is made, so an
@@ -305,3 +329,15 @@ def present_review(drafts, on_decision=None, on_complete=None):
305
329
  _active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_(
306
330
  drafts, on_decision, on_complete
307
331
  )
332
+
333
+
334
+ def extend_active(drafts):
335
+ """Push newly-queued drafts into the open review card, if one is up. Returns
336
+ the count actually added (0 if no card is open or nothing is new). Main thread
337
+ only (called from the menu bar's rumps timer)."""
338
+ if _active is None:
339
+ return 0
340
+ try:
341
+ return _active.extend_drafts(drafts)
342
+ except Exception:
343
+ return 0
@@ -521,8 +521,6 @@ class S4LMenuBar(rumps.App):
521
521
  self._posting_batch_done = 0
522
522
 
523
523
  def _maybe_start_review(self):
524
- if self._review_active:
525
- return
526
524
  req = st.read_review_request()
527
525
  if not req:
528
526
  return
@@ -546,6 +544,26 @@ class S4LMenuBar(rumps.App):
546
544
  sig = tuple((d.get("n"), d.get("reply_text") or "") for d in drafts)
547
545
  if sig == self._last_review_sig:
548
546
  return
547
+ # A review is already in flight. Two cases:
548
+ # - A card is ON SCREEN (_panel_open): push the newly-queued drafts into
549
+ # the open card so the "X of N" counter and the reviewable stack grow
550
+ # live. This is the fix for the "card froze at 1 of 4 while 137 piled
551
+ # up" bug — drafts that arrived after the card opened used to be
552
+ # stranded because this method returned early on _review_active.
553
+ # - Posting is DRAINING with no panel up (_review_active but not
554
+ # _panel_open): leave the signature untouched so the full pending set
555
+ # is presented fresh once the drain completes (don't pop a card mid-post).
556
+ if self._review_active:
557
+ if self._panel_open:
558
+ try:
559
+ import s4l_card
560
+
561
+ s4l_card.extend_active(drafts)
562
+ except Exception as e:
563
+ sys.stderr.write(f"[s4l-menubar] extend cards failed: {e}\n")
564
+ sys.stderr.flush()
565
+ self._last_review_sig = sig
566
+ return
549
567
  with self._review_lock:
550
568
  self._reset_posting_progress_locked()
551
569
  self._review_active = True
@@ -604,7 +622,23 @@ class S4LMenuBar(rumps.App):
604
622
  if self._posts_outstanding <= 0:
605
623
  self._review_active = False
606
624
  self._reset_posting_progress_locked()
607
- st.clear_review_request()
625
+ # Only clear the review marker when the queue is actually drained. The old
626
+ # code cleared it unconditionally, so if the user closed the card with
627
+ # drafts still undecided (or more had piled up than they reviewed), the
628
+ # backlog was stranded — presentation is gated on this marker. Keep it when
629
+ # anything remains so the leftover re-presents fresh on the next tick.
630
+ remaining = 0
631
+ try:
632
+ req = st.read_review_request()
633
+ if req:
634
+ remaining = len(st.review_drafts(st.read_plan(req.get("plan_path") or "")))
635
+ except Exception:
636
+ remaining = 0
637
+ if remaining <= 0:
638
+ st.clear_review_request()
639
+ # Drop the dedup signature so whatever is left is presented fresh (not
640
+ # suppressed as "already shown") once posting finishes draining.
641
+ self._last_review_sig = None
608
642
  if not any(d.get("approved") for d in decisions):
609
643
  self._notify("S4L", "No drafts approved — nothing posted.")
610
644
 
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/social-autoposter-mcp",
3
- "version": "1.6.112",
3
+ "version": "1.6.113",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.6.112",
3
+ "version": "1.6.113",
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"
@@ -2,8 +2,20 @@
2
2
  """Shared accessor for project_search_topics.
3
3
 
4
4
  Single chokepoint for every runtime consumer of the per-project topic list
5
- that used to live in config.json (projects[].search_topics[]). Reads from
6
- the DB only config.json is seed-only via scripts/seed_search_topics.py.
5
+ that used to live in config.json (projects[].search_topics[]). The DB
6
+ (project_search_topics) is the living runtime universe (invent/decay/exclude
7
+ operate on it); config.json's search_topics[] is the human-authored SEED.
8
+
9
+ Self-healing seed-on-empty: if the DB has no active topics for a project that
10
+ DOES carry a search_topics[] seed in config.json, this module mirrors that seed
11
+ into the DB once (first-run bootstrap) and re-reads. That makes "add a project
12
+ to config.json" sufficient to make it run, with no separate manual
13
+ seed_search_topics.py step to forget. Before this, a fully-configured project
14
+ (weight, enabled, topics) could silently never run because the manual seed was
15
+ skipped (Capstacker 2026-06, Karol/pamba earlier). This is a SEED-ON-EMPTY, not
16
+ a live config.json fallback: it fires only for a project the DB has never heard
17
+ of, and once rows exist the living state owns the universe and it never runs
18
+ again — so it does not resurrect decayed/excluded topics.
7
19
 
8
20
  Why this module exists: 10+ scripts (pick_project, score_twitter_candidates,
9
21
  scan_twitter_mentions_browser, scan_dm_candidates, post_reddit, post_github,
@@ -34,12 +46,15 @@ Public surface:
34
46
  """
35
47
  from __future__ import annotations
36
48
 
49
+ import json
37
50
  import os
38
51
  import sys
39
- from typing import Dict, List
52
+ from typing import Dict, List, Set
40
53
 
41
54
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
42
55
 
56
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
57
+
43
58
 
44
59
  class TopicsError(RuntimeError):
45
60
  """Raised when the topics API call itself fails (network, 5xx, auth).
@@ -50,15 +65,12 @@ class TopicsError(RuntimeError):
50
65
 
51
66
 
52
67
  _CACHE: Dict[str, List[str]] = {}
68
+ _BOOTSTRAP_ATTEMPTED: Set[str] = set()
53
69
 
54
70
 
55
- def topics_for_project(name: str) -> List[str]:
56
- """Active topics for one project (DB-only, process-cached)."""
57
- if not name:
58
- return []
59
- key = name.strip()
60
- if key in _CACHE:
61
- return _CACHE[key]
71
+ def _fetch_active_topics(key: str) -> List[str]:
72
+ """Read active topics for one project from the DB. Raises TopicsError on a
73
+ real API failure (network, 5xx, auth) so the cycle aborts loudly."""
62
74
  try:
63
75
  from http_api import api_get
64
76
  resp = api_get(
@@ -78,12 +90,96 @@ def topics_for_project(name: str) -> List[str]:
78
90
  if t and t not in seen:
79
91
  seen.add(t)
80
92
  topics.append(t)
93
+ return topics
94
+
95
+
96
+ def _config_topics_for(name: str) -> List[str]:
97
+ """The seed search_topics[] for one project from config.json (de-duped).
98
+ Used only to bootstrap a project the DB has never seen — see
99
+ _bootstrap_from_config."""
100
+ try:
101
+ with open(CONFIG_PATH) as f:
102
+ cfg = json.load(f)
103
+ except Exception:
104
+ return []
105
+ key = name.strip().lower()
106
+ for p in cfg.get("projects", []):
107
+ if (p.get("name") or "").strip().lower() == key:
108
+ seen, out = set(), []
109
+ for t in (p.get("search_topics") or []):
110
+ t = (t or "").strip()
111
+ if t and t not in seen:
112
+ seen.add(t)
113
+ out.append(t)
114
+ return out
115
+ return []
116
+
117
+
118
+ def _bootstrap_from_config(name: str) -> int:
119
+ """One-time self-heal: mirror a project's config.json search_topics into the
120
+ DB when it has zero active rows. Idempotent (the API upserts on
121
+ install_id+project+topic), so a duplicate/concurrent run is harmless. Never
122
+ raises: a seed failure is logged and treated as "no topics" so the read path
123
+ is never worse than before, just loud instead of silent. Returns the number
124
+ of topics POSTed. Honors SAPS_NO_TOPIC_AUTOSEED=1 for read-only contexts."""
125
+ if os.environ.get("SAPS_NO_TOPIC_AUTOSEED") == "1":
126
+ return 0
127
+ topics = _config_topics_for(name)
128
+ if not topics:
129
+ return 0
130
+ try:
131
+ from http_api import api_post
132
+ except Exception as e:
133
+ sys.stderr.write(
134
+ f"[project_topics] auto-seed unavailable project={name!r}: {e}\n"
135
+ )
136
+ return 0
137
+ seeded = 0
138
+ for topic in topics:
139
+ try:
140
+ api_post(
141
+ "/api/v1/project-search-topics",
142
+ body={"project": name, "topic": topic,
143
+ "source": "seed", "status": "active"},
144
+ )
145
+ seeded += 1
146
+ except Exception as e:
147
+ sys.stderr.write(
148
+ f"[project_topics] auto-seed FAILED project={name!r} "
149
+ f"topic={topic!r}: {e}\n"
150
+ )
151
+ if seeded:
152
+ sys.stderr.write(
153
+ f"[project_topics] auto-seeded {seeded}/{len(topics)} topic(s) for "
154
+ f"project={name!r} from config.json (first-run bootstrap)\n"
155
+ )
156
+ return seeded
157
+
158
+
159
+ def topics_for_project(name: str) -> List[str]:
160
+ """Active topics for one project (DB-backed, process-cached, self-healing).
161
+
162
+ On the first read where the DB has no active topics but config.json carries
163
+ a search_topics[] seed, the seed is mirrored into the DB once and re-read,
164
+ so adding a project to config.json is enough to make it run. After that the
165
+ DB is the single living source of truth."""
166
+ if not name:
167
+ return []
168
+ key = name.strip()
169
+ if key in _CACHE:
170
+ return _CACHE[key]
171
+ topics = _fetch_active_topics(key)
172
+ if not topics and key not in _BOOTSTRAP_ATTEMPTED:
173
+ _BOOTSTRAP_ATTEMPTED.add(key)
174
+ if _bootstrap_from_config(key) > 0:
175
+ topics = _fetch_active_topics(key)
81
176
  _CACHE[key] = topics
82
177
  return topics
83
178
 
84
179
 
85
180
  def clear_cache() -> None:
86
181
  _CACHE.clear()
182
+ _BOOTSTRAP_ATTEMPTED.clear()
87
183
 
88
184
 
89
185
  if __name__ == "__main__":
@@ -884,8 +884,8 @@ def _like_first_tweet_on_page(page):
884
884
  print("[like] clicked like but unlike state not confirmed", file=sys.stderr)
885
885
  return {"ok": False, "liked": False, "error": "like_unconfirmed"}
886
886
  except Exception as e:
887
- print(f"[like] error liking parent tweet: {e}", file=sys.stderr)
888
- return {"ok": False, "error": str(e)}
887
+ print(f"[like] parent tweet not liked (non-fatal): {str(e).splitlines()[0]}", file=sys.stderr)
888
+ return {"ok": False, "error": str(e).splitlines()[0]}
889
889
 
890
890
 
891
891
  def like_tweet(tweet_url):
@@ -667,11 +667,8 @@ def post_one(c: dict, picker_assignment: dict | None = None) -> tuple[str, str]:
667
667
  flush=True,
668
668
  )
669
669
  else:
670
- print(
671
- f"[like] candidate {cid} parent tweet NOT liked: "
672
- f"{like_result.get('error', 'unknown')}",
673
- flush=True,
674
- )
670
+ err = str(like_result.get("error", "unknown")).splitlines()[0]
671
+ print(f"[like] candidate {cid} parent tweet not liked (non-fatal): {err}", flush=True)
675
672
 
676
673
  if not reply_url or not REPLY_URL_RE.match(reply_url):
677
674
  # Reply was likely sent (browser action returned ok=True with verified)