@m13v/s4l 1.7.4-rc.2 → 1.7.4-rc.22

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/README.md CHANGED
@@ -1,143 +1,176 @@
1
- # social-autoposter
1
+ # S4L
2
2
 
3
- Open-source repo behind **[S4L (s4lai)](https://s4l.ai)**: an automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Ships as a Claude Code skill plus a set of standalone Python helpers and macOS launchd jobs.
3
+ S4L is a desktop plugin for running a Reddit and X reply-drafting workflow from
4
+ your own machine.
4
5
 
5
- > The hosted managed version is **S4L** (written `s4lai`, domain `s4l.ai`): done-for-you Reddit and Twitter brand-awareness, $1/1K impressions, $50/1K site visits. See https://s4l.ai.
6
+ It watches the conversations your buyers already read, finds threads with real
7
+ momentum and fit, drafts contribution-first replies in your voice, and gives you
8
+ a review queue before anything goes live. The product is positioned as a
9
+ self-serve desktop plugin: subscribe, install, connect your accounts, review the
10
+ drafts, and track the results.
6
11
 
7
- State (posts, replies, candidates, stats) is read and written through the hosted S4L HTTP API (`AUTOPOSTER_API_BASE` + an install key in `~/social-autoposter/.env`); no database to provision. Each platform drives its own persistent Playwright MCP browser profile, so logins survive across runs.
12
+ - Website: https://s4l.ai
13
+ - Pricing: https://s4l.ai/pricing
14
+ - Source: https://github.com/m13v/social-autoposter
8
15
 
9
- ## Prerequisites
16
+ ## What S4L does
10
17
 
11
- A new machine needs all of these before the pipeline can run end to end:
18
+ Most social tools start after you already know what to post. S4L starts earlier:
19
+ it looks for conversations worth entering.
12
20
 
13
- - **macOS** (the launchd plists are mac-only; Linux users can crib the cron snippets from `setup/SKILL.md` Step 7)
14
- - **Node.js 16+** (for `npx`, the installer, and `@playwright/mcp` at runtime)
15
- - **Python 3.9+** with `pip3` (helper scripts; deps auto-installed by the installer)
16
- - **Claude Code CLI** on `PATH` (the cron scripts shell out to `claude -p` with a per-platform MCP config)
17
- - One Chromium install per platform (created on first run by `@playwright/mcp` against the persistent profile dirs)
21
+ - Finds high-traffic Reddit and X threads with enough context to join well.
22
+ - Ranks opportunities by momentum, intent, community norms, and product fit.
23
+ - Drafts replies that answer the thread first and mention your product only when
24
+ it belongs.
25
+ - Keeps project-specific voice, claim language, competitors, sensitive topics,
26
+ and hard lines in the drafting context.
27
+ - Presents drafts in a review workflow so you can approve, reject, or edit before
28
+ posting.
29
+ - Tracks views, upvotes, clicks, deletions, and misses so each run learns from
30
+ the previous one.
18
31
 
19
- Optional:
32
+ Nothing is posted automatically by default. Posting autopilot stays off until you
33
+ explicitly turn it on.
20
34
 
21
- - `MOLTBOOK_API_KEY` in `.env` for Moltbook posting and scanning
22
- - `RESEND_API_KEY` and `NOTIFICATION_EMAIL` in `.env` for DM-escalation emails
35
+ ## Who it is for
23
36
 
24
- ## Install
37
+ S4L is for founders and small teams who want an AI-assisted social workflow
38
+ without handing their accounts to an agency or living inside mention alerts all
39
+ day.
25
40
 
26
- ```bash
27
- npx social-autoposter init
28
- ```
41
+ You bring the accounts, product context, voice, and judgment. The plugin handles
42
+ thread discovery, draft generation, review cards, scheduling, and result memory.
29
43
 
30
- `bin/cli.js` does all of the wiring in one shot:
44
+ ## How users install it
31
45
 
32
- 1. Copies `scripts/`, `skill/`, `setup/`, `SKILL.md`, and `browser-agent-configs/` into `~/social-autoposter/`
33
- 2. Creates `config.json` from `config.example.json` and writes a blank `.env` template (fill in your S4L API key and optional `MOLTBOOK_API_KEY`)
34
- 3. Installs the Python helper deps via `pip3` if missing
35
- 4. Generates launchd plists in `~/social-autoposter/launchd/` with the user's actual `HOME` and `PATH`
36
- 5. Installs the Playwright MCP configs to `~/.claude/browser-agent-configs/` (twitter, reddit, linkedin) with `__HOME__` and `__NODE_BIN__` placeholders substituted. Existing files are left alone, so any window-position tweaks survive `npx social-autoposter update`.
37
- 6. Creates empty persistent browser profile dirs at `~/.claude/browser-profiles/{twitter,reddit,linkedin}`
38
- 7. Symlinks `~/.claude/skills/social-autoposter` and `~/.claude/skills/social-autoposter-setup` to the install dir
46
+ The production path is the desktop plugin download from S4L.
39
47
 
40
- To refresh code without touching user files (`config.json`, `.env`, `SKILL.md`, or any browser config you customized):
48
+ 1. Subscribe at https://s4l.ai/pricing.
49
+ 2. Download the plugin link emailed after checkout.
50
+ 3. Install the plugin in Claude Desktop.
51
+ 4. Start a new Claude chat and send:
41
52
 
42
- ```bash
43
- npx social-autoposter update
53
+ ```text
54
+ Set me up on S4L plugin end to end
44
55
  ```
45
56
 
46
- ## Configure
57
+ The setup flow repairs the local runtime, connects your X browser session,
58
+ discovers product and voice context, seeds search topics, schedules draft
59
+ generation, and verifies that draft cards appear without posting.
47
60
 
48
- Tell your Claude agent: **"set me up on social-autoposter plugin end to end"**. The
49
- setup skill treats that as a terminal goal:
61
+ ## What is in this repo
50
62
 
51
- 1. Inspect and repair the owned runtime.
52
- 2. Auto-detect the best browser profile and connect X/Twitter. macOS may require
53
- a Safe Storage approval; a logged-out account may require one manual sign-in.
54
- 3. Scan the X profile, discover and research the user's product, and infer a
55
- conservative project, ICP, voice, and search topics without an interview.
56
- 4. Save the project and seed its topics into the backend.
57
- 5. Run a draft-only cycle to verify the pipeline without posting.
63
+ This repository contains the open-source runtime behind the S4L plugin:
58
64
 
59
- The agent pauses only for an unavoidable login or when no product can be
60
- identified. Autopilot remains off until explicitly requested.
65
+ ```text
66
+ social-autoposter/
67
+ |-- mcp/ Desktop plugin / MCP server, panel UI, release bundle
68
+ |-- scripts/ Discovery, drafting, stats, telemetry, and queue helpers
69
+ |-- skill/ Shell entrypoints used by scheduled jobs
70
+ |-- setup/ End-to-end setup skill used by Claude
71
+ |-- browser-agent-configs/ Browser automation profile templates
72
+ |-- launchd/ macOS LaunchAgent templates
73
+ |-- mcp-servers/ Local MCP helpers used by the runtime
74
+ |-- config.example.json Example project/account configuration
75
+ `-- SKILL.md Legacy social-autoposter agent playbook
76
+ ```
61
77
 
62
- ## How the runtime is wired
78
+ The `mcp/` package is the plugin users interact with. The rest of the repo is the
79
+ pipeline it bundles, installs, and drives.
63
80
 
64
- ```
65
- launchd ──▶ skill/run-{platform}.sh ──▶ claude -p --strict-mcp-config --mcp-config ~/.claude/browser-agent-configs/{platform}-agent-mcp.json
66
- │ │
67
- │ └──▶ @playwright/mcp@latest
68
- │ │
69
- │ └──▶ ~/.claude/browser-profiles/{platform}/ (persistent userDataDir)
70
-
71
- ├──▶ scripts/find_threads.py, top_twitter_queries.py (no browser, API dedup)
72
- ├──▶ scripts/pick_project.py (weighted project rotation)
73
- ├──▶ scripts/top_performers.py (feedback report from past stats)
74
- └──▶ S4L HTTP API (AUTOPOSTER_API_BASE in .env)
81
+ ## Architecture
82
+
83
+ ```text
84
+ Claude Desktop plugin
85
+ -> S4L MCP server
86
+ -> local runtime + menu bar review UI
87
+ -> scheduled queue worker
88
+ -> browser profiles you control
89
+ -> S4L API for install-scoped queues, stats, and configuration
75
90
  ```
76
91
 
77
- Each `skill/run-*.sh`:
92
+ Important properties:
78
93
 
79
- 1. Controlled by launchd (load/unload). Use the dashboard Pause All / Resume All button, or `launchctl unload/load` directly
80
- 2. Acquires a per-platform lock from `skill/lock.sh` (waits up to 60 min for any prior run)
81
- 3. Sources `~/social-autoposter/.env`
82
- 4. Picks a project, builds a feedback report, fetches `llms.txt` for product context
83
- 5. Calls `find_*.py` for API-side candidates already deduped against the DB
84
- 6. Spawns a child Claude process with `--strict-mcp-config` so it only sees the one platform's browser MCP
94
+ - Runs locally on macOS.
95
+ - Uses the user's own logged-in browser profiles.
96
+ - Stores install state locally and scopes API calls by install identity.
97
+ - Keeps secrets such as `.env`, `config.json`, browser profiles, logs, and local
98
+ databases out of Git.
99
+ - Uses approval-first drafting as the default operating mode.
85
100
 
86
- The launchd schedules generated by `bin/cli.js` on install:
101
+ ## Develop from source
87
102
 
88
- | Job | Cadence |
89
- |-----|---------|
90
- | `com.m13v.social-stats` (`stats.sh`) | every 21600 s (6 h) |
91
- | `com.m13v.social-engage` (`engage.sh`) | every 21600 s (6 h) |
103
+ For normal users, use the plugin download from S4L. These steps are for working
104
+ on the repo itself.
92
105
 
93
- All per-platform plists live in `launchd/` (reddit-search, reddit-threads, twitter-cycle, linkedin, moltbook, github, octolens, audit, dm-replies-*, link-edit-*, scan-reddit-replies, scan-moltbook-replies, etc.) and use either `StartInterval` or `StartCalendarInterval` for fixed wall-clock times. Activate any of them with:
106
+ Prerequisites:
107
+
108
+ - macOS
109
+ - Node.js 16+
110
+ - Python 3.9+
111
+ - Claude Desktop or Claude Code for MCP testing
112
+
113
+ Install dependencies:
94
114
 
95
115
  ```bash
96
- ln -sf ~/social-autoposter/launchd/com.m13v.social-twitter-cycle.plist ~/Library/LaunchAgents/
97
- launchctl load ~/Library/LaunchAgents/com.m13v.social-twitter-cycle.plist
116
+ npm install
117
+ cd mcp
118
+ npm install
98
119
  ```
99
120
 
100
- ## Skill commands
101
-
102
- | Command | What it does |
103
- |---------|-------------|
104
- | `/social-autoposter` | Comment run: find threads, draft, post, log (cron-safe) |
105
- | `/social-autoposter post` | Create an original post or thread (manual only) |
106
- | `/social-autoposter stats` | Update engagement stats via API |
107
- | `/social-autoposter engage` | Scan and reply to responses on our posts |
108
- | `/social-autoposter audit` | Full browser audit of all posts |
121
+ Build the plugin server and panel:
109
122
 
110
- View live stats at `https://s4l.ai/stats/<your-handle>` once posts start landing.
123
+ ```bash
124
+ cd mcp
125
+ npm run build
126
+ ```
111
127
 
112
- ## Repo layout
128
+ Register this checkout with Claude Desktop and Claude Code for local testing:
113
129
 
114
- ```
115
- social-autoposter/
116
- ├── SKILL.md the playbook (locked, immutable)
117
- ├── bin/cli.js installer + dashboard launcher
118
- ├── browser-agent-configs/ Playwright MCP templates (twitter/reddit/linkedin)
119
- ├── config.example.json config template
120
- ├── setup/SKILL.md autonomous end-to-end setup skill (locked)
121
- ├── scripts/ Python and JS helpers (no browser, no LLM)
122
- ├── skill/ shell wrappers invoked by launchd
123
- └── launchd/ generated macOS LaunchAgent plists
130
+ ```bash
131
+ cd mcp
132
+ node install.mjs
124
133
  ```
125
134
 
126
- ## For other AI agents
135
+ Then fully quit and reopen Claude so MCP servers reload.
127
136
 
128
- The skill works with any agent that has shell access, browser automation, and an LLM. The Python and JS helpers in `scripts/` handle thread discovery, reply scanning, and stats updates without needing a browser. `SKILL.md` is the playbook; any agent can read it and execute the workflows with its own tools.
137
+ Run the test suite:
129
138
 
130
- ## Pause and resume
139
+ ```bash
140
+ npm test
141
+ ```
131
142
 
132
- Use the dashboard at `localhost:3141` (Pause All / Resume All button), or manually:
143
+ Build a local `.mcpb` artifact without publishing:
133
144
 
134
145
  ```bash
135
- # Pause: unload all jobs + kill running processes
136
- for plist in ~/Library/LaunchAgents/com.m13v.social-*.plist; do launchctl unload "$plist"; done
137
-
138
- # Resume: reload all jobs
139
- for plist in ~/social-autoposter/launchd/com.m13v.social-*.plist; do
140
- ln -sf "$plist" ~/Library/LaunchAgents/
141
- launchctl load ~/Library/LaunchAgents/$(basename "$plist")
142
- done
146
+ bash scripts/release-mcpb.sh --no-bump --no-npm --no-release
143
147
  ```
148
+
149
+ That command packs the current pipeline into `mcp/dist/pipeline.tgz`, builds the
150
+ plugin, creates `mcp/social-autoposter.mcpb`, and runs the release checks without
151
+ touching npm or GitHub releases.
152
+
153
+ ## Runtime commands
154
+
155
+ The plugin exposes MCP tools for the user-facing workflow:
156
+
157
+ - `project_config` configures projects, products, voice, topics, and X auth.
158
+ - `engagement_mode` chooses personal-brand and product-promotion lanes.
159
+ - `dashboard` opens the review dashboard.
160
+ - `post_drafts` posts only the drafts the user selected.
161
+ - `get_stats` reads X/Twitter stats.
162
+ - `pause_s4l` pauses or resumes scheduled S4L jobs.
163
+ - `runtime` installs, updates, and diagnoses the local runtime.
164
+ - `report_diagnosis` sends a support report to the S4L team.
165
+
166
+ The legacy `/social-autoposter` skill and npm package remain in the repo because
167
+ the plugin bundles and reuses the same pipeline scripts.
168
+
169
+ ## Public-repo hygiene
170
+
171
+ This repo is public. Do not commit local customer data, browser state, generated
172
+ media, private automation experiments, `.mcpb` bundles, `.env` files, databases,
173
+ or logs. The root `.gitignore` intentionally keeps those out of source control.
174
+
175
+ If a workflow needs private scratch space, keep it outside the repo or under an
176
+ ignored directory.
package/mcp/dist/index.js CHANGED
@@ -163,6 +163,9 @@ function plistXml(opts) {
163
163
  const schedule = opts.keepAlive
164
164
  ? `\t<key>KeepAlive</key>\n\t<true/>`
165
165
  : `\t<key>StartInterval</key>\n\t<integer>${opts.intervalSecs}</integer>`;
166
+ const abandon = opts.abandonProcessGroup
167
+ ? `\n\t<key>AbandonProcessGroup</key>\n\t<true/>`
168
+ : "";
166
169
  // Background (cron/autopilot) runs get the same Chrome the interactive cycle
167
170
  // uses, so a no-sudo ~/Applications install (which the shell's own resolver
168
171
  // doesn't scan) is still found off-screen. Omitted when Chrome resolves via
@@ -188,7 +191,7 @@ function plistXml(opts) {
188
191
  \t<array>
189
192
  ${args}
190
193
  \t</array>
191
- ${schedule}
194
+ ${schedule}${abandon}
192
195
  \t<key>StandardOutPath</key>
193
196
  \t<string>${opts.stdoutLog}</string>
194
197
  \t<key>StandardErrorPath</key>
@@ -2987,7 +2990,7 @@ function queueDir() {
2987
2990
  // (which orphans the routines while their global SKILL.md files stay put, so the
2988
2991
  // SKILL.md-presence check in autopilotLoaded() reads a FALSE green). Mirrors the
2989
2992
  // menu bar's AUTOPILOT_STALL_SECONDS in mcp/menubar/s4l_menubar.py — keep in sync.
2990
- const AUTOPILOT_STALL_MS = 180_000;
2993
+ const AUTOPILOT_STALL_MS = 1_200_000;
2991
2994
  // True when no scheduled-task routine is draining the draft queue. Two signals,
2992
2995
  // OR'd (keep in lockstep with mcp/menubar/s4l_menubar.py::_autopilot_stalled and
2993
2996
  // scripts/autopilot_stall_watch.py):
@@ -3500,6 +3503,9 @@ async function ensureQueueKickerInstalled() {
3500
3503
  stdoutLog: path.join(logDir, "launchd-twitter-cycle-stdout.log"),
3501
3504
  stderrLog: path.join(logDir, "launchd-twitter-cycle-stderr.log"),
3502
3505
  extraEnv: kickerEnv(),
3506
+ // Don't let launchd reap the harness Chrome the cycle launches when the
3507
+ // kicker shell exits (2026-07-12 foreground-steal loop).
3508
+ abandonProcessGroup: true,
3503
3509
  });
3504
3510
  // Content-aware install: an existing box has the OLD kicker plist pointing at
3505
3511
  // run-twitter-cycle.sh (no merge step). ensurePlist won't overwrite, so detect
@@ -5315,7 +5321,8 @@ async function main() {
5315
5321
  // the connected X profile and store the top-performing replies as
5316
5322
  // voice.examples + the persona_corpus.txt exemplar section. Additive only
5317
5323
  // (regenerates just its own marked corpus section; respects hand-written
5318
- // examples) and self-limiting (marker file rate-limits scan attempts; it
5324
+ // examples) and self-limiting (no cooldown by design: success stamps
5325
+ // examples_scanned_at which makes later boots a no-op, and until then it
5319
5326
  // WAITS politely on the twitter-browser lock, polling while holding
5320
5327
  // nothing, until cycles/DM runs free the browser, up to 12h before
5321
5328
  // deferring to the next boot). Delayed so boot-time work (runtime
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.4-rc.2",
3
- "installedAt": "2026-07-10T23:25:56.505Z"
2
+ "version": "1.7.4-rc.22",
3
+ "installedAt": "2026-07-12T19:48:21.172Z"
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.7.4-rc.2",
5
+ "version": "1.7.4-rc.22",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L 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 plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
@@ -431,7 +431,21 @@ def _details_lines(d):
431
431
  d = d or {}
432
432
  lines = []
433
433
  style = (d.get("engagement_style") or "").strip()
434
- if style:
434
+ # Two-draft cards (2026-07-11): name each slot's style so the reviewer
435
+ # can tell WHICH style produced the draft they are picking (Draft B is
436
+ # the exploration slot; its source arm renders separately below via the
437
+ # generic experiments lines). Single-draft cards keep the old one-liner.
438
+ _dual = d.get("drafts")
439
+ if isinstance(_dual, list) and len(_dual) == 2:
440
+ _slot_labels = {"a": "Draft A", "b": "Draft B"}
441
+ for _draft in _dual:
442
+ _s = (_draft.get("style") or "").strip()
443
+ if _s:
444
+ _label = _slot_labels.get(
445
+ (_draft.get("variant") or "").strip().lower(), "Draft"
446
+ )
447
+ lines.append(f"{_label} style: {_s}")
448
+ elif style:
435
449
  lines.append(f"Style: {style}")
436
450
  desc = (d.get("style_description") or "").strip()
437
451
  if desc:
@@ -732,6 +746,21 @@ class _ReviewController(NSObject):
732
746
  self._selected_draft = None
733
747
  self._draft_textviews = {}
734
748
  self._draft_scrolls = {}
749
+ # Per-draft hover dwell (two-draft cards, 2026-07-10): accumulated
750
+ # milliseconds the pointer spent over each draft box, so the feedback
751
+ # digest can tell an informed keep of Draft A (they read B and stayed)
752
+ # from a fast approve that says nothing about B. Raw ms ship on the
753
+ # decision; the read-vs-skim threshold lives digest-side so it can be
754
+ # tuned without a client release. _draft_hover_open holds the enter
755
+ # timestamp of any hover still in progress (flushed on decision).
756
+ self._draft_hover_ms = {0: 0, 1: 0}
757
+ self._draft_hover_open = {}
758
+ # Slots the caret has actually been in this card (2026-07-10 follow-up):
759
+ # lets the decision distinguish "clicked into B, then came BACK to A"
760
+ # (an explicit head-to-head choice of A, per user) from "never touched
761
+ # B at all". Only the UNCHOSEN slot's membership matters at decision
762
+ # time; the selected slot is trivially visited.
763
+ self._draft_visited = set()
735
764
  # Attention anchors for the unattended-review watchdog: the stack counts
736
765
  # as "touched" on present, on any tracked interaction, and on any
737
766
  # decision. No touch past the watchdog threshold = the user is not
@@ -1059,6 +1088,9 @@ class _ReviewController(NSObject):
1059
1088
  self._interactions = []
1060
1089
  self._card_shown_at = time.time()
1061
1090
  self._selected_draft = None
1091
+ self._draft_hover_ms = {0: 0, 1: 0}
1092
+ self._draft_hover_open = {}
1093
+ self._draft_visited = set()
1062
1094
  self._reason_field = None
1063
1095
  content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
1064
1096
 
@@ -1389,6 +1421,19 @@ class _ReviewController(NSObject):
1389
1421
  tv.setDelegate_(self)
1390
1422
  outline.addSubview_(scroll)
1391
1423
  content.addSubview_(outline)
1424
+ # Hover dwell per draft box (same NSTrackingArea pattern as the
1425
+ # eye buttons): enter/exit timestamps accumulate into
1426
+ # _draft_hover_ms[slot] so the decision can say whether the
1427
+ # reviewer actually READ the draft they didn't pick. slot rides
1428
+ # on userInfo, mirroring the eyes' `kind` routing.
1429
+ outline.addTrackingArea_(
1430
+ NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
1431
+ outline.bounds(),
1432
+ NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
1433
+ self,
1434
+ {"kind": "draft", "slot": slot},
1435
+ )
1436
+ )
1392
1437
  self._draft_scrolls[slot] = scroll
1393
1438
  self._draft_outlines[slot] = outline
1394
1439
  self._draft_textviews[slot] = tv
@@ -1542,21 +1587,32 @@ class _ReviewController(NSObject):
1542
1587
  self._show_details_popover()
1543
1588
 
1544
1589
  @objc.python_method
1545
- def _hover_kind(self, event):
1546
- """Which eye a tracking-area event belongs to ('stats' | 'details'),
1547
- from the userInfo stamped in _eye_button. Defaults to stats (the
1548
- original single-eye behavior) if the area carries no info."""
1590
+ def _hover_info(self, event):
1591
+ """(kind, slot) a tracking-area event belongs to, from the userInfo
1592
+ stamped at creation: ('stats'|'details', None) for the eye icons,
1593
+ ('draft', 0|1) for the two draft boxes. Defaults to ('stats', None),
1594
+ the original single-eye behavior, if the area carries no info."""
1549
1595
  try:
1550
1596
  info = event.trackingArea().userInfo()
1551
- if info and info.get("kind") == "details":
1552
- return "details"
1597
+ if info:
1598
+ kind = info.get("kind")
1599
+ if kind == "draft":
1600
+ return "draft", int(info.get("slot"))
1601
+ if kind == "details":
1602
+ return "details", None
1553
1603
  except Exception:
1554
1604
  pass
1555
- return "stats"
1605
+ return "stats", None
1556
1606
 
1557
- # NSTrackingArea owner callbacks (hover over either eye icon).
1607
+ # NSTrackingArea owner callbacks (hover over either eye icon or, on
1608
+ # two-draft cards, either draft box). Draft hovers only bank dwell time
1609
+ # (no popover, no logging: the boxes are big and enter/exit fires on
1610
+ # every pass of the pointer).
1558
1611
  def mouseEntered_(self, event):
1559
- kind = self._hover_kind(event)
1612
+ kind, slot = self._hover_info(event)
1613
+ if kind == "draft":
1614
+ self._draft_hover_open[slot] = time.time()
1615
+ return
1560
1616
  _log(f"{kind} eye hover enter")
1561
1617
  if kind == "details":
1562
1618
  self._show_details_popover()
@@ -1564,9 +1620,32 @@ class _ReviewController(NSObject):
1564
1620
  self._show_stats_popover()
1565
1621
 
1566
1622
  def mouseExited_(self, event):
1623
+ kind, slot = self._hover_info(event)
1624
+ if kind == "draft":
1625
+ started = self._draft_hover_open.pop(slot, None)
1626
+ if started is not None:
1627
+ self._draft_hover_ms[slot] = self._draft_hover_ms.get(slot, 0) + int(
1628
+ (time.time() - started) * 1000
1629
+ )
1630
+ return
1567
1631
  _log("eye hover exit")
1568
1632
  self._close_stats_popover()
1569
1633
 
1634
+ @objc.python_method
1635
+ def _flush_draft_hovers(self):
1636
+ """Bank any hover still in progress (pointer inside a draft box at
1637
+ decision time, e.g. a keyboard approve) so _record reads final
1638
+ totals."""
1639
+ now = time.time()
1640
+ for slot, started in list(self._draft_hover_open.items()):
1641
+ self._draft_hover_ms[slot] = self._draft_hover_ms.get(slot, 0) + int(
1642
+ (now - started) * 1000
1643
+ )
1644
+ # Keep the hover open (re-anchored at now) rather than deleting
1645
+ # it: the pointer really is still inside the box, so a later
1646
+ # mouseExited_ must not double-count the pre-flush span.
1647
+ self._draft_hover_open[slot] = now
1648
+
1570
1649
  @objc.python_method
1571
1650
  def _add_link(self, content, frame, text, url, *, size=12, bold=False, right=False, kind="link_click"):
1572
1651
  """Borderless button styled as a link (system link color, underlined).
@@ -1630,11 +1709,18 @@ class _ReviewController(NSObject):
1630
1709
  except Exception:
1631
1710
  return
1632
1711
  for slot, cand_tv in (self._draft_textviews or {}).items():
1633
- if cand_tv is tv and slot != self._selected_draft:
1712
+ if cand_tv is not tv:
1713
+ continue
1714
+ # Visited even when it's already the selected slot: membership of
1715
+ # the eventually-UNCHOSEN slot is what _record reads, and that
1716
+ # slot only ever gets the caret via a deliberate user click (the
1717
+ # auto-focus seat in _render targets the selected slot only).
1718
+ self._draft_visited.add(slot)
1719
+ if slot != self._selected_draft:
1634
1720
  self._selected_draft = slot
1635
1721
  self._textview = cand_tv
1636
1722
  self._update_draft_borders()
1637
- break
1723
+ break
1638
1724
 
1639
1725
  @objc.python_method
1640
1726
  def _update_draft_borders(self):
@@ -1704,9 +1790,32 @@ class _ReviewController(NSObject):
1704
1790
  chosen_draft = drafts[sel_idx]
1705
1791
  orig = (chosen_draft.get("text") or "").strip()
1706
1792
  draft_variant = chosen_draft.get("variant") or ("a" if sel_idx == 0 else "b")
1793
+ # Full pairwise context for the feedback digest (2026-07-10): the
1794
+ # UNCHOSEN draft's text+style ride along so "picked B over A" (or
1795
+ # "kept A after reading B", per the hover dwell) is a usable
1796
+ # preference PAIR, not just a winner with no loser. Shipped as one
1797
+ # nested dict end to end (decision -> review event -> jsonb column)
1798
+ # so adding a field never needs another schema hop.
1799
+ self._flush_draft_hovers()
1800
+ other = drafts[1 - sel_idx]
1801
+ draft_choice = {
1802
+ "variant": draft_variant,
1803
+ "index": sel_idx,
1804
+ "auto_selected": bool(sel_idx == 0),
1805
+ "style": chosen_draft.get("style") or None,
1806
+ "unchosen_text": (other.get("text") or "").strip() or None,
1807
+ "unchosen_style": other.get("style") or None,
1808
+ "hover_a_ms": int(self._draft_hover_ms.get(0, 0)),
1809
+ "hover_b_ms": int(self._draft_hover_ms.get(1, 0)),
1810
+ # True = the caret was in the unchosen box at some point, i.e.
1811
+ # they tried the other draft and came back: an explicit choice
1812
+ # even when the winner is the preselected default.
1813
+ "visited_other": bool((1 - sel_idx) in self._draft_visited),
1814
+ }
1707
1815
  else:
1708
1816
  orig = (d.get("reply_text") or "").strip()
1709
1817
  draft_variant = None
1818
+ draft_choice = None
1710
1819
  link = d.get("link_url") or ""
1711
1820
  drop_link = False
1712
1821
  if approved:
@@ -1762,6 +1871,11 @@ class _ReviewController(NSObject):
1762
1871
  "draft_variant": draft_variant,
1763
1872
  "draft_index": sel_idx,
1764
1873
  "draft_auto_selected": bool(dual and sel_idx == 0),
1874
+ # Nested pairwise record (chosen vs unchosen text/style plus
1875
+ # per-box hover dwell); None on single-draft candidates. The
1876
+ # flat three fields above stay for their existing consumers
1877
+ # (edit-learning variant stamp in s4l_menubar).
1878
+ "draft_choice": draft_choice,
1765
1879
  }
1766
1880
  )
1767
1881
  self._last_decision_at = time.time()
@@ -403,13 +403,13 @@ DIAGNOSE_PROMPT_TEMPLATE = (
403
403
  # were firing. False-positive-free: an idle queue has no pending job at all, so a
404
404
  # quiet pipeline (no candidates) never trips this. Comfortably above the host
405
405
  # scheduler's per-minute cadence + a slow claim.
406
- AUTOPILOT_STALL_SECONDS = 180
406
+ AUTOPILOT_STALL_SECONDS = 1200
407
407
 
408
408
  # A job CLAIMED but never finished (sits in running/ this long) means a worker
409
409
  # picked it up and then wedged mid-run (the claude -p drafting child died / never
410
410
  # spawned). Generous enough that the longest real drafting turn never trips it.
411
411
  # Keep in sync with RUNNING_STALL_SECONDS (scripts/autopilot_stall_watch.py).
412
- AUTOPILOT_RUNNING_STALL_SECONDS = 900
412
+ AUTOPILOT_RUNNING_STALL_SECONDS = 1200
413
413
 
414
414
  # The "firing" window (how fresh lastRunAt must be) lives in the single source of
415
415
  # truth, scripts/schedule_state.py (FIRING_WINDOW there). _schedule_state delegates
@@ -898,7 +898,7 @@ class S4LMenuBar(rumps.App):
898
898
  to see between cycles) -> the ⚠ stays on continuously instead of
899
899
  flickering off. This is the durable signal.
900
900
  (2) FAST: a draft job has sat unclaimed in pending/ past
901
- AUTOPILOT_STALL_SECONDS -> catches a fresh stall ~3 min in, before
901
+ AUTOPILOT_STALL_SECONDS -> catches a fresh stall ~20 min in, before
902
902
  the first full producer timeout has even latched (1).
903
903
  (3) IN-FLIGHT: a draft job was claimed (moved to running/) but never
904
904
  finished within AUTOPILOT_RUNNING_STALL_SECONDS -> the worker picked
@@ -2941,6 +2941,13 @@ class S4LMenuBar(rumps.App):
2941
2941
  # posts); English translations on the card are display-only
2942
2942
  # and never shipped here.
2943
2943
  "language": decision.get("language"),
2944
+ # Two-draft pairwise context (None on single-draft cards):
2945
+ # {variant, index, auto_selected, style, unchosen_text,
2946
+ # unchosen_style, hover_a_ms, hover_b_ms}. Lets the
2947
+ # feedback digest learn "picked B over A" / "kept A after
2948
+ # actually reading B" as preference pairs. Older servers
2949
+ # simply ignore the key.
2950
+ "draft_choice": decision.get("draft_choice"),
2944
2951
  }
2945
2952
  )
2946
2953
  except Exception:
@@ -786,6 +786,10 @@ def store_stamp_decision(batch, decision):
786
786
  "drop_link": bool(decision.get("drop_link")),
787
787
  "loved": bool(decision.get("loved")),
788
788
  "reject_category": decision.get("reject_category"),
789
+ # Two-draft pairwise record (chosen vs unchosen + hover dwell),
790
+ # None on single-draft cards. Durable locally so the choice
791
+ # survives even if the review-events flush never lands.
792
+ "draft_choice": decision.get("draft_choice"),
789
793
  "decided_at": time_iso(),
790
794
  }
791
795
  if decision.get("approved"):
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.4-rc.2",
3
+ "version": "1.7.4-rc.22",
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": "@m13v/s4l",
3
- "version": "1.7.4-rc.2",
3
+ "version": "1.7.4-rc.22",
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",