@m13v/s4l 1.7.1-rc.2 → 1.7.1-rc.21
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/bin/cli.js +23 -0
- package/mcp/dist/index.js +332 -24
- package/mcp/dist/onboarding.js +3 -10
- package/mcp/dist/panel.html +42 -31
- package/mcp/dist/product-link.html +1 -1
- package/mcp/dist/runtime.js +101 -1
- package/mcp/dist/telemetry.js +80 -3
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +9 -1
- package/mcp/menubar/dashboard_server.py +12 -0
- package/mcp/menubar/s4l_card.py +472 -96
- package/mcp/menubar/s4l_menubar.py +269 -26
- package/mcp/menubar/s4l_state.py +169 -23
- package/mcp/package.json +1 -1
- package/mcp/shared/doctor.cjs +8 -0
- package/mcp/shared/onboarding-ledger.cjs +1 -0
- package/package.json +3 -1
- package/scripts/active_users.py +3 -6
- package/scripts/autopilot_stall_watch.py +200 -16
- package/scripts/claude_job.py +6 -0
- package/scripts/feedback_digest.py +34 -6
- package/scripts/get-latest-staging-mcpb.sh +50 -0
- package/scripts/harness_overlay.py +45 -0
- package/scripts/identity.py +26 -0
- package/scripts/learned_preferences.py +241 -32
- package/scripts/link_tail.py +167 -91
- package/scripts/mark_event.py +128 -0
- package/scripts/memory_snapshot.py +62 -0
- package/scripts/pick_twitter_thread_target.py +1 -1
- package/scripts/release-mcpb.sh +48 -0
- package/scripts/reset-test-machine.sh +85 -3
- package/scripts/s4l_mode.py +7 -5
- package/scripts/scheduled_tasks_snapshot.py +107 -0
- package/scripts/send_gmail_report.py +76 -0
- package/scripts/sentry_digest.py +81 -189
- package/scripts/sentry_init.py +43 -15
- package/scripts/setup_twitter_auth.py +76 -38
- package/scripts/snapshot.py +20 -0
- package/scripts/twitter_post_plan.py +39 -13
- package/skill/dm-outreach-twitter.sh +19 -0
- package/skill/engage-twitter.sh +27 -0
- package/skill/run-draft-and-publish.sh +13 -12
- package/skill/run-linkedin.sh +9 -1
- package/skill/run-twitter-cycle.sh +199 -27
- package/skill/sentry-digest.sh +125 -12
|
@@ -2,18 +2,28 @@
|
|
|
2
2
|
"""Feedback digest: distill human card decisions into learned_preferences.
|
|
3
3
|
|
|
4
4
|
The scheduled half of the review-events feedback loop (see
|
|
5
|
-
scripts/learned_preferences.py for the full loop).
|
|
5
|
+
scripts/learned_preferences.py for the full loop). learned_preferences is a
|
|
6
|
+
SINGLE install-wide block (2026-07-08), not one per project: events are
|
|
7
|
+
still fetched and prompted per project (that's where the candidates/cards
|
|
8
|
+
came from), but every project's digest writes into the same shared block, on
|
|
9
|
+
the theory that what a reviewer is actually correcting (voice, quality bar,
|
|
10
|
+
author-quality signals) is a fact about them, not about one product's
|
|
11
|
+
audience. main() runs learned_preferences.migrate_to_global() once per tick
|
|
12
|
+
(idempotent) to fold any pre-existing per-project blocks into the shared one.
|
|
13
|
+
|
|
14
|
+
Per run:
|
|
6
15
|
|
|
7
16
|
1. GET /api/v1/review-events?counts=true — which (project, platform) pairs
|
|
8
17
|
have unprocessed events. The API scopes to this installation, so a
|
|
9
18
|
customer box only ever digests its own user's decisions.
|
|
10
19
|
2. For each project that exists in the local config.json: fetch the
|
|
11
|
-
unprocessed events, build a conservative digest prompt (current
|
|
12
|
-
events + approval counter-evidence), run Claude headless via
|
|
20
|
+
unprocessed events, build a conservative digest prompt (current shared
|
|
21
|
+
block + events + approval counter-evidence), run Claude headless via
|
|
13
22
|
run_claude.sh (script_tag feedback-digest, cost-tracked like every other
|
|
14
23
|
pipeline Claude call).
|
|
15
24
|
3. Apply the returned mutation plan through
|
|
16
|
-
learned_preferences.apply_mutations() (whitelist, flock, backup, atomic)
|
|
25
|
+
learned_preferences.apply_mutations() (whitelist, flock, backup, atomic)
|
|
26
|
+
into the shared block.
|
|
17
27
|
4. PATCH the events processed (processed_batch=digest-<ts>) so they are
|
|
18
28
|
never digested twice. Events are marked processed even when the plan is
|
|
19
29
|
"no changes" — a considered no-op is a completed digestion, not a retry.
|
|
@@ -180,9 +190,11 @@ def build_prompt(project: dict, events: list[dict], overall_events: list[dict] |
|
|
|
180
190
|
f"{notes}"
|
|
181
191
|
)
|
|
182
192
|
|
|
183
|
-
return f"""You maintain the learned_preferences block for
|
|
193
|
+
return f"""You maintain the SINGLE, install-wide learned_preferences block for this social-posting pipeline (shared across every configured project, not just "{project.get('name')}"). It distills the user's own approve/reject decisions on draft cards into short standing preferences that steer future thread selection and drafting across ALL projects. It is SOFT guidance read by the drafting model, not a filter.
|
|
194
|
+
|
|
195
|
+
The events below all happened to come from project "{project.get('name')}"'s cards, but since the block is shared, only propose an entry that generalizes to a human reviewer's standing taste or quality bar (voice, tone, structural habits, author-quality signals, what counts as a good vs bad reply) — NOT something that is true only because of this one product's specific audience, niche, or content angle (e.g. "prefers accounts studying for nursing boards" belongs to one product's ICP, not to every project this reviewer runs). When the evidence is really project-specific, prefer NO change over forcing it into the shared block.
|
|
184
196
|
|
|
185
|
-
CURRENT learned_preferences:
|
|
197
|
+
CURRENT learned_preferences (shared by every project):
|
|
186
198
|
{json.dumps({k: block[k] for k in ("audience_avoid", "audience_prefer", "thread_avoid", "draft_style_notes")}, indent=2)}
|
|
187
199
|
|
|
188
200
|
CURRENT voice.never: {json.dumps(voice_never)}
|
|
@@ -203,6 +215,7 @@ Propose changes to the block. RULES, in priority order:
|
|
|
203
215
|
5. bad_draft events feed draft_style_notes (or, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
|
|
204
216
|
6. Each entry: one sentence, under 200 characters, plain language, no em dashes, no hashtags, understandable a month from now without these events.
|
|
205
217
|
7. Respect the cap: at most {lp.MAX_ENTRIES_PER_LIST} entries per list. If a list is full, fold the new signal into an existing entry via remove+add.
|
|
218
|
+
8. Scope check (this block is shared across every project): before proposing an entry, ask whether it would still make sense read from a totally different product's cards. If it only makes sense for "{project.get('name')}" specifically, don't add it.
|
|
206
219
|
|
|
207
220
|
OUTPUT: a single JSON object, nothing else. Schema:
|
|
208
221
|
{{"changes": {{"audience_avoid": {{"add": [], "remove": []}}, "audience_prefer": {{"add": [], "remove": []}}, "thread_avoid": {{"add": [], "remove": []}}, "draft_style_notes": {{"add": [], "remove": []}}}}, "voice_never_add": [], "guardrails_do_not_add": [], "block_authors": [{{"handle": "somehandle", "reason": "one short sentence citing the evidence"}}], "rationale": "one short sentence"}}
|
|
@@ -463,6 +476,21 @@ def main() -> int:
|
|
|
463
476
|
log("another digest run holds the lock; exiting")
|
|
464
477
|
return 0
|
|
465
478
|
|
|
479
|
+
# One-time, idempotent move from per-project learned_preferences to the
|
|
480
|
+
# single learned_preferences_global block (2026-07-08). Runs on every
|
|
481
|
+
# digest tick everywhere (this operator's box AND every customer install,
|
|
482
|
+
# since this file ships in the same release); a config already migrated
|
|
483
|
+
# (no project carries the old key) is a fast no-op. Skipped in --dry-run
|
|
484
|
+
# since that mode promises to change nothing.
|
|
485
|
+
if not args.dry_run:
|
|
486
|
+
try:
|
|
487
|
+
mig = lp.migrate_to_global()
|
|
488
|
+
if mig.get("migrated"):
|
|
489
|
+
log(f"learned_preferences migrated to global: projects={mig.get('projects_merged')} "
|
|
490
|
+
f"excluded={len(mig.get('excluded') or [])} dropped_at_cap={len(mig.get('dropped_at_cap') or [])}")
|
|
491
|
+
except Exception as e:
|
|
492
|
+
log(f"learned_preferences migration error (non-fatal): {e}")
|
|
493
|
+
|
|
466
494
|
cfg = load_config()
|
|
467
495
|
by_name = {p.get("name"): p for p in (cfg.get("projects") or [])}
|
|
468
496
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Print (or download) the permanent download link for the latest S4L staging
|
|
3
|
+
# release (.mcpb).
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# bash scripts/get-latest-staging-mcpb.sh [--download] [--channel]
|
|
7
|
+
#
|
|
8
|
+
# Options:
|
|
9
|
+
# --download Download the .mcpb to the current directory (instead of
|
|
10
|
+
# just printing the URL)
|
|
11
|
+
# --channel After downloading, opt this machine into the staging
|
|
12
|
+
# channel (delegates to scripts/s4l_channel.py, the single
|
|
13
|
+
# source of truth for the channel marker)
|
|
14
|
+
#
|
|
15
|
+
# The link below is a FIXED URL, not a live GitHub API lookup: every
|
|
16
|
+
# `bash scripts/release-mcpb.sh --staging` run mirrors its .mcpb onto a
|
|
17
|
+
# separate, non-version-tagged release ("staging-latest") so this exact URL
|
|
18
|
+
# always serves whatever's newest — see scripts/release-mcpb.sh step 7b. That
|
|
19
|
+
# means this script needs zero network calls to resolve anything; it only
|
|
20
|
+
# hits the network when actually downloading.
|
|
21
|
+
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
STAGING_MCPB_URL="https://github.com/m13v/s4l/releases/download/staging-latest/social-autoposter.mcpb"
|
|
25
|
+
|
|
26
|
+
DO_DOWNLOAD=0
|
|
27
|
+
DO_CHANNEL=0
|
|
28
|
+
|
|
29
|
+
while [[ $# -gt 0 ]]; do
|
|
30
|
+
case "$1" in
|
|
31
|
+
--download) DO_DOWNLOAD=1; shift ;;
|
|
32
|
+
--channel) DO_CHANNEL=1; shift ;;
|
|
33
|
+
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
|
34
|
+
esac
|
|
35
|
+
done
|
|
36
|
+
|
|
37
|
+
if [ "$DO_DOWNLOAD" -eq 0 ]; then
|
|
38
|
+
echo "$STAGING_MCPB_URL"
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
FILENAME="social-autoposter-staging.mcpb"
|
|
43
|
+
echo "Downloading $FILENAME from $STAGING_MCPB_URL" >&2
|
|
44
|
+
curl -fL "$STAGING_MCPB_URL" -o "$FILENAME"
|
|
45
|
+
echo "$FILENAME"
|
|
46
|
+
|
|
47
|
+
if [ "$DO_CHANNEL" -eq 1 ]; then
|
|
48
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
49
|
+
python3 "$REPO_ROOT/scripts/s4l_channel.py" set staging >&2
|
|
50
|
+
fi
|
|
@@ -43,6 +43,7 @@ import os
|
|
|
43
43
|
import re
|
|
44
44
|
import signal
|
|
45
45
|
import sys
|
|
46
|
+
import threading
|
|
46
47
|
import time
|
|
47
48
|
from pathlib import Path
|
|
48
49
|
|
|
@@ -306,6 +307,21 @@ class Harness:
|
|
|
306
307
|
ctx.add_init_script(script)
|
|
307
308
|
except Exception:
|
|
308
309
|
pass
|
|
310
|
+
# The overlay shares this CDP session with the real twitter-harness
|
|
311
|
+
# automation. When x.com throws up a native JS dialog, both clients'
|
|
312
|
+
# Playwright drivers race to auto-dismiss it (the default behavior
|
|
313
|
+
# when no listener is registered); the loser gets a
|
|
314
|
+
# "Page.handleJavaScriptDialog: No dialog is showing" protocol error
|
|
315
|
+
# that is an UNCAUGHT rejection in the Node driver, killing that
|
|
316
|
+
# whole driver process. A no-op listener opts this connection out of
|
|
317
|
+
# the race entirely (dialog handling stays the automation's job);
|
|
318
|
+
# once the driver's default-dismiss is suppressed here, evaluate()
|
|
319
|
+
# calls on the affected page resume normally as soon as the other
|
|
320
|
+
# side clears the dialog.
|
|
321
|
+
try:
|
|
322
|
+
ctx.on("dialog", lambda dialog: None)
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
309
325
|
|
|
310
326
|
def paint(self, title: str, reassure: str, status: str) -> int:
|
|
311
327
|
"""Paint/refresh the overlay on every live page. Returns pages touched."""
|
|
@@ -473,11 +489,40 @@ def cmd_watch(interval: float = 2.0) -> int:
|
|
|
473
489
|
# Treat SIGTERM (launchd unload, `kill`) like Ctrl-C so the overlay is
|
|
474
490
|
# cleared on the way out instead of lingering until the next navigation.
|
|
475
491
|
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
|
492
|
+
|
|
493
|
+
# Hard watchdog: a wedged CDP call (e.g. a page whose JS is paused on a
|
|
494
|
+
# native dialog) can block the loop body indefinitely without ever raising,
|
|
495
|
+
# so the normal try/except reconnect logic below never gets a chance to
|
|
496
|
+
# run. That let a past incident spin a full core for ~3.5 hours with the
|
|
497
|
+
# overlay silently stuck instead of failing loudly. A background thread
|
|
498
|
+
# that self-terminates the process if the main loop hasn't ticked in too
|
|
499
|
+
# long is the only thing that reliably fires even when the main thread is
|
|
500
|
+
# stuck inside a blocking Playwright/CDP call; os._exit() skips Python-level
|
|
501
|
+
# cleanup on purpose since a stuck process can't be trusted to run it
|
|
502
|
+
# either. launchd's KeepAlive relaunches a clean instance immediately.
|
|
503
|
+
WATCHDOG_MAX_STALL_SEC = 60.0
|
|
504
|
+
_last_tick = {"t": time.time()}
|
|
505
|
+
|
|
506
|
+
def _watchdog() -> None:
|
|
507
|
+
while True:
|
|
508
|
+
time.sleep(10)
|
|
509
|
+
stalled = time.time() - _last_tick["t"]
|
|
510
|
+
if stalled > WATCHDOG_MAX_STALL_SEC:
|
|
511
|
+
print(
|
|
512
|
+
f"[watchdog] loop stalled {stalled:.0f}s (>{WATCHDOG_MAX_STALL_SEC:.0f}s); "
|
|
513
|
+
"self-terminating for a clean launchd restart",
|
|
514
|
+
file=sys.stderr,
|
|
515
|
+
)
|
|
516
|
+
os._exit(1)
|
|
517
|
+
|
|
518
|
+
threading.Thread(target=_watchdog, daemon=True).start()
|
|
519
|
+
|
|
476
520
|
last_status = None
|
|
477
521
|
h: Harness | None = None
|
|
478
522
|
registered = False
|
|
479
523
|
try:
|
|
480
524
|
while True:
|
|
525
|
+
_last_tick["t"] = time.time()
|
|
481
526
|
# Never let status computation (log globbing/stat, all racing against
|
|
482
527
|
# live log rotation) kill the watcher; fall back to a neutral status.
|
|
483
528
|
try:
|
package/scripts/identity.py
CHANGED
|
@@ -328,6 +328,32 @@ def get_identity_header(refresh: bool = False) -> str:
|
|
|
328
328
|
return base64.b64encode(raw).decode("ascii")
|
|
329
329
|
|
|
330
330
|
|
|
331
|
+
# Our own infra: operator Mac, mk0r E2B sandbox fleet, MacStadium remote QA boxes.
|
|
332
|
+
# Single source of truth for "is this install actually a customer", consumed by
|
|
333
|
+
# active_users.py (customer-roster dedupe) and autopilot_stall_watch.py (stall-page
|
|
334
|
+
# severity), so the two lists can't drift apart. "71522" was the MacStadium box
|
|
335
|
+
# retired by the 2026-07-06 hardware swap (ticket #10904); "71732" is its
|
|
336
|
+
# replacement, both listed since the old box's hostname could still appear in
|
|
337
|
+
# historical data.
|
|
338
|
+
INTERNAL_EMAILS = {"i@m13v.com", "agent@mk0r.com", "matt@mediar.ai"}
|
|
339
|
+
INTERNAL_HOSTNAME_SUBSTR = ("e2b.local", "71522", "71732")
|
|
340
|
+
INTERNAL_HARDWARE_UUIDS = {"07CB793D-6E32-5EF8-82E2-7CDEABD47FBC"}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def is_internal_install(ident: dict | None = None) -> bool:
|
|
344
|
+
"""True when this install is our own infra (staging/QA/dev), not a real
|
|
345
|
+
customer. Local-only (no DB), so it works on shipped .mcpb installs too."""
|
|
346
|
+
ident = ident if ident is not None else get_identity()
|
|
347
|
+
if (ident.get("git_email") or "").strip().lower() in INTERNAL_EMAILS:
|
|
348
|
+
return True
|
|
349
|
+
hostname = ident.get("hostname") or ""
|
|
350
|
+
if any(sub in hostname for sub in INTERNAL_HOSTNAME_SUBSTR):
|
|
351
|
+
return True
|
|
352
|
+
if (ident.get("hardware_uuid") or "") in INTERNAL_HARDWARE_UUIDS:
|
|
353
|
+
return True
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
|
|
331
357
|
def main():
|
|
332
358
|
cmd = sys.argv[1] if len(sys.argv) > 1 else "show"
|
|
333
359
|
if cmd == "show":
|
|
@@ -1,39 +1,54 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
|
|
2
|
+
"""learned_preferences: the agent-owned config block distilled from human
|
|
3
|
+
card decisions (review_events).
|
|
4
|
+
|
|
5
|
+
**Single install-wide block (2026-07-08).** Earlier this lived once PER
|
|
6
|
+
PROJECT. In practice every entry ever learned was a fact about the human
|
|
7
|
+
reviewer (their voice, their quality bar, their reviewing habits), never
|
|
8
|
+
about one product's audience specifically, so a multi-project install (every
|
|
9
|
+
install gets at least a persona project plus, usually, a product project)
|
|
10
|
+
just relearned the same reviewer-level facts independently in each project.
|
|
11
|
+
Consolidated into ONE top-level `learned_preferences_global` block so a
|
|
12
|
+
correction made anywhere reaches every project's drafting/judging prompt.
|
|
13
|
+
migrate_to_global() performs the one-time move from the old per-project
|
|
14
|
+
shape and runs automatically (idempotent) from feedback_digest.py.
|
|
4
15
|
|
|
5
16
|
The feedback loop:
|
|
6
17
|
1. The menubar review card ships every approve/reject (with reason chips,
|
|
7
18
|
free-text note, link-click interactions, dwell) to /api/v1/review-events.
|
|
8
19
|
2. scripts/feedback_digest.py (scheduled) claims unprocessed events per
|
|
9
20
|
project and asks Claude for a conservative mutation plan.
|
|
10
|
-
3. apply_mutations() here writes that plan into config.json
|
|
11
|
-
|
|
21
|
+
3. apply_mutations() here writes that plan into config.json's single
|
|
22
|
+
`learned_preferences_global` block, whitelist-enforced, with flock +
|
|
12
23
|
backup + atomic replace.
|
|
13
24
|
4. Enforcement is SOFT (prompt-level, never a deterministic filter): the
|
|
14
25
|
twitter prep prompt embeds every project entry verbatim via
|
|
15
|
-
ALL_PROJECTS_JSON
|
|
16
|
-
|
|
17
|
-
|
|
26
|
+
ALL_PROJECTS_JSON (each project dict is stamped with the same global
|
|
27
|
+
block at prompt-build time), so the block (with its self-describing
|
|
28
|
+
_instruction) reaches the judging/drafting model automatically.
|
|
29
|
+
prompt_block() renders the same content for callers that want an
|
|
30
|
+
explicit section instead of the raw embed.
|
|
18
31
|
|
|
19
|
-
Block shape (
|
|
32
|
+
Block shape (config.json top-level key `learned_preferences_global`):
|
|
20
33
|
|
|
21
|
-
"
|
|
34
|
+
"learned_preferences_global": {
|
|
22
35
|
"_instruction": "<how the drafting model should apply this block>",
|
|
23
36
|
"enabled": true,
|
|
24
37
|
"audience_avoid": ["crypto/web3-native authors ..."],
|
|
25
38
|
"audience_prefer": [],
|
|
26
39
|
"thread_avoid": ["engagement-bait question threads ..."],
|
|
27
40
|
"draft_style_notes": [],
|
|
41
|
+
"edit_examples": [{"original", "final", "ts"}],
|
|
28
42
|
"updated_at": "...",
|
|
29
43
|
"history": [{"ts", "change", "rationale", "source_events": [ids]}]
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
WHITELIST: this module writes ONLY
|
|
33
|
-
voice.never and content_guardrails.do_not.
|
|
34
|
-
touchable through this path; unknown keys
|
|
35
|
-
counted, never applied. Facts
|
|
36
|
-
|
|
46
|
+
WHITELIST: this module writes ONLY learned_preferences_global plus
|
|
47
|
+
(append-only, still per-project) voice.never and content_guardrails.do_not.
|
|
48
|
+
Nothing else in a project entry is touchable through this path; unknown keys
|
|
49
|
+
in a mutation plan are dropped and counted, never applied. Facts
|
|
50
|
+
(content_angle, links, identity fields) are deliberately unreachable so a
|
|
51
|
+
bad digest can never poison grounding.
|
|
37
52
|
"""
|
|
38
53
|
from __future__ import annotations
|
|
39
54
|
|
|
@@ -41,6 +56,7 @@ import datetime
|
|
|
41
56
|
import fcntl
|
|
42
57
|
import json
|
|
43
58
|
import os
|
|
59
|
+
import re
|
|
44
60
|
import shutil
|
|
45
61
|
import sys
|
|
46
62
|
from pathlib import Path
|
|
@@ -137,14 +153,40 @@ def normalized(block) -> dict:
|
|
|
137
153
|
return out
|
|
138
154
|
|
|
139
155
|
|
|
140
|
-
def
|
|
141
|
-
|
|
156
|
+
def get_global_block(cfg: dict | None = None, cfg_path: str | None = None) -> dict:
|
|
157
|
+
"""Load + normalize the single install-wide learned_preferences block.
|
|
158
|
+
|
|
159
|
+
If `cfg` (an already-parsed config.json dict) isn't supplied, reads
|
|
160
|
+
config.json fresh from disk — the same "re-read on every call" pattern
|
|
161
|
+
already used elsewhere (e.g. link_tail.py's project loader), so callers
|
|
162
|
+
that only ever had a single project's dict slice (not the whole config)
|
|
163
|
+
can still reach the global block with no signature change on their end.
|
|
164
|
+
"""
|
|
165
|
+
if cfg is None:
|
|
166
|
+
try:
|
|
167
|
+
cfg = json.loads(Path(cfg_path or config_path()).read_text())
|
|
168
|
+
except Exception:
|
|
169
|
+
cfg = {}
|
|
170
|
+
return normalized(cfg.get("learned_preferences_global"))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_block(project_cfg=None) -> dict:
|
|
174
|
+
"""Back-compat shim (2026-07-08): learned_preferences is now a SINGLE
|
|
175
|
+
install-wide block, not one per project — see module docstring.
|
|
176
|
+
`project_cfg` is accepted but ignored; kept so every existing caller
|
|
177
|
+
(post_reddit.py, engage_reddit.py, post_github.py, engage_github.py,
|
|
178
|
+
link_tail.py — 4 of those 5 files are chflags-uchg locked) needs zero
|
|
179
|
+
changes. Callers that already have the full parsed config in scope
|
|
180
|
+
should call get_global_block(cfg) directly instead (apply_mutations and
|
|
181
|
+
record_edit_examples below do)."""
|
|
182
|
+
return get_global_block()
|
|
142
183
|
|
|
143
184
|
|
|
144
|
-
def prompt_block(project_cfg) -> str:
|
|
185
|
+
def prompt_block(project_cfg=None) -> str:
|
|
145
186
|
"""Explicit prompt section for callers that don't embed the raw project
|
|
146
187
|
JSON (mirrors the engagement_styles STYLES_BLOCK pattern). Empty string
|
|
147
|
-
when the block is disabled or has no entries.
|
|
188
|
+
when the block is disabled or has no entries. `project_cfg` is accepted
|
|
189
|
+
but ignored (see get_block())."""
|
|
148
190
|
b = get_block(project_cfg)
|
|
149
191
|
if not b["enabled"]:
|
|
150
192
|
return ""
|
|
@@ -190,12 +232,16 @@ def _validate_add_list(raw, cap=MAX_ENTRIES_PER_LIST):
|
|
|
190
232
|
|
|
191
233
|
def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None) -> dict:
|
|
192
234
|
"""Deterministically record (original, final) card-edit pairs as few-shot
|
|
193
|
-
examples in the
|
|
194
|
-
capped at MAX_EDIT_EXAMPLES). This is CODE-owned, not part of the
|
|
195
|
-
LLM's mutation plan: an edit the user made IS the ground truth, no
|
|
196
|
-
call needed to keep it (mirrors the s4l-email inbox repo, which
|
|
197
|
-
the last 5 human-edited drafts into every draft prompt). Same
|
|
198
|
-
backup + atomic-replace mechanics as apply_mutations().
|
|
235
|
+
examples in the single learned_preferences_global.edit_examples (newest
|
|
236
|
+
first, capped at MAX_EDIT_EXAMPLES). This is CODE-owned, not part of the
|
|
237
|
+
digest LLM's mutation plan: an edit the user made IS the ground truth, no
|
|
238
|
+
judgment call needed to keep it (mirrors the s4l-email inbox repo, which
|
|
239
|
+
injects the last 5 human-edited drafts into every draft prompt). Same
|
|
240
|
+
flock + backup + atomic-replace mechanics as apply_mutations().
|
|
241
|
+
|
|
242
|
+
`project_name` is validated against config.json (catches a caller typo)
|
|
243
|
+
but no longer selects a per-project block to write into — every project's
|
|
244
|
+
edits feed the same global few-shot pool (2026-07-08).
|
|
199
245
|
|
|
200
246
|
pairs: iterable of {"original": str, "final": str} (ts optional).
|
|
201
247
|
Returns {ok, recorded: int}.
|
|
@@ -230,7 +276,7 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
|
|
|
230
276
|
if proj is None:
|
|
231
277
|
return {"ok": False, "error": "project not in config", "recorded": 0}
|
|
232
278
|
|
|
233
|
-
block =
|
|
279
|
+
block = get_global_block(cfg)
|
|
234
280
|
existing = block["edit_examples"]
|
|
235
281
|
# Dedup on the final text (a retried digest re-records the same batch).
|
|
236
282
|
seen = {e["final"] for e in existing}
|
|
@@ -245,9 +291,10 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
|
|
|
245
291
|
"change": f"edit_examples recorded: {len(fresh)}",
|
|
246
292
|
"rationale": "user rewrote the draft on the review card",
|
|
247
293
|
"source_events": [],
|
|
294
|
+
"project": project_name,
|
|
248
295
|
}
|
|
249
296
|
])[-MAX_HISTORY:]
|
|
250
|
-
|
|
297
|
+
cfg["learned_preferences_global"] = block
|
|
251
298
|
|
|
252
299
|
stamp = _now_iso().replace(":", "-").replace(".", "-")
|
|
253
300
|
try:
|
|
@@ -263,7 +310,8 @@ def record_edit_examples(project_name: str, pairs, cfg_path: str | None = None)
|
|
|
263
310
|
|
|
264
311
|
|
|
265
312
|
def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_path: str | None = None) -> dict:
|
|
266
|
-
"""Apply a digest mutation plan to
|
|
313
|
+
"""Apply a digest mutation plan to the single learned_preferences_global
|
|
314
|
+
block. Returns a summary dict:
|
|
267
315
|
{ok, applied: [change strings], dropped: [reasons], project_found: bool}.
|
|
268
316
|
|
|
269
317
|
plan shape (all optional):
|
|
@@ -271,6 +319,13 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
|
|
|
271
319
|
"voice_never_add": [...], "guardrails_do_not_add": [...],
|
|
272
320
|
"rationale": "..."}
|
|
273
321
|
|
|
322
|
+
`project_name` is validated against config.json (catches a caller typo)
|
|
323
|
+
and recorded in the history entry's rationale, but no longer selects a
|
|
324
|
+
per-project block: every project's digest writes into the same global
|
|
325
|
+
block now (2026-07-08). `voice_never_add`/`guardrails_do_not_add` stay
|
|
326
|
+
per-project (proj['voice']/proj['content_guardrails']) since those are a
|
|
327
|
+
separate, narrower whitelist unrelated to this consolidation.
|
|
328
|
+
|
|
274
329
|
flock on <config>.lock serializes against other writers (setup tools, a
|
|
275
330
|
concurrent digest); backup + atomic replace mirrors mcp/src/setup.ts
|
|
276
331
|
applySetup(). Unknown keys are DROPPED, never applied: the whitelist is
|
|
@@ -295,7 +350,7 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
|
|
|
295
350
|
if proj is None:
|
|
296
351
|
return {"ok": False, "error": "project not in config", "applied": [], "dropped": [], "project_found": False}
|
|
297
352
|
|
|
298
|
-
block =
|
|
353
|
+
block = get_global_block(cfg)
|
|
299
354
|
|
|
300
355
|
for key, ops in changes.items():
|
|
301
356
|
if key not in MANAGED_LISTS:
|
|
@@ -345,9 +400,10 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
|
|
|
345
400
|
"change": "; ".join(applied)[:1000],
|
|
346
401
|
"rationale": rationale,
|
|
347
402
|
"source_events": list(source_event_ids or [])[:100],
|
|
403
|
+
"project": project_name,
|
|
348
404
|
}
|
|
349
405
|
])[-MAX_HISTORY:]
|
|
350
|
-
|
|
406
|
+
cfg["learned_preferences_global"] = block
|
|
351
407
|
|
|
352
408
|
# Backup + atomic replace (same shape as setup.ts applySetup).
|
|
353
409
|
stamp = _now_iso().replace(":", "-").replace(".", "-")
|
|
@@ -363,19 +419,172 @@ def apply_mutations(project_name: str, plan: dict, source_event_ids=None, cfg_pa
|
|
|
363
419
|
return {"ok": True, "applied": applied, "dropped": dropped, "project_found": True}
|
|
364
420
|
|
|
365
421
|
|
|
422
|
+
# --- Per-project -> global migration (2026-07-08) -------------------------
|
|
423
|
+
# Two categories of existing per-project draft_style_notes are EXCLUDED
|
|
424
|
+
# (dropped, not carried forward) rather than merged into the new global
|
|
425
|
+
# block, per explicit user instruction: notes that tell the drafter to
|
|
426
|
+
# suppress/strip the trailing product link, and notes about avoiding a
|
|
427
|
+
# trailing period/punctuation. The link-suppression notes directly
|
|
428
|
+
# contradict the tail-link bridge feature (scripts/link_tail.py), whose
|
|
429
|
+
# entire job is appending that link — when fed into the bridge prompt as a
|
|
430
|
+
# MANDATORY learned preference they produced degraded output (e.g. a
|
|
431
|
+
# duplicated discourse-marker artifact traced 2026-07-08). Everything else
|
|
432
|
+
# (audience_avoid/prefer, thread_avoid, other draft_style_notes,
|
|
433
|
+
# edit_examples, and each project's own history[]) is carried forward
|
|
434
|
+
# verbatim — nothing else is dropped, and every excluded/dropped entry is
|
|
435
|
+
# still recorded in the merged history for audit purposes.
|
|
436
|
+
_LINK_SUPPRESSION_RE = re.compile(
|
|
437
|
+
r"do not (append|add)\b.{0,60}\b(link|url)\b"
|
|
438
|
+
r"|\b(link|url)\b.{0,60}\bdo not (append|add)\b"
|
|
439
|
+
r"|leave\b.{0,40}\bout\b.{0,40}\b(link|url|product url)\b"
|
|
440
|
+
r"|\b(link|url)\b.{0,60}\b(strip|remove)\b"
|
|
441
|
+
r"|\bstrip(s|ped)?\b.{0,60}\b(link|url)\b"
|
|
442
|
+
r"|let the copy stand alone"
|
|
443
|
+
r"|end (right after|on) the (sentence|product name)",
|
|
444
|
+
re.IGNORECASE,
|
|
445
|
+
)
|
|
446
|
+
_PUNCTUATION_SUPPRESSION_RE = re.compile(
|
|
447
|
+
r"\b(trailing|no|without a?|avoid|remove|do not (end|use))\b.{0,40}\b(period|dot|full stop|punctuation)\b",
|
|
448
|
+
re.IGNORECASE,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _excluded_note_reason(text: str) -> str | None:
|
|
453
|
+
"""Reason string if this draft_style_notes entry should be dropped
|
|
454
|
+
during migration (not carried into the global block), else None."""
|
|
455
|
+
if _LINK_SUPPRESSION_RE.search(text):
|
|
456
|
+
return "contradicts the tail-link bridge feature (link/url suppression note)"
|
|
457
|
+
if _PUNCTUATION_SUPPRESSION_RE.search(text):
|
|
458
|
+
return "trailing punctuation suppression note"
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def migrate_to_global(cfg_path: str | None = None) -> dict:
|
|
463
|
+
"""One-time, idempotent consolidation of every project's
|
|
464
|
+
learned_preferences into the single learned_preferences_global block,
|
|
465
|
+
then deletes the per-project key. Safe to call on every process start
|
|
466
|
+
(feedback_digest.py does): a config where no project carries a
|
|
467
|
+
`learned_preferences` key is already migrated and this is a cheap no-op.
|
|
468
|
+
|
|
469
|
+
Returns {ok, migrated: bool, projects_merged: [...], excluded: [...],
|
|
470
|
+
dropped_at_cap: [...]}.
|
|
471
|
+
"""
|
|
472
|
+
cfg_path = cfg_path or config_path()
|
|
473
|
+
lock_path = cfg_path + ".lock"
|
|
474
|
+
Path(lock_path).parent.mkdir(parents=True, exist_ok=True)
|
|
475
|
+
with open(lock_path, "w") as lock_f:
|
|
476
|
+
fcntl.flock(lock_f, fcntl.LOCK_EX)
|
|
477
|
+
try:
|
|
478
|
+
cfg = json.loads(Path(cfg_path).read_text())
|
|
479
|
+
except Exception as e:
|
|
480
|
+
return {"ok": False, "error": f"config unreadable: {e}"}
|
|
481
|
+
|
|
482
|
+
projects = cfg.get("projects") or []
|
|
483
|
+
if not any(isinstance(p.get("learned_preferences"), dict) for p in projects):
|
|
484
|
+
return {"ok": True, "migrated": False, "projects_merged": [], "excluded": [], "dropped_at_cap": []}
|
|
485
|
+
|
|
486
|
+
global_block = normalized(cfg.get("learned_preferences_global"))
|
|
487
|
+
merged_history = list(global_block["history"])
|
|
488
|
+
excluded = []
|
|
489
|
+
dropped_at_cap = []
|
|
490
|
+
projects_merged = []
|
|
491
|
+
|
|
492
|
+
for proj in projects:
|
|
493
|
+
raw = proj.pop("learned_preferences", None)
|
|
494
|
+
if not isinstance(raw, dict):
|
|
495
|
+
continue
|
|
496
|
+
pname = proj.get("name", "?")
|
|
497
|
+
block = normalized(raw)
|
|
498
|
+
touched = False
|
|
499
|
+
for key in MANAGED_LISTS:
|
|
500
|
+
for v in block[key]:
|
|
501
|
+
if key == "draft_style_notes":
|
|
502
|
+
reason = _excluded_note_reason(v)
|
|
503
|
+
if reason:
|
|
504
|
+
excluded.append({"project": pname, "key": key, "value": v, "reason": reason})
|
|
505
|
+
continue
|
|
506
|
+
if v in global_block[key]:
|
|
507
|
+
continue
|
|
508
|
+
if len(global_block[key]) >= MAX_ENTRIES_PER_LIST:
|
|
509
|
+
dropped_at_cap.append({"project": pname, "key": key, "value": v})
|
|
510
|
+
continue
|
|
511
|
+
global_block[key].append(v)
|
|
512
|
+
touched = True
|
|
513
|
+
existing_finals = {e["final"] for e in global_block["edit_examples"]}
|
|
514
|
+
for e in block["edit_examples"]:
|
|
515
|
+
if e["final"] not in existing_finals:
|
|
516
|
+
global_block["edit_examples"].append(e)
|
|
517
|
+
existing_finals.add(e["final"])
|
|
518
|
+
touched = True
|
|
519
|
+
for h in block["history"]:
|
|
520
|
+
merged_history.append({**h, "project": pname})
|
|
521
|
+
touched = True
|
|
522
|
+
if touched:
|
|
523
|
+
projects_merged.append(pname)
|
|
524
|
+
|
|
525
|
+
# Keep only the newest MAX_EDIT_EXAMPLES, most-recent ts first.
|
|
526
|
+
global_block["edit_examples"].sort(key=lambda e: e.get("ts") or "", reverse=True)
|
|
527
|
+
global_block["edit_examples"] = global_block["edit_examples"][:MAX_EDIT_EXAMPLES]
|
|
528
|
+
|
|
529
|
+
summary = f"migrated {len(projects_merged)} project block(s) into learned_preferences_global"
|
|
530
|
+
if excluded:
|
|
531
|
+
summary += f"; excluded {len(excluded)} note(s) (link/punctuation suppression)"
|
|
532
|
+
if dropped_at_cap:
|
|
533
|
+
summary += f"; {len(dropped_at_cap)} entrie(s) dropped at the {MAX_ENTRIES_PER_LIST}-per-list cap"
|
|
534
|
+
merged_history.append({
|
|
535
|
+
"ts": _now_iso(),
|
|
536
|
+
"change": summary,
|
|
537
|
+
"rationale": "learned_preferences is now a single install-wide block, not per-project "
|
|
538
|
+
"(2026-07-08); every reviewer-level fact ever learned applied to the "
|
|
539
|
+
"reviewer, not to one product's audience, so per-project storage just "
|
|
540
|
+
"caused the same corrections to be relearned independently in every project.",
|
|
541
|
+
"source_events": [],
|
|
542
|
+
"excluded": excluded,
|
|
543
|
+
"dropped_at_cap": dropped_at_cap,
|
|
544
|
+
})
|
|
545
|
+
global_block["history"] = merged_history[-MAX_HISTORY:]
|
|
546
|
+
global_block["updated_at"] = _now_iso()
|
|
547
|
+
cfg["learned_preferences_global"] = global_block
|
|
548
|
+
|
|
549
|
+
stamp = _now_iso().replace(":", "-").replace(".", "-")
|
|
550
|
+
try:
|
|
551
|
+
if os.path.exists(cfg_path):
|
|
552
|
+
shutil.copyfile(cfg_path, f"{cfg_path}.bak-{stamp}")
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
tmp = cfg_path + ".tmp"
|
|
556
|
+
Path(tmp).write_text(json.dumps(cfg, indent=2) + "\n")
|
|
557
|
+
os.replace(tmp, cfg_path)
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
"ok": True, "migrated": True, "projects_merged": projects_merged,
|
|
561
|
+
"excluded": excluded, "dropped_at_cap": dropped_at_cap,
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
366
565
|
if __name__ == "__main__":
|
|
367
566
|
import argparse
|
|
368
567
|
|
|
369
568
|
ap = argparse.ArgumentParser(description="Inspect/render learned_preferences")
|
|
370
|
-
ap.add_argument("command", choices=["show", "block"
|
|
371
|
-
|
|
569
|
+
ap.add_argument("command", choices=["show", "block", "migrate"],
|
|
570
|
+
help="show = raw JSON, block = prompt block, migrate = run migrate_to_global()")
|
|
571
|
+
ap.add_argument("project", nargs="?", help="unused for 'migrate'; kept for show/block back-compat")
|
|
372
572
|
args = ap.parse_args()
|
|
573
|
+
|
|
574
|
+
if args.command == "migrate":
|
|
575
|
+
print(json.dumps(migrate_to_global(), indent=2))
|
|
576
|
+
sys.exit(0)
|
|
577
|
+
|
|
578
|
+
if not args.project:
|
|
579
|
+
print("show/block require a project name (used only to validate against config.json; "
|
|
580
|
+
"the rendered block is now global)", file=sys.stderr)
|
|
581
|
+
sys.exit(1)
|
|
373
582
|
cfg = json.loads(Path(config_path()).read_text())
|
|
374
583
|
proj = next((p for p in (cfg.get("projects") or []) if p.get("name") == args.project), None)
|
|
375
584
|
if proj is None:
|
|
376
585
|
print(f"project {args.project!r} not found", file=sys.stderr)
|
|
377
586
|
sys.exit(1)
|
|
378
587
|
if args.command == "show":
|
|
379
|
-
print(json.dumps(
|
|
588
|
+
print(json.dumps(get_global_block(cfg), indent=2))
|
|
380
589
|
else:
|
|
381
590
|
print(prompt_block(proj))
|