🦄 Unicorn.Land ▸ docs/superpowers/plans/2026-06-16-oannes-learning-loop.md
updated 2026-06-16

Oannes Code Learning Loop Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Collect per-post performance from all 4 platforms via Blotato, learn what “sticks” per platform, and bias future generation toward what works — without breaking the running daily machine.

Architecture: A new src/metrics/ module. A collect-metrics CLI pulls Blotato analytics, scores each post’s stickiness, and appends time-stamped snapshots to a dedicated oannes_post_metrics table in the Cambium Postgres DB (the engine opens its own thin pg client — no monorepo coupling). field-research writes per-platform best-practice priors; analyze-metrics aggregates snapshots into per-platform weights. Generation reads weights + priors to bias vibe/art/topic selection (money-weighted blend, epsilon-greedy), guide the script prompt, and diverge per-platform text. Everything is gated: absent data files/table → today’s exact behavior.

Tech Stack: TypeScript (ESM, NodeNext — all relative imports end in .js), vitest (tests in tests/*.test.ts, inject fetch/deps), pg for Postgres, zod for response validation, claude --print shell-out for field research.

Reference spec: docs/superpowers/specs/2026-06-16-oannes-learning-loop-design.md


File Structure

File Responsibility New/Modify
src/metrics/stickiness.ts Pure per-platform engagement-rate composite score Create
src/metrics/blotato.ts Blotato analytics client + tolerant parsers Create
src/metrics/store.ts Postgres: ensure table, insert snapshot, fetch latest Create
src/metrics/collect.ts Orchestrate collection (injectable deps) Create
src/metrics/field.ts Per-platform best-practice priors via Claude Create
src/metrics/analyze.ts Aggregate snapshots → per-platform weights Create
src/metrics/apply.ts Pure bias helpers (blend, epsilon-greedy, per-platform text) Create
src/auto/state.ts Add hook?/topic? to PendingItem Modify
src/auto/daily.ts Populate hook/topic; use learned planner + field-guided script Modify
src/auto/rotate.ts Add planTodayLearned (gated; falls back to planToday) Modify
src/script/reelscript.ts Inject field priors into the prompt (gated) Modify
src/publish/publishAll.ts Per-platform hashtag count from field priors (gated) Modify
src/cli.ts Register collect-metrics, field-research, analyze-metrics Modify
package.json Add pg + @types/pg Modify
deploy/*.plist + README Daily collect+analyze job, weekly field job Create
tests/*.test.ts One test file per new module Create

Shared types (defined once in stickiness.ts, imported everywhere):

export type Platform = "instagram" | "facebook" | "youtube" | "tiktok";
export interface RawMetrics {
  views: number | null; likes: number | null; comments: number | null;
  shares: number | null; saves: number | null; reach: number | null;
  watchTimeSec: number | null;
}

COMMIT 1 — Collector

Task 1: Add pg dependency

Files: - Modify: package.json

Run (in oannes-engine/):

npm install pg && npm install -D @types/pg

Expected: pg appears in dependencies, @types/pg in devDependencies, no errors.

git add package.json package-lock.json
git commit -m "chore(oannes-engine): add pg for metrics storage"

Task 2: Stickiness scoring (pure)

Files: - Create: src/metrics/stickiness.ts - Test: tests/stickiness.test.ts

// tests/stickiness.test.ts
import { describe, it, expect } from "vitest";
import { stickiness, type RawMetrics } from "../src/metrics/stickiness.js";

const base: RawMetrics = { views: null, likes: null, comments: null, shares: null, saves: null, reach: null, watchTimeSec: null };

describe("stickiness", () => {
  it("returns null when reach is missing or zero (cannot normalize)", () => {
    expect(stickiness("instagram", { ...base, likes: 10, reach: null })).toBeNull();
    expect(stickiness("instagram", { ...base, likes: 10, reach: 0 })).toBeNull();
  });

  it("computes weighted engagement rate normalized by reach", () => {
    // likes=10, comments=5, shares=2, saves=4, reach=100
    // (10 + 2*5 + 3*2 + 4) / 100 = 30/100 = 0.30
    const s = stickiness("instagram", { ...base, likes: 10, comments: 5, shares: 2, saves: 4, reach: 100 });
    expect(s).toBeCloseTo(0.30, 5);
  });

  it("treats missing engagement components as zero", () => {
    const s = stickiness("facebook", { ...base, likes: 5, reach: 100 });
    expect(s).toBeCloseTo(0.05, 5);
  });

  it("adds a watch-time completion term when watchTimeSec and views are present", () => {
    // engagement: 5 likes / 100 reach = 0.05
    // watch: (watchTimeSec/views)/REEL_SECONDS = (1500/100)/30 = 0.5; youtube watch weight 0.5 => +0.25
    const s = stickiness("youtube", { ...base, likes: 5, reach: 100, views: 100, watchTimeSec: 1500 });
    expect(s).toBeCloseTo(0.05 + 0.25, 5);
  });

  it("caps the watch-completion ratio at 1", () => {
    // (6000/100)/30 = 2 -> capped to 1; youtube weight 0.5 => +0.5
    const s = stickiness("youtube", { ...base, reach: 100, views: 100, watchTimeSec: 6000 });
    expect(s).toBeCloseTo(0.5, 5);
  });
});

Run: npx vitest run tests/stickiness.test.ts Expected: FAIL — cannot find module ../src/metrics/stickiness.js.

// src/metrics/stickiness.ts
export type Platform = "instagram" | "facebook" | "youtube" | "tiktok";

export interface RawMetrics {
  views: number | null;
  likes: number | null;
  comments: number | null;
  shares: number | null;
  saves: number | null;
  reach: number | null;
  watchTimeSec: number | null;
}

/** Assumed reel length for the watch-completion proxy (we render ~25-35s reels). */
const REEL_SECONDS = 30;

/** Per-platform weights. `watch` weights the completion term; 0 disables it. Tunable. */
export const STICKINESS_WEIGHTS: Record<Platform, { comment: number; share: number; save: number; watch: number }> = {
  instagram: { comment: 2, share: 3, save: 4, watch: 0 },
  facebook:  { comment: 2, share: 3, save: 4, watch: 0 },
  tiktok:    { comment: 2, share: 3, save: 4, watch: 0.5 },
  youtube:   { comment: 2, share: 3, save: 4, watch: 0.5 },
};

const n = (v: number | null): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x));

/**
 * Per-platform engagement-rate composite, normalized by reach.
 * Returns null when reach is unknown/zero (cannot normalize → no usable signal yet).
 */
export function stickiness(platform: Platform, m: RawMetrics): number | null {
  if (m.reach == null || m.reach <= 0) return null;
  const w = STICKINESS_WEIGHTS[platform];
  const base = (n(m.likes) + w.comment * n(m.comments) + w.share * n(m.shares) + w.save * n(m.saves)) / m.reach;
  let watchTerm = 0;
  if (m.watchTimeSec != null && m.views != null && m.views > 0 && w.watch > 0) {
    watchTerm = w.watch * clamp01((m.watchTimeSec / m.views) / REEL_SECONDS);
  }
  return base + watchTerm;
}

Run: npx vitest run tests/stickiness.test.ts Expected: PASS (5 tests).

git add src/metrics/stickiness.ts tests/stickiness.test.ts
git commit -m "feat(oannes-engine): per-platform stickiness scoring"

Task 3: Blotato analytics client + parsers

Files: - Create: src/metrics/blotato.ts - Test: tests/metrics-blotato.test.ts

Note: the exact analytics JSON shape is not yet documented in our code. Parsers are deliberately tolerant — they read several common field names and fall back to null. The collector logs the first raw analytics payload so field names can be corrected live.

// tests/metrics-blotato.test.ts
import { describe, it, expect } from "vitest";
import { parsePublishedPosts, parseAnalytics, matchPostByUrl, getPostAnalytics } from "../src/metrics/blotato.js";

describe("parsePublishedPosts", () => {
  it("extracts id/url/platform from a list payload, tolerating field-name variants", () => {
    const json = { items: [
      { id: "pp_1", platform: "instagram", publicUrl: "https://instagram.com/reel/abc" },
      { postId: "pp_2", target: { targetType: "youtube" }, url: "https://youtu.be/xyz" },
    ]};
    const posts = parsePublishedPosts(json);
    expect(posts).toEqual([
      { id: "pp_1", url: "https://instagram.com/reel/abc", platform: "instagram" },
      { id: "pp_2", url: "https://youtu.be/xyz", platform: "youtube" },
    ]);
  });

  it("returns [] for an unrecognized payload", () => {
    expect(parsePublishedPosts({ nope: true })).toEqual([]);
  });
});

describe("matchPostByUrl", () => {
  const posts = [
    { id: "pp_1", url: "https://instagram.com/reel/abc", platform: "instagram" },
    { id: "pp_2", url: "https://youtu.be/xyz", platform: "youtube" },
  ];
  it("matches by exact url", () => {
    expect(matchPostByUrl(posts, "https://youtu.be/xyz")?.id).toBe("pp_2");
  });
  it("returns null when no url matches", () => {
    expect(matchPostByUrl(posts, "https://tiktok.com/@x/video/1")).toBeNull();
  });
});

describe("parseAnalytics", () => {
  it("reads common metric field names and nulls the rest", () => {
    const m = parseAnalytics({ views: 1000, likes: 50, comments: 4, shares: 2, saves: 7, reach: 800, watchTimeSeconds: 12000 });
    expect(m).toEqual({ views: 1000, likes: 50, comments: 4, shares: 2, saves: 7, reach: 800, watchTimeSec: 12000 });
  });
  it("defaults absent metrics to null", () => {
    const m = parseAnalytics({ views: 10 });
    expect(m).toEqual({ views: 10, likes: null, comments: null, shares: null, saves: null, reach: null, watchTimeSec: null });
  });
});

describe("getPostAnalytics", () => {
  it("GETs the per-post analytics endpoint with the api key header", async () => {
    let seenUrl = ""; let seenKey = "";
    const f = (async (url: string, init?: RequestInit) => {
      seenUrl = String(url); seenKey = (init?.headers as Record<string, string>)["blotato-api-key"];
      return { ok: true, json: async () => ({ views: 5, reach: 100 }) } as Response;
    }) as unknown as typeof fetch;
    const m = await getPostAnalytics("KEY", "pp_9", f);
    expect(seenUrl).toBe("https://backend.blotato.com/v2/posts/pp_9/analytics");
    expect(seenKey).toBe("KEY");
    expect(m.reach).toBe(100);
  });
});

Run: npx vitest run tests/metrics-blotato.test.ts Expected: FAIL — cannot find module.

// src/metrics/blotato.ts
import type { RawMetrics } from "./stickiness.js";

const BASE = "https://backend.blotato.com/v2";

export interface PublishedPost {
  id: string;
  url: string;
  platform: string;
}

function pickNum(o: Record<string, unknown>, keys: string[]): number | null {
  for (const k of keys) {
    const v = o[k];
    if (typeof v === "number" && Number.isFinite(v)) return v;
  }
  return null;
}

function pickStr(o: Record<string, unknown>, keys: string[]): string | undefined {
  for (const k of keys) {
    const v = o[k];
    if (typeof v === "string" && v.length > 0) return v;
  }
  return undefined;
}

/** Tolerant: accept {items|data|posts: [...]} or a bare array. */
export function parsePublishedPosts(json: unknown): PublishedPost[] {
  const root = json as Record<string, unknown>;
  const arr =
    (Array.isArray(json) && (json as unknown[])) ||
    (Array.isArray(root?.items) && (root.items as unknown[])) ||
    (Array.isArray(root?.data) && (root.data as unknown[])) ||
    (Array.isArray(root?.posts) && (root.posts as unknown[])) ||
    [];
  const out: PublishedPost[] = [];
  for (const raw of arr) {
    const o = raw as Record<string, unknown>;
    const id = pickStr(o, ["id", "postId", "publishedPostId"]);
    const url = pickStr(o, ["publicUrl", "url", "postUrl", "permalink"]);
    const target = o.target as Record<string, unknown> | undefined;
    const platform = pickStr(o, ["platform"]) ?? (target ? pickStr(target, ["targetType"]) : undefined) ?? "";
    if (id && url) out.push({ id, url, platform });
  }
  return out;
}

export function matchPostByUrl(posts: PublishedPost[], url: string): PublishedPost | null {
  return posts.find((p) => p.url === url) ?? null;
}

export function parseAnalytics(json: unknown): RawMetrics {
  const o = (json ?? {}) as Record<string, unknown>;
  const src = (o.metrics as Record<string, unknown>) ?? o;
  return {
    views: pickNum(src, ["views", "videoViews", "impressions"]),
    likes: pickNum(src, ["likes", "likeCount"]),
    comments: pickNum(src, ["comments", "commentCount"]),
    shares: pickNum(src, ["shares", "shareCount"]),
    saves: pickNum(src, ["saves", "saved", "saveCount"]),
    reach: pickNum(src, ["reach", "reachCount", "uniqueViewers"]),
    watchTimeSec: pickNum(src, ["watchTimeSec", "watchTimeSeconds", "totalWatchTimeSeconds"]),
  };
}

export async function getPublishedPosts(apiKey: string, f: typeof fetch = fetch): Promise<PublishedPost[]> {
  const res = await f(`${BASE}/published-posts`, { headers: { "blotato-api-key": apiKey } });
  if (!res.ok) throw new Error(`published-posts failed: HTTP ${res.status}`);
  return parsePublishedPosts(await res.json());
}

export async function getPostAnalytics(apiKey: string, publishedPostId: string, f: typeof fetch = fetch): Promise<RawMetrics> {
  const res = await f(`${BASE}/posts/${publishedPostId}/analytics`, { headers: { "blotato-api-key": apiKey } });
  if (!res.ok) throw new Error(`post analytics failed: HTTP ${res.status}`);
  return parseAnalytics(await res.json());
}

/** Raw passthrough for logging the first payload live (field-name verification). */
export async function getPostAnalyticsRaw(apiKey: string, publishedPostId: string, f: typeof fetch = fetch): Promise<unknown> {
  const res = await f(`${BASE}/posts/${publishedPostId}/analytics`, { headers: { "blotato-api-key": apiKey } });
  return res.json().catch(() => ({}));
}

Run: npx vitest run tests/metrics-blotato.test.ts Expected: PASS.

git add src/metrics/blotato.ts tests/metrics-blotato.test.ts
git commit -m "feat(oannes-engine): Blotato analytics client + tolerant parsers"

Task 4: Postgres store

Files: - Create: src/metrics/store.ts - Test: tests/metrics-store.test.ts

The pure buildInsert (SQL text + values) is unit-tested without a live DB. ensureTable/insertSnapshot/fetchLatestSnapshots/withDb are thin wrappers over pg, exercised live by the CLI, not in CI.

// tests/metrics-store.test.ts
import { describe, it, expect } from "vitest";
import { buildInsert, CREATE_TABLE_SQL, type MetricSnapshot } from "../src/metrics/store.js";

const snap: MetricSnapshot = {
  claimId: "clm-1", platform: "instagram", publishedPostId: "pp_1",
  url: "https://ig/abc", vibe: "wonder", artKey: "energy", hook: "Is mind quantum?",
  topic: "consciousness",
  metrics: { views: 1000, likes: 50, comments: 4, shares: 2, saves: 7, reach: 800, watchTimeSec: null },
  stickiness: 0.09, recordedAt: new Date("2026-06-16T12:00:00Z"),
};

describe("buildInsert", () => {
  it("produces a parameterized insert with the snapshot fields in order", () => {
    const { text, values } = buildInsert(snap);
    expect(text).toContain("INSERT INTO oannes_post_metrics");
    expect(text).toContain("$1");
    // id is deterministic: claimId:platform:isoRecordedAt
    expect(values[0]).toBe("clm-1:instagram:2026-06-16T12:00:00.000Z");
    expect(values).toContain("clm-1");
    expect(values).toContain("instagram");
    expect(values).toContain(0.09);
    expect(values).toContain(800); // reach
  });
});

describe("CREATE_TABLE_SQL", () => {
  it("is idempotent and indexes platform/vibe/art", () => {
    expect(CREATE_TABLE_SQL).toContain("CREATE TABLE IF NOT EXISTS oannes_post_metrics");
    expect(CREATE_TABLE_SQL).toContain("CREATE INDEX IF NOT EXISTS");
    expect(CREATE_TABLE_SQL).toContain("platform");
  });
});

Run: npx vitest run tests/metrics-store.test.ts Expected: FAIL — cannot find module.

// src/metrics/store.ts
import pg from "pg";
import type { Platform, RawMetrics } from "./stickiness.js";

export interface MetricSnapshot {
  claimId: string;
  platform: Platform | string;
  publishedPostId: string;
  url: string;
  vibe: string;
  artKey: string;
  hook: string;
  topic: string;
  metrics: RawMetrics;
  stickiness: number | null;
  recordedAt: Date;
}

export const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS oannes_post_metrics (
  id text PRIMARY KEY,
  claim_id text NOT NULL,
  platform text NOT NULL,
  published_post_id text,
  url text,
  vibe text,
  art_key text,
  hook text,
  topic text,
  views integer, likes integer, comments integer, shares integer, saves integer, reach integer,
  watch_time_sec real,
  stickiness real,
  recorded_at timestamptz NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_opm_platform ON oannes_post_metrics (platform);
CREATE INDEX IF NOT EXISTS idx_opm_claim_platform ON oannes_post_metrics (claim_id, platform);
CREATE INDEX IF NOT EXISTS idx_opm_recorded_at ON oannes_post_metrics (recorded_at);
CREATE INDEX IF NOT EXISTS idx_opm_platform_vibe_art ON oannes_post_metrics (platform, vibe, art_key);
`;

export function buildInsert(s: MetricSnapshot): { text: string; values: unknown[] } {
  const id = `${s.claimId}:${s.platform}:${s.recordedAt.toISOString()}`;
  const text = `INSERT INTO oannes_post_metrics
    (id, claim_id, platform, published_post_id, url, vibe, art_key, hook, topic,
     views, likes, comments, shares, saves, reach, watch_time_sec, stickiness, recorded_at)
    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
    ON CONFLICT (id) DO NOTHING`;
  const m = s.metrics;
  const values = [id, s.claimId, s.platform, s.publishedPostId, s.url, s.vibe, s.artKey, s.hook, s.topic,
    m.views, m.likes, m.comments, m.shares, m.saves, m.reach, m.watchTimeSec, s.stickiness, s.recordedAt];
  return { text, values };
}

export async function withDb<T>(databaseUrl: string, fn: (client: pg.Client) => Promise<T>): Promise<T> {
  const client = new pg.Client({ connectionString: databaseUrl });
  await client.connect();
  try { return await fn(client); } finally { await client.end(); }
}

export async function ensureTable(client: pg.Client): Promise<void> {
  await client.query(CREATE_TABLE_SQL);
}

export async function insertSnapshot(client: pg.Client, snap: MetricSnapshot): Promise<void> {
  const { text, values } = buildInsert(snap);
  await client.query(text, values);
}

/** Latest snapshot per (claim_id, platform) — the current view for analysis. */
export async function fetchLatestSnapshots(client: pg.Client): Promise<MetricSnapshot[]> {
  const { rows } = await client.query(`
    SELECT DISTINCT ON (claim_id, platform) *
    FROM oannes_post_metrics
    ORDER BY claim_id, platform, recorded_at DESC`);
  return rows.map((r: Record<string, unknown>) => ({
    claimId: r.claim_id as string,
    platform: r.platform as string,
    publishedPostId: (r.published_post_id as string) ?? "",
    url: (r.url as string) ?? "",
    vibe: (r.vibe as string) ?? "",
    artKey: (r.art_key as string) ?? "",
    hook: (r.hook as string) ?? "",
    topic: (r.topic as string) ?? "",
    metrics: {
      views: r.views as number | null, likes: r.likes as number | null,
      comments: r.comments as number | null, shares: r.shares as number | null,
      saves: r.saves as number | null, reach: r.reach as number | null,
      watchTimeSec: r.watch_time_sec as number | null,
    },
    stickiness: r.stickiness as number | null,
    recordedAt: r.recorded_at as Date,
  }));
}

Run: npx vitest run tests/metrics-store.test.ts Expected: PASS.

git add src/metrics/store.ts tests/metrics-store.test.ts
git commit -m "feat(oannes-engine): Postgres store for metric snapshots"

Task 5: Collector orchestration

Files: - Create: src/metrics/collect.ts - Test: tests/metrics-collect.test.ts

// tests/metrics-collect.test.ts
import { describe, it, expect } from "vitest";
import { collectMetrics, type CollectDeps } from "../src/metrics/collect.js";
import type { MetricSnapshot } from "../src/metrics/store.js";
import type { AutoState } from "../src/auto/state.js";

const NOW = new Date("2026-06-16T12:00:00Z").getTime();

function mkState(): AutoState {
  return {
    rotation: { recentArt: [] },
    pending: [
      { claimId: "clm-1", vibe: "wonder", artKey: "energy", videoPath: "x.mp4",
        caption: "c", hashtags: [], youtubeTitle: "t", attribution: "A · B",
        messageId: "m1", createdAtMs: NOW - 3 * 24 * 3600 * 1000, status: "published",
        hook: "Is mind quantum?", topic: "consciousness",
        results: [{ platform: "instagram", ok: true, url: "https://ig/abc" }] },
      // a too-young published post (skipped by minAge)
      { claimId: "clm-2", vibe: "believer", artKey: "craft", videoPath: "y.mp4",
        caption: "c", hashtags: [], youtubeTitle: "t", attribution: "C · D",
        messageId: "m2", createdAtMs: NOW - 1000, status: "published",
        results: [{ platform: "youtube", ok: true, url: "https://yt/xyz" }] },
      // pending (not published) — ignored
      { claimId: "clm-3", vibe: "wonder", artKey: "cosmic", videoPath: "z.mp4",
        caption: "c", hashtags: [], youtubeTitle: "t", attribution: "E · F",
        messageId: "m3", createdAtMs: NOW, status: "pending" },
    ],
  };
}

describe("collectMetrics", () => {
  it("inserts a scored snapshot for each old published result, skipping young/pending", async () => {
    const inserted: MetricSnapshot[] = [];
    const deps: CollectDeps = {
      readState: async () => mkState(),
      getPublishedPosts: async () => [{ id: "pp_1", url: "https://ig/abc", platform: "instagram" }],
      getPostAnalytics: async () => ({ views: 1000, likes: 50, comments: 4, shares: 2, saves: 7, reach: 800, watchTimeSec: null }),
      insert: async (s) => { inserted.push(s); },
      now: () => NOW,
      minAgeMs: 24 * 3600 * 1000,
      logRaw: async () => {},
    };
    const r = await collectMetrics(deps);
    expect(r.inserted).toBe(1);
    expect(inserted[0].claimId).toBe("clm-1");
    expect(inserted[0].platform).toBe("instagram");
    expect(inserted[0].topic).toBe("consciousness");
    expect(inserted[0].publishedPostId).toBe("pp_1");
    expect(inserted[0].stickiness).toBeCloseTo(0.09, 5); // (50+8+6+28)/800 = 0.1150 -> see weights
  });

  it("skips a result whose url has no matching published post", async () => {
    const inserted: MetricSnapshot[] = [];
    const deps: CollectDeps = {
      readState: async () => mkState(),
      getPublishedPosts: async () => [], // no matches
      getPostAnalytics: async () => ({ views: 1, likes: 1, comments: null, shares: null, saves: null, reach: 1, watchTimeSec: null }),
      insert: async (s) => { inserted.push(s); },
      now: () => NOW, minAgeMs: 24 * 3600 * 1000, logRaw: async () => {},
    };
    const r = await collectMetrics(deps);
    expect(r.inserted).toBe(0);
    expect(r.skipped).toBeGreaterThan(0);
  });
});

Note: recompute the expected stickiness from the weights in Task 2 — (50 + 2*4 + 3*2 + 4*7)/800 = (50+8+6+28)/800 = 92/800 = 0.115. Update the toBeCloseTo to 0.115.

Run: npx vitest run tests/metrics-collect.test.ts Expected: FAIL — cannot find module.

// src/metrics/collect.ts
import type { AutoState } from "../auto/state.js";
import type { MetricSnapshot } from "./store.js";
import type { PublishedPost } from "./blotato.js";
import { matchPostByUrl } from "./blotato.js";
import { stickiness, type Platform, type RawMetrics } from "./stickiness.js";

export interface CollectDeps {
  readState: () => Promise<AutoState>;
  getPublishedPosts: () => Promise<PublishedPost[]>;
  getPostAnalytics: (publishedPostId: string) => Promise<RawMetrics>;
  insert: (snap: MetricSnapshot) => Promise<void>;
  now: () => number;
  minAgeMs: number;
  /** Log the first raw analytics payload so live field names can be verified. */
  logRaw: (publishedPostId: string) => Promise<void>;
}

const PLATFORMS = new Set(["instagram", "facebook", "youtube", "tiktok"]);

export async function collectMetrics(deps: CollectDeps): Promise<{ inserted: number; skipped: number }> {
  const state = await deps.readState();
  const posts = await deps.getPublishedPosts();
  const recordedAt = new Date(deps.now());
  let inserted = 0, skipped = 0, loggedRaw = false;

  for (const item of state.pending) {
    if (item.status !== "published" || !item.results) continue;
    if (deps.now() - item.createdAtMs < deps.minAgeMs) { skipped += item.results.length; continue; }

    for (const res of item.results) {
      if (!res.ok || !res.url || !PLATFORMS.has(res.platform)) { skipped++; continue; }
      const match = matchPostByUrl(posts, res.url);
      if (!match) { skipped++; continue; }

      if (!loggedRaw) { await deps.logRaw(match.id); loggedRaw = true; }
      const metrics = await deps.getPostAnalytics(match.id);
      const platform = res.platform as Platform;
      const snap: MetricSnapshot = {
        claimId: item.claimId, platform, publishedPostId: match.id, url: res.url,
        vibe: item.vibe, artKey: item.artKey, hook: item.hook ?? "", topic: item.topic ?? "",
        metrics, stickiness: stickiness(platform, metrics), recordedAt,
      };
      await deps.insert(snap);
      inserted++;
    }
  }
  return { inserted, skipped };
}

Run: npx vitest run tests/metrics-collect.test.ts Expected: PASS.

git add src/metrics/collect.ts tests/metrics-collect.test.ts
git commit -m "feat(oannes-engine): metrics collector orchestration"

Task 6: Add hook/topic to PendingItem and populate them

Files: - Modify: src/auto/state.ts (add optional fields) - Modify: src/auto/daily.ts:72-77 (populate them)

In src/auto/state.ts, inside PendingItem (after results?):

  // Learning-loop tags (optional; older items load without them):
  hook?: string;   // the reel's opening hook
  topic?: string;  // primary claim topic

In src/auto/daily.ts, change the item construction (currently lines 72-77) to include:

  const item: PendingItem = {
    claimId: brief.claimId, vibe, artKey: art.key, videoPath,
    caption: script.caption, hashtags: script.hashtags, youtubeTitle: script.youtubeTitle,
    attribution: `${script.attributionName} · ${script.attributionRole}`,
    messageId, createdAtMs: Date.now(), status: "pending",
    hook: script.hook, topic: brief.claim.topics[0] ?? "",
  };

Run: npm run typecheck Expected: no errors.

git add src/auto/state.ts src/auto/daily.ts
git commit -m "feat(oannes-engine): tag pending items with hook + topic for metrics"

Task 7: Wire collect-metrics CLI command

Files: - Modify: src/cli.ts (add command before the “Unknown command” line)

In src/cli.ts, before console.error(\Unknown command: ${cmd}`);`:

  if (cmd === "collect-metrics") {
    const databaseUrl = process.env.DATABASE_URL;
    if (!databaseUrl) { console.error("DATABASE_URL not set — skipping metrics collection"); return; }
    if (!cfg.keys.blotato) { console.error("BLOTATO_API_KEY not set — skipping"); return; }
    const blotato = cfg.keys.blotato;
    const { readState } = await import("./auto/state.js");
    const { getPublishedPosts, getPostAnalytics, getPostAnalyticsRaw } = await import("./metrics/blotato.js");
    const { withDb, ensureTable, insertSnapshot } = await import("./metrics/store.js");
    const { collectMetrics } = await import("./metrics/collect.js");
    try {
      const r = await withDb(databaseUrl, async (client) => {
        await ensureTable(client);
        return collectMetrics({
          readState: () => readState("renders/auto/state.json"),
          getPublishedPosts: () => getPublishedPosts(blotato),
          getPostAnalytics: (id) => getPostAnalytics(blotato, id),
          insert: (snap) => insertSnapshot(client, snap),
          now: () => Date.now(),
          minAgeMs: 24 * 60 * 60 * 1000,
          logRaw: async (id) => { console.log(`[raw analytics ${id}]`, JSON.stringify(await getPostAnalyticsRaw(blotato, id)).slice(0, 1000)); },
        });
      });
      console.log(`collect-metrics: inserted ${r.inserted}, skipped ${r.skipped}`);
    } catch (e) {
      console.error(`collect-metrics failed (non-fatal): ${String(e).slice(0, 300)}`);
    }
    return;
  }

Run: npm run typecheck Expected: no errors.

git add src/cli.ts
git commit -m "feat(oannes-engine): collect-metrics CLI command"

Task 8: Run the full suite for Commit 1

Run: npm test Expected: all tests pass (existing 34 + new), tsc clean.


COMMIT 2 — Field research + Analysis

Task 9: Field research priors

Files: - Create: src/metrics/field.ts - Test: tests/metrics-field.test.ts

// tests/metrics-field.test.ts
import { describe, it, expect } from "vitest";
import { buildFieldPrompt, parseFieldResponse, runFieldResearch } from "../src/metrics/field.js";

const sample = {
  tiktok:    { idealLengthSec: 21, hookStyles: ["question"], hashtagCount: 4, captionTips: "punchy", cadence: "1-2/day", notes: "trend audio" },
  instagram: { idealLengthSec: 30, hookStyles: ["bold claim"], hashtagCount: 5, captionTips: "emoji", cadence: "1/day", notes: "reels reach" },
  youtube:   { idealLengthSec: 45, hookStyles: ["mystery"], hashtagCount: 3, captionTips: "title-driven", cadence: "1/day", notes: "shorts shelf" },
  facebook:  { idealLengthSec: 30, hookStyles: ["story"], hashtagCount: 3, captionTips: "plain", cadence: "1/day", notes: "older audience" },
};

describe("buildFieldPrompt", () => {
  it("asks per-platform and demands fenced JSON", () => {
    const p = buildFieldPrompt();
    expect(p).toMatch(/tiktok/i);
    expect(p).toMatch(/```json/);
  });
});

describe("parseFieldResponse", () => {
  it("parses a fenced JSON payload into a FieldFile and stamps researchedAt", () => {
    const raw = "blah\n```json\n" + JSON.stringify(sample) + "\n```\n";
    const f = parseFieldResponse(raw, new Date("2026-06-16T00:00:00Z"));
    expect(f.tiktok.idealLengthSec).toBe(21);
    expect(f.researchedAt).toBe("2026-06-16T00:00:00.000Z");
  });
  it("throws on a payload missing a platform", () => {
    const bad = { tiktok: sample.tiktok };
    expect(() => parseFieldResponse("```json\n" + JSON.stringify(bad) + "\n```", new Date())).toThrow();
  });
});

describe("runFieldResearch", () => {
  it("calls the runner with the prompt and returns the parsed file", async () => {
    const run = async () => "```json\n" + JSON.stringify(sample) + "\n```";
    const f = await runFieldResearch(run, () => new Date("2026-06-16T00:00:00Z"));
    expect(f.youtube.hookStyles).toEqual(["mystery"]);
  });
});

Run: npx vitest run tests/metrics-field.test.ts Expected: FAIL — cannot find module.

// src/metrics/field.ts
import * as fs from "node:fs/promises";
import { z } from "zod";
import type { RunClaude } from "../script/scriptwriter.js";

export const PlatformPriorSchema = z.object({
  idealLengthSec: z.number(),
  hookStyles: z.array(z.string()),
  hashtagCount: z.number().int().min(0).max(30),
  captionTips: z.string(),
  cadence: z.string(),
  notes: z.string(),
});
export type PlatformPrior = z.infer<typeof PlatformPriorSchema>;

const FieldSchema = z.object({
  tiktok: PlatformPriorSchema,
  instagram: PlatformPriorSchema,
  youtube: PlatformPriorSchema,
  facebook: PlatformPriorSchema,
});
export interface FieldFile extends z.infer<typeof FieldSchema> {
  researchedAt: string;
  sources?: string[];
}

export function buildFieldPrompt(): string {
  return [
    "You are a short-form social strategist. For FACELESS short-form video (UFO/mysteries/consciousness niche),",
    "summarize what is CURRENTLY working as of mid-2026 on each platform: tiktok, instagram (reels), youtube (shorts), facebook (reels).",
    "For each platform give: idealLengthSec (number), hookStyles (array of short strings), hashtagCount (number),",
    "captionTips (one short string), cadence (string), notes (one short string).",
    "Be concrete and platform-specific (they differ). Return ONLY JSON inside a ```json fenced block:",
    '{"tiktok":{"idealLengthSec":0,"hookStyles":[],"hashtagCount":0,"captionTips":"","cadence":"","notes":""},"instagram":{...},"youtube":{...},"facebook":{...}}',
  ].join("\n");
}

function parseFenced(text: string): unknown {
  const m = text.match(/```json\s*([\s\S]*?)```/i) ?? text.match(/```\s*([\s\S]*?)```/);
  return JSON.parse((m ? m[1] : text).trim());
}

export function parseFieldResponse(raw: string, now: Date): FieldFile {
  const parsed = FieldSchema.parse(parseFenced(raw));
  return { ...parsed, researchedAt: now.toISOString() };
}

const execRunner: RunClaude = async (prompt) => {
  const { execFile } = await import("node:child_process");
  const { promisify } = await import("node:util");
  const { stdout } = await promisify(execFile)("claude", ["--print", prompt], { maxBuffer: 10 * 1024 * 1024 });
  return stdout;
};

export async function runFieldResearch(run: RunClaude = execRunner, now: () => Date = () => new Date()): Promise<FieldFile> {
  return parseFieldResponse(await run(buildFieldPrompt()), now());
}

export async function readFieldFile(path: string): Promise<FieldFile | null> {
  try {
    const parsed = FieldSchema.extend({ researchedAt: z.string(), sources: z.array(z.string()).optional() })
      .parse(JSON.parse(await fs.readFile(path, "utf8")));
    return parsed as FieldFile;
  } catch { return null; }
}

export async function writeFieldFile(path: string, f: FieldFile): Promise<void> {
  const p = await import("node:path");
  await fs.mkdir(p.dirname(path), { recursive: true });
  await fs.writeFile(path, JSON.stringify(f, null, 2));
}

Run: npx vitest run tests/metrics-field.test.ts Expected: PASS.

git add src/metrics/field.ts tests/metrics-field.test.ts
git commit -m "feat(oannes-engine): per-platform field-research priors"

Task 10: Analysis aggregation

Files: - Create: src/metrics/analyze.ts - Test: tests/metrics-analyze.test.ts

// tests/metrics-analyze.test.ts
import { describe, it, expect } from "vitest";
import { aggregate } from "../src/metrics/analyze.js";
import type { MetricSnapshot } from "../src/metrics/store.js";

const mk = (over: Partial<MetricSnapshot>): MetricSnapshot => ({
  claimId: "c", platform: "instagram", publishedPostId: "p", url: "u",
  vibe: "wonder", artKey: "energy", hook: "h", topic: "consciousness",
  metrics: { views: null, likes: null, comments: null, shares: null, saves: null, reach: null, watchTimeSec: null },
  stickiness: 0, recordedAt: new Date("2026-06-16T00:00:00Z"), ...over,
});

describe("aggregate", () => {
  it("computes per-platform mean stickiness per dimension value and sample size", () => {
    const rows: MetricSnapshot[] = [
      mk({ claimId: "1", platform: "instagram", vibe: "wonder", artKey: "energy", topic: "consciousness", stickiness: 0.4 }),
      mk({ claimId: "2", platform: "instagram", vibe: "wonder", artKey: "cosmic", topic: "dmt", stickiness: 0.2 }),
      mk({ claimId: "3", platform: "instagram", vibe: "believer", artKey: "craft", topic: "uap", stickiness: 0.8 }),
      mk({ claimId: "4", platform: "youtube", vibe: "wonder", artKey: "energy", topic: "consciousness", stickiness: 0.1 }),
    ];
    const w = aggregate(rows, new Date("2026-06-16T00:00:00Z"));
    expect(w.instagram.vibe.wonder).toBeCloseTo(0.3, 5); // (0.4+0.2)/2
    expect(w.instagram.vibe.believer).toBeCloseTo(0.8, 5);
    expect(w.instagram.artKey.energy).toBeCloseTo(0.4, 5);
    expect(w.instagram.sampleSize).toBe(3);
    expect(w.youtube.sampleSize).toBe(1);
    expect(w.updatedAt).toBe("2026-06-16T00:00:00.000Z");
  });

  it("ignores rows with null stickiness", () => {
    const rows = [
      mk({ claimId: "1", platform: "tiktok", vibe: "wonder", stickiness: null }),
      mk({ claimId: "2", platform: "tiktok", vibe: "wonder", stickiness: 0.5 }),
    ];
    const w = aggregate(rows, new Date());
    expect(w.tiktok.vibe.wonder).toBeCloseTo(0.5, 5);
    expect(w.tiktok.sampleSize).toBe(1);
  });
});

Run: npx vitest run tests/metrics-analyze.test.ts Expected: FAIL — cannot find module.

// src/metrics/analyze.ts
import * as fs from "node:fs/promises";
import { z } from "zod";
import type { MetricSnapshot } from "./store.js";
import type { Platform } from "./stickiness.js";

export interface PlatformWeights {
  vibe: Record<string, number>;
  artKey: Record<string, number>;
  topic: Record<string, number>;
  sampleSize: number;
}
export interface WeightsFile {
  instagram: PlatformWeights; facebook: PlatformWeights;
  youtube: PlatformWeights; tiktok: PlatformWeights;
  updatedAt: string;
}

const PLATFORMS: Platform[] = ["instagram", "facebook", "youtube", "tiktok"];
const empty = (): PlatformWeights => ({ vibe: {}, artKey: {}, topic: {}, sampleSize: 0 });

function meanByKey(rows: MetricSnapshot[], key: (r: MetricSnapshot) => string): Record<string, number> {
  const sum: Record<string, number> = {}, cnt: Record<string, number> = {};
  for (const r of rows) {
    if (r.stickiness == null) continue;
    const k = key(r);
    if (!k) continue;
    sum[k] = (sum[k] ?? 0) + r.stickiness;
    cnt[k] = (cnt[k] ?? 0) + 1;
  }
  const out: Record<string, number> = {};
  for (const k of Object.keys(sum)) out[k] = sum[k] / cnt[k];
  return out;
}

export function aggregate(rows: MetricSnapshot[], now: Date): WeightsFile {
  const file = { updatedAt: now.toISOString() } as WeightsFile;
  for (const p of PLATFORMS) {
    const scored = rows.filter((r) => r.platform === p && r.stickiness != null);
    file[p] = {
      vibe: meanByKey(scored, (r) => r.vibe),
      artKey: meanByKey(scored, (r) => r.artKey),
      topic: meanByKey(scored, (r) => r.topic),
      sampleSize: scored.length,
    };
  }
  return file;
}

const PlatformWeightsSchema = z.object({
  vibe: z.record(z.number()), artKey: z.record(z.number()),
  topic: z.record(z.number()), sampleSize: z.number(),
});
const WeightsFileSchema = z.object({
  instagram: PlatformWeightsSchema, facebook: PlatformWeightsSchema,
  youtube: PlatformWeightsSchema, tiktok: PlatformWeightsSchema, updatedAt: z.string(),
});

export async function readWeightsFile(path: string): Promise<WeightsFile | null> {
  try { return WeightsFileSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))) as WeightsFile; }
  catch { return null; }
}

export async function writeWeightsFile(path: string, w: WeightsFile): Promise<void> {
  const p = await import("node:path");
  await fs.mkdir(p.dirname(path), { recursive: true });
  await fs.writeFile(path, JSON.stringify(w, null, 2));
}

Run: npx vitest run tests/metrics-analyze.test.ts Expected: PASS.

git add src/metrics/analyze.ts tests/metrics-analyze.test.ts
git commit -m "feat(oannes-engine): per-platform metric aggregation"

Task 11: Wire field-research + analyze-metrics CLI commands

Files: - Modify: src/cli.ts

  if (cmd === "field-research") {
    const { runFieldResearch, writeFieldFile } = await import("./metrics/field.js");
    try {
      const f = await runFieldResearch();
      await writeFieldFile("renders/metrics/field.json", f);
      console.log(`field-research: wrote renders/metrics/field.json (${f.researchedAt})`);
    } catch (e) { console.error(`field-research failed (non-fatal): ${String(e).slice(0, 300)}`); }
    return;
  }
  if (cmd === "analyze-metrics") {
    const databaseUrl = process.env.DATABASE_URL;
    if (!databaseUrl) { console.error("DATABASE_URL not set — skipping analysis"); return; }
    const { withDb, ensureTable, fetchLatestSnapshots } = await import("./metrics/store.js");
    const { aggregate, writeWeightsFile } = await import("./metrics/analyze.js");
    try {
      const w = await withDb(databaseUrl, async (client) => {
        await ensureTable(client);
        return aggregate(await fetchLatestSnapshots(client), new Date());
      });
      await writeWeightsFile("renders/metrics/weights.json", w);
      const sizes = `ig=${w.instagram.sampleSize} fb=${w.facebook.sampleSize} yt=${w.youtube.sampleSize} tt=${w.tiktok.sampleSize}`;
      console.log(`analyze-metrics: wrote renders/metrics/weights.json (${sizes})`);
    } catch (e) { console.error(`analyze-metrics failed (non-fatal): ${String(e).slice(0, 300)}`); }
    return;
  }

Run: npm run typecheck Expected: no errors.

git add src/cli.ts
git commit -m "feat(oannes-engine): field-research + analyze-metrics CLI commands"

COMMIT 3 — Apply (gated bias into generation)

Task 12: Apply helpers (pure)

Files: - Create: src/metrics/apply.ts - Test: tests/metrics-apply.test.ts

// tests/metrics-apply.test.ts
import { describe, it, expect } from "vitest";
import { blendedDimensionScores, pickVibeWithExploration, biasArtKey, perPlatformHashtags } from "../src/metrics/apply.js";
import type { WeightsFile } from "../src/metrics/analyze.js";
import type { FieldFile } from "../src/metrics/field.js";

const emptyPW = () => ({ vibe: {}, artKey: {}, topic: {}, sampleSize: 0 });
const weights: WeightsFile = {
  instagram: { vibe: { wonder: 0.2, believer: 0.6 }, artKey: { energy: 0.3 }, topic: {}, sampleSize: 10 },
  facebook: emptyPW(), youtube: { vibe: { wonder: 0.9, believer: 0.1 }, artKey: {}, topic: {}, sampleSize: 10 },
  tiktok: emptyPW(), updatedAt: "x",
};

describe("blendedDimensionScores", () => {
  it("returns {} when weights is null (gating → no bias)", () => {
    expect(blendedDimensionScores(null, "vibe", 5)).toEqual({});
  });
  it("money-weights platforms and scales by confidence (sampleSize/(sampleSize+K))", () => {
    const s = blendedDimensionScores(weights, "vibe", 0); // K=0 → full confidence
    // wonder: ig 0.2*0.20 + yt 0.9*0.35 = 0.04 + 0.315 = 0.355
    // believer: ig 0.6*0.20 + yt 0.1*0.35 = 0.12 + 0.035 = 0.155
    expect(s.wonder).toBeCloseTo(0.355, 3);
    expect(s.believer).toBeCloseTo(0.155, 3);
  });
});

describe("pickVibeWithExploration", () => {
  it("exploits the argmax when rng > epsilon", () => {
    const v = pickVibeWithExploration({ wonder: 0.355, believer: 0.155 }, ["wonder", "believer"], () => 0.99, 0.2);
    expect(v).toBe("wonder");
  });
  it("explores (rng < epsilon) by indexing into candidates", () => {
    // rng=0.0 → first candidate; with 2 candidates floor(0.0*2)=0
    const v = pickVibeWithExploration({ wonder: 0.9, believer: 0.1 }, ["wonder", "believer"], () => 0.0, 0.5);
    expect(["wonder", "believer"]).toContain(v);
  });
  it("falls back to candidates[0] when there are no scores", () => {
    expect(pickVibeWithExploration({}, ["wonder", "believer"], () => 0.99, 0.2)).toBe("wonder");
  });
});

describe("biasArtKey", () => {
  it("picks the highest-scoring fresh candidate, respecting recentKeys", () => {
    const scores = { energy: 0.1, cosmic: 0.9 };
    const k = biasArtKey(["energy", "cosmic", "abyss"], ["cosmic"], scores, () => 0.99, 0.0);
    expect(k).toBe("energy"); // cosmic excluded as recent → energy is the only scored fresh one
  });
  it("falls back to first fresh candidate when unscored", () => {
    const k = biasArtKey(["energy", "cosmic"], ["energy"], {}, () => 0.99, 0.0);
    expect(k).toBe("cosmic");
  });
});

describe("perPlatformHashtags", () => {
  const field: FieldFile = {
    tiktok: { idealLengthSec: 21, hookStyles: [], hashtagCount: 3, captionTips: "", cadence: "", notes: "" },
    instagram: { idealLengthSec: 30, hookStyles: [], hashtagCount: 5, captionTips: "", cadence: "", notes: "" },
    youtube: { idealLengthSec: 45, hookStyles: [], hashtagCount: 2, captionTips: "", cadence: "", notes: "" },
    facebook: { idealLengthSec: 30, hookStyles: [], hashtagCount: 4, captionTips: "", cadence: "", notes: "" },
    researchedAt: "x",
  };
  const tags = ["#a", "#b", "#c", "#d", "#e", "#f"];
  it("caps per platform from field priors, never above 5 for instagram", () => {
    expect(perPlatformHashtags(tags, "youtube", field)).toEqual(["#a", "#b"]);
    expect(perPlatformHashtags(tags, "instagram", field)).toEqual(["#a", "#b", "#c", "#d", "#e"]);
  });
  it("falls back to today's behavior when field is null (IG=5, others all)", () => {
    expect(perPlatformHashtags(tags, "instagram", null)).toEqual(["#a", "#b", "#c", "#d", "#e"]);
    expect(perPlatformHashtags(tags, "facebook", null)).toEqual(tags);
  });
});

Run: npx vitest run tests/metrics-apply.test.ts Expected: FAIL — cannot find module.

// src/metrics/apply.ts
import type { WeightsFile } from "./analyze.js";
import type { FieldFile } from "./field.js";
import type { Platform } from "./stickiness.js";
import type { Vibe } from "../script/reelscript.js";

/** Money-weighted platform priority (sums to 1). YouTube/TikTok lead per "money fast". */
export const PLATFORM_PRIORITY: Record<Platform, number> = {
  youtube: 0.35, tiktok: 0.30, instagram: 0.20, facebook: 0.15,
};

const PLATFORMS: Platform[] = ["instagram", "facebook", "youtube", "tiktok"];

/**
 * Blend a dimension's per-platform scores into one money-weighted map,
 * scaling each platform's contribution by confidence = sampleSize/(sampleSize+K).
 * Returns {} when weights is null (gating → caller keeps default behavior).
 */
export function blendedDimensionScores(
  weights: WeightsFile | null, dim: "vibe" | "artKey" | "topic", K: number,
): Record<string, number> {
  if (!weights) return {};
  const out: Record<string, number> = {};
  for (const p of PLATFORMS) {
    const pw = weights[p];
    const confidence = pw.sampleSize / (pw.sampleSize + K);
    const w = PLATFORM_PRIORITY[p] * confidence;
    for (const [value, score] of Object.entries(pw[dim])) {
      out[value] = (out[value] ?? 0) + score * w;
    }
  }
  return out;
}

function argmax(scores: Record<string, number>, candidates: string[]): string | null {
  let best: string | null = null, bestVal = -Infinity;
  for (const c of candidates) {
    const v = scores[c];
    if (typeof v === "number" && v > bestVal) { bestVal = v; best = c; }
  }
  return best;
}

/** Epsilon-greedy vibe pick: explore with prob epsilon, else exploit argmax. */
export function pickVibeWithExploration(
  scores: Record<string, number>, candidates: Vibe[], rng: () => number, epsilon: number,
): Vibe {
  if (rng() < epsilon) return candidates[Math.floor(rng() * candidates.length)] ?? candidates[0];
  return (argmax(scores, candidates) as Vibe) ?? candidates[0];
}

/** Pick an art key among FRESH candidates (not in recentKeys), epsilon-greedy by score. */
export function biasArtKey(
  candidates: string[], recentKeys: string[], scores: Record<string, number>,
  rng: () => number, epsilon: number,
): string {
  const fresh = candidates.filter((k) => !recentKeys.includes(k));
  const pool = fresh.length ? fresh : candidates;
  if (rng() < epsilon) return pool[Math.floor(rng() * pool.length)] ?? pool[0];
  return argmax(scores, pool) ?? pool[0];
}

/** Per-platform hashtag set. IG is always hard-capped at 5. Null field → today's behavior. */
export function perPlatformHashtags(hashtags: string[], platform: Platform, field: FieldFile | null): string[] {
  const igCap = 5;
  if (!field) return platform === "instagram" ? hashtags.slice(0, igCap) : hashtags;
  const want = field[platform].hashtagCount;
  const cap = platform === "instagram" ? Math.min(want, igCap) : want;
  return hashtags.slice(0, Math.max(0, cap));
}

Run: npx vitest run tests/metrics-apply.test.ts Expected: PASS.

git add src/metrics/apply.ts tests/metrics-apply.test.ts
git commit -m "feat(oannes-engine): learning-bias helpers (blend, epsilon-greedy, per-platform text)"

Task 13: Learned daily planner (gated)

Files: - Modify: src/auto/rotate.ts (add planTodayLearned) - Test: tests/rotate-learned.test.ts

// tests/rotate-learned.test.ts
import { describe, it, expect } from "vitest";
import { planTodayLearned } from "../src/auto/rotate.js";
import type { WeightsFile } from "../src/metrics/analyze.js";

const emptyPW = () => ({ vibe: {}, artKey: {}, topic: {}, sampleSize: 0 });

describe("planTodayLearned", () => {
  it("falls back to alternation when weights is null (today's behavior)", () => {
    const { vibe } = planTodayLearned({ lastVibe: "wonder", recentArt: [] }, null, () => 0.99, 0);
    expect(vibe).toBe("believer");
  });

  it("biases vibe toward the learned winner when exploiting", () => {
    const weights: WeightsFile = {
      instagram: emptyPW(), facebook: emptyPW(),
      youtube: { vibe: { wonder: 0.9, believer: 0.1 }, artKey: {}, topic: {}, sampleSize: 10 },
      tiktok: emptyPW(), updatedAt: "x",
    };
    const { vibe, art } = planTodayLearned({ recentArt: [] }, weights, () => 0.99, 0);
    expect(vibe).toBe("wonder");
    expect(art.key).toBeTruthy();
  });
});

Run: npx vitest run tests/rotate-learned.test.ts Expected: FAIL — planTodayLearned not exported.

import type { WeightsFile } from "../metrics/analyze.js";
import { blendedDimensionScores, pickVibeWithExploration, biasArtKey } from "../metrics/apply.js";
import { ART_DIRECTIONS } from "../render/styles.js";

const CONFIDENCE_K = 8;     // measured-dominant once sampleSize >> 8
const EPSILON = 0.2;        // 20% exploration to keep learning alive
const VIBE_CANDIDATES: Vibe[] = ["wonder", "believer"];
const VIBE_ART: Record<Vibe, string[]> = {
  wonder: ["energy", "cosmic", "abyss", "liminal"],
  believer: ["declassified", "craft", "cosmic", "ruins"],
};

/**
 * Learned daily plan. With null weights, identical to planToday (alternation +
 * no-consecutive-repeat art). With weights, epsilon-greedy money-weighted bias.
 */
export function planTodayLearned(
  state: RotationState, weights: WeightsFile | null,
  rng: () => number = Math.random, epsilon: number = EPSILON,
): { vibe: Vibe; art: ArtDirection } {
  if (!weights) return planToday(state);
  const vibeScores = blendedDimensionScores(weights, "vibe", CONFIDENCE_K);
  const vibe = Object.keys(vibeScores).length
    ? pickVibeWithExploration(vibeScores, VIBE_CANDIDATES, rng, epsilon)
    : nextVibe(state);
  const artScores = blendedDimensionScores(weights, "artKey", CONFIDENCE_K);
  const artKey = biasArtKey(VIBE_ART[vibe], state.recentArt, artScores, rng, epsilon);
  return { vibe, art: ART_DIRECTIONS[artKey] };
}

Run: npx vitest run tests/rotate-learned.test.ts Expected: PASS.

git add src/auto/rotate.ts tests/rotate-learned.test.ts
git commit -m "feat(oannes-engine): learned daily planner (gated, epsilon-greedy)"

Task 14: Field-guided script prompt (gated)

Files: - Modify: src/script/reelscript.ts (buildReelPrompt takes optional platform-blended guidance) - Test: tests/reelscript-field.test.ts

The reel is one render, so we feed a single blended length/hook guidance (money-weighted toward YouTube/TikTok). Absent field → prompt unchanged.

// tests/reelscript-field.test.ts
import { describe, it, expect } from "vitest";
import { buildReelPrompt } from "../src/script/reelscript.js";
import type { ContentBrief } from "../src/types.js";

const brief: ContentBrief = {
  claimId: "c", format: "carousel",
  claim: { id: "c", claimText: "X claims Y about consciousness and reality here.", claimant: "Dr X",
    source: "Ep", topics: ["consciousness"], status: "", dateDisplay: "", file: "" },
};

describe("buildReelPrompt with field guidance", () => {
  it("is unchanged when no guidance is given", () => {
    const p = buildReelPrompt(brief, "wonder");
    expect(p).not.toMatch(/CURRENT BEST PRACTICES/);
  });
  it("injects best-practice guidance when provided", () => {
    const p = buildReelPrompt(brief, "wonder", { idealLengthSec: 22, hookStyles: ["question hook", "bold claim"] });
    expect(p).toMatch(/CURRENT BEST PRACTICES/);
    expect(p).toMatch(/22/);
    expect(p).toMatch(/question hook/);
  });
});

Run: npx vitest run tests/reelscript-field.test.ts Expected: FAIL — buildReelPrompt takes 2 args.

export interface ScriptGuidance { idealLengthSec: number; hookStyles: string[]; }

export function buildReelPrompt(brief: ContentBrief, vibe: Vibe, guidance?: ScriptGuidance): string {
  const c = brief.claim;
  const tone =
    vibe === "wonder"
      ? "REFLECTIVE AWE & WONDER — calm, profound, invites the viewer to feel the mystery of mind/reality/the universe."
      : "PROVOCATIVE & GRIPPING — bold, edge-of-your-seat intrigue about a wild claim, but never stated as settled fact.";
  const lines = [
    "You write a 25-35 second faceless short-form VIDEO for a UFO/mysteries channel called Oannes.",
    `TONE: ${tone}`,
  ];
  if (guidance) {
    lines.push(
      `CURRENT BEST PRACTICES (bias toward these): aim for ~${guidance.idealLengthSec}s; ` +
      `favor these hook styles: ${guidance.hookStyles.join("; ")}.`,
    );
  }
  lines.push(
    "HARD RULES:",
    "- EVERY claim must be ATTRIBUTED to the person who made it ('X says/claims...'). Never assert a contested claim as objective fact.",
    "- Never fabricate. Do not copy the source's exact wording — paraphrase in your own words.",
    "- Do NOT make serious criminal accusations against named living private individuals.",
    "",
    `CLAIM: ${c.claimText}`,
    `CLAIMANT (attribute to this person): ${c.claimant}`,
    `TOPICS: ${c.topics.join(", ")}`,
    "",
    "Write a spoken narration (~60-95 words, conversational, hooky first sentence), and break the SAME narration into short on-screen caption chunks (3-6 words each, in spoken order).",
    'Return ONLY JSON inside a ```json fenced block:',
    '{"hook":"<=7 word punchy opener","narration":"<the full VO script>","captionChunks":["...","..."],"attributionName":"<person>","attributionRole":"<short role>","caption":"<IG/FB caption, 1-2 emoji, ends with a question>","youtubeTitle":"<=90 chars, ends with #Shorts","hashtags":["...4-7..."]}',
  );
  return lines.join("\n");
}

Then update writeReelScript to accept + forward optional guidance:

export async function writeReelScript(
  brief: ContentBrief, vibe: Vibe, run: RunClaude = runClaudeCli, guidance?: ScriptGuidance,
): Promise<ReelScript> {
  const raw = await run(buildReelPrompt(brief, vibe, guidance));
  return ReelSchema.parse(parseFencedJson(raw)) as ReelScript;
}

Run: npx vitest run tests/reelscript-field.test.ts Expected: PASS. Also run npm run typecheck (existing callers pass 2-3 args; the new param is optional → no break).

git add src/script/reelscript.ts tests/reelscript-field.test.ts
git commit -m "feat(oannes-engine): field-guided reel script prompt (gated)"

Task 15: Per-platform hashtags at publish (gated)

Files: - Modify: src/publish/publishAll.ts (publishReelToAll accepts optional field; use perPlatformHashtags) - Test: tests/publishall-field.test.ts

// tests/publishall-field.test.ts
import { describe, it, expect } from "vitest";
import { platformTexts } from "../src/publish/publishAll.js";
import type { FieldFile } from "../src/metrics/field.js";

const field: FieldFile = {
  tiktok: { idealLengthSec: 21, hookStyles: [], hashtagCount: 2, captionTips: "", cadence: "", notes: "" },
  instagram: { idealLengthSec: 30, hookStyles: [], hashtagCount: 5, captionTips: "", cadence: "", notes: "" },
  youtube: { idealLengthSec: 45, hookStyles: [], hashtagCount: 1, captionTips: "", cadence: "", notes: "" },
  facebook: { idealLengthSec: 30, hookStyles: [], hashtagCount: 3, captionTips: "", cadence: "", notes: "" },
  researchedAt: "x",
};

describe("platformTexts", () => {
  it("uses field hashtag counts per platform when field present", () => {
    const t = platformTexts("caption", ["#a", "#b", "#c", "#d", "#e", "#f"], field);
    expect(t.youtube).toContain("#a");
    expect(t.youtube).not.toContain("#b"); // youtube count = 1
    expect(t.tiktok.match(/#/g)?.length).toBe(2);
  });
  it("falls back to today's behavior (IG=5, others all) when field null", () => {
    const t = platformTexts("caption", ["#a", "#b", "#c", "#d", "#e", "#f"], null);
    expect(t.instagram.match(/#/g)?.length).toBe(5);
    expect(t.facebook.match(/#/g)?.length).toBe(6);
  });
});

Run: npx vitest run tests/publishall-field.test.ts Expected: FAIL — platformTexts not exported.

import { formatPostText } from "./blotato.js";
import { perPlatformHashtags } from "../metrics/apply.js";
import type { FieldFile } from "../metrics/field.js";
import type { Platform } from "../metrics/stickiness.js";

export function platformTexts(caption: string, hashtags: string[], field: FieldFile | null): Record<Platform, string> {
  const mk = (p: Platform) => formatPostText(caption, perPlatformHashtags(hashtags, p, field));
  return { instagram: mk("instagram"), facebook: mk("facebook"), youtube: mk("youtube"), tiktok: mk("tiktok") };
}

Then change publishReelToAll to accept an optional field and use platformTexts instead of the hand-rolled igText/fbText/ytText:

export async function publishReelToAll(
  apiKey: string, item: PublishItem, f: typeof fetch = fetch, field: FieldFile | null = null,
): Promise<PlatformResult[]> {
  const videoUrl = await uploadVideo(apiKey, item.videoPath, f);
  const text = platformTexts(item.caption, item.hashtags, field);

  const targets: { platform: PlatformResult["platform"]; submit: () => Promise<string | undefined> }[] = [
    { platform: "instagram", submit: async () => (await publishVideo({ apiKey, dryRun: false, platform: "instagram", accountId: ACCOUNTS.instagram, videoUrl, text: text.instagram }, f)).response as never },
    { platform: "facebook", submit: async () => (await publishVideo({ apiKey, dryRun: false, platform: "facebook", accountId: ACCOUNTS.facebook, facebookPageId: ACCOUNTS.facebookPageId, videoUrl, text: text.facebook }, f)).response as never },
    { platform: "youtube", submit: async () => (await publishVideo({ apiKey, dryRun: false, platform: "youtube", accountId: ACCOUNTS.youtube, videoUrl, text: text.youtube, youtubeTitle: item.youtubeTitle, youtubePrivacy: "public" }, f)).response as never },
    { platform: "tiktok", submit: async () => (await publishVideo({ apiKey, dryRun: false, platform: "tiktok", accountId: ACCOUNTS.tiktok, videoUrl, text: text.tiktok }, f)).response as never },
  ];
  // ... rest unchanged (the for-loop polling) ...
}

Run: npx vitest run tests/publishall-field.test.ts Expected: PASS. Run npm run typecheckapprovals.ts calls publishReelToAll(blotato, item) with 2 args; the new param is optional → no break.

git add src/publish/publishAll.ts tests/publishall-field.test.ts
git commit -m "feat(oannes-engine): per-platform hashtag text at publish (gated)"

Task 16: Wire the learned planner + field-guided script + per-platform publish into the live flow

Files: - Modify: src/auto/daily.ts (use planTodayLearned + field guidance) - Modify: src/auto/approvals.ts (pass field to publishReelToAll)

Replace the planning + script lines in src/auto/daily.ts:

  const state = await readState(STATE_FILE);
  const { readWeightsFile } = await import("../metrics/analyze.js");
  const { readFieldFile } = await import("../metrics/field.js");
  const { planTodayLearned } = await import("./rotate.js");
  const { blendedDimensionScores } = await import("../metrics/apply.js");
  const weights = await readWeightsFile("renders/metrics/weights.json");
  const field = await readFieldFile("renders/metrics/field.json");
  const { vibe, art } = planTodayLearned(state.rotation, weights);
  console.log(`today: vibe=${vibe} art=${art.label}${weights ? " (learned)" : ""}`);

And build the script with blended length/hook guidance (money-weighted toward YT/TikTok via field priors):

  // Single blended guidance for the one shared render (field priors, YT/TikTok-leaning).
  const guidance = field
    ? { idealLengthSec: Math.round((field.youtube.idealLengthSec + field.tiktok.idealLengthSec) / 2),
        hookStyles: [...field.tiktok.hookStyles, ...field.youtube.hookStyles].slice(0, 4) }
    : undefined;
  const script = await writeReelScript(brief, vibe, undefined, guidance);

(Keep the import { planToday } from "./rotate.js" only if still referenced; otherwise remove the now-unused planToday import to satisfy tsc.)

In src/auto/approvals.ts, before the loop:

  const { readFieldFile } = await import("../metrics/field.js");
  const field = await readFieldFile("renders/metrics/field.json");

And change the publish call:

      const results = await publishReelToAll(blotato, item, undefined, field);

Run: npm test Expected: all pass, tsc clean.

git add src/auto/daily.ts src/auto/approvals.ts
git commit -m "feat(oannes-engine): wire learned planner + field guidance into daily flow"

Task 17: launchd jobs + docs

Files: - Create: deploy/land.unicorn.oannes.collect-metrics.plist - Create: deploy/land.unicorn.oannes.field-research.plist - Modify: deploy/README.md

Run: cat deploy/land.unicorn.oannes.generate-daily.plist 2>/dev/null || ls deploy/ Expected: see the PATH pinning (nvm node path) and engine invocation to copy.

Mirror the existing generate-daily plist exactly (same EnvironmentVariables/PATH, same working dir, same node path), but: run a small wrapper that does both collect-metrics then analyze-metrics once/day at 18:00. Use ProgramArguments invoking the project’s run script twice via -c "... collect-metrics; ... analyze-metrics", or two <plist>s. Use the same npm run engine -- collect-metrics style the other plists use. Schedule StartCalendarInterval Hour 18, Minute 0.

Same template; schedule weekly (e.g. StartCalendarInterval with Weekday 1, Hour 8) running engine -- field-research.

Add a “Learning loop” section: what each job does, how to load them (launchctl load ~/Library/LaunchAgents/...), where logs go, and the note that generation is gated on renders/metrics/weights.json + field.json (absent → today’s behavior).

git add deploy/
git commit -m "feat(oannes-engine): launchd jobs for collect/analyze + field-research"

Task 18: Final verification

Run: npm test Expected: all green, tsc clean.

Run: npm run engine -- analyze-metrics (with no DATABASE_URL) → expect the “skipping analysis” message and exit 0. Run: npm run engine -- collect-metrics (with no DATABASE_URL) → expect the “skipping” message.

Confirm renders/metrics/weights.json and field.json do not yet exist → generate-daily planning falls back to alternation. (Code path: planTodayLearned(state, null)planToday(state).)


Self-Review

Spec coverage: - Collector (Blotato analytics → snapshots) → Tasks 2–8 ✓ - Storage = Cambium Postgres own table → Task 4 ✓ - Stickiness = engagement composite + watch-time → Task 2 ✓ - Field research (continual best-practices) → Task 9 ✓ - Analysis per-platform weights → Task 10 ✓ - Apply: money-weighted blend, confidence blend, epsilon-greedy, per-platform text → Tasks 12–16 ✓ - Don’t break the daily machine (gating) → Tasks 13/14/15/16/18 (all gated on file presence) ✓ - launchd jobs → Task 17 ✓

Placeholder scan: Task 17 steps 2–3 describe plist creation rather than showing full XML, because the exact PATH/node-version string must be copied from the live machine’s existing plist (step 1 surfaces it) — this is a deliberate “match the existing file” instruction, not a TODO. All code steps contain complete code.

Type consistency: Platform/RawMetrics defined once in stickiness.ts, imported by blotato/store/collect/apply/publishAll. MetricSnapshot in store.ts used by collect/analyze. WeightsFile/PlatformWeights in analyze.ts used by apply/rotate. FieldFile/PlatformPrior in field.ts used by apply/publishAll/daily. ScriptGuidance in reelscript.ts. planTodayLearned, blendedDimensionScores, pickVibeWithExploration, biasArtKey, perPlatformHashtags, platformTexts names are consistent across definition and call sites.

Note for Task 5: the in-test expected stickiness must be recomputed from Task 2 weights — (50 + 2·4 + 3·2 + 4·7)/800 = 0.115 (the test comment says update toBeCloseTo to 0.115).