Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ form can teach the agent to submit *all* of them.
4. ✨ **Create.** From an approved analysis, generate a reusable **Skill** and/or a
scheduled **Automation**.

For skills, **Review SKILL.md** optionally shows the complete, read-only file text
(including frontmatter) before installation or export. Preparing a preview writes
no skill files. Close and reopen it without regenerating; Add/Export from either
surface uses that exact preview. Editing the plan discards the preview. You can
still choose Add/Export directly without reviewing or adding a confirmation step.
Review provides visibility into the generated instructions, not a security guarantee.

## Get started

Skill Recorder is published as a **source release**: one command downloads a pinned Node.js
Expand Down
25 changes: 24 additions & 1 deletion common/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,23 @@ export interface SkillPlanResult {
*/
export type SkillPlacement = TargetPlacement;

/** Exact rendered file held in the main process until placement or discard. */
export interface SkillPreview {
id: string;
markdown: string;
}

export interface SkillPreviewResult {
ok: boolean;
preview?: SkillPreview;
error?: string;
}

/** Result of finalizing + placing a skill. */
export interface SkillCreateResult {
ok: boolean;
/** The reviewed candidate was discarded or no longer matches the plan. */
previewExpired?: boolean;
skill?: BuiltSkill;
/** Absolute path of the placed SKILL.md. */
path?: string;
Expand Down Expand Up @@ -475,6 +489,8 @@ export const IPC = {
deleteSession: "sessions:delete",
exportDebugBundle: "sessions:export-debug",
buildSkill: "skill:build",
prepareSkill: "skill:prepare",
discardSkillPreview: "skill:discard-preview",
createSkill: "skill:create",
getSkill: "skill:get",
cancelSkill: "skill:cancel",
Expand Down Expand Up @@ -602,7 +618,14 @@ export interface SkillRecorderApi {
* skills folder (Scout); `"export"` prompts for a folder and downloads it there (the
* only option for Cowork). Defaults to `"install"`.
*/
createSkill(sessionId: string, plan: SkillPlan, placement?: SkillPlacement): Promise<SkillCreateResult>;
prepareSkill(sessionId: string, plan: SkillPlan): Promise<SkillPreviewResult>;
discardSkillPreview(sessionId: string, previewId?: string): Promise<{ ok: boolean; error?: string }>;
createSkill(
sessionId: string,
plan: SkillPlan,
placement?: SkillPlacement,
previewId?: string,
): Promise<SkillCreateResult>;
/** Load a previously built skill for a session, if any. */
getSkill(sessionId: string): Promise<BuiltSkill | null>;
/** Abort an in-flight build. */
Expand Down
48 changes: 40 additions & 8 deletions electron/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
SkillCreateResult,
SkillPlacement,
SkillPlanResult,
SkillPreviewResult,
} from "../common/ipc";
import { IPC } from "../common/ipc";
import type { AutomationPlan } from "../common/automation";
Expand All @@ -52,7 +53,7 @@ import {
import type { SensitiveModelManager } from "./sensitive/model-manager";
import { buildRedactor, loadSensitiveReport, saveSensitiveReport, scanSession } from "./sensitive/scanner";
import { deleteSession, listSessions } from "./sessions";
import { loadPersistedSkill, SkillBuilder, type SkillTarget } from "./skillbuilder/builder";
import { loadPersistedSkill, SkillBuilder, SkillPreviewExpiredError, type SkillTarget } from "./skillbuilder/builder";

const log = createLogger("IPC");

Expand Down Expand Up @@ -428,18 +429,48 @@ export function registerIpc(
}
});

ipcMain.handle(
IPC.prepareSkill,
async (_event, sessionId: string, plan: SkillPlan): Promise<SkillPreviewResult> => {
if (!isValidSessionId(sessionId)) return { ok: false, error: "Unknown session." };
try {
return { ok: true, preview: await builder.prepare(sessionId, plan) };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
log.warn("prepare skill failed:", error);
return { ok: false, error };
}
},
);

ipcMain.handle(IPC.discardSkillPreview, (_event, sessionId: string, previewId?: string) => {
if (!isValidSessionId(sessionId)) return { ok: false, error: "Unknown session." };
if (previewId !== undefined && typeof previewId !== "string") {
return { ok: false, error: "Invalid skill preview." };
}
builder.discardPreview(sessionId, previewId);
return { ok: true };
});

ipcMain.handle(
IPC.createSkill,
async (
event,
sessionId: string,
plan?: SkillPlan,
placement: SkillPlacement = "install",
previewId?: string,
): Promise<SkillCreateResult> => {
if (!isValidSessionId(sessionId)) return { ok: false, error: "Unknown session." };
if (placement !== "install" && placement !== "export") {
return { ok: false, error: "Unknown skill placement." };
}
if (previewId !== undefined && typeof previewId !== "string") {
return { ok: false, error: "Invalid skill preview." };
}
try {
let target: SkillTarget = { kind: "install" };
if (placement === "export") {
const placed = await builder.create(sessionId, plan, async (): Promise<SkillTarget | null> => {
if (placement === "install") return { kind: "install" };
// Export == download: let the user pick a destination folder; we drop a
// ready-to-use <name>/SKILL.md inside it. A dismissed dialog is a cancel, not an error.
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined;
Expand All @@ -451,15 +482,16 @@ export function registerIpc(
const result = win
? await dialog.showOpenDialog(win, opts)
: await dialog.showOpenDialog(opts);
if (result.canceled || result.filePaths.length === 0) return { ok: false, canceled: true };
target = { kind: "export", dir: result.filePaths[0] };
}
const { skill, path: file } = await builder.create(sessionId, plan, target);
if (result.canceled || result.filePaths.length === 0) return null;
return { kind: "export", dir: result.filePaths[0] };
}, previewId);
if (!placed) return { ok: false, canceled: true };
const { skill, path: file } = placed;
return { ok: true, skill, path: file, placement };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
log.warn("create skill failed:", error);
return { ok: false, error };
return { ok: false, error, previewExpired: err instanceof SkillPreviewExpiredError };
}
},
);
Expand Down
7 changes: 6 additions & 1 deletion electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const IPC = {
deleteSession: "sessions:delete",
exportDebugBundle: "sessions:export-debug",
buildSkill: "skill:build",
prepareSkill: "skill:prepare",
discardSkillPreview: "skill:discard-preview",
createSkill: "skill:create",
getSkill: "skill:get",
cancelSkill: "skill:cancel",
Expand Down Expand Up @@ -180,7 +182,10 @@ contextBridge.exposeInMainWorld("skillRecorder", {
deleteSession: (sessionId) => ipcRenderer.invoke(IPC.deleteSession, sessionId),
exportDebugBundle: (sessionId) => ipcRenderer.invoke(IPC.exportDebugBundle, sessionId),
buildSkill: (input) => ipcRenderer.invoke(IPC.buildSkill, input),
createSkill: (sessionId, plan, placement) => ipcRenderer.invoke(IPC.createSkill, sessionId, plan, placement),
prepareSkill: (sessionId, plan) => ipcRenderer.invoke(IPC.prepareSkill, sessionId, plan),
discardSkillPreview: (sessionId, previewId) => ipcRenderer.invoke(IPC.discardSkillPreview, sessionId, previewId),
createSkill: (sessionId, plan, placement, previewId) =>
ipcRenderer.invoke(IPC.createSkill, sessionId, plan, placement, previewId),
getSkill: (sessionId) => ipcRenderer.invoke(IPC.getSkill, sessionId),
cancelSkill: (sessionId) => ipcRenderer.invoke(IPC.cancelSkill, sessionId),
revealSkill: (sessionId) => ipcRenderer.invoke(IPC.revealSkill, sessionId),
Expand Down
Loading
Loading