@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 +41 -18
- package/mcp/dist/setup.js +35 -28
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +18 -1
- package/scripts/angles.py +8 -0
- package/scripts/claude_job.py +13 -29
- package/scripts/config.py +51 -3
- package/scripts/post_reddit.py +40 -43
- package/scripts/reddit_ban_check.py +28 -28
- package/scripts/voice_exemplars.py +8 -3
- package/scripts/tlh_freshness_284.py +0 -134
- package/scripts/tlh_pick_248.py +0 -92
- package/scripts/tlh_pick_504.py +0 -115
- package/scripts/tlh_pick_505.py +0 -61
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 =
|
|
3836
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
3987
|
-
`other tool, or trying to
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4021
|
-
`reason, then Ctrl-D) and
|
|
4022
|
-
`4. Report in ONE short line what you did
|
|
4023
|
-
`do NOT loop, do NOT read other files
|
|
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)
|
|
4029
|
-
`
|
|
4030
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.12-rc.
|
|
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.
|
|
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.
|
|
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()])
|
package/scripts/claude_job.py
CHANGED
|
@@ -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
|
|
1307
|
-
exact process the stale-session reaper (reap_stale_claude_sessions.py)
|
|
1308
|
-
target.
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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;
|
package/scripts/post_reddit.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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",
|
|
812
|
+
_mirror_ban_to_backend("thread_blocked", changed[1])
|
|
799
813
|
else:
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
blocked
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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()
|
package/scripts/tlh_pick_248.py
DELETED
|
@@ -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])
|
package/scripts/tlh_pick_504.py
DELETED
|
@@ -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)
|
package/scripts/tlh_pick_505.py
DELETED
|
@@ -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]}")
|