[object Object]

← back to Linkedin Voice Agent

linkedin-voice-agent: local Okara-style LinkedIn voice engine (draft-only, $0 Ollama)

b5c52a64588473e0c4a48e0400cab9034c84fcd1 · 2026-08-08 08:49:53 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b5c52a64588473e0c4a48e0400cab9034c84fcd1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 8 08:49:53 2026 -0700

    linkedin-voice-agent: local Okara-style LinkedIn voice engine (draft-only, $0 Ollama)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .gitignore                  |  13 +++
 SKILL.md                    |  86 ++++++++++++++++++++
 data/drafts/.gitkeep        |   0
 data/profiles/.gitkeep      |   0
 references/daily-cadence.md |  52 ++++++++++++
 scripts/_ollama.py          |  89 ++++++++++++++++++++
 scripts/build_profile.py    | 193 ++++++++++++++++++++++++++++++++++++++++++++
 scripts/generate.py         | 188 ++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 621 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d371db0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+# generated artifacts — voice profiles + draft queues are per-user, not source
+data/profiles/*.json
+data/drafts/*
+!data/profiles/.gitkeep
+!data/drafts/.gitkeep
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..4da8667
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,86 @@
+---
+name: linkedin-voice-agent
+description: 'Persona-voice LinkedIn content engine — the local, build-it-don''t-buy-it answer to Okara''s "LinkedIn Agent v2". Analyzes your OWN past LinkedIn posts to learn your core themes/topics + voice, style, and tone into a reusable profile, then writes new LinkedIn post drafts in that voice — daily or on demand, about your themes, a topic, or a news item — scoring each draft''s hook strength so the strongest float to the top. Runs on LOCAL Ollama by default (zero cost). DRAFT-ONLY — never auto-posts; publishing an approved draft is handed to the linkedin-api skill and stays Steve-gated. Use when Steve says "linkedin voice agent", "/linkedin-voice-agent", "write LinkedIn posts in my voice", "learn my LinkedIn voice", "daily LinkedIn posts like Okara", "build a LinkedIn voice profile", "draft LinkedIn posts about X", or wants Okara-style LinkedIn ghostwriting run locally instead of paying for it.'
+---
+
+# linkedin-voice-agent
+
+Local clone of the capability Okara (@askOkara) sells as **LinkedIn Agent v2**:
+*"analyzes your past posts to learn your themes + voice/style/tone, then writes
+new LinkedIn posts for you every day."* Steve's directive was **"Do not buy.
+Build."** — so this runs entirely on local Ollama at **$0**, with no okara.ai
+subscription and no data leaving the Mac.
+
+Sibling to the `kartiseira` skill (same draft-only + voice-profile-JSON + local-
+Ollama architecture, but for **X/Twitter**). This one is **LinkedIn-native**:
+long-form professional register, and it hands publishing to the real
+`linkedin-api` skill.
+
+## Hard rail — DRAFT ONLY
+This skill NEVER posts to LinkedIn. Automated third-party posting violates
+LinkedIn's TOS, and outward publishing is a Steve-gated action regardless. It
+writes drafts to a local review queue. To publish an approved draft, hand its
+text to the **`linkedin-api`** skill (`scripts/post.py`) — Steve's go.
+
+## When to use
+- "linkedin voice agent" / "/linkedin-voice-agent"
+- "learn my LinkedIn voice" / "build a LinkedIn voice profile"
+- "write LinkedIn posts in my voice" / "daily LinkedIn posts like Okara"
+- "draft LinkedIn posts about <topic>" / "react to <news> on LinkedIn in my voice"
+
+Do NOT use for: actually posting (draft-only → `linkedin-api`), X/Twitter content
+(use `kartiseira`), Instagram/TikTok (use the DW marketing agents), or DW commerce
+copy (use `dw-marketing-copy`).
+
+## Workflow — two stages
+
+### Stage 1 — Learn the voice (once per person)
+Build a reusable JSON voice profile from real past posts.
+
+**Getting your past posts (the honest, TOS-clean, $0 path):** LinkedIn has no
+free API to read your own feed, so use LinkedIn's official **Download your data**
+export: linkedin.com → Settings → *Data privacy* → **Get a copy of your data** →
+tick **Posts** → wait for the email → unzip → `Shares.csv`.
+
+```bash
+python3 scripts/build_profile.py --export ~/Downloads/Shares.csv --name steve
+# or paste-in fallback: a .txt with posts split by a blank line or a --- line
+python3 scripts/build_profile.py --posts ~/Desktop/my_posts.txt --name steve
+```
+
+Writes `data/profiles/<name>.json` with: measured style **stats** (computed
+deterministically — length, line count, emoji/hashtag/question rates), plus
+Ollama-synthesised `core_themes`, `voice_summary`, `signature_moves`, `avoid`,
+and `sample_hooks`. Skim `voice_summary` and `signature_moves` — hand-refining
+them a little gives the sharpest generations. Falls back to a stats-only profile
+if Ollama is unreachable (records which in `_voice_source`).
+
+### Stage 2 — Generate drafts (daily or on demand)
+```bash
+python3 scripts/generate.py --profile steve --count 3                    # about their own themes
+python3 scripts/generate.py --profile steve --topic "AI in interior design"
+python3 scripts/generate.py --profile steve --news "<headline or paragraph to react to>"
+```
+
+Generates N distinct posts in the captured voice, scores each draft's **hook
+strength 0-100** (deterministic first-line heuristic — free, reproducible),
+sorts strongest-first, and writes:
+- `data/drafts/<name>-<stamp>.md` — human review (hook score + angle + full text)
+- `data/drafts/<name>-<stamp>.jsonl` — machine-readable
+
+Review the `.md`, pick a winner, and publish via `linkedin-api` (Steve-gated).
+
+## Daily automation (the "every day" part) — Steve-gated
+Okara posts daily; the equivalent here is a launchd job that runs Stage 2 each
+morning and drops fresh drafts in the queue (still draft-only — a human still
+approves + publishes). Installing a scheduled job is a gated action, so the
+plist + install command are staged in `references/daily-cadence.md` for Steve to
+run, not auto-installed.
+
+## Config
+- `LVA_MODEL` (default `qwen3:14b`), `OLLAMA_URL` (default `http://localhost:11434`).
+- Fallbacks: qwen3 → hermes3 → gemma3 → qwen2.5 → llama3. All local, $0.
+
+## Cost
+Profile build + generation are **$0 (local Ollama)**. The only paid/gated step is
+publishing, which this skill never does — that's `linkedin-api` on Steve's go.
diff --git a/data/drafts/.gitkeep b/data/drafts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/data/profiles/.gitkeep b/data/profiles/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/references/daily-cadence.md b/references/daily-cadence.md
new file mode 100644
index 0000000..d3083ea
--- /dev/null
+++ b/references/daily-cadence.md
@@ -0,0 +1,52 @@
+# Daily cadence — the "every day" automation (Steve-gated)
+
+Okara auto-writes a LinkedIn post every day. The local equivalent is a launchd
+job that runs Stage 2 each morning and drops fresh drafts in `data/drafts/`.
+It is still **draft-only** — a human reviews and publishes. Installing a
+scheduled job is a gated action, so run this yourself when you want it on.
+
+## 1. Plist template
+Save as `~/Library/LaunchAgents/com.steve.linkedin-voice-agent.plist`
+(edit `--profile` to your profile name; adjust the hour in `StartCalendarInterval`):
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
+  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key><string>com.steve.linkedin-voice-agent</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/usr/bin/python3</string>
+    <string>/Users/macstudio3/.claude/skills/linkedin-voice-agent/scripts/generate.py</string>
+    <string>--profile</string><string>steve</string>
+    <string>--count</string><string>3</string>
+  </array>
+  <key>StartCalendarInterval</key>
+  <dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>30</integer></dict>
+  <key>StandardOutPath</key><string>/tmp/lva-daily.log</string>
+  <key>StandardErrorPath</key><string>/tmp/lva-daily.err</string>
+</dict>
+</plist>
+```
+
+## 2. Install / load
+```bash
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.linkedin-voice-agent.plist
+launchctl kickstart gui/$(id -u)/com.steve.linkedin-voice-agent   # run once now to test
+```
+
+## 3. Review + publish (unchanged)
+Each morning, read the newest `data/drafts/steve-*.md`, pick a winner, and
+publish it via the `linkedin-api` skill (your go):
+
+```bash
+python3 ~/.claude/skills/linkedin-api/scripts/post.py --text "<approved draft text>"
+```
+
+## Uninstall
+```bash
+launchctl bootout gui/$(id -u)/com.steve.linkedin-voice-agent
+rm ~/Library/LaunchAgents/com.steve.linkedin-voice-agent.plist
+```
diff --git a/scripts/_ollama.py b/scripts/_ollama.py
new file mode 100644
index 0000000..4d1d82d
--- /dev/null
+++ b/scripts/_ollama.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""Shared local-Ollama helper for the linkedin-voice-agent skill.
+
+Zero external dependencies (stdlib urllib). Runs on Mac2/Mac1 Ollama at $0.
+Mirrors the call+fallback+think-strip pattern used by the kartiseira skill so
+behaviour is consistent across Steve's persona-voice tools.
+"""
+import json
+import os
+import re
+import sys
+import urllib.request
+
+DEFAULT_MODEL = os.environ.get("LVA_MODEL", "qwen3:14b")
+BASE = os.environ.get("OLLAMA_URL", "http://localhost:11434")
+# Preference order if the requested model isn't pulled locally.
+FALLBACKS = ("qwen3", "hermes3", "gemma3", "qwen2.5", "llama3.1", "llama3")
+
+
+def _available_models():
+    try:
+        with urllib.request.urlopen(f"{BASE}/api/tags", timeout=8) as r:
+            data = json.load(r)
+        return {m["name"].split(":")[0]: m["name"] for m in data.get("models", [])}
+    except Exception:
+        return {}
+
+
+def _resolve_model(want):
+    avail = _available_models()
+    if not avail:
+        return want  # let the call fail loudly if ollama is truly down
+    if want in avail.values():
+        return want
+    for fam in FALLBACKS:
+        if fam in avail:
+            return avail[fam]
+    return next(iter(avail.values()))
+
+
+def ask(prompt, model=None, temperature=0.8, num_predict=1400):
+    """Return (text, model_used). Strips <think> blocks. Raises on hard failure."""
+    model = _resolve_model(model or DEFAULT_MODEL)
+    body = json.dumps({
+        "model": model,
+        "prompt": prompt,
+        "stream": False,
+        "options": {"temperature": temperature, "num_predict": num_predict},
+    }).encode()
+    req = urllib.request.Request(f"{BASE}/api/generate", data=body,
+                                 headers={"Content-Type": "application/json"})
+    with urllib.request.urlopen(req, timeout=240) as r:
+        out = json.load(r).get("response", "")
+    out = re.sub(r"<think>.*?</think>", "", out, flags=re.S | re.I).strip()
+    return out, model
+
+
+def extract_json(text):
+    """Pull the first JSON object/array out of a possibly-chatty model reply."""
+    text = text.strip()
+    m = re.search(r"```(?:json)?\s*(.+?)```", text, flags=re.S)
+    if m:
+        text = m.group(1).strip()
+    # Match whichever bracket appears FIRST — a top-level object may contain
+    # arrays (and vice-versa), so trying "[" unconditionally would grab an
+    # inner array out of an object.
+    pairs = [(o, c) for o, c in (("[", "]"), ("{", "}")) if text.find(o) != -1]
+    pairs.sort(key=lambda p: text.find(p[0]))
+    for opener, closer in pairs:
+        i = text.find(opener)
+        if i == -1:
+            continue
+        depth = 0
+        for j in range(i, len(text)):
+            if text[j] == opener:
+                depth += 1
+            elif text[j] == closer:
+                depth -= 1
+                if depth == 0:
+                    try:
+                        return json.loads(text[i:j + 1])
+                    except Exception:
+                        break
+    return None
+
+
+if __name__ == "__main__":
+    txt, used = ask(sys.argv[1] if len(sys.argv) > 1 else "Say hi in one line.")
+    print(f"[model: {used}]\n{txt}")
diff --git a/scripts/build_profile.py b/scripts/build_profile.py
new file mode 100644
index 0000000..61cec7c
--- /dev/null
+++ b/scripts/build_profile.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""build_profile.py — distill a reusable LinkedIn VOICE PROFILE from your own
+past posts. This is the "analyzes your past posts to learn your themes + voice,
+style, and tone" half of what Okara's LinkedIn Agent v2 sells — run locally at
+$0 instead of paying for it.
+
+INPUTS (pick one):
+  --export  <Shares.csv>   LinkedIn "Download your data" export (the honest,
+                           TOS-clean, $0 way to read YOUR OWN past posts).
+                           Get it at: linkedin.com/mypreferences/d/download-my-data
+                           → "Posts" → wait for email → unzip → Shares.csv
+  --posts   <file.txt>     Plain text: posts separated by a blank line or a
+                           line of "---". Paste-in fallback.
+
+OUTPUT:
+  data/profiles/<name>.json   the voice profile the generator consumes.
+
+The style STATS are computed deterministically in Python (free, reproducible);
+only the natural-language theme/voice synthesis calls local Ollama.
+
+Usage:
+  python3 scripts/build_profile.py --export ~/Downloads/Shares.csv --name steve
+  python3 scripts/build_profile.py --posts  ~/Desktop/my_posts.txt  --name steve
+"""
+import argparse
+import csv
+import datetime
+import json
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+import _ollama  # noqa: E402
+
+ROOT = os.path.dirname(HERE)
+PROFILE_DIR = os.path.join(ROOT, "data", "profiles")
+
+EMOJI_RE = re.compile(
+    "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF←-⇿⬀-⯿]"
+)
+
+
+def load_from_csv(path):
+    """LinkedIn Shares.csv → list of post texts. Column name has varied over
+    the years (ShareCommentary / Commentary / Text), so auto-detect."""
+    posts = []
+    with open(path, newline="", encoding="utf-8", errors="replace") as f:
+        reader = csv.DictReader(f)
+        cols = reader.fieldnames or []
+        # rank candidate text columns
+        cand = None
+        for want in ("ShareCommentary", "Commentary", "ShareText", "Text", "content"):
+            for c in cols:
+                if c and c.strip().lower() == want.lower():
+                    cand = c
+                    break
+            if cand:
+                break
+        if not cand:
+            sys.exit(f"Could not find a post-text column in {os.path.basename(path)}. "
+                     f"Columns present: {cols}")
+        for row in reader:
+            t = (row.get(cand) or "").strip()
+            if t:
+                posts.append(t)
+    return posts
+
+
+def load_from_txt(path):
+    raw = open(path, encoding="utf-8", errors="replace").read()
+    # split on --- lines or blank-line gaps of 1+
+    chunks = re.split(r"\n\s*(?:-{3,}|\*{3,})\s*\n|\n{2,}", raw)
+    return [c.strip() for c in chunks if c.strip()]
+
+
+def compute_stats(posts):
+    n = len(posts)
+    def avg(f):
+        return round(sum(f(p) for p in posts) / n, 2) if n else 0
+    hashtags = [len(re.findall(r"#\w+", p)) for p in posts]
+    return {
+        "n_posts": n,
+        "avg_chars": avg(len),
+        "avg_lines": avg(lambda p: p.count("\n") + 1),
+        "avg_words": avg(lambda p: len(p.split())),
+        "pct_with_question": round(100 * sum(1 for p in posts if "?" in p) / n) if n else 0,
+        "pct_with_emoji": round(100 * sum(1 for p in posts if EMOJI_RE.search(p)) / n) if n else 0,
+        "pct_with_hashtag": round(100 * sum(1 for h in hashtags if h) / n) if n else 0,
+        "avg_hashtags": round(sum(hashtags) / n, 2) if n else 0,
+        "pct_with_list": round(100 * sum(1 for p in posts if re.search(r"(?m)^\s*(?:[-•*\d]|\d[.)])", p)) / n) if n else 0,
+    }
+
+
+def top_hooks(posts, k=12):
+    """First line of the longest / most-engaging-looking posts = hook exemplars."""
+    ranked = sorted(posts, key=len, reverse=True)
+    hooks, seen = [], set()
+    for p in ranked:
+        first = p.strip().splitlines()[0].strip()
+        if 8 <= len(first) <= 140 and first.lower() not in seen:
+            hooks.append(first)
+            seen.add(first.lower())
+        if len(hooks) >= k:
+            break
+    return hooks
+
+
+def synthesize(posts, stats, name):
+    sample = "\n\n---\n\n".join(p[:600] for p in posts[:25])
+    prompt = f"""You are a brand-voice analyst. Below are real LinkedIn posts written by {name}.
+Study them and return ONLY a JSON object (no prose) with these keys:
+
+  "core_themes":     array of 4-8 short topic labels this person posts about
+  "voice_summary":   2-4 sentences describing their voice, tone, and register
+                     (e.g. warm/direct, plain-spoken, wry, expert, first-person)
+                     — specific enough to imitate, grounded in the samples.
+  "signature_moves": array of 3-6 concrete recurring stylistic habits
+                     (e.g. "opens with a one-line contrarian hook",
+                      "uses short 1-2 line paragraphs with whitespace",
+                      "ends with a soft question", "no hashtags").
+  "avoid":           array of 2-5 things that would break the voice
+                     (things they never do — corporate buzzwords, hashtag spam, etc.)
+
+Measured style stats (respect these when describing habits): {json.dumps(stats)}
+
+POSTS:
+{sample}
+
+Return the JSON object only."""
+    try:
+        raw, model = _ollama.ask(prompt, temperature=0.4, num_predict=900)
+        data = _ollama.extract_json(raw)
+        if isinstance(data, dict) and data.get("voice_summary"):
+            data["_voice_source"] = f"ollama:{model} (local, $0)"
+            return data
+    except Exception as e:
+        print(f"  (ollama synthesis failed: {e} — falling back to stats-only)", file=sys.stderr)
+    # graceful fallback: build a stats-derived profile so the tool still works
+    return {
+        "core_themes": [],
+        "voice_summary": (f"{name} writes ~{int(stats['avg_words'])}-word LinkedIn posts, "
+                          f"{'often asking a question' if stats['pct_with_question'] > 40 else 'mostly declarative'}, "
+                          f"{'emoji-friendly' if stats['pct_with_emoji'] > 40 else 'light on emoji'}, "
+                          f"{'hashtag-light' if stats['avg_hashtags'] < 2 else 'hashtag-forward'}."),
+        "signature_moves": [],
+        "avoid": [],
+        "_voice_source": "stats-only (ollama unavailable)",
+    }
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    src = ap.add_mutually_exclusive_group(required=True)
+    src.add_argument("--export", help="LinkedIn Shares.csv from Download-your-data")
+    src.add_argument("--posts", help="plain .txt, posts split by blank line or ---")
+    ap.add_argument("--name", required=True, help="profile name, e.g. steve")
+    ap.add_argument("--max", type=int, default=200, help="cap posts analysed")
+    args = ap.parse_args()
+
+    posts = load_from_csv(args.export) if args.export else load_from_txt(args.posts)
+    if not posts:
+        sys.exit("No posts found in the input.")
+    posts = posts[: args.max]
+    print(f"Loaded {len(posts)} posts for '{args.name}'.")
+
+    stats = compute_stats(posts)
+    print("Computing voice profile via local Ollama ($0)...")
+    synth = synthesize(posts, stats, args.name)
+
+    profile = {
+        "name": args.name,
+        "source": os.path.basename(args.export or args.posts),
+        "built_at": datetime.datetime.now().isoformat(timespec="seconds"),
+        "stats": stats,
+        "sample_hooks": top_hooks(posts),
+        **synth,
+    }
+    os.makedirs(PROFILE_DIR, exist_ok=True)
+    out = os.path.join(PROFILE_DIR, f"{args.name}.json")
+    with open(out, "w") as f:
+        json.dump(profile, f, indent=2, ensure_ascii=False)
+
+    print(f"\n✅ Voice profile → {out}")
+    print(f"   themes: {', '.join(profile.get('core_themes') or ['(none synthesised)'])}")
+    print(f"   voice : {profile['voice_summary']}")
+    print(f"   source: {profile['_voice_source']}")
+    print(f"\nNext:  python3 scripts/generate.py --profile {args.name} --count 3")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/scripts/generate.py b/scripts/generate.py
new file mode 100644
index 0000000..0fcdf7e
--- /dev/null
+++ b/scripts/generate.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""generate.py — write new LinkedIn posts in a captured voice. This is the
+"writes new LinkedIn posts for you every day" half of Okara's LinkedIn Agent v2,
+run locally at $0.
+
+Reads a voice profile built by build_profile.py, generates N post drafts (in
+that voice, constrained by the measured style + a topic/news angle), scores each
+draft's hook strength deterministically, sorts strongest-first, and writes a
+human-review queue.
+
+HARD RAIL — DRAFT ONLY. This never posts to LinkedIn. Automated 3rd-party
+posting violates LinkedIn's TOS, and outward publishing is Steve-gated anyway.
+To publish an approved draft, hand it to the `linkedin-api` skill (Steve's go).
+
+Usage:
+  python3 scripts/generate.py --profile steve --count 3
+  python3 scripts/generate.py --profile steve --topic "AI in interior design"
+  python3 scripts/generate.py --profile steve --news "headline or paragraph to react to"
+"""
+import argparse
+import datetime
+import json
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+import _ollama  # noqa: E402
+
+ROOT = os.path.dirname(HERE)
+PROFILE_DIR = os.path.join(ROOT, "data", "profiles")
+DRAFT_DIR = os.path.join(ROOT, "data", "drafts")
+
+POWER_WORDS = {"how", "why", "what", "stop", "start", "never", "always", "most",
+               "nobody", "everyone", "truth", "mistake", "secret", "lesson",
+               "here's", "unpopular", "hot take", "i was wrong", "the hard way"}
+
+
+def load_profile(name):
+    p = os.path.join(PROFILE_DIR, f"{name}.json")
+    if not os.path.exists(p):
+        sys.exit(f"No profile '{name}'. Build one first:\n"
+                 f"  python3 scripts/build_profile.py --export <Shares.csv> --name {name}")
+    return json.load(open(p))
+
+
+def hook_score(text):
+    """Deterministic 0-100 hook-strength heuristic on the FIRST line.
+    Reproducible and free — no model call. Rewards curiosity + specificity."""
+    first = text.strip().splitlines()[0].strip()
+    s, low = 0, first.lower()
+    n = len(first)
+    # length sweet-spot for a scroll-stopping first line (~30-90 chars)
+    if 25 <= n <= 90:
+        s += 30
+    elif n < 25:
+        s += 12
+    elif n <= 130:
+        s += 18
+    if "?" in first:
+        s += 12
+    if re.search(r"\d", first):
+        s += 14                       # a concrete number
+    if any(w in low for w in POWER_WORDS):
+        s += 16
+    if first[:1].isupper() and not first.isupper():
+        s += 6                        # cased, not shouty
+    if first.count(" ") <= 14:
+        s += 8                        # tight, not a run-on
+    if "#" in first:
+        s -= 10                       # hashtag in the hook = weak
+    if low.startswith(("i am excited", "i'm excited", "excited to announce")):
+        s -= 12                       # the cliché LinkedIn opener
+    return max(0, min(100, s))
+
+
+def build_prompt(profile, count, topic, news):
+    st = profile["stats"]
+    themes = ", ".join(profile.get("core_themes") or ["(their usual topics)"])
+    moves = "\n".join(f"  - {m}" for m in (profile.get("signature_moves") or []))
+    avoid = "\n".join(f"  - {a}" for a in (profile.get("avoid") or []))
+    hooks = "\n".join(f"  - {h}" for h in (profile.get("sample_hooks") or [])[:8])
+
+    if news:
+        brief = f'React to / build on this in the author\'s own voice:\n"""{news}"""'
+    elif topic:
+        brief = f'Write about this topic: "{topic}".'
+    else:
+        brief = ("Write about the author's own core themes. Pick fresh, specific "
+                 "angles — no generic advice.")
+
+    return f"""You ghostwrite LinkedIn posts in ONE specific person's voice. Imitate them exactly.
+
+VOICE OF {profile['name'].upper()}:
+{profile['voice_summary']}
+
+CORE THEMES: {themes}
+
+SIGNATURE MOVES (do these):
+{moves or '  - short paragraphs, first-person, plain language'}
+
+NEVER DO:
+{avoid or '  - corporate buzzwords, hashtag spam, humble-brag openers'}
+
+MEASURED STYLE (match it): ~{int(st['avg_words'])} words, ~{int(st['avg_lines'])} lines,
+avg {st['avg_hashtags']} hashtags, question in ~{st['pct_with_question']}% of posts,
+emoji in ~{st['pct_with_emoji']}% of posts.
+
+EXAMPLES OF THEIR REAL HOOKS (match this energy, don't copy):
+{hooks or '  - (none captured)'}
+
+TASK: {brief}
+Write {count} DISTINCT LinkedIn posts. Each: a scroll-stopping first line, then
+the body with whitespace between short paragraphs. Native LinkedIn — no markdown
+headers, no "**bold**". Sound like a human, not a brand.
+
+Return ONLY a JSON array of objects, each:
+  {{"angle": "<2-4 word description of the angle>", "text": "<the full post>"}}"""
+
+
+def generate(profile, count, topic, news):
+    prompt = build_prompt(profile, count, topic, news)
+    raw, model = _ollama.ask(prompt, temperature=0.9, num_predict=1800)
+    data = _ollama.extract_json(raw)
+    drafts = []
+    if isinstance(data, list):
+        for d in data:
+            if isinstance(d, dict) and d.get("text"):
+                drafts.append({"angle": d.get("angle", ""), "text": d["text"].strip()})
+    if not drafts:
+        # last-ditch: treat the whole reply as one post so the run isn't a dead end
+        drafts = [{"angle": "raw", "text": raw.strip()}]
+    for d in drafts:
+        d["hook_score"] = hook_score(d["text"])
+        d["chars"] = len(d["text"])
+    drafts.sort(key=lambda d: d["hook_score"], reverse=True)
+    return drafts, model
+
+
+def write_queue(profile, drafts, model, topic, news):
+    os.makedirs(DRAFT_DIR, exist_ok=True)
+    stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M")
+    base = os.path.join(DRAFT_DIR, f"{profile['name']}-{stamp}")
+    # jsonl for machines
+    with open(base + ".jsonl", "w") as f:
+        for d in drafts:
+            f.write(json.dumps(d, ensure_ascii=False) + "\n")
+    # markdown for the human review
+    ctx = news or topic or "core themes"
+    lines = [f"# LinkedIn drafts — {profile['name']}  ({stamp})",
+             f"_brief: {ctx}  ·  model: {model} (local, $0)  ·  DRAFT ONLY — not posted_\n"]
+    for i, d in enumerate(drafts, 1):
+        lines.append(f"## {i}. [{d['hook_score']}/100 hook] {d['angle']}  ·  {d['chars']} chars\n")
+        lines.append(d["text"])
+        lines.append("\n---\n")
+    lines.append("To publish an approved draft (Steve's go): hand its text to the "
+                 "`linkedin-api` skill →\n"
+                 "`python3 ~/.claude/skills/linkedin-api/scripts/post.py --text \"<draft>\"`")
+    md = base + ".md"
+    with open(md, "w") as f:
+        f.write("\n".join(lines))
+    return md
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--profile", required=True, help="voice profile name")
+    ap.add_argument("--count", type=int, default=3)
+    ap.add_argument("--topic", help="topic to write about")
+    ap.add_argument("--news", help="a headline/paragraph to react to")
+    args = ap.parse_args()
+
+    profile = load_profile(args.profile)
+    print(f"Generating {args.count} drafts in {args.profile}'s voice (local, $0)...")
+    drafts, model = generate(profile, args.count, args.topic, args.news)
+    md = write_queue(profile, drafts, model, args.topic, args.news)
+
+    print(f"\n✅ {len(drafts)} drafts → {md}")
+    for i, d in enumerate(drafts, 1):
+        first = d["text"].splitlines()[0][:80]
+        print(f"  {i}. [{d['hook_score']}/100] {first}")
+    print("\nDRAFT ONLY — review the .md, then publish an approved one via the "
+          "linkedin-api skill (Steve-gated).")
+
+
+if __name__ == "__main__":
+    main()

(oldest)  ·  back to Linkedin Voice Agent  ·  (newest)