evOo-local/packages/core/src/teach-playbook.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

207 lines
6.2 KiB
TypeScript

export type TeachRecordingEvent = {
at: string;
kind: "pointer" | "key" | "clipboard" | "snapshot" | "scroll";
x?: number;
y?: number;
button?: string;
type?: string;
key?: string;
text?: string;
summary?: string;
};
export type TeachSnapshot = {
at: string;
summary: string;
hash?: string;
};
export type SkillPlaybook = {
whenToUse: string;
inputs: string[];
steps: string[];
howToCheck: string;
whatToReturn: string;
approvalBoundaries: string;
failureHandling: string;
};
const DEFAULT_APPROVAL =
"Do not send messages, spend money, publish content, or delete data without explicit user approval.";
const DEFAULT_FAILURE =
"If a step fails or the expected screen is not visible, stop and ask the user before retrying.";
function describePointer(event: TeachRecordingEvent): string {
const action = event.type ?? "click";
const button = event.button ?? "left";
if (action === "move") {
return `Move pointer to (${event.x ?? 0}, ${event.y ?? 0}).`;
}
if (action === "down") {
return `Press ${button} button at (${event.x ?? 0}, ${event.y ?? 0}).`;
}
if (action === "up") {
return `Release ${button} button at (${event.x ?? 0}, ${event.y ?? 0}).`;
}
return `${action === "click" ? "Click" : action} ${button} button at (${event.x ?? 0}, ${event.y ?? 0}).`;
}
function describeScroll(event: TeachRecordingEvent): string {
const direction = event.type === "up" ? "up" : "down";
const amount = Number(event.text ?? 3);
return Number.isFinite(amount) && amount > 1
? `Scroll ${direction} ${amount} times.`
: `Scroll ${direction}.`;
}
function redactSensitiveText(text: string): string {
const trimmed = text.trim();
if (/password|secret|token|api[_-]?key/i.test(trimmed)) return "[redacted input]";
return trimmed;
}
function isTypedCharacter(key: string): boolean {
return key.length === 1;
}
export function buildPlaybookFromRecording(
goal: string,
events: TeachRecordingEvent[],
snapshots: TeachSnapshot[] = [],
): SkillPlaybook {
const steps: string[] = [];
let typed = "";
let drag: { button: string; fromX: number; fromY: number; toX: number; toY: number } | null =
null;
function flushTyped() {
if (!typed) return;
const text = redactSensitiveText(typed);
if (text) steps.push(`Type ${JSON.stringify(text)}.`);
typed = "";
}
function flushDrag() {
if (!drag) return;
const moved = Math.hypot(drag.toX - drag.fromX, drag.toY - drag.fromY) >= 8;
steps.push(
moved
? `Drag ${drag.button} button from (${drag.fromX}, ${drag.fromY}) to (${drag.toX}, ${drag.toY}).`
: `Click ${drag.button} button at (${drag.fromX}, ${drag.fromY}).`,
);
drag = null;
}
for (const event of events) {
if (event.kind === "key") {
const key = event.key;
if (!key) continue;
flushDrag();
if (isTypedCharacter(key)) {
typed += key;
continue;
}
flushTyped();
steps.push(`Press key: ${key}.`);
continue;
}
flushTyped();
if (event.kind === "pointer") {
const action = event.type ?? "click";
const button = event.button ?? "left";
const x = event.x ?? 0;
const y = event.y ?? 0;
if (action === "down") {
flushDrag();
drag = { button, fromX: x, fromY: y, toX: x, toY: y };
continue;
}
if (action === "move" && drag) {
drag.toX = x;
drag.toY = y;
continue;
}
if (action === "up") {
if (drag) {
drag.toX = x;
drag.toY = y;
flushDrag();
} else {
steps.push(describePointer(event));
}
continue;
}
flushDrag();
steps.push(describePointer(event));
} else if (event.kind === "clipboard") {
flushDrag();
const text = event.text ? redactSensitiveText(event.text) : "";
if (text) steps.push(`Paste or type: ${text}.`);
} else if (event.kind === "scroll") {
flushDrag();
steps.push(describeScroll(event));
} else {
flushDrag();
}
}
flushTyped();
flushDrag();
if (steps.length === 0) {
steps.push("Repeat the demonstrated workflow using the same navigation pattern.");
}
return {
whenToUse: goal.trim() || "When the user asks to repeat the demonstrated task.",
inputs: goal.trim() ? [goal.trim()] : [],
steps,
howToCheck: snapshots.at(-1)?.summary
? `The final screen should match: ${snapshots.at(-1)?.summary}.`
: "Confirm the outcome matches the user's goal before reporting success.",
whatToReturn: "A short summary of what was completed and where outputs were saved.",
approvalBoundaries: DEFAULT_APPROVAL,
failureHandling: DEFAULT_FAILURE,
};
}
const SKILL_RUN_PROMPT_PREFIX = "run taught skill:";
const SKILL_INVOCATION_VERB = /\b(run|use|do|repeat|perform|execute)\b/;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* A saved skill only takes over a task when the user actually asks for it: an invocation verb
* plus the whole skill name. A bare substring match would hijack any request that happens to
* mention a common word used as a skill name.
*/
export function promptInvokesSkill(prompt: string, name: string): boolean {
const skill = name.trim().toLowerCase();
const text = prompt.toLowerCase();
if (skill.length < 3) return false;
if (text.startsWith(SKILL_RUN_PROMPT_PREFIX)) return false;
if (!SKILL_INVOCATION_VERB.test(text)) return false;
return new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(skill)}([^\\p{L}\\p{N}]|$)`, "u").test(text);
}
export function formatSkillRunPrompt(name: string, playbook: SkillPlaybook, test = false): string {
const safety = test
? "This is a safe test run. Do not send, spend, delete, or publish anything."
: "";
return [
`Run taught skill: ${name}`,
safety,
`When to use: ${playbook.whenToUse}`,
playbook.inputs.length ? `Inputs: ${playbook.inputs.join("; ")}` : undefined,
"Steps:",
...playbook.steps.map((step, index) => `${index + 1}. ${step}`),
`How to check: ${playbook.howToCheck}`,
`Return: ${playbook.whatToReturn}`,
`Approval boundaries: ${playbook.approvalBoundaries}`,
`Failure handling: ${playbook.failureHandling}`,
]
.filter(Boolean)
.join("\n");
}