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
- [ ] Step 1: Install pg
Run (in oannes-engine/):
npm install pg && npm install -D @types/pg
Expected: pg appears in dependencies, @types/pg in devDependencies, no errors.
- [ ] Step 2: Commit
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
- [ ] Step 1: Write the failing test
// 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);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/stickiness.test.ts
Expected: FAIL — cannot find module ../src/metrics/stickiness.js.
- [ ] Step 3: Write minimal implementation
// 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;
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/stickiness.test.ts
Expected: PASS (5 tests).
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing test
// 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);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/metrics-blotato.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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(() => ({}));
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/metrics-blotato.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing test
// 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");
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/metrics-store.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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,
}));
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/metrics-store.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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
- [ ] Step 1: Write the failing test
// 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.
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/metrics-collect.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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 };
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/metrics-collect.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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)
- [ ] Step 1: Add the fields to the type
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
- [ ] Step 2: Populate them at generation
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] ?? "",
};
- [ ] Step 3: Verify the build typechecks
Run: npm run typecheck
Expected: no errors.
- [ ] Step 4: Commit
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)
- [ ] Step 1: Add the command
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;
}
- [ ] Step 2: Verify typecheck
Run: npm run typecheck
Expected: no errors.
- [ ] Step 3: Commit
git add src/cli.ts
git commit -m "feat(oannes-engine): collect-metrics CLI command"
Task 8: Run the full suite for Commit 1
- [ ] Step 1: Full test + typecheck
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
- [ ] Step 1: Write the failing test
// 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"]);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/metrics-field.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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));
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/metrics-field.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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
- [ ] Step 1: Write the failing test
// 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);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/metrics-analyze.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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));
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/metrics-analyze.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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
- [ ] Step 1: Add both commands (before the “Unknown command” line)
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;
}
- [ ] Step 2: Verify typecheck
Run: npm run typecheck
Expected: no errors.
- [ ] Step 3: Commit
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
- [ ] Step 1: Write the failing test
// 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);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/metrics-apply.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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));
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/metrics-apply.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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
- [ ] Step 1: Write the failing test
// 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();
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/rotate-learned.test.ts
Expected: FAIL — planTodayLearned not exported.
- [ ] Step 3: Implement — add to
src/auto/rotate.ts:
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] };
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/rotate-learned.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing test
// 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/);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/reelscript-field.test.ts
Expected: FAIL — buildReelPrompt takes 2 args.
- [ ] Step 3: Implement — modify
buildReelPromptsignature + body insrc/script/reelscript.ts:
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;
}
- [ ] Step 4: Run test to verify it passes
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).
- [ ] Step 5: Commit
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
- [ ] Step 1: Write the failing test
// 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);
});
});
- [ ] Step 2: Run test to verify it fails
Run: npx vitest run tests/publishall-field.test.ts
Expected: FAIL — platformTexts not exported.
- [ ] Step 3: Implement — in
src/publish/publishAll.ts, add an exported helper and use it inpublishReelToAll:
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) ...
}
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run tests/publishall-field.test.ts
Expected: PASS. Run npm run typecheck — approvals.ts calls publishReelToAll(blotato, item) with 2 args; the new param is optional → no break.
- [ ] Step 5: Commit
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)
- [ ] Step 1: Update daily.ts to read weights + field and use them
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.)
- [ ] Step 2: Update approvals.ts to pass field
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);
- [ ] Step 3: Verify typecheck + full suite
Run: npm test
Expected: all pass, tsc clean.
- [ ] Step 4: Commit
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
- [ ] Step 1: Inspect an existing plist for the exact format
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.
- [ ] Step 2: Create the collect+analyze plist
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.
- [ ] Step 3: Create the field-research plist
Same template; schedule weekly (e.g. StartCalendarInterval with Weekday 1, Hour 8) running engine -- field-research.
- [ ] Step 4: Document load/unload + the gating behavior in
deploy/README.md
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).
- [ ] Step 5: Commit
git add deploy/
git commit -m "feat(oannes-engine): launchd jobs for collect/analyze + field-research"
Task 18: Final verification
- [ ] Step 1: Full suite
Run: npm test
Expected: all green, tsc clean.
- [ ] Step 2: Dry exercise the new CLI paths (no DB writes if DATABASE_URL unset)
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.
- [ ] Step 3: Confirm the daily machine is unaffected
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).