@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 +134 -101
- package/mcp/dist/index.js +10 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +126 -12
- package/mcp/menubar/s4l_menubar.py +10 -3
- package/mcp/menubar/s4l_state.py +4 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/active_experiments.py +30 -6
- package/scripts/autopilot_stall_watch.py +2 -2
- package/scripts/cdp_ready_check.py +64 -0
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +221 -26
- package/scripts/enrich_reply_parents.py +312 -0
- package/scripts/feedback_digest.py +76 -0
- package/scripts/generate_daily_human_style.py +31 -7
- package/scripts/invent_styles.py +103 -26
- package/scripts/linkedin_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +43 -0
- package/scripts/memory_snapshot.py +57 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/scan_x_profile.py +69 -8
- package/scripts/setup_twitter_auth.py +6 -1
- package/scripts/snapshot.py +1 -1
- package/scripts/top_performers.py +34 -0
- package/scripts/twitter_browser.py +64 -5
- package/scripts/twitter_post_plan.py +10 -1
- package/scripts/voice_exemplars.py +11 -17
- package/skill/engage-twitter.sh +9 -0
- package/skill/lib/linkedin-backend.sh +12 -1
- package/skill/lib/twitter-backend.sh +107 -5
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/run-twitter-cycle.sh +181 -49
package/README.md
CHANGED
|
@@ -1,143 +1,176 @@
|
|
|
1
|
-
#
|
|
1
|
+
# S4L
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
S4L is a desktop plugin for running a Reddit and X reply-drafting workflow from
|
|
4
|
+
your own machine.
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
|
|
12
|
+
- Website: https://s4l.ai
|
|
13
|
+
- Pricing: https://s4l.ai/pricing
|
|
14
|
+
- Source: https://github.com/m13v/social-autoposter
|
|
8
15
|
|
|
9
|
-
##
|
|
16
|
+
## What S4L does
|
|
10
17
|
|
|
11
|
-
|
|
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
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
-
|
|
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
|
-
|
|
32
|
+
Nothing is posted automatically by default. Posting autopilot stays off until you
|
|
33
|
+
explicitly turn it on.
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
- `RESEND_API_KEY` and `NOTIFICATION_EMAIL` in `.env` for DM-escalation emails
|
|
35
|
+
## Who it is for
|
|
23
36
|
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
44
|
+
## How users install it
|
|
31
45
|
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
43
|
-
|
|
53
|
+
```text
|
|
54
|
+
Set me up on S4L plugin end to end
|
|
44
55
|
```
|
|
45
56
|
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
setup skill treats that as a terminal goal:
|
|
61
|
+
## What is in this repo
|
|
50
62
|
|
|
51
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
92
|
+
Important properties:
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
101
|
+
## Develop from source
|
|
87
102
|
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
116
|
+
npm install
|
|
117
|
+
cd mcp
|
|
118
|
+
npm install
|
|
98
119
|
```
|
|
99
120
|
|
|
100
|
-
|
|
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
|
-
|
|
123
|
+
```bash
|
|
124
|
+
cd mcp
|
|
125
|
+
npm run build
|
|
126
|
+
```
|
|
111
127
|
|
|
112
|
-
|
|
128
|
+
Register this checkout with Claude Desktop and Claude Code for local testing:
|
|
113
129
|
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
135
|
+
Then fully quit and reopen Claude so MCP servers reload.
|
|
127
136
|
|
|
128
|
-
|
|
137
|
+
Run the test suite:
|
|
129
138
|
|
|
130
|
-
|
|
139
|
+
```bash
|
|
140
|
+
npm test
|
|
141
|
+
```
|
|
131
142
|
|
|
132
|
-
|
|
143
|
+
Build a local `.mcpb` artifact without publishing:
|
|
133
144
|
|
|
134
145
|
```bash
|
|
135
|
-
|
|
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 =
|
|
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 (
|
|
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
|
package/mcp/dist/version.json
CHANGED
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.
|
|
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": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1546
|
-
"""
|
|
1547
|
-
|
|
1548
|
-
|
|
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
|
|
1552
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 ~
|
|
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:
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -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.
|
|
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