@m13v/s4l 1.7.5-rc.23 → 1.7.5-rc.26
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/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +2 -2
- package/mcp/menubar/s4l_card_canvas.py +1023 -0
- package/mcp/menubar/s4l_menubar.py +7 -0
- package/mcp/menubar/s4l_state.py +37 -0
- package/mcp/package.json +1 -1
- package/package.json +2 -1
- package/scripts/release-mcpb.sh +66 -2
- package/scripts/twitter_prompt_sandbox.py +20 -4
- package/skill/lib/browser-launch.sh +6 -1
- package/skill/run-twitter-cycle.sh +21 -5
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.5-rc.
|
|
5
|
+
"version": "1.7.5-rc.26",
|
|
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": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"type": "node",
|
|
15
15
|
"entry_point": "dist/index.js",
|
|
16
16
|
"mcp_config": {
|
|
17
|
-
"command": "node",
|
|
17
|
+
"command": "${__dirname}/vendor/node-darwin-arm64/bin/node",
|
|
18
18
|
"args": [
|
|
19
19
|
"${__dirname}/dist/index.js"
|
|
20
20
|
],
|
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
"""Large centered multi-select canvas for draft review (AppKit / pyobjc).
|
|
2
|
+
|
|
3
|
+
`present_review_canvas(drafts, on_decision, on_complete, focus)` is the canvas
|
|
4
|
+
counterpart to `s4l_card.present_review`: same `drafts` input shape (see
|
|
5
|
+
s4l_state.review_drafts), same decision-dict contract fired via
|
|
6
|
+
on_decision/on_complete -- the menu bar's existing wiring
|
|
7
|
+
(_on_card_decision / _on_review_closed) drives either surface unchanged.
|
|
8
|
+
Where the corner card walks the backlog one draft at a time, this shows the
|
|
9
|
+
WHOLE pending backlog at once in one large centered window: one scrollable
|
|
10
|
+
row per thread (full thread text + its draft(s)), a checkbox per row, and a
|
|
11
|
+
bulk "Approve selected" / "Discard selected" action bar, for a reviewer who
|
|
12
|
+
wants to skim many threads and act on a handful rather than read one card
|
|
13
|
+
at a time. Rows the reviewer never touches are simply left pending when the
|
|
14
|
+
window closes (same as closing the corner card mid-stack); the menu bar's
|
|
15
|
+
existing snooze handling re-presents them later.
|
|
16
|
+
|
|
17
|
+
Rows are sorted by stats.virality_score descending when a candidate has one
|
|
18
|
+
(Twitter only, today, per score_twitter_candidates.py); candidates without a
|
|
19
|
+
score sort as -1 and keep review_drafts()'s existing newest-first order
|
|
20
|
+
among themselves (Python's stable sort) at the bottom of the list -- no new
|
|
21
|
+
scoring is invented for platforms that don't have one.
|
|
22
|
+
|
|
23
|
+
Two-draft threads (candidate["drafts"], Draft A / Draft B) render as two
|
|
24
|
+
side-by-side editable boxes in the same row (the wider canvas has room,
|
|
25
|
+
unlike the corner card's stacked layout, per 2026-07-15 product direction);
|
|
26
|
+
whichever box the reviewer's caret is in is the "armed" draft for that row,
|
|
27
|
+
via the exact same textViewDidChangeSelection_ convention the corner card
|
|
28
|
+
uses (see s4l_card.py's _update_draft_borders), just duplicated per-row.
|
|
29
|
+
|
|
30
|
+
Decided rows (approved/discarded) stay visible but frozen -- disabled
|
|
31
|
+
controls, dimmed, a status line -- rather than being removed and the rest
|
|
32
|
+
reflowed: correctness and a stable scroll position matter more here than
|
|
33
|
+
tidying the list.
|
|
34
|
+
|
|
35
|
+
Deliberately NOT reimplemented here (present on the corner card, cut for
|
|
36
|
+
this surface's first pass, none of them decision-contract changes so any
|
|
37
|
+
can land later): per-second live expiry countdowns (age/expiry is a static
|
|
38
|
+
label computed at render/extend time), hover-dwell telemetry on two-draft
|
|
39
|
+
boxes (draft_choice ships with hover_*_ms=0 and visited_other=False), the
|
|
40
|
+
stats/details hover popovers (folded into a single tooltip), and the
|
|
41
|
+
categorized reject-reason picker (discard here always ships
|
|
42
|
+
reject_category=None, same as the corner card's own "Reject commits with
|
|
43
|
+
no category" fallback path).
|
|
44
|
+
|
|
45
|
+
Must be driven on the main thread (mirrors s4l_card.py).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import datetime
|
|
49
|
+
import re
|
|
50
|
+
import time
|
|
51
|
+
|
|
52
|
+
import objc
|
|
53
|
+
from Foundation import NSObject, NSMakeRect, NSMakeSize, NSURL
|
|
54
|
+
from AppKit import (
|
|
55
|
+
NSApp,
|
|
56
|
+
NSButton,
|
|
57
|
+
NSFont,
|
|
58
|
+
NSScrollView,
|
|
59
|
+
NSTextView,
|
|
60
|
+
NSView,
|
|
61
|
+
NSColor,
|
|
62
|
+
NSWorkspace,
|
|
63
|
+
NSWindowOcclusionStateVisible,
|
|
64
|
+
NSBackingStoreBuffered,
|
|
65
|
+
NSFloatingWindowLevel,
|
|
66
|
+
NSApplicationActivationPolicyAccessory,
|
|
67
|
+
NSWindowStyleMaskTitled,
|
|
68
|
+
NSWindowStyleMaskClosable,
|
|
69
|
+
NSWindowStyleMaskUtilityWindow,
|
|
70
|
+
NSTextAlignmentRight,
|
|
71
|
+
NSBezelStyleRounded,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from AppKit import NSButtonTypeSwitch
|
|
76
|
+
except Exception:
|
|
77
|
+
try:
|
|
78
|
+
from AppKit import NSSwitchButton as NSButtonTypeSwitch
|
|
79
|
+
except Exception:
|
|
80
|
+
NSButtonTypeSwitch = 3 # raw NSSwitchButton value, pre-10.12 AppKit
|
|
81
|
+
try:
|
|
82
|
+
from AppKit import NSBezelStyleInline
|
|
83
|
+
except Exception:
|
|
84
|
+
NSBezelStyleInline = None
|
|
85
|
+
|
|
86
|
+
# Reuse the corner card's panel class (Cmd+V/C/X/A/Z routing for a status-bar
|
|
87
|
+
# app with no Edit menu) and its styling/rendering helpers instead of
|
|
88
|
+
# duplicating them -- this module is a second PRESENTATION only, the drafts
|
|
89
|
+
# data, decision contract, and low-level AppKit skinning stay single-sourced
|
|
90
|
+
# in s4l_card.py.
|
|
91
|
+
from s4l_card import (
|
|
92
|
+
_ReviewPanel,
|
|
93
|
+
_label,
|
|
94
|
+
_editable_scroll,
|
|
95
|
+
_round_rect,
|
|
96
|
+
_frosted,
|
|
97
|
+
_fill_color,
|
|
98
|
+
_solid,
|
|
99
|
+
_fit_thread_body,
|
|
100
|
+
_engagement_line,
|
|
101
|
+
_details_lines,
|
|
102
|
+
_reply_heading_suffix,
|
|
103
|
+
_followers_str,
|
|
104
|
+
_age_expiry_display,
|
|
105
|
+
_mouse_screen,
|
|
106
|
+
_write_review_state,
|
|
107
|
+
_log,
|
|
108
|
+
NSWindowStyleMaskNonactivatingPanel,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Strong reference to the live controller so pyobjc doesn't GC it mid-review,
|
|
112
|
+
# mirroring s4l_card._active. Separate global -- only one of the two surfaces
|
|
113
|
+
# is ever open at a time (the menu bar's _review_active/_panel_open flags
|
|
114
|
+
# already enforce that), but each module owns its own reference regardless.
|
|
115
|
+
_active = None
|
|
116
|
+
|
|
117
|
+
WIN_MARGIN = 20
|
|
118
|
+
HEADER_H = 46
|
|
119
|
+
ROW_H = 258
|
|
120
|
+
ROW_GAP = 16
|
|
121
|
+
ROW_PAD = 14
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _now_iso():
|
|
125
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _sort_key(d):
|
|
129
|
+
"""virality_score descending when present (Twitter only today); -1 for
|
|
130
|
+
everything else so unscored candidates sort last, keeping their existing
|
|
131
|
+
review_drafts() order among themselves (stable sort). No score is
|
|
132
|
+
invented for platforms that don't have one."""
|
|
133
|
+
v = ((d or {}).get("stats") or {}).get("virality_score")
|
|
134
|
+
try:
|
|
135
|
+
return float(v)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return -1.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _center_frame(screen):
|
|
141
|
+
"""Large centered frame on the given screen -- the canvas is a
|
|
142
|
+
deliberate review session, not a glanceable corner widget."""
|
|
143
|
+
vf = screen.visibleFrame() if screen is not None else NSMakeRect(0, 0, 1440, 900)
|
|
144
|
+
w = min(1180.0, vf.size.width * 0.88)
|
|
145
|
+
h = vf.size.height * 0.86
|
|
146
|
+
x = vf.origin.x + (vf.size.width - w) / 2.0
|
|
147
|
+
y = vf.origin.y + (vf.size.height - h) / 2.0
|
|
148
|
+
return NSMakeRect(x, y, w, h)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _FlippedView(NSView):
|
|
152
|
+
"""Top-down document view: origin at the top-left, y grows downward.
|
|
153
|
+
Makes row stacking (and appending more rows via extend_drafts) simple
|
|
154
|
+
arithmetic independent of the document's current height, and the scroll
|
|
155
|
+
view opens showing the top of the list by default."""
|
|
156
|
+
|
|
157
|
+
def isFlipped(self):
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _CanvasController(NSObject):
|
|
162
|
+
def initWithDrafts_onDecision_onComplete_focus_(
|
|
163
|
+
self, drafts, on_decision, on_complete, focus
|
|
164
|
+
):
|
|
165
|
+
self = objc.super(_CanvasController, self).init()
|
|
166
|
+
if self is None:
|
|
167
|
+
return None
|
|
168
|
+
self._sorted = sorted(list(drafts), key=_sort_key, reverse=True)
|
|
169
|
+
self._on_decision = on_decision
|
|
170
|
+
self._on_complete = on_complete
|
|
171
|
+
self._focus = bool(focus)
|
|
172
|
+
self._decisions = []
|
|
173
|
+
self._rows = []
|
|
174
|
+
self._panel = None
|
|
175
|
+
self._scroll = None
|
|
176
|
+
self._doc = None
|
|
177
|
+
self._select_all_btn = None
|
|
178
|
+
self._count_label = None
|
|
179
|
+
self._approve_btn = None
|
|
180
|
+
self._discard_btn = None
|
|
181
|
+
self._presented_at = time.time()
|
|
182
|
+
self._last_decision_at = None
|
|
183
|
+
self._last_interaction_at = None
|
|
184
|
+
self._last_move_log = 0.0
|
|
185
|
+
self._build()
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
# ---- surface lifecycle -------------------------------------------------
|
|
189
|
+
|
|
190
|
+
@objc.python_method
|
|
191
|
+
def _build(self):
|
|
192
|
+
frame = _center_frame(_mouse_screen())
|
|
193
|
+
style = (
|
|
194
|
+
NSWindowStyleMaskTitled
|
|
195
|
+
| NSWindowStyleMaskClosable
|
|
196
|
+
| NSWindowStyleMaskUtilityWindow
|
|
197
|
+
| NSWindowStyleMaskNonactivatingPanel
|
|
198
|
+
)
|
|
199
|
+
panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
200
|
+
frame, style, NSBackingStoreBuffered, False
|
|
201
|
+
)
|
|
202
|
+
panel.setLevel_(NSFloatingWindowLevel)
|
|
203
|
+
panel.setFloatingPanel_(True)
|
|
204
|
+
panel.setBecomesKeyOnlyIfNeeded_(True)
|
|
205
|
+
panel.setHidesOnDeactivate_(False)
|
|
206
|
+
panel.setReleasedWhenClosed_(False)
|
|
207
|
+
panel.setDelegate_(self)
|
|
208
|
+
self._panel = panel
|
|
209
|
+
self._render()
|
|
210
|
+
panel.orderFrontRegardless()
|
|
211
|
+
self._log_surface("presented")
|
|
212
|
+
if self._focus:
|
|
213
|
+
try:
|
|
214
|
+
NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
|
215
|
+
NSApp.activateIgnoringOtherApps_(True)
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
panel.makeKeyAndOrderFront_(None)
|
|
219
|
+
panel.orderFrontRegardless()
|
|
220
|
+
|
|
221
|
+
@objc.python_method
|
|
222
|
+
def _render(self):
|
|
223
|
+
frame = self._panel.frame()
|
|
224
|
+
w, h = frame.size.width, frame.size.height
|
|
225
|
+
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, w, h))
|
|
226
|
+
|
|
227
|
+
header_y = h - HEADER_H
|
|
228
|
+
select_all = NSButton.alloc().initWithFrame_(
|
|
229
|
+
NSMakeRect(WIN_MARGIN, header_y + 12, 110, 22)
|
|
230
|
+
)
|
|
231
|
+
select_all.setButtonType_(NSButtonTypeSwitch)
|
|
232
|
+
select_all.setTitle_("Select all")
|
|
233
|
+
select_all.setTarget_(self)
|
|
234
|
+
select_all.setAction_("selectAllToggled:")
|
|
235
|
+
content.addSubview_(select_all)
|
|
236
|
+
self._select_all_btn = select_all
|
|
237
|
+
|
|
238
|
+
self._count_label = _label(
|
|
239
|
+
NSMakeRect(WIN_MARGIN + 130, header_y + 14, 320, 18), "", size=12, bold=True
|
|
240
|
+
)
|
|
241
|
+
content.addSubview_(self._count_label)
|
|
242
|
+
|
|
243
|
+
approve_w, discard_w, btn_gap = 150.0, 140.0, 10.0
|
|
244
|
+
approve_x = w - WIN_MARGIN - approve_w
|
|
245
|
+
discard_x = approve_x - btn_gap - discard_w
|
|
246
|
+
|
|
247
|
+
discard_btn = NSButton.alloc().initWithFrame_(
|
|
248
|
+
NSMakeRect(discard_x, header_y + 8, discard_w, 28)
|
|
249
|
+
)
|
|
250
|
+
discard_btn.setTitle_("Discard selected")
|
|
251
|
+
discard_btn.setBezelStyle_(NSBezelStyleRounded)
|
|
252
|
+
try:
|
|
253
|
+
discard_btn.setHasDestructiveAction_(True)
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
discard_btn.setTarget_(self)
|
|
257
|
+
discard_btn.setAction_("discardSelected:")
|
|
258
|
+
content.addSubview_(discard_btn)
|
|
259
|
+
self._discard_btn = discard_btn
|
|
260
|
+
|
|
261
|
+
approve_btn = NSButton.alloc().initWithFrame_(
|
|
262
|
+
NSMakeRect(approve_x, header_y + 8, approve_w, 28)
|
|
263
|
+
)
|
|
264
|
+
approve_btn.setTitle_("Approve selected")
|
|
265
|
+
approve_btn.setBezelStyle_(NSBezelStyleRounded)
|
|
266
|
+
approve_btn.setKeyEquivalent_("\r")
|
|
267
|
+
approve_btn.setTarget_(self)
|
|
268
|
+
approve_btn.setAction_("approveSelected:")
|
|
269
|
+
content.addSubview_(approve_btn)
|
|
270
|
+
self._approve_btn = approve_btn
|
|
271
|
+
|
|
272
|
+
body_y = WIN_MARGIN
|
|
273
|
+
body_h = header_y - WIN_MARGIN - 8
|
|
274
|
+
body_w = w - 2 * WIN_MARGIN
|
|
275
|
+
scroll = NSScrollView.alloc().initWithFrame_(
|
|
276
|
+
NSMakeRect(WIN_MARGIN, body_y, body_w, body_h)
|
|
277
|
+
)
|
|
278
|
+
scroll.setHasVerticalScroller_(True)
|
|
279
|
+
scroll.setAutohidesScrollers_(True)
|
|
280
|
+
scroll.setDrawsBackground_(False)
|
|
281
|
+
content.addSubview_(scroll)
|
|
282
|
+
self._scroll = scroll
|
|
283
|
+
|
|
284
|
+
self._rows = []
|
|
285
|
+
self._build_rows(scroll)
|
|
286
|
+
|
|
287
|
+
self._panel.setContentView_(_frosted(content))
|
|
288
|
+
self._refresh_header()
|
|
289
|
+
|
|
290
|
+
@objc.python_method
|
|
291
|
+
def _build_rows(self, scroll):
|
|
292
|
+
cs = scroll.contentSize()
|
|
293
|
+
doc_w = cs.width
|
|
294
|
+
n = len(self._sorted)
|
|
295
|
+
doc_h = max(cs.height, ROW_PAD + n * (ROW_H + ROW_GAP))
|
|
296
|
+
doc = _FlippedView.alloc().initWithFrame_(NSMakeRect(0, 0, doc_w, doc_h))
|
|
297
|
+
self._doc = doc
|
|
298
|
+
for i, d in enumerate(self._sorted):
|
|
299
|
+
row_y = ROW_PAD + i * (ROW_H + ROW_GAP)
|
|
300
|
+
self._add_row(doc, d, i, NSMakeRect(0, row_y, doc_w, ROW_H))
|
|
301
|
+
scroll.setDocumentView_(doc)
|
|
302
|
+
|
|
303
|
+
@objc.python_method
|
|
304
|
+
def _add_row(self, doc, d, idx, frame):
|
|
305
|
+
row_view = NSView.alloc().initWithFrame_(frame)
|
|
306
|
+
_round_rect(row_view)
|
|
307
|
+
try:
|
|
308
|
+
row_view.layer().setBackgroundColor_(_fill_color().CGColor())
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
doc.addSubview_(row_view)
|
|
312
|
+
|
|
313
|
+
rw, rh = frame.size.width, frame.size.height
|
|
314
|
+
rm = 14
|
|
315
|
+
|
|
316
|
+
checkbox = NSButton.alloc().initWithFrame_(NSMakeRect(rm, rh - 30, 20, 20))
|
|
317
|
+
checkbox.setButtonType_(NSButtonTypeSwitch)
|
|
318
|
+
checkbox.setTitle_("")
|
|
319
|
+
checkbox.setTag_(idx)
|
|
320
|
+
checkbox.setTarget_(self)
|
|
321
|
+
checkbox.setAction_("rowCheckToggled:")
|
|
322
|
+
row_view.addSubview_(checkbox)
|
|
323
|
+
|
|
324
|
+
content_x = rm + 28
|
|
325
|
+
content_w = rw - content_x - rm
|
|
326
|
+
|
|
327
|
+
handle = (d.get("thread_author") or "").lstrip("@").strip()
|
|
328
|
+
followers = _followers_str(d.get("stats"))
|
|
329
|
+
heading = f"@{handle}" if handle else "(unknown author)"
|
|
330
|
+
if followers:
|
|
331
|
+
heading += f" · {followers}"
|
|
332
|
+
tag = _reply_heading_suffix(d)
|
|
333
|
+
if tag:
|
|
334
|
+
heading += f" — {tag}"
|
|
335
|
+
age_text, urgent = _age_expiry_display(
|
|
336
|
+
(d.get("stats") or {}).get("tweet_posted_at"), d.get("platform")
|
|
337
|
+
)
|
|
338
|
+
age_w = 150
|
|
339
|
+
age_x = rw - rm - age_w
|
|
340
|
+
heading_w = (age_x - 10 - content_x) if age_text else (content_w - 10)
|
|
341
|
+
row_view.addSubview_(
|
|
342
|
+
_label(
|
|
343
|
+
NSMakeRect(content_x, rh - 28, max(60, heading_w), 18),
|
|
344
|
+
heading,
|
|
345
|
+
size=12,
|
|
346
|
+
bold=True,
|
|
347
|
+
truncates=True,
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
if age_text:
|
|
351
|
+
age_label = _label(
|
|
352
|
+
NSMakeRect(age_x, rh - 28, age_w, 18),
|
|
353
|
+
age_text,
|
|
354
|
+
size=11,
|
|
355
|
+
bold=urgent,
|
|
356
|
+
muted=not urgent,
|
|
357
|
+
truncates=True,
|
|
358
|
+
)
|
|
359
|
+
age_label.setAlignment_(NSTextAlignmentRight)
|
|
360
|
+
row_view.addSubview_(age_label)
|
|
361
|
+
|
|
362
|
+
tooltip_bits = []
|
|
363
|
+
eng = _engagement_line(d.get("stats"))
|
|
364
|
+
if eng:
|
|
365
|
+
tooltip_bits.append(eng)
|
|
366
|
+
tooltip_bits.extend(_details_lines(d))
|
|
367
|
+
if tooltip_bits:
|
|
368
|
+
info_btn = NSButton.alloc().initWithFrame_(NSMakeRect(content_x, rh - 50, 18, 16))
|
|
369
|
+
if NSBezelStyleInline is not None:
|
|
370
|
+
try:
|
|
371
|
+
info_btn.setBezelStyle_(NSBezelStyleInline)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
info_btn.setTitle_("ⓘ")
|
|
375
|
+
info_btn.setFont_(NSFont.systemFontOfSize_(10))
|
|
376
|
+
try:
|
|
377
|
+
info_btn.setToolTip_("\n".join(tooltip_bits))
|
|
378
|
+
except Exception:
|
|
379
|
+
pass
|
|
380
|
+
row_view.addSubview_(info_btn)
|
|
381
|
+
|
|
382
|
+
thread_top = rh - 54
|
|
383
|
+
thread_h = 56
|
|
384
|
+
thread_scroll = NSScrollView.alloc().initWithFrame_(
|
|
385
|
+
NSMakeRect(content_x, thread_top - thread_h, content_w, thread_h)
|
|
386
|
+
)
|
|
387
|
+
thread_scroll.setHasVerticalScroller_(False)
|
|
388
|
+
thread_scroll.setDrawsBackground_(False)
|
|
389
|
+
thread_scroll.setBorderType_(0)
|
|
390
|
+
tcs = thread_scroll.contentSize()
|
|
391
|
+
thread_tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, tcs.width, tcs.height))
|
|
392
|
+
thread_tv.setEditable_(False)
|
|
393
|
+
thread_tv.setSelectable_(True)
|
|
394
|
+
thread_tv.setDrawsBackground_(False)
|
|
395
|
+
thread_tv.setDelegate_(self)
|
|
396
|
+
thread_tv.textContainer().setWidthTracksTextView_(True)
|
|
397
|
+
thread_scroll.setDocumentView_(thread_tv)
|
|
398
|
+
row_view.addSubview_(thread_scroll)
|
|
399
|
+
# thread_text_en (stamped at draft time for non-English drafts) is
|
|
400
|
+
# shown here so the reviewer reads English; the editable box below
|
|
401
|
+
# always keeps the ORIGINAL-language reply_text, which is what posts.
|
|
402
|
+
thread_text = (d.get("thread_text_en") or d.get("thread_text") or "").strip()
|
|
403
|
+
_fit_thread_body(thread_tv, thread_text, d.get("thread_url"), font_size=12, step=15, floor=10)
|
|
404
|
+
|
|
405
|
+
row = {
|
|
406
|
+
"d": d,
|
|
407
|
+
"n": d.get("n"),
|
|
408
|
+
"idx": idx,
|
|
409
|
+
"container": row_view,
|
|
410
|
+
"checkbox": checkbox,
|
|
411
|
+
"selected": False,
|
|
412
|
+
"decided": False,
|
|
413
|
+
"hidden": False,
|
|
414
|
+
"dual": False,
|
|
415
|
+
"armed_slot": 0,
|
|
416
|
+
"outlines": {},
|
|
417
|
+
"textviews": {},
|
|
418
|
+
"orig_texts": {},
|
|
419
|
+
"thread_tv": thread_tv,
|
|
420
|
+
"thread_url": d.get("thread_url"),
|
|
421
|
+
"interactions": [],
|
|
422
|
+
"discard_btn": None,
|
|
423
|
+
"status_label": None,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
drafts_field = d.get("drafts")
|
|
427
|
+
dual = isinstance(drafts_field, list) and len(drafts_field) == 2
|
|
428
|
+
row["dual"] = dual
|
|
429
|
+
link = d.get("link_url")
|
|
430
|
+
|
|
431
|
+
def _compose(text):
|
|
432
|
+
text = text or ""
|
|
433
|
+
return text if (not link or link in text) else f"{text} {link}"
|
|
434
|
+
|
|
435
|
+
bottom_bar_h = 26
|
|
436
|
+
edit_bottom = bottom_bar_h + 4
|
|
437
|
+
edit_top = thread_top - thread_h - 8
|
|
438
|
+
label_h = 14 if dual else 0
|
|
439
|
+
edit_h = edit_top - edit_bottom - label_h
|
|
440
|
+
|
|
441
|
+
if dual:
|
|
442
|
+
gap2 = 10
|
|
443
|
+
box_w = (content_w - gap2) / 2.0
|
|
444
|
+
for slot in (0, 1):
|
|
445
|
+
box_x = content_x + slot * (box_w + gap2)
|
|
446
|
+
outline = NSView.alloc().initWithFrame_(NSMakeRect(box_x, edit_bottom, box_w, edit_h))
|
|
447
|
+
_round_rect(outline)
|
|
448
|
+
inset = 4
|
|
449
|
+
escroll, etv = _editable_scroll(
|
|
450
|
+
NSMakeRect(inset, inset, box_w - 2 * inset, edit_h - 2 * inset),
|
|
451
|
+
_compose(drafts_field[slot].get("text")),
|
|
452
|
+
)
|
|
453
|
+
etv.setDelegate_(self)
|
|
454
|
+
outline.addSubview_(escroll)
|
|
455
|
+
row_view.addSubview_(outline)
|
|
456
|
+
row_view.addSubview_(
|
|
457
|
+
_label(
|
|
458
|
+
NSMakeRect(box_x, edit_bottom + edit_h, box_w, label_h),
|
|
459
|
+
"Draft A" if slot == 0 else "Draft B",
|
|
460
|
+
size=10,
|
|
461
|
+
muted=True,
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
row["outlines"][slot] = outline
|
|
465
|
+
row["textviews"][slot] = etv
|
|
466
|
+
row["orig_texts"][slot] = (drafts_field[slot].get("text") or "").strip()
|
|
467
|
+
else:
|
|
468
|
+
outline = NSView.alloc().initWithFrame_(NSMakeRect(content_x, edit_bottom, content_w, edit_h))
|
|
469
|
+
_round_rect(outline)
|
|
470
|
+
inset = 4
|
|
471
|
+
escroll, etv = _editable_scroll(
|
|
472
|
+
NSMakeRect(inset, inset, content_w - 2 * inset, edit_h - 2 * inset),
|
|
473
|
+
_compose(d.get("reply_text")),
|
|
474
|
+
)
|
|
475
|
+
etv.setDelegate_(self)
|
|
476
|
+
outline.addSubview_(escroll)
|
|
477
|
+
row_view.addSubview_(outline)
|
|
478
|
+
row["outlines"][0] = outline
|
|
479
|
+
row["textviews"][0] = etv
|
|
480
|
+
row["orig_texts"][0] = (d.get("reply_text") or "").strip()
|
|
481
|
+
|
|
482
|
+
discard_btn = NSButton.alloc().initWithFrame_(NSMakeRect(content_x, 2, 90, 22))
|
|
483
|
+
discard_btn.setTitle_("Discard")
|
|
484
|
+
if NSBezelStyleInline is not None:
|
|
485
|
+
try:
|
|
486
|
+
discard_btn.setBezelStyle_(NSBezelStyleInline)
|
|
487
|
+
except Exception:
|
|
488
|
+
discard_btn.setBezelStyle_(NSBezelStyleRounded)
|
|
489
|
+
else:
|
|
490
|
+
discard_btn.setBezelStyle_(NSBezelStyleRounded)
|
|
491
|
+
try:
|
|
492
|
+
discard_btn.setHasDestructiveAction_(True)
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
discard_btn.setFont_(NSFont.systemFontOfSize_(11))
|
|
496
|
+
discard_btn.setTag_(idx)
|
|
497
|
+
discard_btn.setTarget_(self)
|
|
498
|
+
discard_btn.setAction_("rowDiscard:")
|
|
499
|
+
row_view.addSubview_(discard_btn)
|
|
500
|
+
row["discard_btn"] = discard_btn
|
|
501
|
+
|
|
502
|
+
status_label = _label(
|
|
503
|
+
NSMakeRect(content_x + 100, 2, content_w - 100, 22), "", size=11, bold=True
|
|
504
|
+
)
|
|
505
|
+
status_label.setHidden_(True)
|
|
506
|
+
row_view.addSubview_(status_label)
|
|
507
|
+
row["status_label"] = status_label
|
|
508
|
+
|
|
509
|
+
self._rows.append(row)
|
|
510
|
+
if dual:
|
|
511
|
+
self._update_row_borders(row)
|
|
512
|
+
|
|
513
|
+
# ---- two-draft arm selection --------------------------------------------
|
|
514
|
+
|
|
515
|
+
def textViewDidChangeSelection_(self, notification):
|
|
516
|
+
# Same convention as the corner card (s4l_card._ReviewController):
|
|
517
|
+
# whichever draft box the reviewer's caret is in becomes the armed
|
|
518
|
+
# slot for that row. Linear `is` scan, not a dict keyed by the
|
|
519
|
+
# NSTextView itself -- mirrors the corner card's own defensive
|
|
520
|
+
# pattern rather than trusting pyobjc object identity for hashing.
|
|
521
|
+
try:
|
|
522
|
+
tv = notification.object()
|
|
523
|
+
except Exception:
|
|
524
|
+
return
|
|
525
|
+
for row in self._rows:
|
|
526
|
+
if not row["dual"] or row["decided"] or row["hidden"]:
|
|
527
|
+
continue
|
|
528
|
+
for slot, cand_tv in row["textviews"].items():
|
|
529
|
+
if cand_tv is not tv:
|
|
530
|
+
continue
|
|
531
|
+
if slot != row["armed_slot"]:
|
|
532
|
+
row["armed_slot"] = slot
|
|
533
|
+
self._update_row_borders(row)
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
@objc.python_method
|
|
537
|
+
def _update_row_borders(self, row):
|
|
538
|
+
if not row["dual"]:
|
|
539
|
+
return
|
|
540
|
+
selected_color = NSColor.blackColor()
|
|
541
|
+
for slot, outline in row["outlines"].items():
|
|
542
|
+
try:
|
|
543
|
+
layer = outline.layer()
|
|
544
|
+
if layer is None:
|
|
545
|
+
continue
|
|
546
|
+
if slot == row["armed_slot"]:
|
|
547
|
+
layer.setBorderWidth_(3.0)
|
|
548
|
+
layer.setBorderColor_(selected_color.CGColor())
|
|
549
|
+
tint = _solid(NSColor.textBackgroundColor()).blendedColorWithFraction_ofColor_(
|
|
550
|
+
0.30, selected_color
|
|
551
|
+
)
|
|
552
|
+
layer.setBackgroundColor_((tint or selected_color).CGColor())
|
|
553
|
+
else:
|
|
554
|
+
layer.setBorderWidth_(1.0)
|
|
555
|
+
layer.setBorderColor_(_solid(NSColor.separatorColor()).CGColor())
|
|
556
|
+
layer.setBackgroundColor_(_solid(NSColor.textBackgroundColor()).CGColor())
|
|
557
|
+
except Exception:
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
# ---- thread link click ---------------------------------------------------
|
|
561
|
+
|
|
562
|
+
def textView_clickedOnLink_atIndex_(self, tv, link, idx):
|
|
563
|
+
url = None
|
|
564
|
+
for row in self._rows:
|
|
565
|
+
if row.get("thread_tv") is tv:
|
|
566
|
+
url = row.get("thread_url")
|
|
567
|
+
break
|
|
568
|
+
try:
|
|
569
|
+
NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url or str(link)))
|
|
570
|
+
except Exception:
|
|
571
|
+
pass
|
|
572
|
+
self._last_interaction_at = time.time()
|
|
573
|
+
return True
|
|
574
|
+
|
|
575
|
+
# ---- selection / decisions ------------------------------------------------
|
|
576
|
+
|
|
577
|
+
def rowCheckToggled_(self, sender):
|
|
578
|
+
try:
|
|
579
|
+
idx = int(sender.tag())
|
|
580
|
+
except Exception:
|
|
581
|
+
return
|
|
582
|
+
if not (0 <= idx < len(self._rows)):
|
|
583
|
+
return
|
|
584
|
+
row = self._rows[idx]
|
|
585
|
+
if row["decided"] or row["hidden"]:
|
|
586
|
+
return
|
|
587
|
+
row["selected"] = bool(sender.state())
|
|
588
|
+
row["interactions"].append(
|
|
589
|
+
{"type": "canvas_select" if row["selected"] else "canvas_deselect", "ts": _now_iso()}
|
|
590
|
+
)
|
|
591
|
+
self._last_interaction_at = time.time()
|
|
592
|
+
self._refresh_header()
|
|
593
|
+
|
|
594
|
+
def selectAllToggled_(self, sender):
|
|
595
|
+
want = bool(sender.state())
|
|
596
|
+
for row in self._rows:
|
|
597
|
+
if row["decided"] or row["hidden"]:
|
|
598
|
+
continue
|
|
599
|
+
row["selected"] = want
|
|
600
|
+
try:
|
|
601
|
+
row["checkbox"].setState_(1 if want else 0)
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
self._last_interaction_at = time.time()
|
|
605
|
+
self._refresh_header()
|
|
606
|
+
|
|
607
|
+
def rowDiscard_(self, sender):
|
|
608
|
+
try:
|
|
609
|
+
idx = int(sender.tag())
|
|
610
|
+
except Exception:
|
|
611
|
+
return
|
|
612
|
+
if not (0 <= idx < len(self._rows)):
|
|
613
|
+
return
|
|
614
|
+
self._apply_decision_to_row(self._rows[idx], False)
|
|
615
|
+
self._refresh_header()
|
|
616
|
+
|
|
617
|
+
def approveSelected_(self, sender):
|
|
618
|
+
acted = [r for r in self._rows if not r["decided"] and not r["hidden"] and r["selected"]]
|
|
619
|
+
for row in acted:
|
|
620
|
+
self._apply_decision_to_row(row, True)
|
|
621
|
+
self._refresh_header()
|
|
622
|
+
|
|
623
|
+
def discardSelected_(self, sender):
|
|
624
|
+
acted = [r for r in self._rows if not r["decided"] and not r["hidden"] and r["selected"]]
|
|
625
|
+
for row in acted:
|
|
626
|
+
self._apply_decision_to_row(row, False)
|
|
627
|
+
self._refresh_header()
|
|
628
|
+
|
|
629
|
+
@objc.python_method
|
|
630
|
+
def _current_text(self, row):
|
|
631
|
+
slot = row["armed_slot"] if row["dual"] else 0
|
|
632
|
+
tv = row["textviews"].get(slot)
|
|
633
|
+
if tv is not None:
|
|
634
|
+
try:
|
|
635
|
+
return str(tv.string())
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
d = row["d"]
|
|
639
|
+
if row["dual"]:
|
|
640
|
+
drafts = d.get("drafts")
|
|
641
|
+
return (drafts[slot].get("text") or "") if drafts else ""
|
|
642
|
+
return d.get("reply_text") or ""
|
|
643
|
+
|
|
644
|
+
@objc.python_method
|
|
645
|
+
def _build_decision(self, row, approved, loved=False, reject_category=None, reject_note=None):
|
|
646
|
+
# Faithful port of s4l_card._ReviewController._record, adapted to
|
|
647
|
+
# per-row state instead of a single current-card index. Same field
|
|
648
|
+
# shape end to end so _ship_review_event / post_drafts need no
|
|
649
|
+
# changes to consume either surface's decisions.
|
|
650
|
+
d = row["d"]
|
|
651
|
+
dual = row["dual"]
|
|
652
|
+
if dual:
|
|
653
|
+
sel_idx = row["armed_slot"] if row["armed_slot"] in (0, 1) else 0
|
|
654
|
+
drafts = d.get("drafts")
|
|
655
|
+
chosen = drafts[sel_idx]
|
|
656
|
+
orig = (row["orig_texts"].get(sel_idx) or "").strip()
|
|
657
|
+
draft_variant = chosen.get("variant") or ("a" if sel_idx == 0 else "b")
|
|
658
|
+
other = drafts[1 - sel_idx]
|
|
659
|
+
draft_choice = {
|
|
660
|
+
"variant": draft_variant,
|
|
661
|
+
"index": sel_idx,
|
|
662
|
+
"auto_selected": bool(sel_idx == 0),
|
|
663
|
+
"style": chosen.get("style") or None,
|
|
664
|
+
"unchosen_text": (other.get("text") or "").strip() or None,
|
|
665
|
+
"unchosen_style": other.get("style") or None,
|
|
666
|
+
# No per-box hover tracking on this surface (see module
|
|
667
|
+
# docstring) -- shipped as known-zero, not fabricated.
|
|
668
|
+
"hover_a_ms": 0,
|
|
669
|
+
"hover_b_ms": 0,
|
|
670
|
+
"visited_other": False,
|
|
671
|
+
}
|
|
672
|
+
else:
|
|
673
|
+
sel_idx = None
|
|
674
|
+
orig = (row["orig_texts"].get(0) or "").strip()
|
|
675
|
+
draft_variant = None
|
|
676
|
+
draft_choice = None
|
|
677
|
+
link = d.get("link_url") or ""
|
|
678
|
+
drop_link = False
|
|
679
|
+
if approved:
|
|
680
|
+
text = self._current_text(row)
|
|
681
|
+
if link and link not in text:
|
|
682
|
+
drop_link = True
|
|
683
|
+
body = re.sub(r"[ \t]{2,}", " ", text).strip()
|
|
684
|
+
else:
|
|
685
|
+
body = orig
|
|
686
|
+
return {
|
|
687
|
+
"n": d.get("n"),
|
|
688
|
+
"approved": bool(approved),
|
|
689
|
+
"loved": bool(approved and loved),
|
|
690
|
+
"text": body,
|
|
691
|
+
"edited": bool(approved and body != orig),
|
|
692
|
+
"original_text": orig if (approved and body != orig) else None,
|
|
693
|
+
"drop_link": bool(approved and drop_link),
|
|
694
|
+
"reject_category": reject_category,
|
|
695
|
+
"reject_note": (reject_note or "").strip() or None,
|
|
696
|
+
"interactions": list(row.get("interactions") or []),
|
|
697
|
+
"dwell_ms": self._dwell_ms(),
|
|
698
|
+
"candidate_id": d.get("candidate_id"),
|
|
699
|
+
"platform": d.get("platform"),
|
|
700
|
+
"project": d.get("project"),
|
|
701
|
+
"thread_url": d.get("thread_url"),
|
|
702
|
+
"thread_author": d.get("thread_author"),
|
|
703
|
+
"language": d.get("language"),
|
|
704
|
+
"draft_variant": draft_variant,
|
|
705
|
+
"draft_index": sel_idx,
|
|
706
|
+
"draft_auto_selected": bool(dual and sel_idx == 0),
|
|
707
|
+
"draft_choice": draft_choice,
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
@objc.python_method
|
|
711
|
+
def _apply_decision_to_row(self, row, approved, loved=False, reject_category=None, reject_note=None):
|
|
712
|
+
if row["decided"] or row["hidden"]:
|
|
713
|
+
return
|
|
714
|
+
decision = self._build_decision(
|
|
715
|
+
row, approved, loved=loved, reject_category=reject_category, reject_note=reject_note
|
|
716
|
+
)
|
|
717
|
+
self._decisions.append(decision)
|
|
718
|
+
row["decided"] = True
|
|
719
|
+
self._freeze_row(row, approved)
|
|
720
|
+
self._last_decision_at = time.time()
|
|
721
|
+
self._log_surface("decision")
|
|
722
|
+
cb = self._on_decision
|
|
723
|
+
if cb is not None:
|
|
724
|
+
try:
|
|
725
|
+
cb(dict(decision))
|
|
726
|
+
except Exception:
|
|
727
|
+
pass
|
|
728
|
+
|
|
729
|
+
@objc.python_method
|
|
730
|
+
def _freeze_row(self, row, approved):
|
|
731
|
+
try:
|
|
732
|
+
row["checkbox"].setEnabled_(False)
|
|
733
|
+
row["checkbox"].setState_(0)
|
|
734
|
+
except Exception:
|
|
735
|
+
pass
|
|
736
|
+
for tv in row["textviews"].values():
|
|
737
|
+
try:
|
|
738
|
+
tv.setEditable_(False)
|
|
739
|
+
except Exception:
|
|
740
|
+
pass
|
|
741
|
+
try:
|
|
742
|
+
row["discard_btn"].setHidden_(True)
|
|
743
|
+
except Exception:
|
|
744
|
+
pass
|
|
745
|
+
try:
|
|
746
|
+
row["status_label"].setStringValue_("Approved ✓" if approved else "Discarded ✕")
|
|
747
|
+
row["status_label"].setHidden_(False)
|
|
748
|
+
except Exception:
|
|
749
|
+
pass
|
|
750
|
+
try:
|
|
751
|
+
row["container"].setAlphaValue_(0.55)
|
|
752
|
+
except Exception:
|
|
753
|
+
pass
|
|
754
|
+
row["selected"] = False
|
|
755
|
+
|
|
756
|
+
@objc.python_method
|
|
757
|
+
def _refresh_header(self):
|
|
758
|
+
pending = [r for r in self._rows if not r["decided"] and not r["hidden"]]
|
|
759
|
+
selected = [r for r in pending if r["selected"]]
|
|
760
|
+
try:
|
|
761
|
+
self._count_label.setStringValue_(f"{len(selected)} selected of {len(pending)} pending")
|
|
762
|
+
except Exception:
|
|
763
|
+
pass
|
|
764
|
+
try:
|
|
765
|
+
self._approve_btn.setEnabled_(bool(selected))
|
|
766
|
+
self._discard_btn.setEnabled_(bool(selected))
|
|
767
|
+
except Exception:
|
|
768
|
+
pass
|
|
769
|
+
try:
|
|
770
|
+
self._panel.setTitle_(f"s4l · Review {len(pending)} drafts (canvas)")
|
|
771
|
+
except Exception:
|
|
772
|
+
pass
|
|
773
|
+
|
|
774
|
+
# ---- live backlog changes -------------------------------------------------
|
|
775
|
+
|
|
776
|
+
@objc.python_method
|
|
777
|
+
def extend_drafts(self, drafts):
|
|
778
|
+
"""Append newly-queued drafts as new rows below the current bottom.
|
|
779
|
+
Never touches an existing row's frame or content -- an in-progress
|
|
780
|
+
edit/selection on screen is never disturbed, mirroring the corner
|
|
781
|
+
card's extend_drafts. New drafts are sorted among THEMSELVES only
|
|
782
|
+
(not merged back into the whole list's order)."""
|
|
783
|
+
if self._panel is None or self._doc is None:
|
|
784
|
+
return 0
|
|
785
|
+
have = {r["n"] for r in self._rows}
|
|
786
|
+
added = [d for d in drafts if d.get("n") not in have]
|
|
787
|
+
if not added:
|
|
788
|
+
return 0
|
|
789
|
+
added.sort(key=_sort_key, reverse=True)
|
|
790
|
+
self._sorted.extend(added)
|
|
791
|
+
doc = self._doc
|
|
792
|
+
doc_w = doc.frame().size.width
|
|
793
|
+
base_idx = len(self._rows)
|
|
794
|
+
new_h = ROW_PAD + (base_idx + len(added)) * (ROW_H + ROW_GAP)
|
|
795
|
+
doc.setFrameSize_(NSMakeSize(doc_w, new_h))
|
|
796
|
+
for j, d in enumerate(added):
|
|
797
|
+
i = base_idx + j
|
|
798
|
+
row_y = ROW_PAD + i * (ROW_H + ROW_GAP)
|
|
799
|
+
self._add_row(doc, d, i, NSMakeRect(0, row_y, doc_w, ROW_H))
|
|
800
|
+
self._refresh_header()
|
|
801
|
+
self._log_surface("extended")
|
|
802
|
+
return len(added)
|
|
803
|
+
|
|
804
|
+
@objc.python_method
|
|
805
|
+
def prune_drafts(self, ns):
|
|
806
|
+
"""Hide not-yet-decided rows whose plan index `n` is in `ns` (a
|
|
807
|
+
candidate that expired backend-side mid-review). The row's vertical
|
|
808
|
+
slot is left in place rather than reflowed -- a small visible gap is
|
|
809
|
+
an acceptable trade for never risking a mislaid, still-editable row
|
|
810
|
+
underneath it."""
|
|
811
|
+
if self._panel is None:
|
|
812
|
+
return 0
|
|
813
|
+
ns = set(ns)
|
|
814
|
+
removed = 0
|
|
815
|
+
for row in self._rows:
|
|
816
|
+
if row["decided"] or row["hidden"]:
|
|
817
|
+
continue
|
|
818
|
+
if row["n"] in ns:
|
|
819
|
+
row["hidden"] = True
|
|
820
|
+
row["selected"] = False
|
|
821
|
+
try:
|
|
822
|
+
row["container"].setHidden_(True)
|
|
823
|
+
except Exception:
|
|
824
|
+
pass
|
|
825
|
+
removed += 1
|
|
826
|
+
if removed:
|
|
827
|
+
self._refresh_header()
|
|
828
|
+
self._log_surface("pruned")
|
|
829
|
+
return removed
|
|
830
|
+
|
|
831
|
+
# ---- status / observability (same shape as s4l_card, see _write_review_state) --
|
|
832
|
+
|
|
833
|
+
@objc.python_method
|
|
834
|
+
def _dwell_ms(self):
|
|
835
|
+
if not self._presented_at:
|
|
836
|
+
return None
|
|
837
|
+
return int((time.time() - self._presented_at) * 1000)
|
|
838
|
+
|
|
839
|
+
@objc.python_method
|
|
840
|
+
def _occlusion_visible(self):
|
|
841
|
+
try:
|
|
842
|
+
return bool(self._panel.occlusionState() & NSWindowOcclusionStateVisible)
|
|
843
|
+
except Exception:
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
@objc.python_method
|
|
847
|
+
def status_dict(self):
|
|
848
|
+
frame = None
|
|
849
|
+
try:
|
|
850
|
+
fr = self._panel.frame()
|
|
851
|
+
frame = [int(fr.origin.x), int(fr.origin.y), int(fr.size.width), int(fr.size.height)]
|
|
852
|
+
except Exception:
|
|
853
|
+
pass
|
|
854
|
+
screen_name = None
|
|
855
|
+
try:
|
|
856
|
+
scr = self._panel.screen()
|
|
857
|
+
if scr is not None:
|
|
858
|
+
screen_name = str(scr.localizedName())
|
|
859
|
+
except Exception:
|
|
860
|
+
pass
|
|
861
|
+
pending = sum(1 for r in self._rows if not r["decided"] and not r["hidden"])
|
|
862
|
+
return {
|
|
863
|
+
"open": self._panel is not None,
|
|
864
|
+
"total": len(self._rows),
|
|
865
|
+
"pending": pending,
|
|
866
|
+
"decided": len(self._decisions),
|
|
867
|
+
"presented_at": self._presented_at,
|
|
868
|
+
"last_decision_at": self._last_decision_at,
|
|
869
|
+
"last_interaction_at": self._last_interaction_at,
|
|
870
|
+
"occlusion_visible": self._occlusion_visible(),
|
|
871
|
+
"frame": frame,
|
|
872
|
+
"screen": screen_name,
|
|
873
|
+
"layout": "canvas",
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
@objc.python_method
|
|
877
|
+
def _log_surface(self, event):
|
|
878
|
+
s = self.status_dict()
|
|
879
|
+
_log(
|
|
880
|
+
f"canvas {event}: {s['pending']} pending of {s['total']}, "
|
|
881
|
+
f"frame={s['frame']} screen={s['screen']} visible={s['occlusion_visible']}"
|
|
882
|
+
)
|
|
883
|
+
_write_review_state(controller=self, last_event=event)
|
|
884
|
+
|
|
885
|
+
def windowDidMove_(self, notification):
|
|
886
|
+
now = time.time()
|
|
887
|
+
if now - self._last_move_log < 1.0:
|
|
888
|
+
return
|
|
889
|
+
self._last_move_log = now
|
|
890
|
+
self._log_surface("moved")
|
|
891
|
+
|
|
892
|
+
def windowDidChangeOcclusionState_(self, notification):
|
|
893
|
+
self._log_surface("occlusion_changed")
|
|
894
|
+
|
|
895
|
+
def windowShouldClose_(self, sender):
|
|
896
|
+
# Closing leaves undecided rows pending (not posted); the menu bar
|
|
897
|
+
# treats this exactly like an unfinished corner-card stack (snooze,
|
|
898
|
+
# re-present later), same as the corner card's own windowShouldClose_.
|
|
899
|
+
self._finish()
|
|
900
|
+
return True
|
|
901
|
+
|
|
902
|
+
@objc.python_method
|
|
903
|
+
def _finish(self):
|
|
904
|
+
global _active
|
|
905
|
+
try:
|
|
906
|
+
if self._panel is not None:
|
|
907
|
+
self._panel.setDelegate_(None)
|
|
908
|
+
self._panel.close()
|
|
909
|
+
except Exception:
|
|
910
|
+
pass
|
|
911
|
+
self._panel = None
|
|
912
|
+
cb, self._on_complete = self._on_complete, None
|
|
913
|
+
if cb is not None:
|
|
914
|
+
try:
|
|
915
|
+
cb(list(self._decisions))
|
|
916
|
+
except Exception:
|
|
917
|
+
pass
|
|
918
|
+
_active = None
|
|
919
|
+
_log(f"canvas closed: {len(self._decisions)} decided of {len(self._rows)}")
|
|
920
|
+
_write_review_state(controller=self, last_event="closed")
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# ---- module-level API, mirrors s4l_card.py's public surface -----------------
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def present_review_canvas(drafts, on_decision=None, on_complete=None, focus=False):
|
|
927
|
+
"""Show the canvas review surface (main thread only). Same `drafts` shape
|
|
928
|
+
and decision contract as s4l_card.present_review -- see that docstring
|
|
929
|
+
and this module's for the differences. focus=True (the menu's "Review N
|
|
930
|
+
pending drafts") activates the app; a fresh/auto presentation does not
|
|
931
|
+
steal focus, same posture as the corner card."""
|
|
932
|
+
global _active
|
|
933
|
+
if not drafts:
|
|
934
|
+
if on_complete is not None:
|
|
935
|
+
on_complete([])
|
|
936
|
+
return
|
|
937
|
+
_active = _CanvasController.alloc().initWithDrafts_onDecision_onComplete_focus_(
|
|
938
|
+
drafts, on_decision, on_complete, focus
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def extend_active(drafts):
|
|
943
|
+
if _active is None:
|
|
944
|
+
return 0
|
|
945
|
+
try:
|
|
946
|
+
return _active.extend_drafts(drafts)
|
|
947
|
+
except Exception:
|
|
948
|
+
return 0
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def prune_active(ns):
|
|
952
|
+
if _active is None or not ns:
|
|
953
|
+
return 0
|
|
954
|
+
try:
|
|
955
|
+
return _active.prune_drafts(ns)
|
|
956
|
+
except Exception:
|
|
957
|
+
return 0
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def active_status():
|
|
961
|
+
c = _active
|
|
962
|
+
if c is None or c._panel is None:
|
|
963
|
+
return None
|
|
964
|
+
try:
|
|
965
|
+
return c.status_dict()
|
|
966
|
+
except Exception:
|
|
967
|
+
return None
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def heal_active():
|
|
971
|
+
"""Move the open canvas window to the pointer's current screen and raise
|
|
972
|
+
it. Returns True if a window was open."""
|
|
973
|
+
c = _active
|
|
974
|
+
if c is None or c._panel is None:
|
|
975
|
+
return False
|
|
976
|
+
try:
|
|
977
|
+
c._panel.setFrame_display_(_center_frame(_mouse_screen()), True)
|
|
978
|
+
c._panel.orderFrontRegardless()
|
|
979
|
+
c._log_surface("healed")
|
|
980
|
+
return True
|
|
981
|
+
except Exception:
|
|
982
|
+
return False
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def focus_active():
|
|
986
|
+
c = _active
|
|
987
|
+
if c is None or c._panel is None:
|
|
988
|
+
return False
|
|
989
|
+
try:
|
|
990
|
+
NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
|
991
|
+
NSApp.activateIgnoringOtherApps_(True)
|
|
992
|
+
except Exception:
|
|
993
|
+
pass
|
|
994
|
+
try:
|
|
995
|
+
c._panel.makeKeyAndOrderFront_(None)
|
|
996
|
+
c._panel.orderFrontRegardless()
|
|
997
|
+
c._panel.makeMainWindow()
|
|
998
|
+
return True
|
|
999
|
+
except Exception:
|
|
1000
|
+
return False
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def dismiss_active():
|
|
1004
|
+
"""Force-close the open canvas WITHOUT firing on_decision/on_complete,
|
|
1005
|
+
for a bulk discard whose fate for every remaining row was already decided
|
|
1006
|
+
elsewhere (mirrors s4l_card.dismiss_active). Returns True if a window was
|
|
1007
|
+
actually open."""
|
|
1008
|
+
global _active
|
|
1009
|
+
c = _active
|
|
1010
|
+
if c is None or c._panel is None:
|
|
1011
|
+
return False
|
|
1012
|
+
try:
|
|
1013
|
+
c._panel.setDelegate_(None)
|
|
1014
|
+
c._panel.close()
|
|
1015
|
+
except Exception:
|
|
1016
|
+
pass
|
|
1017
|
+
c._panel = None
|
|
1018
|
+
c._on_complete = None
|
|
1019
|
+
c._on_decision = None
|
|
1020
|
+
_active = None
|
|
1021
|
+
_log(f"canvas closed: dismissed (bulk discard, {len(c._decisions)} decided of {len(c._rows)})")
|
|
1022
|
+
_write_review_state(controller=c, last_event="dismissed")
|
|
1023
|
+
return True
|
|
@@ -516,6 +516,13 @@ class S4LMenuBar(rumps.App):
|
|
|
516
516
|
self._post_worker = None
|
|
517
517
|
self._review_lock = threading.Lock()
|
|
518
518
|
self._panel_open = False
|
|
519
|
+
# Which review-surface module ("cards" | "canvas") is driving the
|
|
520
|
+
# CURRENTLY open panel/window, None when nothing is open. Set at
|
|
521
|
+
# presentation time from st.read_review_layout(); kept fixed for the
|
|
522
|
+
# life of that surface so flipping the "Canvas review" menu checkbox
|
|
523
|
+
# mid-session never talks to the wrong module for an already-open
|
|
524
|
+
# panel. See _review_mod().
|
|
525
|
+
self._review_mod_name = None
|
|
519
526
|
# Review snooze: epoch until which draft cards must not auto-present
|
|
520
527
|
# (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
|
|
521
528
|
# doesn't re-pop the card the user just put away.
|
package/mcp/menubar/s4l_state.py
CHANGED
|
@@ -1263,6 +1263,43 @@ def write_reveal_cadence(secs):
|
|
|
1263
1263
|
return secs
|
|
1264
1264
|
|
|
1265
1265
|
|
|
1266
|
+
# Review surface layout (2026-07-15): which UI presents pending drafts.
|
|
1267
|
+
# "cards" (default) is the one-at-a-time corner-panel carousel (s4l_card.py);
|
|
1268
|
+
# "canvas" is the large centered multi-select grid (s4l_card_canvas.py). Lives
|
|
1269
|
+
# in mode.json next to reveal_cadence_secs, same read/write shape. Additive —
|
|
1270
|
+
# the menu bar picks the module to call per-review based on this value, both
|
|
1271
|
+
# modules share the exact same drafts list and decision contract, so nothing
|
|
1272
|
+
# about drafting/posting/the ledger changes when this flips.
|
|
1273
|
+
REVIEW_LAYOUT_DEFAULT = "cards"
|
|
1274
|
+
REVIEW_LAYOUTS = ("cards", "canvas")
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def read_review_layout():
|
|
1278
|
+
d = read_json(MODE_FILE)
|
|
1279
|
+
layout = d.get("review_layout") if isinstance(d, dict) else None
|
|
1280
|
+
return layout if layout in REVIEW_LAYOUTS else REVIEW_LAYOUT_DEFAULT
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def write_review_layout(layout):
|
|
1284
|
+
"""Persist the review layout, preserving every other mode.json key. Returns
|
|
1285
|
+
the written value. Never raises (a menu click must not crash)."""
|
|
1286
|
+
if layout not in REVIEW_LAYOUTS:
|
|
1287
|
+
return read_review_layout()
|
|
1288
|
+
try:
|
|
1289
|
+
payload = read_json(MODE_FILE)
|
|
1290
|
+
if not isinstance(payload, dict):
|
|
1291
|
+
payload = {}
|
|
1292
|
+
payload["review_layout"] = layout
|
|
1293
|
+
p = Path(state_dir()) / MODE_FILE
|
|
1294
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1295
|
+
tmp = p.with_suffix(".json.tmp")
|
|
1296
|
+
tmp.write_text(json.dumps(payload))
|
|
1297
|
+
os.replace(str(tmp), str(p))
|
|
1298
|
+
except Exception:
|
|
1299
|
+
pass
|
|
1300
|
+
return layout
|
|
1301
|
+
|
|
1302
|
+
|
|
1266
1303
|
# --- Posting volume mode (2026-07-13) ---------------------------------------
|
|
1267
1304
|
# Server-side per-install throttle for the twitter cycle's virality bar:
|
|
1268
1305
|
# high|medium|low|None (None = driver default). Source of truth is
|
package/mcp/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m13v/s4l-mcp",
|
|
3
|
-
"version": "1.7.5-rc.
|
|
3
|
+
"version": "1.7.5-rc.26",
|
|
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.5-rc.
|
|
3
|
+
"version": "1.7.5-rc.26",
|
|
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",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"scripts/*.py",
|
|
20
20
|
"scripts/patches/",
|
|
21
21
|
"!scripts/db_direct.py",
|
|
22
|
+
"!scripts/admin_fetch_install_config.py",
|
|
22
23
|
"!scripts/sync_blocked_authors.py",
|
|
23
24
|
"!scripts/backfill_real_clicks.py",
|
|
24
25
|
"!scripts/historical_engagement.py",
|
package/scripts/release-mcpb.sh
CHANGED
|
@@ -359,6 +359,57 @@ fs.writeFileSync(p, JSON.stringify(j,null,2)+'\n');
|
|
|
359
359
|
console.log(' manifest tools ('+tools.length+'): '+tools.map(t=>t.name).join(', '));
|
|
360
360
|
"
|
|
361
361
|
|
|
362
|
+
# ---- 3d. Vendor an owned Node runtime (bare .mcpb installs) ------------------
|
|
363
|
+
# manifest.json's mcp_config.command used to be the bare string "node", resolved
|
|
364
|
+
# by whatever spawns the extension. Claude Desktop's own chat bridge covers this
|
|
365
|
+
# with an internal "built-in Node" shortcut (an Electron-hosted UtilityProcess),
|
|
366
|
+
# but a DIFFERENT Desktop session type (agent-mode/Cowork, which spawns a real
|
|
367
|
+
# `claude` CLI subprocess with --mcp-config) does a literal subprocess spawn of
|
|
368
|
+
# "node" resolved via system PATH instead. On a box with no system Node/Homebrew
|
|
369
|
+
# (true of every MacStadium QA box we've checked), that spawn fails with ENOENT
|
|
370
|
+
# and that session type sees zero S4L tools — even though Desktop's own bridge
|
|
371
|
+
# reports "connected" for the SAME extension. Confirmed 2026-07-15: installing a
|
|
372
|
+
# real Node on a broken box fixed it immediately, no other change. Root cause is
|
|
373
|
+
# Desktop's own internal built-in-node eligibility, which is undocumented and
|
|
374
|
+
# apparently shifted between app versions without any change on our side (the
|
|
375
|
+
# manifest's "node" declaration is unchanged since the very first commit). Owning
|
|
376
|
+
# our Node the same way we already own Python (uv) and Chromium removes the
|
|
377
|
+
# dependency on Desktop's internal behavior entirely: manifest.json's command now
|
|
378
|
+
# points at this vendored binary's absolute path, so every session type — main
|
|
379
|
+
# chat, agent-mode, any future session type — spawns the SAME real binary.
|
|
380
|
+
# macOS arm64 only for now (matches every deployed box); revisit if x64 support
|
|
381
|
+
# is ever needed.
|
|
382
|
+
NODE_RUNTIME_VERSION="v24.18.0"
|
|
383
|
+
NODE_RUNTIME_PLATFORM="darwin-arm64"
|
|
384
|
+
NODE_VENDOR_DIR="$MCP_DIR/vendor/node-$NODE_RUNTIME_PLATFORM"
|
|
385
|
+
NODE_VENDOR_BIN="$NODE_VENDOR_DIR/bin/node"
|
|
386
|
+
NODE_CACHE_DIR="$REPO_ROOT/.cache/node-runtime"
|
|
387
|
+
say "Vendoring Node $NODE_RUNTIME_VERSION ($NODE_RUNTIME_PLATFORM) for the .mcpb"
|
|
388
|
+
if [[ ! -x "$NODE_VENDOR_BIN" ]]; then
|
|
389
|
+
NODE_TARBALL="node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM.tar.gz"
|
|
390
|
+
NODE_CACHED_TARBALL="$NODE_CACHE_DIR/$NODE_TARBALL"
|
|
391
|
+
mkdir -p "$NODE_CACHE_DIR"
|
|
392
|
+
if [[ ! -f "$NODE_CACHED_TARBALL" ]]; then
|
|
393
|
+
say " downloading $NODE_TARBALL (not cached)"
|
|
394
|
+
curl -sL "https://nodejs.org/dist/$NODE_RUNTIME_VERSION/$NODE_TARBALL" -o "$NODE_CACHED_TARBALL.tmp" \
|
|
395
|
+
|| die "Node download failed"
|
|
396
|
+
mv "$NODE_CACHED_TARBALL.tmp" "$NODE_CACHED_TARBALL"
|
|
397
|
+
else
|
|
398
|
+
say " using cached $NODE_CACHED_TARBALL"
|
|
399
|
+
fi
|
|
400
|
+
rm -rf "$NODE_VENDOR_DIR"
|
|
401
|
+
mkdir -p "$NODE_VENDOR_DIR/bin"
|
|
402
|
+
# Extract ONLY the node binary itself (no npm/npx/corepack — the server is
|
|
403
|
+
# invoked directly as `node dist/index.js`, nothing here ever needs npm).
|
|
404
|
+
tar -xzf "$NODE_CACHED_TARBALL" -O "node-$NODE_RUNTIME_VERSION-$NODE_RUNTIME_PLATFORM/bin/node" > "$NODE_VENDOR_BIN" \
|
|
405
|
+
|| die "failed to extract node binary from $NODE_CACHED_TARBALL"
|
|
406
|
+
chmod +x "$NODE_VENDOR_BIN"
|
|
407
|
+
fi
|
|
408
|
+
[[ -x "$NODE_VENDOR_BIN" ]] || die "vendored node binary missing/not executable at $NODE_VENDOR_BIN"
|
|
409
|
+
VENDORED_NODE_VER="$("$NODE_VENDOR_BIN" --version)"
|
|
410
|
+
[[ "$VENDORED_NODE_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node reports $VENDORED_NODE_VER, expected $NODE_RUNTIME_VERSION"
|
|
411
|
+
echo " vendored node: $VENDORED_NODE_VER at mcp/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node ($(du -h "$NODE_VENDOR_BIN" | cut -f1))"
|
|
412
|
+
|
|
362
413
|
# ---- 4. Pack the .mcpb ------------------------------------------------------
|
|
363
414
|
say "Packing $BUNDLE"
|
|
364
415
|
rm -f "$BUNDLE"
|
|
@@ -407,13 +458,26 @@ for sub in mcp/dist/version.json mcp/package.json; do
|
|
|
407
458
|
echo " pipeline.tgz $sub: $SUBV ok"
|
|
408
459
|
done
|
|
409
460
|
|
|
410
|
-
for f in "dist/index.js" "dist/runtime.js" "manifest.json"; do
|
|
461
|
+
for f in "dist/index.js" "dist/runtime.js" "manifest.json" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"; do
|
|
411
462
|
# grep -c reads all input (no SIGPIPE); anchor on the time column + 3-space
|
|
412
463
|
# gutter so node_modules/.../dist/index.js does not false-match the top-level.
|
|
413
464
|
n=$(printf '%s\n' "$LISTING" | grep -c "[0-9:] $f\$" || true)
|
|
414
465
|
[[ "$n" -ge 1 ]] || die "bundle missing $f"
|
|
415
466
|
done
|
|
416
|
-
echo " runtime + server + manifest: ok"
|
|
467
|
+
echo " runtime + server + manifest + vendored node: ok"
|
|
468
|
+
|
|
469
|
+
# The vendored binary must survive the zip round-trip with its executable bit
|
|
470
|
+
# intact (unzip -x extracts to a temp file and runs --version; a permissions
|
|
471
|
+
# regression here would silently produce a Node that Desktop can spawn but that
|
|
472
|
+
# refuses to execute, or vice versa).
|
|
473
|
+
VENDOR_CHECK_DIR="$(mktemp -d)"
|
|
474
|
+
unzip -q -o "$BUNDLE" "vendor/node-$NODE_RUNTIME_PLATFORM/bin/node" -d "$VENDOR_CHECK_DIR"
|
|
475
|
+
VENDOR_CHECK_BIN="$VENDOR_CHECK_DIR/vendor/node-$NODE_RUNTIME_PLATFORM/bin/node"
|
|
476
|
+
[[ -x "$VENDOR_CHECK_BIN" ]] || die "vendored node in bundle lost its executable bit"
|
|
477
|
+
VENDOR_CHECK_VER="$("$VENDOR_CHECK_BIN" --version)"
|
|
478
|
+
[[ "$VENDOR_CHECK_VER" == "$NODE_RUNTIME_VERSION" ]] || die "vendored node in bundle reports $VENDOR_CHECK_VER, expected $NODE_RUNTIME_VERSION"
|
|
479
|
+
rm -rf "$VENDOR_CHECK_DIR"
|
|
480
|
+
echo " vendored node runs from bundle: $VENDOR_CHECK_VER ok"
|
|
417
481
|
|
|
418
482
|
if [[ "$DO_RELEASE" == "0" ]]; then
|
|
419
483
|
say "Done (--no-release). Bundle ready at: $BUNDLE"
|
|
@@ -10,6 +10,15 @@ Usage:
|
|
|
10
10
|
python3 scripts/twitter_prompt_sandbox.py --project fazm --status posted --limit 5 --out /tmp/sandbox.txt
|
|
11
11
|
python3 scripts/twitter_prompt_sandbox.py --urls https://x.com/.../1,https://x.com/.../2 --out /tmp/sandbox.txt
|
|
12
12
|
|
|
13
|
+
# Another install's data (matched_project alone is NOT tenant-safe -- see
|
|
14
|
+
# --install-id's help text): pass --install-id, --project becomes optional
|
|
15
|
+
# (any of that install's candidates) or still scopes to one of their
|
|
16
|
+
# projects. Pair with admin_fetch_install_config.py for their real config
|
|
17
|
+
# + persona_corpus.txt too, if testing voice fidelity rather than just
|
|
18
|
+
# replaying their real threads:
|
|
19
|
+
python3 scripts/twitter_prompt_sandbox.py --install-id ba6519ca-edaf-4fee-95b9-446da86bd346 \\
|
|
20
|
+
--project PersonalBrand --out /tmp/sandbox_karol.txt
|
|
21
|
+
|
|
13
22
|
Then run the cycle against it:
|
|
14
23
|
S4L_SANDBOX_CANDIDATES_FILE=/tmp/sandbox.txt S4L_DRAFT_PROMPT_VARIANT=treatment_v4 \\
|
|
15
24
|
bash skill/run-twitter-cycle.sh
|
|
@@ -57,7 +66,7 @@ def _age_hours(row: dict) -> float:
|
|
|
57
66
|
return 0.0
|
|
58
67
|
|
|
59
68
|
|
|
60
|
-
def fetch(project, status, limit, since, urls):
|
|
69
|
+
def fetch(project, status, limit, since, urls, install_id):
|
|
61
70
|
query = {"limit": limit}
|
|
62
71
|
if project:
|
|
63
72
|
query["matched_project"] = project
|
|
@@ -67,6 +76,8 @@ def fetch(project, status, limit, since, urls):
|
|
|
67
76
|
query["since"] = since
|
|
68
77
|
if urls:
|
|
69
78
|
query["tweet_urls"] = ",".join(urls)
|
|
79
|
+
if install_id:
|
|
80
|
+
query["install_id"] = install_id
|
|
70
81
|
resp = api_get("/api/v1/twitter-candidates", query=query)
|
|
71
82
|
return (resp.get("data") or {}).get("candidates") or []
|
|
72
83
|
|
|
@@ -102,15 +113,20 @@ def main() -> int:
|
|
|
102
113
|
ap.add_argument("--limit", type=int, default=10)
|
|
103
114
|
ap.add_argument("--since", help="ISO8601 discovered_at lower bound")
|
|
104
115
|
ap.add_argument("--urls", help="comma-separated tweet_urls, explicit selection instead of a filtered pull")
|
|
116
|
+
ap.add_argument(
|
|
117
|
+
"--install-id",
|
|
118
|
+
help="scope the pull to one install (required for another install's data -- "
|
|
119
|
+
"matched_project alone is NOT tenant-safe, e.g. 'PersonalBrand' spans 7 installs)",
|
|
120
|
+
)
|
|
105
121
|
ap.add_argument("--out", required=True, help="output path for the pipe-separated candidates file")
|
|
106
122
|
args = ap.parse_args()
|
|
107
123
|
|
|
108
|
-
if not args.project and not args.urls:
|
|
109
|
-
print("[twitter_prompt_sandbox] pass --project or --
|
|
124
|
+
if not args.project and not args.urls and not args.install_id:
|
|
125
|
+
print("[twitter_prompt_sandbox] pass --project, --urls, or --install-id to scope the pull", file=sys.stderr)
|
|
110
126
|
return 1
|
|
111
127
|
|
|
112
128
|
urls = [u.strip() for u in args.urls.split(",") if u.strip()] if args.urls else None
|
|
113
|
-
rows = fetch(args.project, args.status, args.limit, args.since, urls)
|
|
129
|
+
rows = fetch(args.project, args.status, args.limit, args.since, urls, args.install_id)
|
|
114
130
|
if not rows:
|
|
115
131
|
print("[twitter_prompt_sandbox] no candidates matched the given filters", file=sys.stderr)
|
|
116
132
|
return 1
|
|
@@ -43,7 +43,12 @@ json.dump(d, open(p, "w"))' "$_bl_prof_dir" 2>/dev/null || true
|
|
|
43
43
|
*.app/Contents/MacOS/*) _bl_app_bundle="${_bl_chrome_bin%%/Contents/MacOS/*}" ;;
|
|
44
44
|
esac
|
|
45
45
|
if [ "$(uname -s)" = "Darwin" ] && [ -n "$_bl_app_bundle" ] && [ -d "$_bl_app_bundle" ]; then
|
|
46
|
-
|
|
46
|
+
# -j launches HIDDEN on top of -g's no-foreground: -g alone still let
|
|
47
|
+
# every wedge-heal relaunch register a `cause: launched` activation in
|
|
48
|
+
# the browser-foreground telemetry (the last remaining window-pop
|
|
49
|
+
# class, 2026-07-15). Occlusion/backgrounding flags keep a hidden
|
|
50
|
+
# Chrome rendering, so the pipeline is unaffected.
|
|
51
|
+
open -n -g -j -a "$_bl_app_bundle" --args "$@" >/dev/null 2>&1 || true
|
|
47
52
|
else
|
|
48
53
|
"${S4L_PYTHON:-python3}" -c 'import os,sys
|
|
49
54
|
os.setsid()
|
|
@@ -1737,7 +1737,15 @@ ALL_PROJECTS_JSON=$(python3 -c "
|
|
|
1737
1737
|
import json, os, sys
|
|
1738
1738
|
repo_dir = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
1739
1739
|
sys.path.insert(0, os.path.join(repo_dir, 'scripts'))
|
|
1740
|
-
config
|
|
1740
|
+
# Prompt-sandbox config override (2026-07-15): a sandbox run testing another
|
|
1741
|
+
# install's real voice/persona (scripts/admin_fetch_install_config.py
|
|
1742
|
+
# materializes it) points this at a fetched copy instead of the operator's
|
|
1743
|
+
# own config.json. Checked BEFORE the real path since the repo-root
|
|
1744
|
+
# config.json is a symlink to the state dir -- the override must never
|
|
1745
|
+
# follow that symlink back to the operator's own config.
|
|
1746
|
+
_sandbox_dir = os.environ.get('S4L_SANDBOX_CONFIG_DIR')
|
|
1747
|
+
_config_path = os.path.join(_sandbox_dir, 'config.json') if _sandbox_dir else os.path.join(repo_dir, 'config.json')
|
|
1748
|
+
config = json.load(open(_config_path))
|
|
1741
1749
|
projects = config.get('projects', [])
|
|
1742
1750
|
# learned_preferences is a SINGLE install-wide block since 2026-07-08 (see
|
|
1743
1751
|
# scripts/learned_preferences.py). Since 2026-07-14 it is NO LONGER stamped
|
|
@@ -1797,7 +1805,10 @@ import json, os, sys
|
|
|
1797
1805
|
repo_dir = os.path.expanduser(os.environ.get('S4L_REPO_DIR') or os.environ.get('REPO_DIR') or '~/social-autoposter')
|
|
1798
1806
|
sys.path.insert(0, os.path.join(repo_dir, 'scripts'))
|
|
1799
1807
|
import learned_preferences as lp
|
|
1800
|
-
config
|
|
1808
|
+
# Same sandbox config override as ALL_PROJECTS_JSON above (see that comment).
|
|
1809
|
+
_sandbox_dir = os.environ.get('S4L_SANDBOX_CONFIG_DIR')
|
|
1810
|
+
_config_path = os.path.join(_sandbox_dir, 'config.json') if _sandbox_dir else os.path.join(repo_dir, 'config.json')
|
|
1811
|
+
config = json.load(open(_config_path))
|
|
1801
1812
|
block = lp.get_global_block(config)
|
|
1802
1813
|
block.pop('history', None)
|
|
1803
1814
|
print(json.dumps(block, indent=2))
|
|
@@ -2207,12 +2218,17 @@ release_lock "twitter-browser" 2>>"$LOG_FILE"
|
|
|
2207
2218
|
# withholding real examples from control would confound "does showing real
|
|
2208
2219
|
# examples help" with "does emphasizing them differently help."
|
|
2209
2220
|
CORPUS_BLOCK=""
|
|
2210
|
-
|
|
2221
|
+
# Same sandbox override as the config.json reads above: persona_corpus.txt is
|
|
2222
|
+
# a flat repo-root file (not symlinked), so it needs its own override branch
|
|
2223
|
+
# rather than following $REPO_DIR.
|
|
2224
|
+
_PERSONA_CORPUS_PATH="${S4L_SANDBOX_CONFIG_DIR:+$S4L_SANDBOX_CONFIG_DIR/persona_corpus.txt}"
|
|
2225
|
+
_PERSONA_CORPUS_PATH="${_PERSONA_CORPUS_PATH:-$REPO_DIR/persona_corpus.txt}"
|
|
2226
|
+
if [ -f "$_PERSONA_CORPUS_PATH" ]; then
|
|
2211
2227
|
CORPUS_BLOCK="## ACCOUNT VOICE CORPUS (raw first-hand material — ground your reply in THIS)
|
|
2212
2228
|
This is the account holder's own public writing and work, verbatim. Quote and draw real specifics from it: actual projects, real numbers, sharp opinions, real failures. Do NOT invent anything not supported here or in the project's voice block. Use it to make the reply concrete and unmistakably human, and as ground truth for HOW they write (capitalization, punctuation, contractions, length) regardless of which project this reply is for.
|
|
2213
|
-
$(cat "$
|
|
2229
|
+
$(cat "$_PERSONA_CORPUS_PATH")
|
|
2214
2230
|
"
|
|
2215
|
-
log "Phase 2b-prep: injected account voice corpus ($(wc -c < "$
|
|
2231
|
+
log "Phase 2b-prep: injected account voice corpus ($(wc -c < "$_PERSONA_CORPUS_PATH" | tr -d ' ') bytes)."
|
|
2216
2232
|
fi
|
|
2217
2233
|
|
|
2218
2234
|
log "Phase 2b-prep: Claude reading threads and drafting replies (no post cap)..."
|