@m13v/s4l 1.7.4-rc.4 → 1.7.4

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.
Files changed (86) hide show
  1. package/README.md +134 -101
  2. package/bin/cli.js +23 -0
  3. package/mcp/dist/index.js +24 -5
  4. package/mcp/dist/runtime.js +110 -0
  5. package/mcp/dist/version.js +163 -54
  6. package/mcp/dist/version.json +2 -2
  7. package/mcp/manifest.json +1 -1
  8. package/mcp/menubar/s4l_card.py +55 -19
  9. package/mcp/menubar/s4l_menubar.py +179 -56
  10. package/mcp/menubar/s4l_state.py +41 -1
  11. package/mcp/package.json +1 -1
  12. package/package.json +4 -2
  13. package/scripts/active_experiments.py +30 -6
  14. package/scripts/autopilot_stall_watch.py +185 -63
  15. package/scripts/cdp_ready_check.py +94 -0
  16. package/scripts/engage_twitter_helper.py +83 -17
  17. package/scripts/engagement_styles.py +221 -26
  18. package/scripts/enrich_reply_parents.py +68 -22
  19. package/scripts/feedback_digest.py +24 -1
  20. package/scripts/generate_daily_human_style.py +31 -7
  21. package/scripts/invent_styles.py +103 -26
  22. package/scripts/linkedin_cadence.py +234 -0
  23. package/scripts/linkedin_killswitch.py +43 -0
  24. package/scripts/memory_snapshot.py +57 -0
  25. package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
  26. package/scripts/recent_self_posts.py +6 -0
  27. package/scripts/reddit_browser.py +57 -3
  28. package/scripts/s4l_box_update.sh +20 -1
  29. package/scripts/s4l_mode.py +3 -4
  30. package/scripts/scan_x_profile.py +25 -7
  31. package/scripts/schedule_state.py +23 -0
  32. package/scripts/scheduled_task_selfheal.py +213 -16
  33. package/scripts/setup_twitter_auth.py +6 -1
  34. package/scripts/snapshot.py +179 -61
  35. package/scripts/stall_guard.py +70 -0
  36. package/scripts/test_scheduled_task_selfheal.py +121 -0
  37. package/scripts/top_performers.py +34 -0
  38. package/scripts/twitter_browser.py +146 -8
  39. package/scripts/twitter_post_plan.py +10 -1
  40. package/scripts/twitter_scan.py +26 -0
  41. package/scripts/watchdog_hung_runs.py +117 -3
  42. package/skill/amplitude-24h-signups.sh +1 -1
  43. package/skill/audit-dm-staleness.sh +1 -1
  44. package/skill/audit-linkedin.sh +1 -1
  45. package/skill/audit-reddit-resurrect.sh +1 -1
  46. package/skill/audit.sh +5 -5
  47. package/skill/check-web-chats.sh +1 -1
  48. package/skill/dm-outreach-linkedin.sh +4 -4
  49. package/skill/dm-outreach-reddit.sh +1 -1
  50. package/skill/dm-outreach-twitter.sh +1 -1
  51. package/skill/engage-dm-replies.sh +16 -3
  52. package/skill/engage-linkedin.sh +2 -2
  53. package/skill/engage-moltbook.sh +1 -1
  54. package/skill/engage-twitter.sh +4 -4
  55. package/skill/github-engage.sh +1 -1
  56. package/skill/lib/browser-launch.sh +54 -0
  57. package/skill/lib/linkedin-backend.sh +22 -15
  58. package/skill/lib/reddit-backend.sh +17 -11
  59. package/skill/lib/twitter-backend.sh +137 -16
  60. package/skill/link-edit-github.sh +1 -1
  61. package/skill/link-edit-moltbook.sh +1 -1
  62. package/skill/link-edit-reddit.sh +1 -1
  63. package/skill/linkedin-cadence.sh +20 -0
  64. package/skill/linkedin-presence.sh +1 -1
  65. package/skill/lock.sh +30 -6
  66. package/skill/precompute-stats.sh +1 -1
  67. package/skill/refresh-instagram-tokens.sh +1 -1
  68. package/skill/refresh-twitter-following.sh +1 -1
  69. package/skill/run-generate-daily-style.sh +1 -1
  70. package/skill/run-instagram-daily.sh +1 -1
  71. package/skill/run-instagram-render.sh +1 -1
  72. package/skill/run-linkedin-launchd.sh +1 -1
  73. package/skill/run-linkedin-unipile.sh +1 -1
  74. package/skill/run-linkedin.sh +1 -1
  75. package/skill/run-reddit-search.sh +2 -2
  76. package/skill/run-reddit-threads.sh +1 -1
  77. package/skill/run-twitter-cycle.sh +190 -55
  78. package/skill/scan-instagram-replies.sh +1 -1
  79. package/skill/sentry-digest.sh +1 -1
  80. package/skill/stats-instagram.sh +1 -1
  81. package/skill/stats-linkedin.sh +2 -2
  82. package/skill/stats.sh +1 -1
  83. package/skill/sweep-link-clicks.sh +1 -1
  84. package/scripts/_lock_preempt_test.py +0 -60
  85. package/scripts/saps_activity.py +0 -18
  86. package/scripts/saps_mode.py +0 -18
@@ -36,7 +36,7 @@ import json
36
36
  import os
37
37
  import random
38
38
  import sys as _sys_mod
39
- from datetime import datetime, timezone
39
+ from datetime import datetime, timedelta, timezone
40
40
 
41
41
  # ── Style taxonomy ──────────────────────────────────────────────────
42
42
 
@@ -1341,6 +1341,106 @@ def pick_style_for_post(platform, context="posting",
1341
1341
  }
1342
1342
 
1343
1343
 
1344
+ # Days back a model_invented style still counts as "recent" for the Draft-B
1345
+ # exploration pool. The registry holds ~900 pre-2026-07-10 inventions that
1346
+ # are mostly clones of the agree-then-relocate skeleton (see the
1347
+ # invent_styles.py docstring); the exploration slot exists to trial the
1348
+ # standalone job's structurally-diverse output, not to resurrect those.
1349
+ # human_derived rows are NOT date-gated: they arrive one per platform per
1350
+ # day and least-used ordering naturally favors the fresh ones.
1351
+ EXPLORATION_INVENTED_MAX_AGE_DAYS = 14
1352
+ # Hard floor: never trial inventions from before the inline INVENT_RATE was
1353
+ # zeroed (2026-07-10). Anything older is the clone flood, regardless of how
1354
+ # recent the rolling window makes it look.
1355
+ EXPLORATION_INVENTED_EPOCH = datetime(2026, 7, 10, 21, 0,
1356
+ tzinfo=timezone.utc)
1357
+
1358
+
1359
+ def pick_exploration_style(platform, context="posting", exclude=None,
1360
+ rng=None):
1361
+ """Draft-B exploration picker (2026-07-11). NEVER invents.
1362
+
1363
+ Returns an assignment dict in the exact pick_style_for_post() shape
1364
+ (so s4l_render_style_block and every downstream consumer work
1365
+ unchanged) with an extra "source" key in {"human_derived",
1366
+ "model_invented"}, or None when the pool is empty / anything fails
1367
+ (caller falls back to the scored picker).
1368
+
1369
+ Pool: registry rows with kind='human_derived' (any age) plus
1370
+ kind='model_invented' rows registered in the last
1371
+ EXPLORATION_INVENTED_MAX_AGE_DAYS days. Selection is LEAST-USED first
1372
+ (30-day post count on this platform via compute_target_distribution),
1373
+ uniform among the up-to-5 least-used, so every new style gets trial
1374
+ exposure instead of waiting behind the score-weighted sampler's
1375
+ winner-take-most weights. This is the distribution channel for the
1376
+ standalone invent_styles.py job; the review card's pick plus the
1377
+ posted draft's engagement write the style's first real score, and
1378
+ winners graduate into the Draft-A pool through the normal sampler.
1379
+ """
1380
+ rnd = rng or random
1381
+ try:
1382
+ never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
1383
+ skip = set(exclude or ()) | never
1384
+ registry = _fetch_registry_styles()
1385
+ cutoff = max(
1386
+ datetime.now(timezone.utc)
1387
+ - timedelta(days=EXPLORATION_INVENTED_MAX_AGE_DAYS),
1388
+ EXPLORATION_INVENTED_EPOCH,
1389
+ )
1390
+ pool = {}
1391
+ for name, entry in registry.items():
1392
+ if name in skip:
1393
+ continue
1394
+ if (entry.get("status") or "active") != "active":
1395
+ continue
1396
+ kind = entry.get("kind")
1397
+ if kind == "human_derived":
1398
+ pool[name] = entry
1399
+ elif kind == "model_invented":
1400
+ invented_at = _parse_iso_utc(entry.get("invented_at"))
1401
+ if invented_at is not None and invented_at >= cutoff:
1402
+ pool[name] = entry
1403
+ if not pool:
1404
+ return None
1405
+ usage = {r["style"]: int(r.get("n") or 0)
1406
+ for r in compute_target_distribution(platform,
1407
+ context=context)}
1408
+ names = list(pool.keys())
1409
+ rnd.shuffle(names) # random tie order before the stable sort
1410
+ names.sort(key=lambda s: usage.get(s, 0))
1411
+ chosen = rnd.choice(names[:5])
1412
+ entry = pool[chosen]
1413
+ return {
1414
+ "mode": "use",
1415
+ "style": chosen,
1416
+ "description": entry.get("description"),
1417
+ "example": entry.get("example"),
1418
+ "note": entry.get("note"),
1419
+ "target_chars": entry.get("target_chars") or DEFAULT_TARGET_CHARS,
1420
+ "source": entry.get("kind"),
1421
+ "usage_n_30d": usage.get(chosen, 0),
1422
+ "reference_styles": [],
1423
+ "distribution_snapshot": [],
1424
+ "picked_at": datetime.now(timezone.utc).isoformat(
1425
+ timespec="seconds"),
1426
+ }
1427
+ except Exception:
1428
+ return None
1429
+
1430
+
1431
+ def _parse_iso_utc(value):
1432
+ """Parse an ISO timestamp into aware-UTC; None on any failure."""
1433
+ if not value:
1434
+ return None
1435
+ try:
1436
+ dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
1437
+ if dt.tzinfo is None:
1438
+ dt = dt.replace(tzinfo=timezone.utc)
1439
+ return dt.astimezone(timezone.utc)
1440
+ except (ValueError, TypeError):
1441
+ return None
1442
+
1443
+
1344
1444
  def get_assigned_style_prompt(platform, assignment, context="posting"):
1345
1445
  """Compact prompt block built from a pick_style_for_post() assignment.
1346
1446
 
@@ -1364,31 +1464,126 @@ def get_assigned_style_prompt(platform, assignment, context="posting"):
1364
1464
  lines = []
1365
1465
 
1366
1466
  if assignment["mode"] == "use":
1367
- lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
1368
- lines.append("")
1369
- lines.append(
1370
- f"This style was selected by the picker (weighted by live "
1371
- f"click-driven performance across {platform}). Use it. Do not "
1372
- f"swap it for a different listed style."
1373
- )
1374
- lines.append("")
1375
- lines.append(f"Platform tone: {policy.get('note', '')}")
1376
- lines.append("")
1377
- lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1378
- if assignment.get("example"):
1379
- lines.append(f' Example: "{assignment["example"]}"')
1380
- if assignment.get("note"):
1381
- lines.append(f" Note: {assignment['note']}")
1382
- # LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
1383
- # uses the legacy generic length guidance. The treatment's per-style
1384
- # target prompt remains preserved only in the shipped experiment card.
1385
- lines.append("")
1386
- lines.append(
1387
- "**LENGTH: keep it tight.** One or two sentences, well under the "
1388
- "250-character Twitter limit. A short, sharp reply almost always "
1389
- "beats a paragraph. This applies to the comment text only; any "
1390
- "link/CTA the system appends afterward is separate."
1391
- )
1467
+ # Draft-prompt A/B v3 (style-as-form, 2026-07-10): the treatment_v3
1468
+ # arm renders the style as the BINDING FORM of the draft (defining
1469
+ # move + per-style length for EVERY assignment + end-of-block
1470
+ # self-check + two-layer learned_preferences contract). The arm is
1471
+ # read from the env HERE because this block is rendered inside the
1472
+ # cycle process where run-twitter-cycle.sh assigns and exports
1473
+ # S4L_DRAFT_PROMPT_VARIANT (stamp-at-source: that same process also
1474
+ # stamps the arm onto every plan candidate via active_experiments).
1475
+ # Downstream/post-time consumers never re-read this env. Unset or
1476
+ # control_v3 (and every non-twitter caller, which never has the env)
1477
+ # renders the legacy block below unchanged.
1478
+ _dp_arm = (os.environ.get("S4L_DRAFT_PROMPT_VARIANT") or "").strip()
1479
+ if _dp_arm == "treatment_v3":
1480
+ _tc = assignment.get("target_chars") or DEFAULT_TARGET_CHARS
1481
+ lines.append(
1482
+ f"## Your assigned engagement style: **{assignment['style']}** "
1483
+ "(this is the FORM of the draft, not a flavor hint)"
1484
+ )
1485
+ lines.append("")
1486
+ lines.append(
1487
+ f"This style was selected by the picker (weighted by live "
1488
+ f"click-driven performance across {platform}). Use it. Do not "
1489
+ f"swap it for a different listed style."
1490
+ )
1491
+ lines.append("")
1492
+ lines.append(f"Platform tone: {policy.get('note', '')}")
1493
+ lines.append("")
1494
+ lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1495
+ if assignment.get("example"):
1496
+ lines.append(f' Example: "{assignment["example"]}"')
1497
+ if assignment.get("note"):
1498
+ lines.append(f" Note: {assignment['note']}")
1499
+ lines.append("")
1500
+ lines.append(
1501
+ "Commit to the form BEFORE writing. From the description and "
1502
+ "example above, identify the style's DEFINING MOVE (the one "
1503
+ "thing a draft in this style must contain: a specific number, "
1504
+ "a question, a flat disagreement, a confession, whatever the "
1505
+ "description names) and build the reply around that move. The "
1506
+ "test: with the topic removed, a reader should be able to "
1507
+ "identify this style from the draft's shape alone. If your "
1508
+ "draft would read the same under any other style name, it "
1509
+ "does not conform; rewrite it."
1510
+ )
1511
+ lines.append("")
1512
+ lines.append(
1513
+ f"**LENGTH: aim for about {int(_tc)} characters** "
1514
+ "(this style's own winning length; within about 30% either "
1515
+ "way is fine, never above 250). Let the target set the form: "
1516
+ "a very short target means one clipped line, a long one can "
1517
+ "breathe. Do NOT default to the usual two-sentence shape. "
1518
+ "This applies to the comment text only; any link/CTA the "
1519
+ "system appends afterward is separate."
1520
+ )
1521
+ lines.append("")
1522
+ lines.append(
1523
+ "Learned user preferences (the learned_preferences block in "
1524
+ "the project context) apply INSIDE this form: they control "
1525
+ "voice, wording, and what to avoid; they do not replace the "
1526
+ "style's structure or length. If a preference seems to "
1527
+ "conflict with the style's defining move, keep the move and "
1528
+ "satisfy the preference within it."
1529
+ )
1530
+ lines.append("")
1531
+ lines.append(
1532
+ "SELF-CHECK before returning: (1) does the draft contain the "
1533
+ "style's defining move? (2) is the length within about 30% "
1534
+ "of the target? If either fails, rewrite once."
1535
+ )
1536
+ else:
1537
+ lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
1538
+ lines.append("")
1539
+ lines.append(
1540
+ f"This style was selected by the picker (weighted by live "
1541
+ f"click-driven performance across {platform}). Use it. Do not "
1542
+ f"swap it for a different listed style."
1543
+ )
1544
+ lines.append("")
1545
+ lines.append(f"Platform tone: {policy.get('note', '')}")
1546
+ lines.append("")
1547
+ lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
1548
+ if assignment.get("example"):
1549
+ lines.append(f' Example: "{assignment["example"]}"')
1550
+ if assignment.get("note"):
1551
+ lines.append(f" Note: {assignment['note']}")
1552
+ # LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
1553
+ # uses the legacy generic length guidance. The treatment's per-style
1554
+ # target prompt remains preserved only in the shipped experiment card.
1555
+ #
1556
+ # EXCEPTION (2026-07-11, Draft-B explore slot): exploration
1557
+ # assignments (pick_exploration_style, marked by `source`) honor the
1558
+ # style's own target_chars so the A/B pair diverges on the length
1559
+ # axis too. The uniform clamp flattened every draft to the same
1560
+ # 2-sentence shape (user: "the older drafts looked all very similar
1561
+ # in terms of the length"). The 06-04 conclusion still governs the
1562
+ # scored path, which never carries `source`.
1563
+ #
1564
+ # (The draft-prompt v3 treatment arm above supersedes this clamp
1565
+ # for its cycles; this legacy branch IS the v3 control arm.)
1566
+ _explore_tc = assignment.get("target_chars")
1567
+ if (assignment.get("source") in ("human_derived", "model_invented")
1568
+ and _explore_tc):
1569
+ lines.append("")
1570
+ lines.append(
1571
+ f"**LENGTH: aim for about {int(_explore_tc)} characters** "
1572
+ "(this style's own winning length; within about 30% either "
1573
+ "way is fine, never above 250). Let the target set the form: "
1574
+ "a very short target means one clipped line, a long one can "
1575
+ "breathe. Do NOT default to the usual two-sentence shape. "
1576
+ "This applies to the comment text only; any link/CTA the "
1577
+ "system appends afterward is separate."
1578
+ )
1579
+ else:
1580
+ lines.append("")
1581
+ lines.append(
1582
+ "**LENGTH: keep it tight.** One or two sentences, well under the "
1583
+ "250-character Twitter limit. A short, sharp reply almost always "
1584
+ "beats a paragraph. This applies to the comment text only; any "
1585
+ "link/CTA the system appends afterward is separate."
1586
+ )
1392
1587
  lines.append("")
1393
1588
  lines.append(
1394
1589
  'In your output JSON, set "engagement_style" to exactly '
@@ -87,11 +87,14 @@ def fetch_fxtwitter(handle, tweet_id):
87
87
  def walk_ancestors(handle, tweet_id, sleep_s):
88
88
  """Ancestor chain bottom-up: [(id, handle), ...] parent first, root last.
89
89
 
90
- Returns (chain, terminal) where terminal is 'root' when the walk reached a
91
- non-reply tweet, or 'cut' when a hop was deleted/protected/transient (the
92
- chain up to that point is still usable, but root attribution is not).
90
+ Returns (focal, chain, terminal): focal is the fetched tweet object for
91
+ tweet_id itself (None when it is gone/unfetchable — needed for quote
92
+ linkage), terminal is 'root' when the walk reached a non-reply tweet,
93
+ 'gone'/'transient' when a hop was deleted/protected/errored (the chain up
94
+ to that point is still usable, but root attribution is not), or 'hop_cap'.
93
95
  """
94
96
  chain = []
97
+ focal = None
95
98
  cur_handle, cur_id = handle, tweet_id
96
99
  for _ in range(MAX_HOPS):
97
100
  status, tweet = fetch_fxtwitter(cur_handle, cur_id)
@@ -99,14 +102,16 @@ def walk_ancestors(handle, tweet_id, sleep_s):
99
102
  if status != "ok":
100
103
  # 'gone' is terminal (deleted/protected); 'transient' must NOT be
101
104
  # remembered, the next run retries it.
102
- return chain, status
105
+ return focal, chain, status
106
+ if focal is None:
107
+ focal = tweet
103
108
  parent_id = tweet.get("replying_to_status")
104
109
  parent_handle = tweet.get("replying_to") or ""
105
110
  if not parent_id:
106
- return chain, "root"
111
+ return focal, chain, "root"
107
112
  chain.append((str(parent_id), parent_handle))
108
113
  cur_handle, cur_id = parent_handle, parent_id
109
- return chain, "hop_cap"
114
+ return focal, chain, "hop_cap"
110
115
 
111
116
 
112
117
  def lookup_our_post(tweet_id):
@@ -126,6 +131,16 @@ def lookup_tracked_reply(tweet_id):
126
131
  return rows[0] if rows else None
127
132
 
128
133
 
134
+ def lookup_our_posted_reply(tweet_id):
135
+ """Reply row where WE authored the tweet (our_reply_id / our_reply_url)."""
136
+ resp = api_get(
137
+ "/api/v1/replies",
138
+ query={"platform": "x", "our_reply_status_id": str(tweet_id), "limit": "1"},
139
+ )
140
+ rows = (resp.get("data") or {}).get("replies") or []
141
+ return rows[0] if rows else None
142
+
143
+
129
144
  def fetch_work(limit, our_account=None, ids=None, before_id=None):
130
145
  if ids:
131
146
  out = []
@@ -158,13 +173,12 @@ def enrich_row(row, state, sleep_s, dry_run):
158
173
  if state.get(tid):
159
174
  return "state_skip"
160
175
 
161
- chain, terminal = walk_ancestors(handle, tid, sleep_s)
162
- if not chain:
176
+ focal, chain, terminal = walk_ancestors(handle, tid, sleep_s)
177
+ if focal is None:
163
178
  if terminal == "transient":
164
179
  return "transient" # retry next run, no state write
165
- # Deleted/protected focal tweet, or a standalone mention (not a reply).
166
- state[tid] = "gone" if terminal == "gone" else "not_a_reply"
167
- return state[tid]
180
+ state[tid] = "gone" # deleted/protected focal tweet
181
+ return "gone"
168
182
 
169
183
  patch = {}
170
184
  matched_post = None
@@ -178,24 +192,56 @@ def enrich_row(row, state, sleep_s, dry_run):
178
192
  if not row.get("project_name") and matched_post.get("project_name"):
179
193
  patch["project_name"] = matched_post["project_name"]
180
194
 
181
- parent_id, _parent_handle = chain[0]
182
- tracked = lookup_tracked_reply(parent_id)
183
- if tracked and tracked["id"] != rid:
184
- patch["parent_reply_id"] = tracked["id"]
185
- patch["depth"] = (tracked.get("depth") or 1) + 1
195
+ if chain:
196
+ parent_id, _parent_handle = chain[0]
197
+ # Immediate parent: another inbound reply we track, or a reply WE
198
+ # posted (the dominant case: a fan replying to our engagement reply).
199
+ tracked = lookup_tracked_reply(parent_id)
200
+ if tracked and tracked["id"] != rid:
201
+ patch["parent_reply_id"] = tracked["id"]
202
+ patch["depth"] = (tracked.get("depth") or 1) + 1
203
+ else:
204
+ ours = lookup_our_posted_reply(parent_id)
205
+ if ours and ours["id"] != rid:
206
+ patch["parent_reply_id"] = ours["id"]
207
+ patch["depth"] = (ours.get("depth") or 1) + 1
208
+ if "post_id" not in patch and ours.get("post_id"):
209
+ patch["post_id"] = ours["post_id"]
210
+ if not row.get("project_name") and not patch.get("project_name") \
211
+ and ours.get("project_name"):
212
+ patch["project_name"] = ours["project_name"]
213
+
214
+ # Quote linkage: a quote-tweet of our post (or of a reply we posted) is
215
+ # engagement on our content even when replying_to_status is empty.
216
+ quote_id = str(((focal.get("quote") or {}).get("id")) or "")
217
+ if quote_id and "post_id" not in patch:
218
+ qpost = lookup_our_post(quote_id)
219
+ if qpost:
220
+ patch["post_id"] = qpost["id"]
221
+ if not row.get("project_name") and not patch.get("project_name") \
222
+ and qpost.get("project_name"):
223
+ patch["project_name"] = qpost["project_name"]
224
+ elif "parent_reply_id" not in patch:
225
+ qours = lookup_our_posted_reply(quote_id)
226
+ if qours and qours["id"] != rid:
227
+ patch["parent_reply_id"] = qours["id"]
228
+ patch["depth"] = (qours.get("depth") or 1) + 1
229
+ if qours.get("post_id"):
230
+ patch["post_id"] = qours["post_id"]
186
231
 
187
232
  if terminal == "root":
188
- root_handle = (chain[-1][1] or "").lstrip("@")
189
- if root_handle and not row.get("thread_author_handle"):
233
+ root_handle = (chain[-1][1] if chain else (focal.get("author") or {}).get("screen_name") or "")
234
+ root_handle = (root_handle or "").lstrip("@")
235
+ if chain and root_handle and not row.get("thread_author_handle"):
190
236
  patch["thread_author_handle"] = root_handle
191
237
 
192
238
  if not patch:
193
239
  if terminal == "transient":
194
240
  return "transient" # incomplete walk; retry next run
195
- # Full chain walked, nothing of ours in it: a foreign thread. Remember
196
- # so we don't rewalk it every run.
197
- state[tid] = "no_link"
198
- return "no_link"
241
+ # Chain walked to root / cut by a deleted ancestor / not a reply at
242
+ # all, and nothing of ours anywhere in it: remember permanently.
243
+ state[tid] = "no_link" if chain else "not_a_reply"
244
+ return state[tid]
199
245
 
200
246
  if dry_run:
201
247
  print(f" [DRY] reply {rid}: {json.dumps(patch)}")
@@ -145,6 +145,13 @@ def _event_line(e: dict) -> str:
145
145
  if not dc.get("auto_selected"):
146
146
  parts.append("picked_draft_b_over_default_a")
147
147
  show_unchosen = True
148
+ elif dc.get("visited_other"):
149
+ # Clicked into B, then came back and approved A: an explicit
150
+ # head-to-head choice of the default, same strength as a switch
151
+ # (2026-07-10 user rule: only a zero-interaction approve is
152
+ # no-signal).
153
+ parts.append("chose_default_a_after_trying_b")
154
+ show_unchosen = True
148
155
  else:
149
156
  other_ms = dc.get("hover_b_ms") or 0
150
157
  if other_ms >= DRAFT_READ_MS:
@@ -256,7 +263,7 @@ NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reaso
256
263
 
257
264
  Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "no_reason_given" means the user rejected without picking a category or typing a note: the rejection itself is real, but WHY is your inference from the author/thread/draft context alone, so treat it as weak evidence. It can corroborate a pattern that reasoned events already show, but a no_reason_given reject never justifies a new entry or an author block on its own, and 2+ of them agreeing still only justify an entry when the shared pattern in their context is unmistakable. "edited_before_approving" with an ORIGINAL/REWROTE pair means the user hand-corrected our draft before posting: the rewrite is a direct statement of the voice they want. Diff the pair; when 2+ edits show the same correction (a phrase type removed, a structure replaced, tone shifted, length cut), distill that recurring pattern into draft_style_notes. Ignore edit content that is lead-specific or cosmetic (typo fixes, one-off facts); learn only what generalizes. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user picked the heart in the approve row ("this was a really good one"; approve_level_N in interactions carries the strength, 2 = best of the best): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
258
265
 
259
- Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than a switch (reading B does not prove they weighed it; treat like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "second_draft_not_read" means they approved the default without reading the alternative: NO pairwise signal, never infer anything against the unread draft. When 2+ pairwise events agree, diff the picked texts against the not-picked ones and distill WHAT recurs (tone, structure, length, opener type, directness) into draft_style_notes; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
266
+ Two-draft cards show a "did NOT pick" pair. "picked_draft_b_over_default_a" means the card offered two drafts with A preselected and the user deliberately clicked into B and approved it: a direct head-to-head preference for the picked draft over the shown alternative, evidence on par with a hand rewrite. "chose_default_a_after_trying_b" means they clicked into B (trying it as the selection) and then came back and approved A: equally explicit, the same head-to-head strength as a switch, just with the default as the winner. "kept_default_a_after_reading_b=Xs" means they kept the preselected A but spent Xs with the pointer over B first: an informed keep, weaker than either explicit choice (reading B does not prove they weighed it; treat like no_reason_given, corroborating a pattern that stronger events already show rather than founding one). "second_draft_not_read" means they approved the default without touching or reading the alternative: NO pairwise signal, never infer anything against the unread draft. When 2+ pairwise events agree, diff the picked texts against the not-picked ones and distill WHAT recurs (tone, structure, length, opener type, directness) into draft_style_notes; the "styles:" line names each side's engagement style, useful when the same style keeps winning or losing.
260
267
 
261
268
  You can also block SPECIFIC authors via the plan's block_authors list. A block is a permanent hard exclusion of that one handle from all future thread selection, so it is YOUR judgment call, never automatic. Block when the evidence is strong: a wrong_author reject IS a direct human statement about that author (especially with profile_click), and the author context (author_followers, their post, found_via_topic) or the user's note confirms the account itself was the problem rather than the topic. Do NOT block when the reject looks topic-driven (off_topic/bad_draft on a reasonable account) or when you are unsure; the generalizable TYPE entry in audience_avoid is the softer tool for that.
262
269
 
@@ -386,6 +393,22 @@ def _is_actionable(e: dict) -> bool:
386
393
  # as actionable as a reject (and it feeds edit_examples).
387
394
  if e.get("edited"):
388
395
  return True
396
+ # Any pairwise draft signal triggers the digest (2026-07-10 user rule,
397
+ # widened same evening): an explicit choice (switched to B, or tried B
398
+ # and came back to A) is style evidence on par with an edit, and even a
399
+ # hover-read of the other draft (>= DRAFT_READ_MS) is a small but real
400
+ # signal that must flow through the pipeline rather than bank forever
401
+ # behind the plain-approvals gate. The PROMPT still weights the tiers
402
+ # (explicit strong, hover-read weak/corroborating); this gate only
403
+ # decides when a Claude turn is worth burning. A zero-interaction
404
+ # approve (second_draft_not_read) remains non-actionable: no signal.
405
+ dc = _draft_choice(e)
406
+ if dc and (
407
+ not dc.get("auto_selected")
408
+ or dc.get("visited_other")
409
+ or (dc.get("hover_b_ms") or 0) >= DRAFT_READ_MS
410
+ ):
411
+ return True
389
412
  return bool((e.get("reject_note") or "").strip())
390
413
 
391
414
 
@@ -193,11 +193,23 @@ def build_prompt(platform, replies, reserved_names):
193
193
  lines.append("")
194
194
  lines.append(
195
195
  "These replies all WON the thread (top of the conversation by likes). "
196
- "Find the shared pattern that makes them work — the rhetorical move, "
196
+ "Find the shared pattern that makes them work: the rhetorical move, "
197
197
  "the structural shape, the relationship to the OP. Most winners "
198
198
  "share ONE pattern; that pattern is your new engagement style."
199
199
  )
200
200
  lines.append("")
201
+ lines.append(
202
+ "PRESERVE THE STRUCTURAL FINGERPRINT. Do not smooth the pattern "
203
+ "into a generic one-liner ('add a thoughtful counterpoint'); keep "
204
+ "what the winning replies actually DO on the page: how the first "
205
+ "words enter (lowercase noun, a number, the question itself, a "
206
+ "quoted phrase), how many sentences and of what shape (one clipped "
207
+ "line, two short + one long, a fragment), where the punch sits, "
208
+ "and what the reply refuses to do (no greeting, no hedge, no "
209
+ "summary). The char counts shown per reply are part of the "
210
+ "fingerprint; notice where the winners cluster."
211
+ )
212
+ lines.append("")
201
213
  lines.append(
202
214
  "Ignore: replies that win because of follower count, fame, or "
203
215
  "non-repeatable luck. Focus on the structural move that we (a "
@@ -209,14 +221,15 @@ def build_prompt(platform, replies, reserved_names):
209
221
  )
210
222
  lines.append("")
211
223
  for i, r in enumerate(replies, 1):
224
+ content = r.get("reply_content") or ""
212
225
  lines.append(
213
226
  f"### #{i} (likes={r['likes']}, replies={r['replies_count']}, "
214
- f"rt={r['retweets']})"
227
+ f"rt={r['retweets']}, chars={len(content.strip())})"
215
228
  )
216
229
  lines.append(f"Thread: {r['thread_url']}")
217
230
  handle = r.get("reply_author_handle") or "(unknown)"
218
231
  lines.append(f"Reply by @{handle}:")
219
- lines.append(f"> {r['reply_content']}")
232
+ lines.append(f"> {content}")
220
233
  lines.append("")
221
234
 
222
235
  lines.append("## Schema (match exactly)")
@@ -230,7 +243,12 @@ def build_prompt(platform, replies, reserved_names):
230
243
  lines.append("```")
231
244
  lines.append("{")
232
245
  lines.append(' "name": "<snake_case_name>",')
233
- lines.append(' "description": "<one to three sentences describing the style>",')
246
+ lines.append(
247
+ ' "description": "<one to three sentences carrying the structural '
248
+ "fingerprint: the DEFINING MOVE (the one thing every draft in this "
249
+ "style must contain), the OPENING (how the first words enter), and "
250
+ 'the sentence shape>",'
251
+ )
234
252
  lines.append(' "example": "<one short OP + reply pair demonstrating the style>",')
235
253
  lines.append(' "best_in": {')
236
254
  lines.append(f' "{platform}": ["<short context label>", ...],')
@@ -255,8 +273,11 @@ def build_prompt(platform, replies, reserved_names):
255
273
  "MOVE (e.g. `mirror_and_extend`, `flip_to_alt`, not `good_reply`)."
256
274
  )
257
275
  lines.append(
258
- "3. The description should make the style copyable: a future model "
259
- "reading just that one sentence should know what to write."
276
+ "3. The description must make the style copyable AS A FORM: a future "
277
+ "model reading only the description must know the defining move, how "
278
+ "to open, and what sentence shape to write. 'Add a sharp "
279
+ "counterpoint' fails this test; 'one clipped sentence, no greeting, "
280
+ "opens with the concrete number the OP left out' passes."
260
281
  )
261
282
  lines.append(
262
283
  "4. The example should be a realistic OP + reply pair, not lifted "
@@ -270,7 +291,10 @@ def build_prompt(platform, replies, reserved_names):
270
291
  lines.append(
271
292
  "6. NEVER propose a style about including a product, a URL, or a "
272
293
  "mechanism. Our link-tail layer handles that downstream. The style "
273
- "is about the text BEFORE the link."
294
+ "is about the text BEFORE the link. The note MAY state in one "
295
+ "clause how a plain product mention would enter this style if ever "
296
+ "(e.g. 'product name fits as the concrete example slot; never in "
297
+ "the opening'), but no URLs or link mechanics."
274
298
  )
275
299
  return "\n".join(lines)
276
300