@m13v/s4l 1.7.12-rc.8 → 1.7.12-rc.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/dist/index.js CHANGED
@@ -3832,8 +3832,8 @@ async function autopilotLoaded() {
3832
3832
  // fires every minute, claims ONE job, runs the pipeline's own prompt as its
3833
3833
  // Claude turn, writes the result back, and stops.
3834
3834
  // ===========================================================================
3835
- const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 240s -> 900s (see QUEUE_WORKER_POLL_SECONDS); version bump forces the prompt refresh that carries the new --wait-seconds onto existing installs. v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
3836
- // v10 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
3835
+ const QUEUE_WORKER_PROMPT_VERSION = 10; // v10 (2026-10-29): poll window 900s -> 540s so one `next` call fits inside the Bash tool's 600s default timeout, self-reap disabled (S4L_WORKER_SELF_REAP=0 on every claude_job call), and the session ends by archiving itself instead of a SIGTERM. v9 (2026-07-17): poll window widened 240s -> 900s; version bump forces the prompt refresh that carries the new --wait-seconds onto existing installs. v8: worker polls internally (claude_job.py next --wait-seconds) instead of single-shot check-then-die. Empirically verified (2026-07-06) that a single long-running Bash call survives well past the host's ~90s between-tool-call inactivity kill — that timer only fires on MODEL silence, not on one in-flight tool call — so one Bash call can safely poll for QUEUE_WORKER_POLL_SECONDS before giving up. This cuts the every-minute spin-up-empty-then-die husk cycle down to roughly one session per poll window instead of one per cron tick. v7: universal type-blind worker. ONE task claims `--type any`; per-type execution notes (e.g. the v6 incremental-draft pacing for twitter-prep) moved into claude_job.py TYPE_TO_WORKER_NOTES and ride the prompt sidecar, so the worker prompt never mentions job types. Legacy per-type tasks get this same body on refresh and become interchangeable universal workers.
3836
+ // v11 (PLANNED, NOT IMPLEMENTED): delegate the actual drafting to a fresh
3837
3837
  // sub-agent per claimed job (claim -> delegate -> wait -> claim next, looped
3838
3838
  // within one continuous worker session) instead of drafting inline. Validated
3839
3839
  // via throwaway probe tasks 2026-07-07/08 (10 loop iterations, ~210s of real
@@ -3842,10 +3842,19 @@ const QUEUE_WORKER_PROMPT_VERSION = 9; // v9 (2026-07-17): poll window widened 2
3842
3842
  // notification) or the host kills the whole parent+child chain in 1-3 min.
3843
3843
  // Never live-fire tested against a real production job. Full design, what's
3844
3844
  // validated vs not, and the implementation steps: docs/queue-worker-delegation-plan.md
3845
- // Bump this constant to 10 only once that plan is actually implemented.
3845
+ // Bump this constant to 11 only once that plan is actually implemented.
3846
3846
  const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
3847
3847
  // How long ONE `next --wait-seconds` call polls before giving up and exiting.
3848
- // 900s (15 min, per Matthew 2026-07-17, up from 240s): sits AT the single-
3848
+ // 540s (9 min, 2026-10-29, down from 900s): MUST stay below the Bash tool's
3849
+ // 600s default timeout. Measured on 2026-10-29 in Claude Desktop 4.29: the
3850
+ // 900s poll hit the 600s tool timeout on every empty cycle (81 of 106 runs
3851
+ // that day), the CLI then re-emitted a ~200KB prompt snapshot (a 7s
3852
+ // main-thread stall in the app), the poller kept running as an orphaned
3853
+ // background task for the remaining 300s, and its self-reap SIGTERM cost one
3854
+ // or two more 7s stalls plus an exit-143 re-attach. With the poll inside the
3855
+ // tool timeout the call returns normally, nothing is orphaned, and the worker
3856
+ // archives itself (step 5 of the prompt) at zero measured cost.
3857
+ // Earlier rationale (900s, per Matthew 2026-07-17, up from 240s): sits AT the single-
3849
3858
  // Bash-call survival ceiling verified live on 2026-07-06 (the host's ~90s
3850
3859
  // inactivity kill fires only on model silence, and one in-flight tool call
3851
3860
  // survived a full 900s probe). This covers the ~8min average real job
@@ -3861,7 +3870,7 @@ const QUEUE_WORKER_PROMPT_MARKER = "s4l_queue_worker_prompt_version";
3861
3870
  // poll window is legitimately still working, not a husk, and a too-tight
3862
3871
  // claim_grace would SIGTERM it mid-poll before it ever gets to claim.
3863
3872
  // (Bumped to 1020s alongside this change.)
3864
- const QUEUE_WORKER_POLL_SECONDS = 900;
3873
+ const QUEUE_WORKER_POLL_SECONDS = 540;
3865
3874
  // One spec per worker task. queueType MUST match scripts/claude_job.py TAG_TO_TYPE.
3866
3875
  const QUEUE_WORKERS = [
3867
3876
  { taskId: WORKER_TASK_ID, queueType: "any", human: "universal queue" },
@@ -3983,8 +3992,9 @@ function queueWorkerBody(spec) {
3983
3992
  `The deterministic pipeline runs on this Mac. When it needs a Claude turn it ` +
3984
3993
  `drops a job on a local file queue. Your only job: pick up the next job, do ` +
3985
3994
  `EXACTLY what its prompt says, hand the result back. You do this with Bash, ` +
3986
- `Read, and Write, and NOTHING else. This run is unattended — reaching for any ` +
3987
- `other tool, or trying to "investigate", STALLS it forever.`,
3995
+ `Read, and Write, and NOTHING else (except the single archive call in step 5). ` +
3996
+ `This run is unattended — reaching for any other tool, or trying to ` +
3997
+ `"investigate", STALLS it forever.`,
3988
3998
  ``,
3989
3999
  `PACING — CRITICAL: this unattended session is terminated ~90 seconds after ` +
3990
4000
  `your LAST tool call (a host inactivity timeout). That clock only runs BETWEEN ` +
@@ -4000,11 +4010,16 @@ function queueWorkerBody(spec) {
4000
4010
  `1. Look for the next job. Run this EXACT Bash command and let it run to ` +
4001
4011
  `completion — it polls internally for up to ${Math.round(QUEUE_WORKER_POLL_SECONDS / 60)} ` +
4002
4012
  `minutes before giving up, so it may take a while to return. That is normal: ` +
4003
- `do NOT interrupt it and do NOT make any other tool call while it is running.`,
4004
- ` ${py} ${job} next --type any --prompt-file --wait-seconds ${QUEUE_WORKER_POLL_SECONDS} --state-dir ${sd}`,
4013
+ `do NOT interrupt it and do NOT make any other tool call while it is running. ` +
4014
+ `Pass the Bash tool's timeout parameter as 600000 (its 10-minute maximum) so ` +
4015
+ `the call is never moved to the background. If the result nevertheless says ` +
4016
+ `the command was moved to the background (this happens when the Mac sleeps ` +
4017
+ `mid-poll; the timeout is wall-clock), do NOT wait for it: call TaskStop with ` +
4018
+ `the ID it gives you, report "no jobs" in one line, and go to step 5.`,
4019
+ ` S4L_WORKER_SELF_REAP=0 ${py} ${job} next --type any --prompt-file --wait-seconds ${QUEUE_WORKER_POLL_SECONDS} --state-dir ${sd}`,
4005
4020
  ` It prints one line of JSON once it returns. If it prints "{}" (empty), no ` +
4006
4021
  `job showed up during the whole poll window — report "no jobs" in one line ` +
4007
- `and STOP. You are done.`,
4022
+ `and go to step 5.`,
4008
4023
  `2. Otherwise it prints {"job_id":"...","prompt_file":"...","schema_file":...}. ` +
4009
4024
  `Use the Read tool to read prompt_file; it is the complete, self-contained ` +
4010
4025
  `instruction the pipeline wrote for you. If the Read result says it is partial ` +
@@ -4014,20 +4029,28 @@ function queueWorkerBody(spec) {
4014
4029
  `ONLY that JSON object — no prose, no markdown, no code fences.`,
4015
4030
  `3. Submit it. Write your JSON object to ${outDir}/out-<job_id>.json using the ` +
4016
4031
  `Write tool (substitute the real job_id), then run this EXACT Bash command:`,
4017
- ` ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
4032
+ ` S4L_WORKER_SELF_REAP=0 ${py} ${job} result --job <job_id> --result-file ${outDir}/out-<job_id>.json --state-dir ${sd}`,
4018
4033
  ` If it reports the result was rejected (bad JSON / missing keys), fix your JSON ` +
4019
4034
  `and submit again — at most twice. If it still fails, run ` +
4020
- `\`${py} ${job} result --job <job_id> --error --state-dir ${sd}\` (type a one-line ` +
4021
- `reason, then Ctrl-D) and STOP.`,
4022
- `4. Report in ONE short line what you did, then STOP. Do NOT claim another job, ` +
4023
- `do NOT loop, do NOT read other files, do NOT call any other tool.`,
4035
+ `\`S4L_WORKER_SELF_REAP=0 ${py} ${job} result --job <job_id> --error --state-dir ${sd}\` (type a one-line ` +
4036
+ `reason, then Ctrl-D) and go to step 5.`,
4037
+ `4. Report in ONE short line what you did. Do NOT claim another job, ` +
4038
+ `do NOT loop, do NOT read other files.`,
4039
+ `5. End this session yourself. Call ToolSearch with the query ` +
4040
+ `"select:mcp__ccd_session_mgmt__archive_session", then call ` +
4041
+ `mcp__ccd_session_mgmt__archive_session with session_id "self" and reason ` +
4042
+ `"s4l worker done". That archive call must be your very last tool call; it ` +
4043
+ `ends the session cleanly (no SIGTERM, no idle wait). If the tool is not ` +
4044
+ `available, just STOP.`,
4024
4045
  ``,
4025
4046
  `HARD RULES: use ONLY the Bash tool (to run claude_job.py AND any persist ` +
4026
4047
  `commands the job's prompt explicitly gives you), the Read tool (the ` +
4027
4048
  `prompt/schema sidecar + the SKILL/config files the prompt names), and the ` +
4028
- `Write tool (the result file). NEVER post, reply, open a browser, or run any ` +
4029
- `command the prompt does not explicitly give you. An empty queue is the ` +
4030
- `NORMAL, expected case most minutes — it is success, not a problem to debug.`,
4049
+ `Write tool (the result file), plus TaskStop ONLY for a backgrounded poll, and ` +
4050
+ `ToolSearch and mcp__ccd_session_mgmt__archive_session ONLY as the final step 5. NEVER post, ` +
4051
+ `reply, open a browser, or run any command the prompt does not explicitly ` +
4052
+ `give you. An empty queue is the NORMAL, expected case most minutes — it is ` +
4053
+ `success, not a problem to debug.`,
4031
4054
  ].join("\n");
4032
4055
  }
4033
4056
  // Full canonical SKILL.md (frontmatter + body + version marker) the MCP writes
package/mcp/dist/setup.js CHANGED
@@ -45,6 +45,37 @@ export function configPath() {
45
45
  return p; // not created yet: return the intended path unresolved
46
46
  }
47
47
  }
48
+ // Atomic config.json write (2026-10-30). Never write config.json in place:
49
+ // writeFileSync truncates the file first, so a reader in that window (or an
50
+ // interrupted write) sees a torn file and every pipeline lane dies on it.
51
+ // Write a same-directory temp file, fsync, then rename over the target so
52
+ // readers only ever see the old or the new complete file. Keeps the
53
+ // timestamped .bak- copy the call sites used to make.
54
+ export function writeConfigAtomic(cfg) {
55
+ const cfgPath = configPath();
56
+ if (fs.existsSync(cfgPath)) {
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
58
+ fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
59
+ }
60
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
61
+ const tmp = `${cfgPath}.tmp-${process.pid}`;
62
+ try {
63
+ const fd = fs.openSync(tmp, "w");
64
+ try {
65
+ fs.writeSync(fd, JSON.stringify(cfg, null, 2) + "\n");
66
+ fs.fsyncSync(fd);
67
+ }
68
+ finally {
69
+ fs.closeSync(fd);
70
+ }
71
+ fs.renameSync(tmp, cfgPath);
72
+ }
73
+ finally {
74
+ if (fs.existsSync(tmp))
75
+ fs.unlinkSync(tmp);
76
+ }
77
+ return cfgPath;
78
+ }
48
79
  // One-time (idempotent) migration to the state-dir home, run at server boot.
49
80
  // Cases:
50
81
  // - no state config, legacy is a REAL file -> move it up, leave a symlink
@@ -360,13 +391,7 @@ export function applySetup(input) {
360
391
  cfg.projects.push(entry);
361
392
  created = true;
362
393
  }
363
- const cfgPath = configPath();
364
- if (fs.existsSync(cfgPath)) {
365
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
366
- fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
367
- }
368
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
369
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
394
+ writeConfigAtomic(cfg);
370
395
  void sendStateSnapshot("config_write");
371
396
  if (!persona)
372
397
  recordManagedProject(input.name);
@@ -418,13 +443,7 @@ export function recordRedditAccount(username) {
418
443
  if (!reddit.login_method)
419
444
  reddit.login_method = "browser";
420
445
  try {
421
- const cfgPath = configPath();
422
- if (fs.existsSync(cfgPath)) {
423
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
424
- fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
425
- }
426
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
427
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
446
+ writeConfigAtomic(cfg);
428
447
  void sendStateSnapshot("config_write");
429
448
  return { written: true, detail: `accounts.reddit.username = ${clean}` };
430
449
  }
@@ -541,13 +560,7 @@ export function ensurePersonaProject(grounding) {
541
560
  name = PERSONA_DEFAULT_NAME;
542
561
  created = true;
543
562
  }
544
- const cfgPath = configPath();
545
- if (fs.existsSync(cfgPath)) {
546
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
547
- fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
548
- }
549
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
550
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
563
+ const cfgPath = writeConfigAtomic(cfg);
551
564
  // Raw dictation transcript -> persona_corpus.txt sidecar (NOT config.json).
552
565
  // Mirrors scripts/build_persona.py cmd_apply: config.json is inlined into many
553
566
  // prompts (ALL_PROJECTS_JSON), so a multi-KB corpus there would bloat every
@@ -598,13 +611,7 @@ export function ensureShortLinksDefault() {
598
611
  changed = true;
599
612
  }
600
613
  if (changed) {
601
- const cfgPath = configPath();
602
- if (fs.existsSync(cfgPath)) {
603
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
604
- fs.copyFileSync(cfgPath, `${cfgPath}.bak-${stamp}`);
605
- }
606
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
607
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
614
+ writeConfigAtomic(cfg);
608
615
  void sendStateSnapshot("config_write");
609
616
  }
610
617
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.7.12-rc.8",
3
- "installedAt": "2026-09-28T16:44:48.332Z"
2
+ "version": "1.7.12-rc.9",
3
+ "installedAt": "2026-10-30T18:59:09.558Z"
4
4
  }
package/mcp/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "dxt_version": "0.1",
3
3
  "name": "social-autoposter",
4
4
  "display_name": "S4L",
5
- "version": "1.7.12-rc.8",
5
+ "version": "1.7.12-rc.9",
6
6
  "description": "Draft, review, approve, and autopilot X/Twitter posts.",
7
7
  "long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
8
8
  "author": {
package/mcp/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l-mcp",
3
- "version": "1.7.12-rc.8",
3
+ "version": "1.7.12-rc.9",
4
4
  "private": true,
5
5
  "description": "Desktop MCP client for social-autoposter (X/Twitter rail): manual draft/review/approve loop, autopilot control, and stats. Thin wrapper over the existing pipeline scripts.",
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/s4l",
3
- "version": "1.7.12-rc.8",
3
+ "version": "1.7.12-rc.9",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js",
@@ -41,6 +41,23 @@
41
41
  "!scripts/_gsc_roi_query.py",
42
42
  "!scripts/batch_send_dms.py",
43
43
  "!scripts/finalize_post_*.py",
44
+ "!scripts/insert_[0-9]*.py",
45
+ "!scripts/insert[0-9]*.py",
46
+ "!scripts/ins[0-9]*.py",
47
+ "!scripts/tlh_pick_*.py",
48
+ "!scripts/tlh_freshness_*.py",
49
+ "!scripts/tlh_fresh.py",
50
+ "!scripts/tlh_stale.py",
51
+ "!scripts/fresh[0-9]*.py",
52
+ "!scripts/hist[0-9]*.py",
53
+ "!scripts/q[0-9]*.py",
54
+ "!scripts/show[0-9]*.py",
55
+ "!scripts/stale[0-9]*.py",
56
+ "!scripts/stale_[0-9]*.py",
57
+ "!scripts/stale_classes_*.py",
58
+ "!scripts/stale_clips_*.py",
59
+ "!scripts/r[0-9]*.py",
60
+ "!scripts/w[0-9]*.py",
44
61
  "!scripts/insert_li_notifs.py",
45
62
  "!scripts/li_notif_scan_process.py",
46
63
  "!scripts/phase_d_edit.py",
@@ -0,0 +1,8 @@
1
+ import os,psycopg2
2
+ from dotenv import load_dotenv
3
+ load_dotenv(os.path.expanduser('~/social-autoposter/.env'))
4
+ c=psycopg2.connect(os.environ['DATABASE_URL']).cursor()
5
+ c.execute("select distinct metadata->>'theme_angle' from media_posts")
6
+ print(' '.join(sorted(r[0] or '' for r in c.fetchall())))
7
+ c.execute("select distinct metadata->>'engagement_style' from media_posts")
8
+ print([r[0] for r in c.fetchall()])
@@ -1303,38 +1303,22 @@ def cmd_provider(ns) -> int:
1303
1303
  # next (consumer side, run by a scheduled task) #
1304
1304
  # --------------------------------------------------------------------------- #
1305
1305
  def _agent_session_pid():
1306
- """Best-effort: the Claude agent-mode SESSION pid running THIS worker — the
1307
- exact process the stale-session reaper (reap_stale_claude_sessions.py) would
1308
- target. We climb our own process tree to the ancestor whose cmd carries the
1309
- reaper's worker signature ('claude-code/' + 'local-agent-mode-sessions') and
1310
- return its pid, so the claim can be stamped with it and the reaper can SPARE
1311
- that session for the whole drafting turn (instead of SIGTERMing it at the short
1312
- grace window — the 2026-06-29 draft-kill regression). None if not identifiable;
1313
- the reaper then falls back to its newest-spare heuristic.
1314
- """
1306
+ """Best-effort: pid of the Claude agent-mode SESSION running THIS worker —
1307
+ the exact process the stale-session reaper (reap_stale_claude_sessions.py)
1308
+ would target. The claim is stamped with it
1309
+ so the reaper (a) SPARES that session for the whole drafting turn (the
1310
+ 2026-06-29 draft-kill regression) and (b) re-queues the job once that
1311
+ session is dead (release_stale_claims, ~2 min). Shares _find_own_session's
1312
+ signature: the old private copy matched 'local-agent-mode-sessions', which
1313
+ current Desktop builds no longer put on the cmdline, so every claim went
1314
+ unstamped and a claim whose session died stranded its producer for the full
1315
+ ~30 min timeout (2026-10-30: Desktop relaunched a minute after a claim).
1316
+ None if not identifiable."""
1315
1317
  try:
1316
- out = subprocess.run(
1317
- ["/bin/ps", "-axo", "pid=,ppid=,command="],
1318
- capture_output=True, text=True, timeout=10,
1319
- ).stdout
1320
- info = {}
1321
- for line in out.splitlines():
1322
- m = re.match(r"\s*(\d+)\s+(\d+)\s+(.*)$", line)
1323
- if m:
1324
- info[int(m.group(1))] = (int(m.group(2)), m.group(3))
1325
- pid = os.getpid()
1326
- for _ in range(16): # bounded climb up the tree
1327
- ent = info.get(pid)
1328
- if not ent or ent[0] <= 1:
1329
- break
1330
- ppid = ent[0]
1331
- pcmd = info.get(ppid, (0, ""))[1]
1332
- if ("claude-code/" in pcmd) and ("local-agent-mode-sessions" in pcmd):
1333
- return ppid
1334
- pid = ppid
1318
+ found = _find_own_session(_ps_pid_map())
1335
1319
  except Exception:
1336
1320
  return None
1337
- return None
1321
+ return found[0] if found else None
1338
1322
 
1339
1323
 
1340
1324
  def _attempt_claim(ns, qtype: str) -> bool:
package/scripts/config.py CHANGED
@@ -27,15 +27,22 @@ Resolution order for the repo root (single source of truth):
27
27
  $S4L_CONFIG_PATH overrides the config file location outright (some installs
28
28
  pin the operator config, e.g. ~/s4l/config.json, independent of the code dir).
29
29
 
30
- Do NOT add write helpers here. Config writes go through the MCP server's
31
- project_config tool (it owns validation and the state snapshot); pipeline
32
- scripts are readers.
30
+ Config writes: user-facing edits go through the MCP server's project_config
31
+ tool (it owns validation and the state snapshot). The few pipeline scripts
32
+ that must persist runtime state into config.json (subreddit bans) MUST use
33
+ update_config() below and nothing else: never `open(config, "w")` +
34
+ json.dump. That in-place pattern truncates the file to 0 bytes and streams it
35
+ back, so any interruption leaves a torn prefix that breaks every reader
36
+ (2026-09-06, 09-30, 10-22, 10-29 outages; the 10-29 one stopped all X
37
+ drafting for ~23h).
33
38
  """
34
39
 
35
40
  from __future__ import annotations
36
41
 
42
+ import fcntl
37
43
  import json
38
44
  import os
45
+ import sys
39
46
 
40
47
  _CACHE: dict | None = None
41
48
  _CACHE_PATH: str | None = None
@@ -98,6 +105,47 @@ def load_config(fresh: bool = False) -> dict:
98
105
  return _CACHE
99
106
 
100
107
 
108
+ def update_config(mutate, path: str | None = None):
109
+ """Locked, atomic read-modify-write of config.json.
110
+
111
+ `mutate(cfg)` edits the dict in place and returns a truthy value when it
112
+ changed something (nothing is written otherwise); its return value is
113
+ passed back to the caller. The whole read->mutate->write span holds the
114
+ same fcntl lock on <config>.lock that learned_preferences.py takes, so
115
+ concurrent writers cannot erase each other's edits. The new content goes
116
+ to a temp file in the same directory and is swapped in with os.replace,
117
+ so readers only ever see the complete old file or the complete new one;
118
+ a writer killed mid-write leaves a stray temp file, never a torn config.
119
+
120
+ Raises if config.json is missing or unparseable: callers must not
121
+ "repair" a broken config by writing a partial dict over it.
122
+ """
123
+ global _CACHE, _CACHE_PATH, _CACHE_MTIME
124
+ path = path or config_path()
125
+ with open(path + ".lock", "w") as lock_f:
126
+ fcntl.flock(lock_f, fcntl.LOCK_EX)
127
+ with open(path) as f:
128
+ cfg = json.load(f)
129
+ result = mutate(cfg)
130
+ if not result:
131
+ return result
132
+ tmp = f"{path}.tmp-{os.getpid()}"
133
+ try:
134
+ with open(tmp, "w") as f:
135
+ f.write(json.dumps(cfg, indent=2) + "\n")
136
+ f.flush()
137
+ os.fsync(f.fileno())
138
+ os.replace(tmp, path)
139
+ finally:
140
+ if os.path.exists(tmp):
141
+ os.unlink(tmp)
142
+ # One attribution line per write, so the next incident names its writer.
143
+ caller = os.path.basename(sys.argv[0]) if sys.argv and sys.argv[0] else "?"
144
+ print(f"[config] saved {path} by {caller} pid={os.getpid()}", file=sys.stderr)
145
+ _CACHE, _CACHE_PATH, _CACHE_MTIME = None, None, None
146
+ return result
147
+
148
+
101
149
  def get(path: str, default=None):
102
150
  """Dotted-path lookup into config.json: get("accounts.twitter.handle").
103
151
  A single-segment path reads a top-level key. Lists are not traversed;
@@ -48,7 +48,7 @@ REPO_DIR = os.path.expanduser(os.environ.get("S4L_REPO_DIR") or "~/social-autopo
48
48
  # its hardcoded config path (the S4L-4H dead-path class on customer boxes).
49
49
  import os as _cfg_os, sys as _cfg_sys
50
50
  _cfg_sys.path.insert(0, _cfg_os.path.dirname(_cfg_os.path.abspath(__file__)))
51
- from config import config_path as _canonical_config_path, load_config
51
+ from config import config_path as _canonical_config_path, load_config, update_config
52
52
  CONFIG_PATH = _canonical_config_path()
53
53
  REDDIT_BROWSER = os.path.join(REPO_DIR, "scripts", "reddit_browser.py")
54
54
  REDDIT_BROWSER_LOCK = os.path.join(REPO_DIR, "scripts", "reddit_browser_lock.py")
@@ -665,18 +665,16 @@ def mark_comment_blocked(thread_url: str,
665
665
  if not sub_match:
666
666
  return
667
667
  sub = sub_match.group(1).lower()
668
+ def _add(config):
669
+ blocked = config.setdefault("subreddit_bans", {}).setdefault("comment_blocked", [])
670
+ if sub in _ban_entries_to_subs(blocked):
671
+ return False
672
+ blocked.append(_make_ban_entry(sub, reason, project))
673
+ blocked.sort(key=lambda e: _ban_entry_sub(e) or "")
674
+ return True
675
+
668
676
  try:
669
- with open(CONFIG_PATH) as f:
670
- config = json.load(f)
671
- bans = config.setdefault("subreddit_bans", {})
672
- blocked = bans.setdefault("comment_blocked", [])
673
- existing = _ban_entries_to_subs(blocked)
674
- if sub not in existing:
675
- blocked.append(_make_ban_entry(sub, reason, project))
676
- blocked.sort(key=lambda e: _ban_entry_sub(e) or "")
677
- with open(CONFIG_PATH, "w") as f:
678
- json.dump(config, f, indent=2)
679
- f.write("\n")
677
+ if update_config(_add, CONFIG_PATH):
680
678
  print(f"[post_reddit] Added r/{sub} to subreddit_bans.comment_blocked "
681
679
  f"(reason={reason!r} project={project!r})")
682
680
  except Exception as e:
@@ -780,43 +778,42 @@ def mark_thread_blocked(subreddit: str, abort_reason: str = "",
780
778
  if not force and abort_reason and not _abort_is_permanent_block(abort_reason):
781
779
  return
782
780
  reason_str: str | None = (abort_reason or "").strip()[:280] or None
783
- try:
784
- with open(CONFIG_PATH) as f:
785
- config = json.load(f)
786
- bans = config.setdefault("subreddit_bans", {})
787
- blocked = bans.setdefault("thread_blocked", [])
788
- existing = _ban_entries_to_subs(blocked)
789
- if sub not in existing:
781
+
782
+ def _block(config):
783
+ """Returns ("added"|"refreshed", entry) when config changed, else None."""
784
+ blocked = config.setdefault("subreddit_bans", {}).setdefault("thread_blocked", [])
785
+ if sub not in _ban_entries_to_subs(blocked):
790
786
  new_entry = _make_ban_entry(sub, reason_str, project)
791
787
  blocked.append(new_entry)
792
788
  blocked.sort(key=lambda e: _ban_entry_sub(e) or "")
793
- with open(CONFIG_PATH, "w") as f:
794
- json.dump(config, f, indent=2)
795
- f.write("\n")
789
+ return ("added", new_entry)
790
+ # Re-arm an existing QUARANTINE entry on a fresh quarantine strike
791
+ # (both old and new reasons carry the prefix). Never overwrite a
792
+ # permanent entry with a weaker time-limited one.
793
+ entry = next((e for e in blocked
794
+ if isinstance(e, dict) and _ban_entry_sub(e) == sub), None)
795
+ old_reason = (entry or {}).get("reason") or ""
796
+ new_is_q = (reason_str or "").startswith(QUARANTINE_REASON_PREFIX)
797
+ if entry is not None and new_is_q and old_reason.startswith(QUARANTINE_REASON_PREFIX):
798
+ from datetime import datetime, timezone
799
+ entry["reason"] = reason_str
800
+ entry["added_at"] = datetime.now(timezone.utc).strftime(
801
+ "%Y-%m-%dT%H:%M:%SZ")
802
+ return ("refreshed", entry)
803
+ return None
804
+
805
+ try:
806
+ changed = update_config(_block, CONFIG_PATH)
807
+ if not changed:
808
+ print(f"[post_reddit] r/{sub} already in thread_blocked, skipping")
809
+ elif changed[0] == "added":
796
810
  print(f"[post_reddit] Auto-blocked r/{sub} from future thread posts "
797
811
  f"(reason={reason_str!r} project={project!r})")
798
- _mirror_ban_to_backend("thread_blocked", new_entry)
812
+ _mirror_ban_to_backend("thread_blocked", changed[1])
799
813
  else:
800
- # Re-arm an existing QUARANTINE entry on a fresh quarantine strike
801
- # (both old and new reasons carry the prefix). Never overwrite a
802
- # permanent entry with a weaker time-limited one.
803
- entry = next((e for e in blocked
804
- if isinstance(e, dict) and _ban_entry_sub(e) == sub), None)
805
- old_reason = (entry or {}).get("reason") or ""
806
- new_is_q = (reason_str or "").startswith(QUARANTINE_REASON_PREFIX)
807
- if entry is not None and new_is_q and old_reason.startswith(QUARANTINE_REASON_PREFIX):
808
- from datetime import datetime, timezone
809
- entry["reason"] = reason_str
810
- entry["added_at"] = datetime.now(timezone.utc).strftime(
811
- "%Y-%m-%dT%H:%M:%SZ")
812
- with open(CONFIG_PATH, "w") as f:
813
- json.dump(config, f, indent=2)
814
- f.write("\n")
815
- print(f"[post_reddit] Refreshed r/{sub} thread quarantine "
816
- f"(30-day clock re-armed)")
817
- _mirror_ban_to_backend("thread_blocked", entry)
818
- else:
819
- print(f"[post_reddit] r/{sub} already in thread_blocked, skipping")
814
+ print(f"[post_reddit] Refreshed r/{sub} thread quarantine "
815
+ f"(30-day clock re-armed)")
816
+ _mirror_ban_to_backend("thread_blocked", changed[1])
820
817
  except Exception as e:
821
818
  print(f"[post_reddit] WARNING: could not persist thread-blocked sub r/{sub}: {e}")
822
819
 
@@ -184,7 +184,7 @@ def record_confirmed_bans(subs, project=None) -> dict:
184
184
  'account_blocked_in_sub' (the reason platform_strike_events reads to emit
185
185
  platform_banned learning events). Upgrades pre-existing weak entries
186
186
  (reason=None) to the confirmed reason. Never records on None (unknown)."""
187
- from config import config_path
187
+ from config import update_config
188
188
  from post_reddit import _make_ban_entry, _ban_entry_sub
189
189
 
190
190
  state = banned_state(subs)
@@ -194,33 +194,33 @@ def record_confirmed_bans(subs, project=None) -> dict:
194
194
  if not banned:
195
195
  return {"banned": [], "recorded": [], "upgraded": [], "unknown": unknown}
196
196
 
197
- path = config_path()
198
- with open(path) as f:
199
- cfg = json.load(f)
200
- bans = cfg.setdefault("subreddit_bans", {})
201
- blocked = bans.setdefault("comment_blocked", [])
202
- by_sub = {(_ban_entry_sub(e) or "").lower(): e for e in blocked}
203
-
204
- for sub in banned:
205
- entry = by_sub.get(sub)
206
- if entry is None:
207
- blocked.append(_make_ban_entry(sub, "account_blocked_in_sub", project))
208
- recorded.append(sub)
209
- elif entry.get("reason") != "account_blocked_in_sub":
210
- # upgrade a weak/manual entry so the digest ban event can fire
211
- from datetime import datetime, timezone
212
- entry["reason"] = "account_blocked_in_sub"
213
- if not entry.get("added_at"):
214
- entry["added_at"] = datetime.now(timezone.utc).strftime(
215
- "%Y-%m-%dT%H:%M:%SZ")
216
- entry.setdefault("noticed_by_project", project)
217
- upgraded.append(sub)
218
-
219
- if recorded or upgraded:
220
- blocked.sort(key=lambda e: (_ban_entry_sub(e) or ""))
221
- with open(path, "w") as f:
222
- json.dump(cfg, f, indent=2)
223
- f.write("\n")
197
+ blocked: list = [] # filled with the saved list, for the backend mirror
198
+
199
+ def _mutate(cfg):
200
+ entries = cfg.setdefault("subreddit_bans", {}).setdefault("comment_blocked", [])
201
+ blocked[:] = entries
202
+ by_sub = {(_ban_entry_sub(e) or "").lower(): e for e in blocked}
203
+ for sub in banned:
204
+ entry = by_sub.get(sub)
205
+ if entry is None:
206
+ entries.append(_make_ban_entry(sub, "account_blocked_in_sub", project))
207
+ recorded.append(sub)
208
+ elif entry.get("reason") != "account_blocked_in_sub":
209
+ # upgrade a weak/manual entry so the digest ban event can fire
210
+ from datetime import datetime, timezone
211
+ entry["reason"] = "account_blocked_in_sub"
212
+ if not entry.get("added_at"):
213
+ entry["added_at"] = datetime.now(timezone.utc).strftime(
214
+ "%Y-%m-%dT%H:%M:%SZ")
215
+ entry.setdefault("noticed_by_project", project)
216
+ upgraded.append(sub)
217
+ if not (recorded or upgraded):
218
+ return False
219
+ entries.sort(key=lambda e: (_ban_entry_sub(e) or ""))
220
+ blocked[:] = entries
221
+ return True
222
+
223
+ if update_config(_mutate):
224
224
  # Mirror to the backend subreddit_bans table (source of truth as of
225
225
  # 2026-07-19; config.json is the write-through cache). Best-effort:
226
226
  # readers union both sources, so a failed mirror never un-bans.
@@ -226,10 +226,15 @@ def _upsert_corpus_section(corpus_path: Path, section: str) -> str:
226
226
 
227
227
 
228
228
  def _is_hand_written(proj: dict) -> bool:
229
- """examples present but never stamped by us = a human typed them."""
229
+ """examples present but never stamped by us = a human typed them.
230
+ A curated examples_good set counts too: the account-wide scan is the
231
+ user's general X register, which would override a project's own voice."""
230
232
  voice = proj.get("voice")
231
- return (isinstance(voice, dict) and bool(voice.get("examples"))
232
- and not voice.get("examples_scanned_at"))
233
+ if not isinstance(voice, dict):
234
+ return False
235
+ if voice.get("examples_good"):
236
+ return True
237
+ return bool(voice.get("examples")) and not voice.get("examples_scanned_at")
233
238
 
234
239
 
235
240
  def _set_examples(proj: dict, scan: dict, top: int, min_chars: int,
@@ -1,134 +0,0 @@
1
- import os, re, subprocess, hashlib, itertools, json
2
- import psycopg2
3
- from collections import defaultdict
4
-
5
- REM = os.path.expanduser("~/social-autoposter/mixer/remotion")
6
- PUB = os.path.join(REM, "public", "mixer")
7
- FFPROBE = "ffprobe"
8
- os.environ["PATH"] = "/opt/homebrew/Cellar/ffmpeg/8.1.1/bin:" + os.environ.get("PATH","")
9
-
10
- def dburl():
11
- with open(os.path.expanduser("~/social-autoposter/.env")) as f:
12
- for line in f:
13
- if line.startswith("DATABASE_URL="):
14
- return line.split("=",1)[1].strip()
15
- raise SystemExit("no DATABASE_URL")
16
-
17
- def probe_dur(p):
18
- try:
19
- out = subprocess.check_output([FFPROBE,"-v","error","-show_entries","format=duration","-of","csv=p=0",p]).decode().strip()
20
- return float(out)
21
- except Exception:
22
- return None
23
-
24
- def md5(p):
25
- h=hashlib.md5()
26
- with open(p,"rb") as f:
27
- for chunk in iter(lambda:f.read(1<<20), b""):
28
- h.update(chunk)
29
- return h.hexdigest()
30
-
31
- # 1. all tlh-*.mp4 slot files, their duration + content md5
32
- slots={}
33
- for fn in os.listdir(PUB):
34
- if fn.startswith("tlh-") and fn.endswith(".mp4"):
35
- p=os.path.join(PUB,fn)
36
- d=probe_dur(p)
37
- slots[fn]={"dur":d,"md5":md5(p)}
38
-
39
- # content class = md5. Map filename -> class
40
- fn2class={fn:info["md5"] for fn,info in slots.items()}
41
- # which classes are ~2.0s (target 2.0 slots). Consider a file 2.0s if 1.95<=dur<=2.05
42
- two_sec_classes=set()
43
- class_dur=defaultdict(list)
44
- for fn,info in slots.items():
45
- if info["dur"] is not None:
46
- class_dur[info["md5"]].append(info["dur"])
47
- for cls,durs in class_dur.items():
48
- avg=sum(durs)/len(durs)
49
- if 1.95<=avg<=2.05:
50
- two_sec_classes.add(cls)
51
-
52
- # 2. DB: all lesson-% rows, source_clips + post_number
53
- conn=psycopg2.connect(dburl())
54
- cur=conn.cursor()
55
- cur.execute("""SELECT post_number, variant_id, source_clips, metadata->>'theme_angle'
56
- FROM media_posts WHERE variant_id LIKE 'lesson-%' AND source_clips IS NOT NULL
57
- ORDER BY post_number""")
58
- rows=cur.fetchall()
59
-
60
- # map each row -> set of content classes (from src basenames), record post ordering
61
- variant_classes=[] # (post_number, variant_id, frozenset(classes))
62
- class_last_post=defaultdict(lambda:-1)
63
- for pn,vid,sc,angle in rows:
64
- if isinstance(sc,str):
65
- sc=json.loads(sc)
66
- classes=set()
67
- for c in sc:
68
- src=c.get("src","")
69
- base=os.path.basename(src)
70
- cls=fn2class.get(base)
71
- if cls:
72
- classes.add(cls)
73
- variant_classes.append((pn,vid,frozenset(classes)))
74
- for cls in classes:
75
- if pn>class_last_post[cls]:
76
- class_last_post[cls]=pn
77
-
78
- # recent organic renders = last 5 lesson rows by post_number
79
- recent5=set()
80
- for pn,vid,cls in sorted(variant_classes,key=lambda x:-x[0])[:5]:
81
- recent5|=set(cls)
82
-
83
- # candidate 2.0s classes, ranked by staleness (lowest last_post first)
84
- cands=[(class_last_post[c],c) for c in two_sec_classes]
85
- cands.sort() # stalest first
86
- print("== 2.0s content classes, stalest first (last_post, class, example_files) ==")
87
- class_files=defaultdict(list)
88
- for fn,cls in fn2class.items():
89
- class_files[cls].append(fn)
90
- for lp,c in cands[:20]:
91
- print(f" last_post={lp:>4} {c[:12]} files={sorted(class_files[c])[:4]}")
92
-
93
- # no-3+-co-occurrence: chosen 4-set shares <=2 classes with ANY prior variant
94
- def ok_set(fourset):
95
- fs=set(fourset)
96
- for pn,vid,cls in variant_classes:
97
- if len(fs & set(cls))>=3:
98
- return False
99
- return True
100
-
101
- # not in recent5
102
- pool=[c for lp,c in cands if c not in recent5]
103
- print(f"\n== pool (stale, not in last-5) size={len(pool)} ==")
104
-
105
- # greedily search: take combinations of the stalest, prefer overall stalest sum
106
- chosen=None
107
- # limit search to stalest ~14 for tractability
108
- search=pool[:14]
109
- best=None
110
- for combo in itertools.combinations(search,4):
111
- if ok_set(combo):
112
- score=sum(class_last_post[c] for c in combo)
113
- if best is None or score<best[0]:
114
- best=(score,combo)
115
- if best:
116
- chosen=best[1]
117
- print("\n== CHOSEN 4-set (min staleness score, no 3+ co-occurrence) ==")
118
- for c in chosen:
119
- print(f" last_post={class_last_post[c]:>4} {c[:12]} pick_file={sorted(class_files[c])[0]}")
120
- # verify max pairwise co-occurrence
121
- maxco=0
122
- for pn,vid,cls in variant_classes:
123
- ov=len(set(chosen)&set(cls))
124
- maxco=max(maxco,ov)
125
- print(f" max overlap with any prior variant = {maxco}")
126
- print(" RESULT_FILES="+",".join(sorted(class_files[c])[0] for c in chosen))
127
- else:
128
- print("NO VALID SET FOUND")
129
-
130
- # also report existing max lesson number
131
- cur.execute("SELECT variant_id FROM media_posts WHERE variant_id ~ '^lesson-[0-9]+$'")
132
- nums=[int(r[0].split('-')[1]) for r in cur.fetchall()]
133
- print("\nDB max lesson num =", max(nums) if nums else None)
134
- cur.close(); conn.close()
@@ -1,92 +0,0 @@
1
- import re, os, subprocess, json, hashlib
2
- from collections import Counter, defaultdict
3
- from itertools import combinations
4
-
5
- DATA = os.path.expanduser("~/social-autoposter/mixer/remotion/src/mixer/data.ts")
6
- PUB = os.path.expanduser("~/social-autoposter/mixer/remotion/public/mixer")
7
- src = open(DATA).read()
8
-
9
- # Extract every clipsV2 block and legacy clips arrays referencing tlh-*.mp4
10
- # Find all "mixer/tlh-...mp4" occurrences grouped per variant via clipsV2:[ ... ] blocks
11
- sets = []
12
- for m in re.finditer(r'clipsV2:\s*\[(.*?)\]', src, re.S):
13
- clips = re.findall(r'mixer/(tlh-[0-9a-z\-]+\.mp4)', m.group(1))
14
- if clips:
15
- sets.append(clips)
16
- # legacy v1: clips arrays inside TLH? lesson-1 uses clips[] with tlh-1..5
17
- for m in re.finditer(r'clips:\s*\[(.*?)\]', src, re.S):
18
- clips = re.findall(r'"(tlh-[0-9a-z\-]+\.mp4)"', m.group(1))
19
- if clips:
20
- sets.append(clips)
21
-
22
- def family(fn):
23
- # tlh-<firstint>-... -> first integer group
24
- mm = re.match(r'tlh-(\d+)', fn)
25
- return mm.group(1) if mm else fn
26
-
27
- usage = Counter()
28
- cooc = Counter()
29
- for s in sets:
30
- for c in set(s):
31
- usage[c]+=1
32
- for a,b in combinations(sorted(set(s)),2):
33
- cooc[(a,b)]+=1
34
-
35
- # available encoded tlh clips on disk
36
- avail = sorted([f for f in os.listdir(PUB) if re.match(r'tlh-[0-9a-z\-]+\.mp4$', f)])
37
-
38
- def probe(fn):
39
- p = os.path.join(PUB, fn)
40
- out = subprocess.run(["ffprobe","-v","error","-select_streams","v:0",
41
- "-show_entries","stream=width,height:format=duration","-of","json",p],
42
- capture_output=True,text=True).stdout
43
- j = json.loads(out)
44
- st = j["stream"][0] if "stream" in j else j["streams"][0]
45
- dur = float(j["format"]["duration"])
46
- return st["width"], st["height"], dur
47
-
48
- def blackframes(fn):
49
- p = os.path.join(PUB, fn)
50
- r = subprocess.run(["ffmpeg","-v","error","-i",p,"-vf","blackdetect=d=0.1:pic_th=0.98",
51
- "-an","-f","null","-"],capture_output=True,text=True)
52
- return "black_start" in r.stderr
53
-
54
- def sha(fn):
55
- return hashlib.sha256(open(os.path.join(PUB,fn),'rb').read()).hexdigest()
56
-
57
- # Filter avail to valid 1080x1920, dur>=1.75 (holds a 2.0s slot acceptably or exact), non-black
58
- os.environ["PATH"]="/opt/homebrew/Cellar/ffmpeg/8.1.1/bin:"+os.environ["PATH"]
59
- valid=[]
60
- info={}
61
- for f in avail:
62
- try:
63
- w,h,d = probe(f)
64
- except Exception as e:
65
- continue
66
- if (w,h)!=(1080,1920): continue
67
- info[f]=(w,h,d)
68
- valid.append(f)
69
-
70
- print("total prior sets:", len(sets))
71
- print("valid 1080x1920 clips on disk:", len(valid))
72
-
73
- # Build candidate 4-sets: distinct families, zero pairwise co-occurrence, low total usage.
74
- # Rank by (max pairwise cooc, total usage). Verify durations >=1.75 and non-black + distinct sha lazily on the winner.
75
- from itertools import combinations as comb
76
- # group valid by family, prefer low-usage clips
77
- valid_sorted = sorted(valid, key=lambda f:(usage[f], f))
78
- best=None
79
- # to keep it tractable, restrict candidate pool to the 40 lowest-usage valid clips across distinct families
80
- pool = valid_sorted[:60]
81
- results=[]
82
- for quad in comb(pool,4):
83
- fams=[family(f) for f in quad]
84
- if len(set(fams))!=4: continue
85
- pairs=list(comb(sorted(quad),2))
86
- maxco=max(cooc[p] for p in pairs)
87
- tot=sum(usage[f] for f in quad)
88
- results.append((maxco,tot,quad))
89
- results.sort(key=lambda x:(x[0],x[1]))
90
- print("\ntop 15 candidate quads (maxcooc, totalusage, clips):")
91
- for maxco,tot,quad in results[:15]:
92
- print(maxco,tot,list(quad),"durs",[round(info[f][2],3) for f in quad])
@@ -1,115 +0,0 @@
1
- import os, re, glob, json, subprocess, hashlib
2
- import psycopg
3
- from collections import defaultdict
4
-
5
- FFPROBE="/opt/homebrew/Cellar/ffmpeg/8.1.1/bin/ffprobe"
6
- FFMPEG="/opt/homebrew/Cellar/ffmpeg/8.1.1/bin/ffmpeg"
7
- PUB=os.path.expanduser("~/social-autoposter/mixer/remotion/public/mixer")
8
-
9
- def dburl():
10
- for line in open(os.path.expanduser("~/social-autoposter/.env")):
11
- if line.startswith("DATABASE_URL="):
12
- return line.split("=",1)[1].strip()
13
- url=dburl()
14
-
15
- with psycopg.connect(url) as conn, conn.cursor() as cur:
16
- cur.execute("SELECT MAX(post_number) FROM media_posts")
17
- print("MAX_post_number:", cur.fetchone()[0])
18
- cur.execute("SELECT COUNT(*) FROM media_posts WHERE post_number=504")
19
- print("post_504_rows:", cur.fetchone()[0])
20
- cur.execute("SELECT COUNT(*) FROM media_posts WHERE variant_id='lesson-506'")
21
- print("lesson506_rows:", cur.fetchone()[0])
22
- # used theme angles ever
23
- cur.execute("SELECT DISTINCT metadata->>'theme_angle' FROM media_posts WHERE metadata->>'theme_angle' IS NOT NULL")
24
- angles=sorted(r[0] for r in cur.fetchall())
25
- print("USED_ANGLES_COUNT:", len(angles))
26
- # recent posts source_clips + variant + post_number
27
- cur.execute("""SELECT post_number, variant_id, source_clips FROM media_posts
28
- WHERE source_clips IS NOT NULL ORDER BY post_number DESC LIMIT 12""")
29
- recent=cur.fetchall()
30
- # per-clip-src -> last post_number across ALL rows
31
- cur.execute("SELECT post_number, source_clips FROM media_posts WHERE source_clips IS NOT NULL")
32
- allrows=cur.fetchall()
33
-
34
- print("\n=== RECENT 12 (post_number, variant, srcs) ===")
35
- recent_srcs_by_post={}
36
- for pn,vid,sc in recent:
37
- srcs=[os.path.basename(c.get('src','')) for c in sc] if sc else []
38
- recent_srcs_by_post[pn]=srcs
39
- print(pn, vid, srcs)
40
-
41
- # save angles
42
- json.dump(angles, open("/tmp/tlh504_angles.json","w"))
43
-
44
- # Build clip -> last post_number map, keyed by basename
45
- lastpost_by_base=defaultdict(int)
46
- for pn,sc in allrows:
47
- for c in (sc or []):
48
- b=os.path.basename(c.get('src',''))
49
- if b: lastpost_by_base[b]=max(lastpost_by_base[b], pn)
50
-
51
- # Now probe every tlh-*.mp4: video md5 class, dims, duration
52
- def vmd5(path):
53
- out=subprocess.run([FFMPEG,"-i",path,"-map","0:v","-an","-f","md5","-"],
54
- capture_output=True,text=True)
55
- m=re.search(r'MD5=([0-9a-f]+)', out.stdout+out.stderr)
56
- return m.group(1)[:10] if m else None
57
- def dims_dur(path):
58
- out=subprocess.run([FFPROBE,"-v","error","-select_streams","v:0",
59
- "-show_entries","stream=width,height","-show_entries","format=duration",
60
- "-of","json",path],capture_output=True,text=True)
61
- j=json.loads(out.stdout)
62
- w=j["streams"][0]["width"]; h=j["streams"][0]["height"]
63
- d=float(j["format"]["duration"])
64
- return w,h,d
65
-
66
- clips=sorted(glob.glob(os.path.join(PUB,"tlh-*.mp4")))
67
- info={} # base -> dict
68
- cls_members=defaultdict(list)
69
- for p in clips:
70
- b=os.path.basename(p)
71
- w,h,d=dims_dur(p)
72
- cl=vmd5(p)
73
- info[b]={"w":w,"h":h,"dur":round(d,3),"cls":cl}
74
- cls_members[cl].append(b)
75
-
76
- # class last-used = max lastpost among its member basenames
77
- cls_last={}
78
- for cl,members in cls_members.items():
79
- cls_last[cl]=max((lastpost_by_base[m] for m in members), default=0)
80
-
81
- # candidates: 1080x1920 classes with on-disk dur >= 1.5, sorted by staleness (class_last asc)
82
- cand=[]
83
- for cl,members in cls_members.items():
84
- reps=[m for m in members if info[m]["w"]==1080 and info[m]["h"]==1920 and info[m]["dur"]>=1.5]
85
- if not reps: continue
86
- # pick representative basename (shortest dur>=1.5 tiebreak by name)
87
- rep=sorted(reps, key=lambda m:(info[m]["dur"], m))[0]
88
- cand.append((cls_last[cl], cl, rep, info[rep]["dur"], members))
89
- cand.sort(key=lambda x:(x[0], x[2]))
90
-
91
- recent5=set()
92
- for pn in sorted(recent_srcs_by_post, reverse=True)[:3]:
93
- recent5.update(recent_srcs_by_post[pn])
94
- # also add classes of those srcs
95
- print("\n=== recent 3 posts clip basenames (avoid classes) ===")
96
- print(sorted(recent5))
97
- recent_classes=set()
98
- for b in recent5:
99
- if b in info: recent_classes.add(info[b]["cls"])
100
- print("recent_classes:", recent_classes)
101
-
102
- print("\n=== STALEST 1080x1920 classes (dur>=1.5), class_last asc ===")
103
- picked=[]
104
- for class_last, cl, rep, dur, members in cand:
105
- if cl in recent_classes:
106
- tag="(SKIP recent)"
107
- else:
108
- tag=""
109
- if len(picked)<8:
110
- picked.append((rep,dur,cl,class_last))
111
- print(f"class_last={class_last} cls={cl} rep={rep} dur={dur} members={members} {tag}")
112
-
113
- print("\n=== PICK (first 5 non-recent stalest) ===")
114
- for rep,dur,cl,cl_last in picked[:5]:
115
- print(rep, "dur=",dur, "cls=",cl, "class_last=",cl_last)
@@ -1,61 +0,0 @@
1
- import os, glob, subprocess, json, hashlib, psycopg2
2
- os.chdir(os.path.expanduser("~/social-autoposter/mixer/remotion"))
3
- os.environ["PATH"]="/opt/homebrew/Cellar/ffmpeg/8.1.1/bin:"+os.environ["PATH"]
4
- FF="/opt/homebrew/Cellar/ffmpeg/8.1.1/bin/ffmpeg"; FP="/opt/homebrew/Cellar/ffmpeg/8.1.1/bin/ffprobe"
5
- clips=sorted(glob.glob("public/mixer/tlh-*.mp4"))
6
- info={}
7
- for c in clips:
8
- base=os.path.basename(c)
9
- # skip the post-505 leftover slots (belong to posted lesson-505)
10
- dur=subprocess.run([FP,"-v","error","-show_entries","format=duration","-of","csv=p=0",c],capture_output=True,text=True).stdout.strip()
11
- dims=subprocess.run([FP,"-v","error","-select_streams","v:0","-show_entries","stream=width,height","-of","csv=p=0:s=x",c],capture_output=True,text=True).stdout.strip()
12
- md5=subprocess.run([FF,"-i",c,"-map","0:v","-an","-f","md5","-"],capture_output=True,text=True).stdout.strip().split("=")[-1]
13
- info[base]={"dur":float(dur) if dur else 0,"dims":dims,"md5":md5[:10]}
14
- # DB: most recent post_number per basename
15
- import os as _o
16
- from urllib.parse import urlparse
17
- # load .env DATABASE_URL
18
- env={}
19
- for line in open(os.path.expanduser("~/social-autoposter/.env")):
20
- if line.strip() and not line.startswith("#") and "=" in line:
21
- k,v=line.split("=",1); env[k.strip()]=v.strip().strip('"').strip("'")
22
- conn=psycopg2.connect(env["DATABASE_URL"]); cur=conn.cursor()
23
- cur.execute("select post_number, source_clips from media_posts where source_clips is not null")
24
- last_by_base={}
25
- for pn,sc in cur.fetchall():
26
- if not sc: continue
27
- for e in sc:
28
- s=(e.get("src") or "")
29
- b=os.path.basename(s)
30
- if b:
31
- last_by_base[b]=max(last_by_base.get(b,0),pn)
32
- # class_last = max post over all basenames sharing md5 class
33
- class_last={}
34
- by_class={}
35
- for base,d in info.items():
36
- by_class.setdefault(d["md5"],[]).append(base)
37
- for md5,bases in by_class.items():
38
- cl=max((last_by_base.get(b,0) for b in bases), default=0)
39
- class_last[md5]=cl
40
- # recent renders to avoid (post 500..504)
41
- cur.execute("select post_number, source_clips from media_posts where post_number>=500 order by post_number desc")
42
- recent={}
43
- for pn,sc in cur.fetchall():
44
- recent[pn]=[os.path.basename(e.get("src","")) for e in (sc or [])]
45
- print("RECENT RENDERS 500..504:")
46
- for pn in sorted(recent,reverse=True): print(" ",pn,recent[pn])
47
- # candidate classes: 1080x1920, on-disk dur>=1.5, exclude tlh-505-* leftovers
48
- rows=[]
49
- seen_class=set()
50
- for md5,bases in by_class.items():
51
- bs=[b for b in bases if not b.startswith("tlh-505-")]
52
- if not bs: continue
53
- # representative: pick one 1080x1920 with dur>=1.5
54
- reps=[b for b in bs if info[b]["dims"]=="1080x1920" and info[b]["dur"]>=1.5]
55
- if not reps: continue
56
- rep=sorted(reps)[0]
57
- rows.append((class_last[md5], md5, rep, info[rep]["dur"], [b for b in bs]))
58
- rows.sort(key=lambda r:(r[0], r[2]))
59
- print("\nSTALEST 1080x1920 classes (dur>=1.5), by class_last asc:")
60
- for r in rows[:14]:
61
- print(f" class_last=post-{r[0]:>3} md5={r[1]} rep={r[2]:<14} dur={r[3]:.3f} bases={r[4]}")