@m13v/s4l 1.7.3 → 1.7.4-rc.10

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.
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env python3
2
+ """Fill parent-thread linkage on X replies discovered via the notifications lane.
3
+
4
+ The X notifications feed does not expose the parent tweet id, so
5
+ scan_twitter_mentions_browser.py inserts `replies` rows with mention_id only:
6
+ no post_id, no parent_reply_id, and often no project_name. This script
7
+ resolves the parent chain AFTER the fact, deterministically, with no browser
8
+ and no model: fxtwitter's public JSON (already used by fetch_twitter_t1.py)
9
+ returns `replying_to_status` for any tweet.
10
+
11
+ Per row with (post_id IS NULL AND parent_reply_id IS NULL):
12
+ 1. fxtwitter GET on their_comment_id -> parent tweet id + handle.
13
+ 2. Walk up the ancestor chain (bounded hops) until the root.
14
+ 3. First ancestor that is one of OUR posts (/api/v1/posts/lookup, wide
15
+ window) -> PATCH replies.post_id (+ project_name when the row has none).
16
+ 4. Immediate parent that is another tracked reply
17
+ (/api/v1/replies?their_comment_id=) -> PATCH parent_reply_id + depth.
18
+ 5. Root author -> PATCH thread_author_handle.
19
+
20
+ Terminal misses (tweet deleted, protected, not-a-reply with nothing to link)
21
+ are remembered in a local state file so recurring runs don't refetch forever.
22
+
23
+ Usage:
24
+ python3 scripts/enrich_reply_parents.py --limit 20 # recurring lane
25
+ python3 scripts/enrich_reply_parents.py --backfill # all missing rows
26
+ python3 scripts/enrich_reply_parents.py --ids 546832 542579 # specific rows
27
+ python3 scripts/enrich_reply_parents.py --limit 5 --dry-run
28
+
29
+ All DB I/O goes through the s4l.ai HTTP API (http_api), never direct SQL.
30
+ """
31
+ import argparse
32
+ import json
33
+ import os
34
+ import sys
35
+ import time
36
+ import urllib.error
37
+ import urllib.request
38
+
39
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
40
+ from http_api import api_get, api_patch # noqa: E402
41
+
42
+ STATE_PATH = os.environ.get(
43
+ "S4L_ENRICH_PARENTS_STATE",
44
+ os.path.expanduser("~/.social-autoposter-enrich-parents.json"),
45
+ )
46
+ MAX_HOPS = 6
47
+ LOOKUP_DAYS = 3650
48
+
49
+
50
+ def load_state():
51
+ try:
52
+ with open(STATE_PATH) as f:
53
+ return json.load(f)
54
+ except Exception:
55
+ return {}
56
+
57
+
58
+ def save_state(state):
59
+ tmp = STATE_PATH + ".tmp"
60
+ with open(tmp, "w") as f:
61
+ json.dump(state, f)
62
+ os.replace(tmp, STATE_PATH)
63
+
64
+
65
+ def fetch_fxtwitter(handle, tweet_id):
66
+ """Returns (status, tweet_dict). status: 'ok' | 'gone' | 'transient'."""
67
+ url = f"https://api.fxtwitter.com/{handle or 'i'}/status/{tweet_id}"
68
+ req = urllib.request.Request(url, headers={"User-Agent": "social-autoposter/1.0"})
69
+ try:
70
+ with urllib.request.urlopen(req, timeout=15) as resp:
71
+ data = json.loads(resp.read())
72
+ except urllib.error.HTTPError as e:
73
+ # 401 = protected account, 404 = deleted. Both terminal.
74
+ if e.code in (401, 404):
75
+ return "gone", None
76
+ return "transient", None
77
+ except Exception:
78
+ return "transient", None
79
+ code = data.get("code")
80
+ if code == 200 and data.get("tweet"):
81
+ return "ok", data["tweet"]
82
+ if code in (401, 404):
83
+ return "gone", None
84
+ return "transient", None
85
+
86
+
87
+ def walk_ancestors(handle, tweet_id, sleep_s):
88
+ """Ancestor chain bottom-up: [(id, handle), ...] parent first, root last.
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).
93
+ """
94
+ chain = []
95
+ cur_handle, cur_id = handle, tweet_id
96
+ for _ in range(MAX_HOPS):
97
+ status, tweet = fetch_fxtwitter(cur_handle, cur_id)
98
+ time.sleep(sleep_s)
99
+ if status != "ok":
100
+ # 'gone' is terminal (deleted/protected); 'transient' must NOT be
101
+ # remembered, the next run retries it.
102
+ return chain, status
103
+ parent_id = tweet.get("replying_to_status")
104
+ parent_handle = tweet.get("replying_to") or ""
105
+ if not parent_id:
106
+ return chain, "root"
107
+ chain.append((str(parent_id), parent_handle))
108
+ cur_handle, cur_id = parent_handle, parent_id
109
+ return chain, "hop_cap"
110
+
111
+
112
+ def lookup_our_post(tweet_id):
113
+ resp = api_get(
114
+ "/api/v1/posts/lookup",
115
+ query={"platform": "twitter", "post_id": str(tweet_id), "days": str(LOOKUP_DAYS)},
116
+ )
117
+ return (resp.get("data") or {}).get("post") or None
118
+
119
+
120
+ def lookup_tracked_reply(tweet_id):
121
+ resp = api_get(
122
+ "/api/v1/replies",
123
+ query={"platform": "x", "their_comment_id": str(tweet_id), "limit": "1"},
124
+ )
125
+ rows = (resp.get("data") or {}).get("replies") or []
126
+ return rows[0] if rows else None
127
+
128
+
129
+ def fetch_work(limit, our_account=None, ids=None, before_id=None):
130
+ if ids:
131
+ out = []
132
+ for rid in ids:
133
+ resp = api_get(f"/api/v1/replies/{rid}")
134
+ row = (resp.get("data") or {}).get("reply")
135
+ if row:
136
+ out.append(row)
137
+ return out
138
+ query = {
139
+ "platform": "x",
140
+ "missing_parent": "1",
141
+ "order_by": "id",
142
+ "limit": str(limit),
143
+ }
144
+ if our_account:
145
+ query["our_account"] = our_account
146
+ if before_id:
147
+ query["before_id"] = str(before_id)
148
+ resp = api_get("/api/v1/replies", query=query)
149
+ return (resp.get("data") or {}).get("replies") or []
150
+
151
+
152
+ def enrich_row(row, state, sleep_s, dry_run):
153
+ rid = row["id"]
154
+ tid = str(row.get("their_comment_id") or "")
155
+ handle = (row.get("their_author") or "").lstrip("@")
156
+ if not tid:
157
+ return "no_tweet_id"
158
+ if state.get(tid):
159
+ return "state_skip"
160
+
161
+ chain, terminal = walk_ancestors(handle, tid, sleep_s)
162
+ if not chain:
163
+ if terminal == "transient":
164
+ 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]
168
+
169
+ patch = {}
170
+ matched_post = None
171
+ for anc_id, _anc_handle in chain:
172
+ post = lookup_our_post(anc_id)
173
+ if post:
174
+ matched_post = post
175
+ break
176
+ if matched_post:
177
+ patch["post_id"] = matched_post["id"]
178
+ if not row.get("project_name") and matched_post.get("project_name"):
179
+ patch["project_name"] = matched_post["project_name"]
180
+
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
186
+
187
+ if terminal == "root":
188
+ root_handle = (chain[-1][1] or "").lstrip("@")
189
+ if root_handle and not row.get("thread_author_handle"):
190
+ patch["thread_author_handle"] = root_handle
191
+
192
+ if not patch:
193
+ if terminal == "transient":
194
+ 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"
199
+
200
+ if dry_run:
201
+ print(f" [DRY] reply {rid}: {json.dumps(patch)}")
202
+ return "would_patch"
203
+
204
+ resp = api_patch(f"/api/v1/replies/{rid}", patch)
205
+ if resp.get("error"):
206
+ print(f" ERROR patching reply {rid}: {resp['error']}", file=sys.stderr)
207
+ return "patch_error"
208
+ state[tid] = "linked"
209
+ kinds = "+".join(k for k in ("post_id", "parent_reply_id", "thread_author_handle") if k in patch)
210
+ print(f" linked reply {rid}: {kinds} {json.dumps(patch)}")
211
+ return "linked"
212
+
213
+
214
+ def main():
215
+ ap = argparse.ArgumentParser(description="Backfill parent-thread linkage on X replies")
216
+ ap.add_argument("--limit", type=int, default=20, help="rows per run (recurring lane)")
217
+ ap.add_argument("--backfill", action="store_true", help="keep paging until no work is left")
218
+ ap.add_argument("--ids", nargs="*", type=int, help="enrich specific reply ids")
219
+ ap.add_argument("--our-account", default=None, help="scope to one posting handle")
220
+ ap.add_argument("--all-accounts", action="store_true", help="do not scope by handle")
221
+ ap.add_argument("--sleep", type=float, default=0.5, help="seconds between fxtwitter calls")
222
+ ap.add_argument("--dry-run", action="store_true")
223
+ args = ap.parse_args()
224
+
225
+ our_account = args.our_account
226
+ if not our_account and not args.all_accounts and not args.ids:
227
+ try:
228
+ from account_resolver import resolve as _resolve_account
229
+ our_account = _resolve_account("twitter")
230
+ except Exception:
231
+ our_account = None
232
+ if not our_account:
233
+ print("No twitter account resolvable; pass --our-account or --all-accounts", file=sys.stderr)
234
+ sys.exit(1)
235
+
236
+ state = load_state()
237
+ totals = {}
238
+ seen_ids = set()
239
+ before_id = None
240
+ while True:
241
+ rows = fetch_work(
242
+ args.limit if not args.backfill else 200, our_account, args.ids, before_id
243
+ )
244
+ rows = [r for r in rows if r["id"] not in seen_ids]
245
+ if not rows:
246
+ break
247
+ for row in rows:
248
+ seen_ids.add(row["id"])
249
+ # Terminal misses (deleted tweet, foreign thread) never leave the
250
+ # missing_parent queue; the id cursor pages past them.
251
+ before_id = row["id"] if before_id is None else min(before_id, row["id"])
252
+ outcome = enrich_row(row, state, args.sleep, args.dry_run)
253
+ totals[outcome] = totals.get(outcome, 0) + 1
254
+ if len(seen_ids) % 25 == 0 and not args.dry_run:
255
+ save_state(state)
256
+ if not args.dry_run:
257
+ save_state(state)
258
+ if args.ids or not args.backfill:
259
+ break
260
+
261
+ print(f"[enrich_reply_parents] processed={len(seen_ids)} " +
262
+ " ".join(f"{k}={v}" for k, v in sorted(totals.items())))
263
+
264
+
265
+ if __name__ == "__main__":
266
+ main()
@@ -82,6 +82,13 @@ MAX_EVENTS_PER_RUN = 200
82
82
  # degraded (a bad search topic, a draft-quality regression), not that any
83
83
  # single draft was wrong in a way the digest model could articulate.
84
84
  BULK_NO_REASON_THRESHOLD = int(os.environ.get("S4L_BULK_NO_REASON_THRESHOLD", "3"))
85
+ # Two-draft cards ship raw per-box pointer-dwell ms in draft_choice; this is
86
+ # the read-vs-skim floor for the draft the reviewer did NOT pick. At or above
87
+ # it, keeping the preselected Draft A counts as an informed keep (they read B
88
+ # and stayed); below it the approval says nothing about B. Threshold lives
89
+ # HERE, not in the menubar client, so it can be tuned without a client
90
+ # release.
91
+ DRAFT_READ_MS = int(os.environ.get("S4L_DRAFT_READ_MS", "1000"))
85
92
 
86
93
  DISALLOWED_TOOLS = (
87
94
  "ScheduleWakeup,CronCreate,CronDelete,CronList,EnterPlanMode,EnterWorktree,"
@@ -106,10 +113,54 @@ def load_config():
106
113
  return {"projects": []}
107
114
 
108
115
 
116
+ def _draft_choice(e: dict) -> dict | None:
117
+ """Parsed draft_choice payload (two-draft cards only). The API returns
118
+ jsonb as a dict; a locally-buffered event may still carry it as a JSON
119
+ string. None when absent, unparseable, or missing the unchosen draft
120
+ (nothing pairwise to say without the loser)."""
121
+ dc = e.get("draft_choice")
122
+ if isinstance(dc, str):
123
+ try:
124
+ dc = json.loads(dc)
125
+ except Exception:
126
+ return None
127
+ if not isinstance(dc, dict) or not (dc.get("unchosen_text") or "").strip():
128
+ return None
129
+ return dc
130
+
131
+
109
132
  def _event_line(e: dict) -> str:
110
133
  """One compact evidence line per event for the prompt."""
111
134
  parts = [f"[{e.get('decision')}{'+loved' if e.get('loved') else ''}]"]
112
135
  note = (e.get("reject_note") or "").strip()
136
+ # Two-draft pairwise flags (approvals only: on a reject BOTH drafts died,
137
+ # so which box the caret sat in carries no preference). Weighting ladder,
138
+ # explained to the model in build_prompt: an active switch to Draft B is
139
+ # strong (they necessarily read both); keeping the preselected A counts
140
+ # only when hover dwell shows they actually read B; a fast approve with B
141
+ # unread is flagged as exactly that so no preference gets fabricated.
142
+ dc = _draft_choice(e) if e.get("decision") == "approved" else None
143
+ show_unchosen = False
144
+ if dc:
145
+ if not dc.get("auto_selected"):
146
+ parts.append("picked_draft_b_over_default_a")
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
155
+ else:
156
+ other_ms = dc.get("hover_b_ms") or 0
157
+ if other_ms >= DRAFT_READ_MS:
158
+ parts.append(
159
+ f"kept_default_a_after_reading_b={round(other_ms / 1000, 1)}s"
160
+ )
161
+ show_unchosen = True
162
+ else:
163
+ parts.append("second_draft_not_read")
113
164
  if e.get("reject_category"):
114
165
  parts.append(f"category={e['reject_category']}")
115
166
  elif e.get("decision") == "rejected" and not note:
@@ -147,6 +198,13 @@ def _event_line(e: dict) -> str:
147
198
  line += f"\n user REWROTE it to: {draft[:300]}"
148
199
  elif draft:
149
200
  line += f"\n our draft was: {draft[:200]}"
201
+ if dc and show_unchosen:
202
+ line += f"\n the draft they did NOT pick was: {(dc.get('unchosen_text') or '')[:300]}"
203
+ if dc.get("style") or dc.get("unchosen_style"):
204
+ line += (
205
+ f"\n styles: picked={dc.get('style') or '?'}"
206
+ f" not_picked={dc.get('unchosen_style') or '?'}"
207
+ )
150
208
  url = (e.get("thread_url") or "").strip()
151
209
  if url:
152
210
  line += f"\n thread: {url}"
@@ -205,6 +263,8 @@ NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(no_reaso
205
263
 
206
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.
207
265
 
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.
267
+
208
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.
209
269
 
210
270
  Propose changes to the block. RULES, in priority order:
@@ -333,6 +393,16 @@ def _is_actionable(e: dict) -> bool:
333
393
  # as actionable as a reject (and it feeds edit_examples).
334
394
  if e.get("edited"):
335
395
  return True
396
+ # An explicit pairwise draft choice (switched to B, or tried B and came
397
+ # back to A) is style evidence on par with an edit; without this trigger a
398
+ # reviewer who mostly approves would bank pairwise signals behind the
399
+ # plain-approvals gate forever. Hover-only informed keeps stay
400
+ # NON-actionable on purpose: they are corroborating-weight evidence (like
401
+ # no_reason_given) and ride along with the next real trigger instead of
402
+ # burning a Claude turn on their own.
403
+ dc = _draft_choice(e)
404
+ if dc and (not dc.get("auto_selected") or dc.get("visited_other")):
405
+ return True
336
406
  return bool((e.get("reject_note") or "").strip())
337
407
 
338
408
 
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env python3
2
+ """scripts/invent_styles.py — standalone daily engagement-style invention.
3
+
4
+ Architectural split (2026-07-10, mirrors the 2026-05-28 invent_topics.py
5
+ split): in-cycle style invention (the picker's 5% INVENT_RATE roll) is
6
+ retired. It ran INSIDE the drafting prep session, with the top-performers
7
+ leaderboard and winner exemplars in context, so every "new" style was a
8
+ renamed variant of the currently-winning move (agree-then-relocate /
9
+ concede-then-reverse). Combined with name-only dedup in register_style,
10
+ the registry accumulated hundreds of semantic clones.
11
+
12
+ This job runs OUTSIDE any drafting context, on the operator Mac only:
13
+
14
+ - The registry is GLOBAL across installs (every install reads every
15
+ style), so invention must NOT fan out per install the way topic
16
+ invention does. One central daily run is the correct scope; that is
17
+ the deliberate difference from invent_topics.py's per-install kicker.
18
+ - The prompt carries the existing style universe and explicitly forbids
19
+ the dominant structural family; the ask is a style from an
20
+ UNREPRESENTED family, not a riff on the champion.
21
+ - Post-hoc semantic dedup (token-Jaccard on description+example, plus a
22
+ reframe-family heuristic) rejects near-clones; a rejection re-prompts
23
+ with a grown avoid-list, at most DUPE_RETRIES times.
24
+ - Accepted styles are registered via engagement_styles.register_style
25
+ (kind='model_invented'), same as the retired inline path, so pickers
26
+ see them on their next tick with zero other wiring.
27
+
28
+ Scheduling: launchd com.m13v.s4l-invent-styles (operator Mac, daily).
29
+ Uses run_claude.sh with tag 'invent-styles'; the tag is NOT in
30
+ claude_job.py TAG_TO_TYPE, so it runs the local `claude -p` lane and has
31
+ no dependency on the Desktop queue worker.
32
+
33
+ CLI:
34
+ python3 scripts/invent_styles.py # invent + register 1
35
+ python3 scripts/invent_styles.py --max-new 2
36
+ python3 scripts/invent_styles.py --dry-run # propose, don't register
37
+ """
38
+
39
+ import argparse
40
+ import json
41
+ import os
42
+ import re
43
+ import subprocess
44
+ import sys
45
+
46
+ _REPO_DIR = os.path.expanduser("~/social-autoposter")
47
+ sys.path.insert(0, os.path.join(_REPO_DIR, "scripts"))
48
+ from engagement_styles import get_all_styles, register_style # noqa: E402
49
+
50
+ _RUN_CLAUDE_SH = os.path.join(_REPO_DIR, "scripts", "run_claude.sh")
51
+ SCRIPT_TAG = "invent-styles"
52
+ CALL_TIMEOUT_SEC = 420
53
+ DUPE_RETRIES = 3
54
+ SIMILARITY_THRESHOLD = 0.5 # Jaccard on description+example tokens
55
+
56
+ # Heuristic markers of the saturated agree-then-relocate/reframe family.
57
+ # A proposal whose description/example leans on these is a clone of the
58
+ # dominant move no matter how novel its name is; reject like a dupe.
59
+ _REFRAME_MARKERS = re.compile(
60
+ r"\b(reframe|relocat\w+|the real (work|question|problem|cost|part|win)"
61
+ r"|hidden (cost|part|work|meter)|easy (part|half)|hard(er)? (part|half)"
62
+ r"|was never (the|about)|nobody (mentions|talks about|tracks|measures)"
63
+ r"|isn.t the .{0,30}it.s|concede)\b",
64
+ re.I,
65
+ )
66
+
67
+
68
+ def _tokens(text):
69
+ return set(re.findall(r"[a-z0-9]+", (text or "").lower()))
70
+
71
+
72
+ def _jaccard(a, b):
73
+ ta, tb = _tokens(a), _tokens(b)
74
+ if not ta or not tb:
75
+ return 0.0
76
+ return len(ta & tb) / len(ta | tb)
77
+
78
+
79
+ def _style_text(entry):
80
+ return f"{entry.get('description') or ''} {entry.get('example') or ''}"
81
+
82
+
83
+ def _find_near_dupe(proposal, universe):
84
+ """Return (reason, existing_name) when the proposal is a clone; else None."""
85
+ name = proposal["name"]
86
+ if name in universe:
87
+ return ("name_exists", name)
88
+ ptext = _style_text(proposal)
89
+ if _REFRAME_MARKERS.search(f"{proposal.get('description','')} {name}"):
90
+ return ("reframe_family", "(dominant agree-then-relocate family)")
91
+ best_name, best_sim = None, 0.0
92
+ for ename, entry in universe.items():
93
+ sim = _jaccard(ptext, _style_text(entry))
94
+ if sim > best_sim:
95
+ best_name, best_sim = ename, sim
96
+ if best_sim >= SIMILARITY_THRESHOLD:
97
+ return (f"jaccard={best_sim:.2f}", best_name)
98
+ return None
99
+
100
+
101
+ def build_prompt(universe, avoid):
102
+ names = sorted(universe.keys())
103
+ # Detail only a bounded sample (prompt-size guard): the seeds plus the
104
+ # most recently registered rows carry enough signal about what already
105
+ # exists; the full NAME list covers the rest for the model's self-check.
106
+ detailed = []
107
+ for name in names:
108
+ e = universe[name]
109
+ if e.get("kind") == "seed" or len(detailed) < 40:
110
+ desc = " ".join((e.get("description") or "").split())[:180]
111
+ detailed.append(f"- {name}: {desc}")
112
+ if len(detailed) >= 60:
113
+ break
114
+ avoid_block = ""
115
+ if avoid:
116
+ avoid_block = (
117
+ "\nAlready proposed and REJECTED this run (do not resubmit or "
118
+ "paraphrase): " + ", ".join(avoid) + "\n"
119
+ )
120
+ return f"""You maintain the engagement-style registry for a social reply system. A style is a named rhetorical TEMPLATE (description + one example reply) that drafters follow when writing short replies on X/Twitter and Reddit.
121
+
122
+ PROBLEM: the registry is saturated with ONE structural family: agree-then-relocate (concede the surface point, move the spotlight to the hidden/harder/unmeasured part, often "X is easy, Y is the real work" or "X was never the point, Y is"). Do NOT invent another member of that family, however disguised.
123
+
124
+ EXISTING STYLE NAMES ({len(names)} total):
125
+ {", ".join(names)}
126
+
127
+ REPRESENTATIVE DETAILS (sample):
128
+ {chr(10).join(detailed)}
129
+ {avoid_block}
130
+ TASK: invent exactly ONE genuinely new style from a structural family that is missing or rare above. Families worth mining (pick ONE, or another you identify): direct answer with zero framing; first-person confession of a specific failure; pure curious question with no thesis; dry understatement one-liner; enthusiastic cosign with one concrete addition; flat disagreement stated plainly without conceding anything first; a tiny numbered checklist; a vivid analogy that does NOT end in a lesson; deadpan humor riffing on the thread's wording.
131
+
132
+ Rules:
133
+ - The example must read like a real human reply (lowercase ok), 40-220 chars, NO links, NO product names.
134
+ - The style must be usable across many products and threads, not thread-specific.
135
+ - Your answer will be dedup-checked by token similarity against every existing style's description+example; if you cannot find a genuinely different move, return the saturation envelope instead of forcing a paraphrase.
136
+
137
+ Answer with ONLY one JSON object, no prose, in one of these two shapes:
138
+ {{"name": "snake_case_name", "description": "...", "example": "...", "why_existing_didnt_fit": "...", "target_chars": <int, the length the example is>}}
139
+ {{"saturated": true, "reason": "..."}}"""
140
+
141
+
142
+ def call_claude(prompt):
143
+ proc = subprocess.run(
144
+ ["bash", _RUN_CLAUDE_SH, SCRIPT_TAG, "-p", "--output-format", "json",
145
+ prompt],
146
+ text=True, capture_output=True, timeout=CALL_TIMEOUT_SEC,
147
+ )
148
+ if proc.returncode == 79:
149
+ raise SystemExit("[invent_styles] provider blocked (exit 79); skipping run")
150
+ if proc.returncode != 0:
151
+ raise SystemExit(
152
+ f"[invent_styles] run_claude.sh exited {proc.returncode}: "
153
+ f"{(proc.stderr or '')[:400]}")
154
+ try:
155
+ envelope = json.loads(proc.stdout)
156
+ text = envelope.get("result") or ""
157
+ except json.JSONDecodeError:
158
+ text = proc.stdout
159
+ return text
160
+
161
+
162
+ def _extract_json(text):
163
+ """Pull the first parseable JSON object out of model output."""
164
+ text = (text or "").strip()
165
+ fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
166
+ if fence:
167
+ text = fence.group(1)
168
+ start = text.find("{")
169
+ while start != -1:
170
+ for end in range(len(text), start, -1):
171
+ try:
172
+ obj = json.loads(text[start:end])
173
+ if isinstance(obj, dict):
174
+ return obj
175
+ except json.JSONDecodeError:
176
+ continue
177
+ start = text.find("{", start + 1)
178
+ return None
179
+
180
+
181
+ def main():
182
+ parser = argparse.ArgumentParser(description="Standalone style invention job")
183
+ parser.add_argument("--max-new", type=int, default=1,
184
+ help="How many styles to invent this run (default 1)")
185
+ parser.add_argument("--dry-run", action="store_true",
186
+ help="Propose and dedup-check but do not register")
187
+ args = parser.parse_args()
188
+
189
+ registered = 0
190
+ for slot in range(max(1, args.max_new)):
191
+ universe = get_all_styles()
192
+ avoid = []
193
+ accepted = None
194
+ for attempt in range(1 + DUPE_RETRIES):
195
+ raw = call_claude(build_prompt(universe, avoid))
196
+ obj = _extract_json(raw)
197
+ if not obj:
198
+ print(f"[invent_styles] slot={slot} attempt={attempt} "
199
+ f"unparseable output: {raw[:200]!r}", file=sys.stderr)
200
+ continue
201
+ if obj.get("saturated"):
202
+ print(f"[invent_styles] slot={slot} model reports saturation: "
203
+ f"{obj.get('reason', '')[:200]}", file=sys.stderr)
204
+ break
205
+ name = str(obj.get("name") or "").strip()
206
+ if not re.fullmatch(r"[a-z0-9_]{3,60}", name):
207
+ print(f"[invent_styles] slot={slot} bad name {name!r}; retrying",
208
+ file=sys.stderr)
209
+ avoid.append(name or "(unnamed)")
210
+ continue
211
+ dupe = _find_near_dupe({**obj, "name": name}, universe)
212
+ if dupe:
213
+ reason, existing = dupe
214
+ print(f"[invent_styles] slot={slot} rejected {name!r}: {reason} "
215
+ f"vs {existing}", file=sys.stderr)
216
+ avoid.append(name)
217
+ continue
218
+ accepted = {**obj, "name": name}
219
+ break
220
+ if not accepted:
221
+ continue
222
+ if args.dry_run:
223
+ print(json.dumps({"dry_run": True, **accepted}, indent=2))
224
+ continue
225
+ status, entry = register_style(
226
+ accepted["name"],
227
+ {
228
+ "description": accepted.get("description", ""),
229
+ "example": accepted.get("example", ""),
230
+ "why_existing_didnt_fit": accepted.get("why_existing_didnt_fit", ""),
231
+ "target_chars": accepted.get("target_chars"),
232
+ },
233
+ source_post={"platform": "invent_styles_job", "model": "invent_styles"},
234
+ )
235
+ print(f"[invent_styles] slot={slot} register status={status} "
236
+ f"name={accepted['name']}", file=sys.stderr)
237
+ if status == "new":
238
+ registered += 1
239
+ print(json.dumps({"registered": accepted["name"],
240
+ "description": accepted.get("description", "")}))
241
+ print(f"[invent_styles] done: registered={registered}", file=sys.stderr)
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()