hvidom@dublin ~/content-factory $ cat packages/types/src/merlin-skills.tscat packages/types/src/merlin-skills.ts
# One typed table drives the editor’s handles, the validator and the engine’s routing.
/** Node outputs: at most one edge from each; a node with no edge on an output ends the run. */
export const SKILL_PORTS: Record<SkillNodeType, readonly string[]> = {
"trigger.lead": ["next"],
"wait.delay": ["next"],
"wait.hours": ["next"],
"if.hours": ["yes", "no"],
"if.lead": ["yes", "no"],
"split.ab": ["a", "b"],
"ai.intent": ["booking", "quote", "warranty", "question", "other"],
"whatsapp.template": ["sent", "failed"],
"message.send": ["sent", "failed"],
"wait.reply": ["replied", "timeout"],
"merlin.chat": ["handoff", "done"],
"inbox.note": ["next"],
assign: ["next"],
notify: ["next"],
task: ["next"],
"lead.stage": ["next"],
"lead.tag": ["next"],
"deal.create": ["created", "exists"],
webhook: ["next", "failed"],
};
✔ 22 lines of TypeScript
hvidom@dublin ~/content-factory $
hvidom@dublin ~/content-factory $ cat apps/api/src/lib/merlin-skills.tscat apps/api/src/lib/merlin-skills.ts
# A resumable state machine: an atomic UPDATE … RETURNING claims the run, then every step returns a port, a wait or a failure.
/**
* Walk a run through its nodes until it has to wait or it ends. The run holds a 3-minute lock:
* the scheduler tick and a start for a new lead never take it at the same time.
*/
export async function advanceRun(
runId: string,
): Promise<"progressed" | "idle" | "busy" | "failed"> {
const [claimed] = await db
.update(merlinSkillRuns)
.set({ lockedUntil: new Date(Date.now() + LOCK_MS) })
.where(
and(
eq(merlinSkillRuns.id, runId),
inArray(merlinSkillRuns.status, ["running", "waiting"]),
sql`(${merlinSkillRuns.lockedUntil} is null or ${merlinSkillRuns.lockedUntil} < now())`,
),
)
.returning();
if (!claimed) return "busy";
// … load the skill and the lead; stop the run if either is gone or the skill is off
for (let step = 0; step < MAX_STEPS; step += 1) {
const node = nodes.get(cur.nodeId);
if (!node) {
await finish("stopped", "node_removed");
return "progressed";
}
const result = await execNode(node, { cur, skill, lead });
if (result.kind === "wait") {
cur.status = "waiting";
cur.waitUntil = result.until;
cur.waitReply = result.reply;
if (result.since !== undefined) cur.waitSince = result.since;
await save(cur);
return cur.nodeId !== startNode || cur.log.length > startLog ? "progressed" : "idle";
}
if (result.kind === "fail") {
await finish("failed", result.code, result.error);
return "failed";
}
cur.state.ports.push(`${node.id}:${result.port}`);
cur.log.push(entry(node.id, "port", { port: result.port }));
const next = nextNodeId(skill.graph, node.id, result.port);
if (!next) {
await finish("done", "done");
return "progressed";
}
cur.nodeId = next;
// … record the visit and keep walking
✔ 49 lines of TypeScript
hvidom@dublin ~/content-factory $
hvidom@dublin ~/content-factory $ cat apps/api/src/lib/merlin-skills.tscat apps/api/src/lib/merlin-skills.ts
# An LLM classification becomes a branch of the graph, and falls back to “other” when there is no conversation or the model fails.
/** Merlin reads the lead's conversation and picks the output by what the customer wants. */
async function intentNode(nodeId: string, ctx: Ctx): Promise<Step> {
const cid = await ensureConversation(ctx);
if (!cid) {
ctx.cur.log.push(entry(nodeId, "no_conversation"));
return { kind: "port", port: "other" };
}
try {
const ai = await classifyConversation(cid, { force: true });
const port = intentPort(ai?.intent);
ctx.cur.log.push(entry(nodeId, "intent", { intent: ai?.intent ?? "other" }));
return { kind: "port", port };
} catch (error) {
ctx.cur.log.push(entry(nodeId, "ai_error", { error: errorText(error).slice(0, 200) }));
return { kind: "port", port: "other" };
}
}
/** Output of the "AI: what the customer wants" step: spam and anything else go to "other". */
export function intentPort(intent: ConversationIntent | null | undefined): string {
return intent === "booking" ||
intent === "quote" ||
intent === "warranty" ||
intent === "question"
? intent
: "other";
}
✔ 27 lines of TypeScript
hvidom@dublin ~/content-factory $
hvidom@dublin ~/content-factory $ cat packages/types/src/merlin-skills.tscat packages/types/src/merlin-skills.ts
# A bounded look-back for the “stuck” trigger, and an A/B split that hashes the run id (FNV-1a), so nothing extra is stored.
/**
* "Stuck" trigger window: the lead entered the stage before `now − days`, but no more than a week
* before that. A skill that is switched on picks up leads that got stuck recently, never a legacy
* backlog of imported leads that have sat in one stage for years.
*/
export const SKILL_STALE_LOOKBACK_DAYS = 7;
export function staleWindow(days: number, now: Date): { from: Date; to: Date } {
const DAY = 86_400_000;
const to = new Date(now.getTime() - days * DAY);
return { from: new Date(to.getTime() - SKILL_STALE_LOOKBACK_DAYS * DAY), to };
}
/** A/B branch per run (FNV-1a): a retried step lands on the same branch, A gets `percentA` of 100. */
export function splitPort(seed: string, percentA: number): "a" | "b" {
let hash = 0x811c9dc5;
for (let i = 0; i < seed.length; i += 1) {
hash ^= seed.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash % 100 < percentA ? "a" : "b";
}
✔ 22 lines of TypeScript
hvidom@dublin ~/content-factory $
hvidom@dublin ~/content-factory $ cat apps/admin/src/lib/merlin-skill-graph.tscat apps/admin/src/lib/merlin-skill-graph.ts
# Layered layout for the canvas: the column is the longest path from the trigger, found with a memoised DFS and a loop guard.
/**
* Lay out left to right: the column is the longest path from the trigger (flows have no loops);
* within a column, by the parent's position and its output number. Steps the trigger can't reach
* go to a separate column on the right. Sticky notes don't move.
*/
export function autoLayout(graph: SkillGraph, heights: Record<string, number> = {}): SkillGraph {
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
const incoming = new Map<string, SkillEdge[]>();
for (const edge of graph.edges) {
if (!byId.has(edge.source) || !byId.has(edge.target)) continue;
incoming.set(edge.target, [...(incoming.get(edge.target) ?? []), edge]);
}
const layer = new Map<string, number>();
const depth = (id: string, trail: Set<string>): number => {
const known = layer.get(id);
if (known !== undefined) return known;
if (trail.has(id)) return 0; // a loop: validation never lets one through, just don't hang here
trail.add(id);
const parents = incoming.get(id) ?? [];
const value =
parents.length === 0 ? 0 : Math.max(...parents.map((edge) => depth(edge.source, trail) + 1));
trail.delete(id);
layer.set(id, value);
return value;
};
const trigger = graph.nodes.find((node) => node.type === "trigger.lead");
const reachable = new Set<string>();
if (trigger) {
const queue = [trigger.id];
while (queue.length > 0) {
const id = queue.shift() as string;
if (reachable.has(id)) continue;
reachable.add(id);
for (const edge of graph.edges) if (edge.source === id) queue.push(edge.target);
}
}
for (const node of graph.nodes) if (reachable.has(node.id)) depth(node.id, new Set());
const maxLayer = Math.max(0, ...layer.values());
for (const node of graph.nodes) if (!reachable.has(node.id)) layer.set(node.id, maxLayer + 1);
// … then each column is stacked under its parents, in output order (yes above, no below)
✔ 40 lines of TypeScript
hvidom@dublin ~/content-factory $