@m13v/s4l 1.7.12-rc.5 → 1.7.12-rc.7
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 +114 -5
- package/mcp/dist/repo.js +65 -5
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/draft_prompt_core.py +15 -2
- package/scripts/dump_visitor_history.py +72 -0
- package/scripts/engage_github.py +25 -12
- package/scripts/invent_topics.py +10 -3
- package/scripts/pick_thread_target.py +4 -0
- package/scripts/pick_twitter_thread_target.py +4 -0
- package/scripts/project_topics.py +59 -1
- package/scripts/qualified_query_bank.py +53 -2
- package/scripts/reap_stale_claude_sessions.py +155 -4
- package/scripts/reddit_query_bank.py +42 -2
- package/scripts/scrape_linkedin_comment_stats.py +25 -7
- package/scripts/tlh_pick_504.py +115 -0
- package/scripts/tlh_pick_505.py +61 -0
- package/scripts/update_linkedin_stats_from_feed.py +20 -1
- package/skill/check-web-chats.sh +45 -0
- package/skill/run-twitter-threads.sh +51 -0
package/mcp/dist/index.js
CHANGED
|
@@ -5271,6 +5271,43 @@ function readPanelEndpoint() {
|
|
|
5271
5271
|
return null;
|
|
5272
5272
|
}
|
|
5273
5273
|
}
|
|
5274
|
+
// A live pid is not enough to trust panel-endpoint.json: the exit-hook unlink
|
|
5275
|
+
// never runs on SIGKILL, so the file can point at a dead pid that the OS later
|
|
5276
|
+
// RECYCLES for an unrelated process — kill(pid, 0) then reports "alive" and
|
|
5277
|
+
// every instance would cede forever (no panel owner, no heartbeat/relay owner)
|
|
5278
|
+
// until the squatter happens to exit. Accept the registration only when the
|
|
5279
|
+
// pid's command line actually looks like an S4L MCP server; on ps failure fall
|
|
5280
|
+
// back to liveness alone rather than stealing a possibly-valid slot.
|
|
5281
|
+
function isS4lServerPid(pid) {
|
|
5282
|
+
if (!isPidAlive(pid))
|
|
5283
|
+
return false;
|
|
5284
|
+
try {
|
|
5285
|
+
const cmd = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
|
|
5286
|
+
encoding: "utf-8",
|
|
5287
|
+
timeout: 4000,
|
|
5288
|
+
});
|
|
5289
|
+
return cmd.includes("dist/index.js") || cmd.includes("@m13v/s4l-mcp");
|
|
5290
|
+
}
|
|
5291
|
+
catch {
|
|
5292
|
+
return true;
|
|
5293
|
+
}
|
|
5294
|
+
}
|
|
5295
|
+
// True when this process should run per-INSTALL periodic work (heartbeat,
|
|
5296
|
+
// transcript/provider relays, state snapshots). Several server instances are
|
|
5297
|
+
// routinely alive at once (Desktop, Code side-panel, per-minute queue workers —
|
|
5298
|
+
// each boots its own copy via the ~/.claude.json registration), and before this
|
|
5299
|
+
// gate every one of them ran every timer: 16 concurrent instances meant 48
|
|
5300
|
+
// heartbeat python spawns per 15 min and a measured ~11k fs-events/sec from
|
|
5301
|
+
// duplicate relay scans. Reuse the panel-endpoint election writePanelUrl already
|
|
5302
|
+
// maintains: the registered live S4L-server pid is the designated worker; when
|
|
5303
|
+
// the file is absent, stale, or squatted, anyone may run the work (the 120s
|
|
5304
|
+
// reclaim tick will elect a new owner shortly).
|
|
5305
|
+
function isInstallTickOwner() {
|
|
5306
|
+
const existing = readPanelEndpoint();
|
|
5307
|
+
if (!existing?.pid || existing.pid === process.pid)
|
|
5308
|
+
return true;
|
|
5309
|
+
return !isS4lServerPid(existing.pid);
|
|
5310
|
+
}
|
|
5274
5311
|
// Publish the loopback URL to stable files so out-of-process readers can find
|
|
5275
5312
|
// the ephemeral port without scraping `lsof`:
|
|
5276
5313
|
// - panel-url plain text, for the Claude Code side-panel reverse proxy.
|
|
@@ -5295,7 +5332,7 @@ function writePanelUrl(url) {
|
|
|
5295
5332
|
fs.mkdirSync(dir, { recursive: true });
|
|
5296
5333
|
fs.writeFileSync(path.join(dir, "panel-url"), url, "utf-8");
|
|
5297
5334
|
const existing = readPanelEndpoint();
|
|
5298
|
-
if (existing?.pid && existing.pid !== process.pid &&
|
|
5335
|
+
if (existing?.pid && existing.pid !== process.pid && isS4lServerPid(existing.pid)) {
|
|
5299
5336
|
// Someone else already holds a live registration (most likely a longer-
|
|
5300
5337
|
// lived session than us) — don't clobber it. Our own panel is still up
|
|
5301
5338
|
// and fully usable via `url` for anything that already has it (e.g. this
|
|
@@ -6212,6 +6249,67 @@ async function main() {
|
|
|
6212
6249
|
const transport = new StdioServerTransport();
|
|
6213
6250
|
await server.connect(transport);
|
|
6214
6251
|
console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
|
|
6252
|
+
// Exit when the client goes away. The SDK's StdioServerTransport listens only
|
|
6253
|
+
// for stdin 'data'/'error' — never 'end'/'close' — so client death is invisible
|
|
6254
|
+
// to it, and the eagerly-started panel HTTP listener below holds the event loop
|
|
6255
|
+
// open forever. That combination orphaned 16 servers (33 GB RSS, duplicate
|
|
6256
|
+
// autopilot timers) on 2026-09-18: every client exit (clean close OR SIGKILL,
|
|
6257
|
+
// which just closes our stdin pipe) left a full server behind, reparented to
|
|
6258
|
+
// launchd. Handle EOF ourselves, plus signals, plus a ppid backstop for the
|
|
6259
|
+
// rare case where an inherited fd keeps the stdin pipe open past client death.
|
|
6260
|
+
let shutdownStarted = false;
|
|
6261
|
+
const exitNow = (reason) => {
|
|
6262
|
+
console.error(`[social-autoposter-mcp] client gone (${reason}); exiting`);
|
|
6263
|
+
try {
|
|
6264
|
+
localPanel?.server.close();
|
|
6265
|
+
}
|
|
6266
|
+
catch {
|
|
6267
|
+
/* best-effort */
|
|
6268
|
+
}
|
|
6269
|
+
process.exit(0);
|
|
6270
|
+
};
|
|
6271
|
+
const shutdown = (reason, force = false) => {
|
|
6272
|
+
if (shutdownStarted) {
|
|
6273
|
+
// A second signal while we wait out a drain means "stop now".
|
|
6274
|
+
if (force)
|
|
6275
|
+
exitNow(`${reason}, forced`);
|
|
6276
|
+
return;
|
|
6277
|
+
}
|
|
6278
|
+
shutdownStarted = true;
|
|
6279
|
+
if (!postingActive) {
|
|
6280
|
+
exitNow(reason);
|
|
6281
|
+
return;
|
|
6282
|
+
}
|
|
6283
|
+
// A posting drain is in flight: this process holds the shared
|
|
6284
|
+
// twitter-browser shell lock under its OWN pid while a python poster child
|
|
6285
|
+
// drives Chrome. Exiting now (a) lets the next per-minute cycle
|
|
6286
|
+
// stale-reclaim the lock mid-post and (b) crashes the poster on EPIPE
|
|
6287
|
+
// between a landed reply and its bookkeeping — the idempotent redrain then
|
|
6288
|
+
// double-posts. Wait for the drain to finish (the interval is deliberately
|
|
6289
|
+
// ref'd so it keeps the process alive), capped at the approve_drafts
|
|
6290
|
+
// worst-case timeout.
|
|
6291
|
+
console.error(`[social-autoposter-mcp] client gone (${reason}); waiting for in-flight posting drain before exit`);
|
|
6292
|
+
const started = Date.now();
|
|
6293
|
+
const wait = setInterval(() => {
|
|
6294
|
+
if (!postingActive || Date.now() - started > 2 * 3600_000) {
|
|
6295
|
+
clearInterval(wait);
|
|
6296
|
+
exitNow(`${reason}, drain done`);
|
|
6297
|
+
}
|
|
6298
|
+
}, 5_000);
|
|
6299
|
+
};
|
|
6300
|
+
process.stdin.on("end", () => shutdown("stdin end"));
|
|
6301
|
+
process.stdin.on("close", () => shutdown("stdin close"));
|
|
6302
|
+
process.on("SIGTERM", () => shutdown("SIGTERM", true));
|
|
6303
|
+
process.on("SIGINT", () => shutdown("SIGINT", true));
|
|
6304
|
+
// Skip the backstop when we were BORN with ppid 1 (a launchd-managed server is
|
|
6305
|
+
// legitimately parented to launchd; only a reparent-after-birth means orphaned).
|
|
6306
|
+
if (process.ppid !== 1) {
|
|
6307
|
+
const ppidWatch = setInterval(() => {
|
|
6308
|
+
if (process.ppid === 1)
|
|
6309
|
+
shutdown("orphaned (ppid=1)");
|
|
6310
|
+
}, 30_000);
|
|
6311
|
+
ppidWatch.unref();
|
|
6312
|
+
}
|
|
6215
6313
|
// Eagerly start the loopback panel server so the Claude Code side panel (and any
|
|
6216
6314
|
// reverse proxy in front of it) always has a backend to hit, without waiting for
|
|
6217
6315
|
// a first `dashboard` call. Best-effort: a bind failure must never block boot.
|
|
@@ -6245,8 +6343,16 @@ async function main() {
|
|
|
6245
6343
|
// (parity with the npx launchd heartbeat). Once on startup, then every 15m
|
|
6246
6344
|
// while the desktop app keeps the server alive. unref() so it never holds the
|
|
6247
6345
|
// process open past a normal exit.
|
|
6346
|
+
// Heartbeat + the relays + snapshots below are per-INSTALL work: gate every
|
|
6347
|
+
// tick on the panel-endpoint election (isInstallTickOwner) so N concurrent
|
|
6348
|
+
// instances produce 1x the spawns, not Nx. The startup heartbeat stays
|
|
6349
|
+
// unconditional — a fresh install must phone home before it ever wins an
|
|
6350
|
+
// election.
|
|
6248
6351
|
void sendHeartbeat("startup");
|
|
6249
|
-
const hb = setInterval(() =>
|
|
6352
|
+
const hb = setInterval(() => {
|
|
6353
|
+
if (isInstallTickOwner())
|
|
6354
|
+
void sendHeartbeat("interval");
|
|
6355
|
+
}, 15 * 60_000);
|
|
6250
6356
|
hb.unref();
|
|
6251
6357
|
// Ship Claude session transcripts (scheduled queue-worker runs + s4l repo
|
|
6252
6358
|
// sessions) to the Cloud Logging relay so a user's session can be
|
|
@@ -6257,7 +6363,7 @@ async function main() {
|
|
|
6257
6363
|
if ((process.env.S4L_TRANSCRIPT_RELAY ?? "1") !== "0") {
|
|
6258
6364
|
let transcriptRelayRunning = false;
|
|
6259
6365
|
const relayTranscripts = () => {
|
|
6260
|
-
if (transcriptRelayRunning)
|
|
6366
|
+
if (transcriptRelayRunning || !isInstallTickOwner())
|
|
6261
6367
|
return;
|
|
6262
6368
|
transcriptRelayRunning = true;
|
|
6263
6369
|
runPython("scripts/relay_session_transcripts.py", ["--max-lines", "600"], {
|
|
@@ -6283,7 +6389,7 @@ async function main() {
|
|
|
6283
6389
|
if ((process.env.S4L_PROVIDER_LOG_RELAY ?? "1") !== "0") {
|
|
6284
6390
|
let providerRelayRunning = false;
|
|
6285
6391
|
const relayProviderLog = () => {
|
|
6286
|
-
if (providerRelayRunning)
|
|
6392
|
+
if (providerRelayRunning || !isInstallTickOwner())
|
|
6287
6393
|
return;
|
|
6288
6394
|
providerRelayRunning = true;
|
|
6289
6395
|
runPython("scripts/relay_provider_log.py", ["--max-lines", "500"], {
|
|
@@ -6306,7 +6412,10 @@ async function main() {
|
|
|
6306
6412
|
// the recurring tick only POSTs when something actually changed; setup.ts
|
|
6307
6413
|
// additionally fires it right after every config write.
|
|
6308
6414
|
void sendStateSnapshot("startup");
|
|
6309
|
-
const ss = setInterval(() =>
|
|
6415
|
+
const ss = setInterval(() => {
|
|
6416
|
+
if (isInstallTickOwner())
|
|
6417
|
+
void sendStateSnapshot("interval");
|
|
6418
|
+
}, 15 * 60_000);
|
|
6310
6419
|
ss.unref();
|
|
6311
6420
|
// Voice-exemplar catch-up + periodic refresh, checked on every boot
|
|
6312
6421
|
// (2026-07-16, extended from the original one-shot-for-legacy-installs
|
package/mcp/dist/repo.js
CHANGED
|
@@ -85,6 +85,21 @@ export function run(cmd, args, opts = {}) {
|
|
|
85
85
|
}
|
|
86
86
|
let stdout = "";
|
|
87
87
|
let stderr = "";
|
|
88
|
+
// Cap captured output to a tail. run() used to accumulate a child's full
|
|
89
|
+
// stdout+stderr unbounded for the child's lifetime; long-lived spawns (the
|
|
90
|
+
// 13h voice backfill) and stuck children pinned those strings and were the
|
|
91
|
+
// main driver of multi-GB server heaps. Callers that parse output read
|
|
92
|
+
// either whole small payloads or the LAST line, so keeping the tail
|
|
93
|
+
// preserves semantics. 8 MB per stream is far above any legitimate payload.
|
|
94
|
+
const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
|
|
95
|
+
const capTail = (s) => s.length > MAX_CAPTURE_BYTES ? s.slice(s.length - (MAX_CAPTURE_BYTES >> 1)) : s;
|
|
96
|
+
// The line-splitter buffers below only shed at '\n', so a child emitting
|
|
97
|
+
// long newline-free output (carriage-return progress bars, one huge line)
|
|
98
|
+
// would grow them unbounded for its lifetime — the same heap pathology the
|
|
99
|
+
// capture cap exists for. Keep only a tail of an oversized partial line
|
|
100
|
+
// (telemetry truncates lines to 8KB anyway).
|
|
101
|
+
const MAX_LINEBUF_BYTES = 1024 * 1024;
|
|
102
|
+
const capLineBuf = (buf) => buf.length > MAX_LINEBUF_BYTES ? buf.slice(-8192) : buf;
|
|
88
103
|
// Per-stream partial-line buffers so onLine fires on whole lines only,
|
|
89
104
|
// regardless of how the OS chunks the pipe reads.
|
|
90
105
|
let outBuf = "";
|
|
@@ -104,7 +119,7 @@ export function run(cmd, args, opts = {}) {
|
|
|
104
119
|
/* a progress sink must never break the wrapped command */
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
|
-
return buf;
|
|
122
|
+
return capLineBuf(buf);
|
|
108
123
|
};
|
|
109
124
|
// Parallel whole-line splitter that tees to the telemetry sink (if any),
|
|
110
125
|
// kept separate from the onLine pump so neither path can affect the other.
|
|
@@ -127,29 +142,56 @@ export function run(cmd, args, opts = {}) {
|
|
|
127
142
|
/* the telemetry sink must never break the wrapped command */
|
|
128
143
|
}
|
|
129
144
|
}
|
|
130
|
-
return buf;
|
|
145
|
+
return capLineBuf(buf);
|
|
131
146
|
};
|
|
147
|
+
let settled = false;
|
|
132
148
|
let timer;
|
|
149
|
+
let killTimer;
|
|
133
150
|
if (opts.timeoutMs) {
|
|
134
151
|
timer = setTimeout(() => {
|
|
135
152
|
child.kill("SIGTERM");
|
|
153
|
+
// A child that ignores SIGTERM used to live (and hold its pipes and our
|
|
154
|
+
// buffers) forever. Escalate — but GENEROUSLY: children trap SIGTERM on
|
|
155
|
+
// purpose to finish critical bookkeeping (twitter_post_plan.py finishes
|
|
156
|
+
// the current candidate's post + recording, which can take minutes, so a
|
|
157
|
+
// fast SIGKILL would reopen the posted-but-unrecorded double-post
|
|
158
|
+
// window). 15 min covers every graceful path; only a truly wedged child
|
|
159
|
+
// gets hard-killed.
|
|
160
|
+
killTimer = setTimeout(() => {
|
|
161
|
+
try {
|
|
162
|
+
child.kill("SIGKILL");
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* already gone */
|
|
166
|
+
}
|
|
167
|
+
}, 15 * 60_000);
|
|
168
|
+
killTimer.unref();
|
|
136
169
|
}, opts.timeoutMs);
|
|
137
170
|
}
|
|
138
171
|
child.stdout.on("data", (d) => {
|
|
172
|
+
if (settled)
|
|
173
|
+
return;
|
|
139
174
|
const s = d.toString();
|
|
140
|
-
stdout
|
|
175
|
+
stdout = capTail(stdout + s);
|
|
141
176
|
outBuf = pump(s, "stdout", outBuf);
|
|
142
177
|
sinkOutBuf = sinkPump(s, "stdout", sinkOutBuf);
|
|
143
178
|
});
|
|
144
179
|
child.stderr.on("data", (d) => {
|
|
180
|
+
if (settled)
|
|
181
|
+
return;
|
|
145
182
|
const s = d.toString();
|
|
146
|
-
stderr
|
|
183
|
+
stderr = capTail(stderr + s);
|
|
147
184
|
errBuf = pump(s, "stderr", errBuf);
|
|
148
185
|
sinkErrBuf = sinkPump(s, "stderr", sinkErrBuf);
|
|
149
186
|
});
|
|
150
|
-
|
|
187
|
+
const finish = (code) => {
|
|
188
|
+
if (settled)
|
|
189
|
+
return;
|
|
190
|
+
settled = true;
|
|
151
191
|
if (timer)
|
|
152
192
|
clearTimeout(timer);
|
|
193
|
+
if (killTimer)
|
|
194
|
+
clearTimeout(killTimer);
|
|
153
195
|
// Flush any trailing partial line (output with no terminating newline).
|
|
154
196
|
if (opts.onLine) {
|
|
155
197
|
if (outBuf)
|
|
@@ -185,10 +227,28 @@ export function run(cmd, args, opts = {}) {
|
|
|
185
227
|
}
|
|
186
228
|
}
|
|
187
229
|
resolve({ code: code ?? -1, stdout, stderr });
|
|
230
|
+
};
|
|
231
|
+
child.on("close", (code) => finish(code));
|
|
232
|
+
child.on("exit", (code) => {
|
|
233
|
+
// 'close' waits for the stdio pipes to drain, which never happens when a
|
|
234
|
+
// grandchild inherited them and outlives the child — the promise (and the
|
|
235
|
+
// captured output) then leaked for the grandchild's lifetime. After the
|
|
236
|
+
// child itself exits, give the pipes a grace to flush, then settle. The
|
|
237
|
+
// setImmediate hop matters: if the event loop was stalled past the grace
|
|
238
|
+
// by sync work, timers fire BEFORE the poll phase reads the data already
|
|
239
|
+
// sitting in the kernel buffer — hopping to the check phase lets those
|
|
240
|
+
// reads land first instead of truncating a successful child's output.
|
|
241
|
+
const drain = setTimeout(() => setImmediate(() => finish(code)), 10_000);
|
|
242
|
+
drain.unref();
|
|
188
243
|
});
|
|
189
244
|
child.on("error", (err) => {
|
|
245
|
+
if (settled)
|
|
246
|
+
return;
|
|
247
|
+
settled = true;
|
|
190
248
|
if (timer)
|
|
191
249
|
clearTimeout(timer);
|
|
250
|
+
if (killTimer)
|
|
251
|
+
clearTimeout(killTimer);
|
|
192
252
|
resolve({ code: -1, stdout, stderr: stderr + String(err) });
|
|
193
253
|
});
|
|
194
254
|
});
|
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.7",
|
|
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.7",
|
|
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
|
@@ -301,10 +301,19 @@ def _load_config():
|
|
|
301
301
|
def projects_json(lane=None, only_project=None):
|
|
302
302
|
"""The ALL_PROJECTS_JSON block: per-project drafting config as a JSON
|
|
303
303
|
string. lane='personal_brand' emits ONLY the persona project through the
|
|
304
|
-
whitelist; otherwise every project through the ops denylist.
|
|
304
|
+
whitelist; otherwise every ROUTABLE project through the ops denylist.
|
|
305
305
|
only_project (reddit lane: one project per draft call) narrows the
|
|
306
306
|
promotion output to that single project's entry. Returns '{}' on any
|
|
307
|
-
failure, mirroring the shell's `|| echo "{}"`.
|
|
307
|
+
failure, mirroring the shell's `|| echo "{}"`.
|
|
308
|
+
|
|
309
|
+
Routable (2026-09-23): the promotion roster excludes persona entries and
|
|
310
|
+
archived projects (enabled: false). ALL_PROJECTS_JSON doubles as the prep
|
|
311
|
+
step's PROJECT ROUTING target list ("...unless the thread content clearly
|
|
312
|
+
better fits another project"), so an unfiltered roster let a promotion
|
|
313
|
+
cycle re-route a candidate onto the persona while the personal_brand lane
|
|
314
|
+
was switched OFF (observed 2026-09-23: a promotion-lane card posted as
|
|
315
|
+
PersonalBrand), or onto an archived project. The persona is reachable
|
|
316
|
+
ONLY via the personal_brand lane's whitelist branch above."""
|
|
308
317
|
lane = lane if lane is not None else os.environ.get("S4L_ACTIVE_LANE", "")
|
|
309
318
|
try:
|
|
310
319
|
config = _load_config()
|
|
@@ -319,6 +328,10 @@ def projects_json(lane=None, only_project=None):
|
|
|
319
328
|
for p in projects:
|
|
320
329
|
if only_project and p.get("name") != only_project:
|
|
321
330
|
continue
|
|
331
|
+
if not only_project and (
|
|
332
|
+
p.get("persona") is True or p.get("enabled", True) is False
|
|
333
|
+
):
|
|
334
|
+
continue
|
|
322
335
|
out[p["name"]] = {k: v for k, v in p.items() if k not in _OPS_KEYS}
|
|
323
336
|
return json.dumps(out, indent=2)
|
|
324
337
|
except Exception:
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dump a visitor's cross-thread history as JSON for prompt injection.
|
|
3
|
+
|
|
4
|
+
Gives the web-chat agent memory of a returning visitor across ALL their
|
|
5
|
+
threads, so it never goes cold ("nothing like that on our side") on a known
|
|
6
|
+
user just because they opened a fresh thread. Reads:
|
|
7
|
+
|
|
8
|
+
GET /api/v1/web-chat/visitor?email=<email>&exclude=<thread>&limit=N
|
|
9
|
+
|
|
10
|
+
Used by skill/check-web-chats.sh alongside dump_web_chat_history.py:
|
|
11
|
+
|
|
12
|
+
$(python3 scripts/dump_visitor_history.py --email "$EMAIL" --exclude "$THREAD_ID")
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
{
|
|
16
|
+
"visitor_email": ..., "thread_count": N, "excluded_thread": ...,
|
|
17
|
+
"threads": [{ thread_id, project, page_url, created_at, last_message_at }, ...],
|
|
18
|
+
"recent_messages": [{ thread_id, sender, sender_name, text, created_at }, ...]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
On any error (no email, thread-only visitor, endpoint 404) it prints an empty
|
|
22
|
+
skeleton and exits 0, so the caller can always inline the output safely.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
from http_api import api_get
|
|
32
|
+
|
|
33
|
+
EMPTY = {"visitor_email": "", "thread_count": 0, "threads": [], "recent_messages": []}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
parser = argparse.ArgumentParser()
|
|
38
|
+
parser.add_argument("--email", required=True)
|
|
39
|
+
parser.add_argument("--exclude", default="", help="thread_id to leave out (the current one)")
|
|
40
|
+
parser.add_argument("--project", default="")
|
|
41
|
+
parser.add_argument("--limit", type=int, default=120)
|
|
42
|
+
args = parser.parse_args()
|
|
43
|
+
|
|
44
|
+
email = (args.email or "").strip()
|
|
45
|
+
if not email or "@" not in email:
|
|
46
|
+
print(json.dumps(EMPTY))
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
query = {"email": email, "limit": args.limit}
|
|
50
|
+
if args.exclude:
|
|
51
|
+
query["exclude"] = args.exclude
|
|
52
|
+
if args.project:
|
|
53
|
+
query["project"] = args.project
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
resp = api_get("/api/v1/web-chat/visitor", query=query, ok_on_404=True)
|
|
57
|
+
except Exception as e: # never let a memory lookup crash the spawn
|
|
58
|
+
print(json.dumps({**EMPTY, "visitor_email": email, "_error": str(e)}))
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
if resp.get("_not_found"):
|
|
62
|
+
print(json.dumps({**EMPTY, "visitor_email": email}))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
out = resp.get("data") or {}
|
|
66
|
+
if not out:
|
|
67
|
+
out = {**EMPTY, "visitor_email": email}
|
|
68
|
+
print(json.dumps(out, indent=2))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
package/scripts/engage_github.py
CHANGED
|
@@ -270,19 +270,32 @@ Read the FULL thread above. There is NO cap on how many times we can reply to a
|
|
|
270
270
|
|
|
271
271
|
CHECK ESCALATION FIRST. Escalation hands the thread to the human who owns
|
|
272
272
|
this account; they answer by email and their answer is posted as the
|
|
273
|
-
comment.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
273
|
+
comment. Escalation is EXPENSIVE (a human inbox interrupt), so investigate
|
|
274
|
+
the thread and decide case by case: escalate ONLY when a well-written
|
|
275
|
+
generated reply cannot adequately serve because the comment needs the
|
|
276
|
+
human's OWN voice, knowledge, or commitment. That means one of:
|
|
277
|
+
- They ask our author something only the human can truthfully answer:
|
|
278
|
+
personal history or experience ("how did you come across this
|
|
279
|
+
project?"), intentions, commitments, or private context.
|
|
280
|
+
- They directly ask our author to explain or justify something WE said
|
|
281
|
+
and the subtext is confusion or pushback about our comment ("@{our_username}
|
|
282
|
+
could you expand on what you mean?", "who is that comment for?").
|
|
283
|
+
Getting the clarification wrong would embarrass us; the human answers.
|
|
284
|
+
- They report a bug, breakage, or defect in one of OUR products
|
|
280
285
|
(anything in config.json). Bug reports need the maker's eyes.
|
|
281
|
-
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
+
- A genuine relationship moment with the repo owner/maintainer: strong
|
|
287
|
+
praise of our contribution, an invitation to collaborate or contribute,
|
|
288
|
+
or an explicit request for a decision from us.
|
|
289
|
+
NOT grounds for escalation on their own (2026-09-04 recalibration; the
|
|
290
|
+
blanket "owner engaging seriously" rule flooded the human's inbox):
|
|
291
|
+
- The repo owner merely engaging seriously: agreeing with us, clarifying
|
|
292
|
+
scope, posting design decisions, or announcing a merge/close. Write a
|
|
293
|
+
substantive reply (or skip if concluded) instead.
|
|
294
|
+
- Thread activity that is weeks old: a personal email answer to a stale
|
|
295
|
+
comment lands worse than a normal reply. Prefer reply or skip.
|
|
296
|
+
When you do escalate, the reason line MUST say what the human uniquely
|
|
297
|
+
adds that a generated reply cannot. Do not draft a reply text for an
|
|
298
|
+
escalated comment.
|
|
286
299
|
|
|
287
300
|
Otherwise, DEFAULT TO REPLY when you have substance. Lean toward engagement, not silence.
|
|
288
301
|
|
package/scripts/invent_topics.py
CHANGED
|
@@ -189,20 +189,27 @@ def load_project_topics(project_name: str,
|
|
|
189
189
|
|
|
190
190
|
|
|
191
191
|
def project_universe_strings(project_name: str) -> set[str]:
|
|
192
|
-
"""Full
|
|
192
|
+
"""Full universe for the project from project_search_topics, EVERY status.
|
|
193
193
|
|
|
194
194
|
Read from /api/v1/project-search-topics for the freshest read at
|
|
195
195
|
invent time. Lowercased for case-insensitive matching against
|
|
196
196
|
proposals.
|
|
197
|
+
|
|
198
|
+
status=all (2026-09-22): the dedup/avoid set must include paused and
|
|
199
|
+
excluded topics, not just active ones. A paused topic absent from the
|
|
200
|
+
universe would be re-proposed as "new" and the validate_proposals upsert
|
|
201
|
+
(status='active') would silently resurrect it, undoing a deliberate
|
|
202
|
+
repositioning. Paused topics in the prompt's covered list are equally
|
|
203
|
+
correct: the model must not re-propose them.
|
|
197
204
|
"""
|
|
198
205
|
try:
|
|
199
206
|
resp = api_get(
|
|
200
207
|
"/api/v1/project-search-topics",
|
|
201
|
-
query={"project": project_name, "status": "
|
|
208
|
+
query={"project": project_name, "status": "all"},
|
|
202
209
|
)
|
|
203
210
|
except Exception as exc:
|
|
204
211
|
raise SystemExit(
|
|
205
|
-
f"could not fetch
|
|
212
|
+
f"could not fetch topic universe for project={project_name!r}: {exc}"
|
|
206
213
|
) from exc
|
|
207
214
|
rows = ((resp or {}).get("data") or {}).get("topics") or []
|
|
208
215
|
return {(r.get("topic") or "").strip().lower() for r in rows if r.get("topic")}
|
|
@@ -215,6 +215,10 @@ def build_candidates(config):
|
|
|
215
215
|
thread_blocked = load_thread_blocked_subs(config)
|
|
216
216
|
candidates = []
|
|
217
217
|
for p in config.get("projects", []):
|
|
218
|
+
# Project-level archive flag wins over the per-lane flag: an archived
|
|
219
|
+
# project (enabled: false) must not post from ANY lane.
|
|
220
|
+
if p.get("enabled", True) is False:
|
|
221
|
+
continue
|
|
218
222
|
t = p.get("threads") or {}
|
|
219
223
|
if not t.get("enabled"):
|
|
220
224
|
continue
|
|
@@ -97,6 +97,10 @@ def angle_recency(project_recents, angle_text):
|
|
|
97
97
|
def build_candidates(config, project_recents):
|
|
98
98
|
candidates = [] # (project_dict, angle_text, floor_days, last_used_days_ago_or_None)
|
|
99
99
|
for p in config.get("projects", []):
|
|
100
|
+
# Project-level archive flag wins over the per-lane flag: an archived
|
|
101
|
+
# project (enabled: false) must not post from ANY lane.
|
|
102
|
+
if p.get("enabled", True) is False:
|
|
103
|
+
continue
|
|
100
104
|
tt = p.get("twitter_threads") or {}
|
|
101
105
|
if not tt.get("enabled"):
|
|
102
106
|
continue
|
|
@@ -156,13 +156,69 @@ def _bootstrap_from_config(name: str) -> int:
|
|
|
156
156
|
return seeded
|
|
157
157
|
|
|
158
158
|
|
|
159
|
+
def _sync_new_config_topics(name: str, active: List[str]) -> List[str]:
|
|
160
|
+
"""Additive config->DB sync (2026-09-22). Before this, config.json
|
|
161
|
+
search_topics only reached the DB on the zero-active-rows first bootstrap;
|
|
162
|
+
topics ADDED to config later were silently ignored forever (this is how
|
|
163
|
+
Sundial's 09-11 math seeds never ran). Now any config topic the DB has
|
|
164
|
+
NEVER seen (in any status) is inserted as source='seed'/active on read.
|
|
165
|
+
|
|
166
|
+
Paused/excluded rows stay authoritative: a topic that still lingers in
|
|
167
|
+
config but was deliberately paused in the DB is known (status=all check)
|
|
168
|
+
and is NOT re-inserted or reactivated. Costs nothing in steady state: the
|
|
169
|
+
status=all read fires only when config carries a topic missing from the
|
|
170
|
+
active set. Returns the newly inserted topics ([] on any failure)."""
|
|
171
|
+
if os.environ.get("S4L_NO_TOPIC_AUTOSEED") == "1":
|
|
172
|
+
return []
|
|
173
|
+
missing = [t for t in _config_topics_for(name) if t not in set(active)]
|
|
174
|
+
if not missing:
|
|
175
|
+
return []
|
|
176
|
+
try:
|
|
177
|
+
from http_api import api_get, api_post
|
|
178
|
+
resp = api_get(
|
|
179
|
+
"/api/v1/project-search-topics",
|
|
180
|
+
query={"project": name, "status": "all"},
|
|
181
|
+
)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
sys.stderr.write(
|
|
184
|
+
f"[project_topics] config-sync skipped project={name!r}: {e}\n"
|
|
185
|
+
)
|
|
186
|
+
return []
|
|
187
|
+
rows = ((resp or {}).get("data") or {}).get("topics") or []
|
|
188
|
+
known = {(r.get("topic") or "").strip() for r in rows}
|
|
189
|
+
inserted: List[str] = []
|
|
190
|
+
for topic in missing:
|
|
191
|
+
if topic in known:
|
|
192
|
+
continue # exists paused/excluded (or raced in): leave it alone
|
|
193
|
+
try:
|
|
194
|
+
api_post(
|
|
195
|
+
"/api/v1/project-search-topics",
|
|
196
|
+
body={"project": name, "topic": topic,
|
|
197
|
+
"source": "seed", "status": "active",
|
|
198
|
+
"notes": "config->DB additive sync"},
|
|
199
|
+
)
|
|
200
|
+
inserted.append(topic)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
sys.stderr.write(
|
|
203
|
+
f"[project_topics] config-sync FAILED project={name!r} "
|
|
204
|
+
f"topic={topic!r}: {e}\n"
|
|
205
|
+
)
|
|
206
|
+
if inserted:
|
|
207
|
+
sys.stderr.write(
|
|
208
|
+
f"[project_topics] config-sync added {len(inserted)} new topic(s) "
|
|
209
|
+
f"for project={name!r}: {inserted}\n"
|
|
210
|
+
)
|
|
211
|
+
return inserted
|
|
212
|
+
|
|
213
|
+
|
|
159
214
|
def topics_for_project(name: str) -> List[str]:
|
|
160
215
|
"""Active topics for one project (DB-backed, process-cached, self-healing).
|
|
161
216
|
|
|
162
217
|
On the first read where the DB has no active topics but config.json carries
|
|
163
218
|
a search_topics[] seed, the seed is mirrored into the DB once and re-read,
|
|
164
219
|
so adding a project to config.json is enough to make it run. After that the
|
|
165
|
-
DB is the
|
|
220
|
+
DB is the living source of truth for topic STATUS (pausing/excluding), while
|
|
221
|
+
topics newly ADDED to config keep flowing in via _sync_new_config_topics."""
|
|
166
222
|
if not name:
|
|
167
223
|
return []
|
|
168
224
|
key = name.strip()
|
|
@@ -173,6 +229,8 @@ def topics_for_project(name: str) -> List[str]:
|
|
|
173
229
|
_BOOTSTRAP_ATTEMPTED.add(key)
|
|
174
230
|
if _bootstrap_from_config(key) > 0:
|
|
175
231
|
topics = _fetch_active_topics(key)
|
|
232
|
+
else:
|
|
233
|
+
topics = topics + _sync_new_config_topics(key, topics)
|
|
176
234
|
_CACHE[key] = topics
|
|
177
235
|
return topics
|
|
178
236
|
|