← back to Linkedin Agent

src/lib/compose.ts

84 lines

/**
 * compose.ts — merges Steve's bio (templates/STEVE_BIO.md) into post drafts.
 *
 * The point: every post the agent generates carries a tasteful pointer back
 * to Steve's current product/agent inventory. NOT spammy CTA — folded into
 * the natural close of the post.
 */

import { readFileSync } from 'fs';
import { join } from 'path';

const ROOT = process.cwd();

let cached: { bio: string; mtime: number } | null = null;

function readBio(): string {
  const path = join(ROOT, 'templates/STEVE_BIO.md');
  try {
    const stat = (require('fs') as typeof import('fs')).statSync(path);
    if (cached && cached.mtime === stat.mtimeMs) return cached.bio;
    const bio = readFileSync(path, 'utf8');
    cached = { bio, mtime: stat.mtimeMs };
    return bio;
  } catch {
    return '';
  }
}

// Pull just the "Active products" + "Autonomous agents" sections, sample N
// items at random, format as a 1-line tagline.
function extractTagline(bio: string, sample = 3): string {
  const sections = bio.split(/^## /m);
  const products = (sections.find((s) => s.startsWith('Active products'))?.match(/^- \*\*([^*]+)\*\*/gm) || [])
    .map((l) => l.replace(/^- \*\*([^*]+)\*\*.*/, '$1'));
  const agents = (sections.find((s) => s.startsWith('Autonomous agents'))?.match(/^- \*\*([^*]+)\*\*/gm) || [])
    .map((l) => l.replace(/^- \*\*([^*]+)\*\*.*/, '$1'));

  const pool = [...products, ...agents];
  if (pool.length === 0) return '';
  // Deterministic-ish sample by current minute (so re-running same minute = same output)
  const seed = Math.floor(Date.now() / 60_000);
  const picked: string[] = [];
  for (let i = 0; i < Math.min(sample, pool.length); i++) {
    const idx = (seed * (i + 1) * 31) % pool.length;
    if (!picked.includes(pool[idx])) picked.push(pool[idx]);
  }
  return picked.join(' · ');
}

/**
 * Compose a final post: takes a draft (LLM-generated body) and weaves in
 * Steve's bio tagline at the end. If the draft is already > 2800 chars
 * (LinkedIn limit is 3000), skip the tagline.
 */
export function composePost(draft: string, opts?: { tagline?: boolean }): string {
  const wantTagline = opts?.tagline !== false;
  const bio = readBio();
  if (!bio || !wantTagline) return draft.trim();

  const tagline = extractTagline(bio, 3);
  if (!tagline) return draft.trim();

  const body = draft.trim();
  if (body.length > 2800) return body;

  // Naturalize: if draft already mentions Steve's product names, skip
  for (const name of tagline.split(' · ')) {
    if (body.toLowerCase().includes(name.toLowerCase())) return body;
  }

  return `${body}\n\n—\nBuilding lately: ${tagline}.`;
}

/**
 * Read a named template from templates/ and return its body.
 */
export function readTemplate(name: string): string {
  try {
    return readFileSync(join(ROOT, 'templates', `${name}.md`), 'utf8');
  } catch {
    return '';
  }
}