@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.
- package/README.md +134 -101
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +24 -5
- package/mcp/dist/runtime.js +110 -0
- package/mcp/dist/version.js +163 -54
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +55 -19
- package/mcp/menubar/s4l_menubar.py +179 -56
- package/mcp/menubar/s4l_state.py +41 -1
- package/mcp/package.json +1 -1
- package/package.json +4 -2
- package/scripts/active_experiments.py +30 -6
- package/scripts/autopilot_stall_watch.py +185 -63
- package/scripts/cdp_ready_check.py +94 -0
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +221 -26
- package/scripts/enrich_reply_parents.py +68 -22
- package/scripts/feedback_digest.py +24 -1
- package/scripts/generate_daily_human_style.py +31 -7
- package/scripts/invent_styles.py +103 -26
- package/scripts/linkedin_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +43 -0
- package/scripts/memory_snapshot.py +57 -0
- package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/reddit_browser.py +57 -3
- package/scripts/s4l_box_update.sh +20 -1
- package/scripts/s4l_mode.py +3 -4
- package/scripts/scan_x_profile.py +25 -7
- package/scripts/schedule_state.py +23 -0
- package/scripts/scheduled_task_selfheal.py +213 -16
- package/scripts/setup_twitter_auth.py +6 -1
- package/scripts/snapshot.py +179 -61
- package/scripts/stall_guard.py +70 -0
- package/scripts/test_scheduled_task_selfheal.py +121 -0
- package/scripts/top_performers.py +34 -0
- package/scripts/twitter_browser.py +146 -8
- package/scripts/twitter_post_plan.py +10 -1
- package/scripts/twitter_scan.py +26 -0
- package/scripts/watchdog_hung_runs.py +117 -3
- package/skill/amplitude-24h-signups.sh +1 -1
- package/skill/audit-dm-staleness.sh +1 -1
- package/skill/audit-linkedin.sh +1 -1
- package/skill/audit-reddit-resurrect.sh +1 -1
- package/skill/audit.sh +5 -5
- package/skill/check-web-chats.sh +1 -1
- package/skill/dm-outreach-linkedin.sh +4 -4
- package/skill/dm-outreach-reddit.sh +1 -1
- package/skill/dm-outreach-twitter.sh +1 -1
- package/skill/engage-dm-replies.sh +16 -3
- package/skill/engage-linkedin.sh +2 -2
- package/skill/engage-moltbook.sh +1 -1
- package/skill/engage-twitter.sh +4 -4
- package/skill/github-engage.sh +1 -1
- package/skill/lib/browser-launch.sh +54 -0
- package/skill/lib/linkedin-backend.sh +22 -15
- package/skill/lib/reddit-backend.sh +17 -11
- package/skill/lib/twitter-backend.sh +137 -16
- package/skill/link-edit-github.sh +1 -1
- package/skill/link-edit-moltbook.sh +1 -1
- package/skill/link-edit-reddit.sh +1 -1
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/linkedin-presence.sh +1 -1
- package/skill/lock.sh +30 -6
- package/skill/precompute-stats.sh +1 -1
- package/skill/refresh-instagram-tokens.sh +1 -1
- package/skill/refresh-twitter-following.sh +1 -1
- package/skill/run-generate-daily-style.sh +1 -1
- package/skill/run-instagram-daily.sh +1 -1
- package/skill/run-instagram-render.sh +1 -1
- package/skill/run-linkedin-launchd.sh +1 -1
- package/skill/run-linkedin-unipile.sh +1 -1
- package/skill/run-linkedin.sh +1 -1
- package/skill/run-reddit-search.sh +2 -2
- package/skill/run-reddit-threads.sh +1 -1
- package/skill/run-twitter-cycle.sh +190 -55
- package/skill/scan-instagram-replies.sh +1 -1
- package/skill/sentry-digest.sh +1 -1
- package/skill/stats-instagram.sh +1 -1
- package/skill/stats-linkedin.sh +2 -2
- package/skill/stats.sh +1 -1
- package/skill/sweep-link-clicks.sh +1 -1
- package/scripts/_lock_preempt_test.py +0 -60
- package/scripts/saps_activity.py +0 -18
- 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
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
lines.append(
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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)
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
#
|
|
166
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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]
|
|
189
|
-
|
|
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
|
-
#
|
|
196
|
-
#
|
|
197
|
-
state[tid] = "no_link"
|
|
198
|
-
return
|
|
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
|
|
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
|
|
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"> {
|
|
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(
|
|
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
|
|
259
|
-
"reading
|
|
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
|
|