Formalize skill artifacts into plugins (#3085)

* Add skill-to-plugin candidate flow

* Fix skill plugin candidate card reuse

Generated-By: looper 0.9.1 (runner=fixer, agent=codex)

* Fix skill plugin candidate dismiss and URL gates

Generated-By: looper 0.9.1 (runner=fixer, agent=codex)

* Polish skill plugin candidate copy
This commit is contained in:
Siri-Ray
2026-05-27 08:26:00 +00:00
committed by GitHub
parent 9923529831
commit 170a05f5d2
14 changed files with 1294 additions and 0 deletions
+82
View File
@@ -930,6 +930,7 @@ async function runPlugin(args) {
case 'scaffold': return runPluginScaffold(rest);
case 'validate': return runPluginValidate(rest);
case 'pack': return runPluginPack(rest);
case 'candidates': return runPluginCandidates(rest);
case 'login': return runPluginLogin(rest);
case 'whoami': return runPluginWhoami(rest);
case 'export': return runPluginExport(rest);
@@ -3018,6 +3019,85 @@ function coerceCliValue(raw) {
return raw;
}
async function runPluginCandidates(rest) {
const sub = rest[0];
const args = rest.slice(1);
const flags = parseFlags(args, {
string: new Set(['daemon-url', 'project', 'action']),
boolean: new Set(['help', 'h', 'json', 'include-dismissed']),
});
if (!sub || flags.help || flags.h) {
console.log(`Usage:
od plugin candidates list --project <projectId> [--json] [--include-dismissed]
od plugin candidates draft <candidateId> --project <projectId> [--json]
od plugin candidates dismiss <candidateId> --project <projectId> [--json]
Lists and formalizes persisted skill-to-plugin candidates.`);
process.exit(!sub ? 2 : 0);
}
const projectId = typeof flags.project === 'string' && flags.project.length > 0 ? flags.project : '';
if (!projectId) {
console.error('--project <projectId> is required');
process.exit(2);
}
const base = (await pluginDaemonUrl(flags)).replace(/\/$/, '');
if (sub === 'list') {
const qs = flags['include-dismissed'] ? '?includeDismissed=true' : '';
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates${qs}`);
const data = await resp.json().catch(() => null);
if (!resp.ok) {
console.error(`GET plugin candidates failed: ${resp.status} ${JSON.stringify(data)}`);
process.exit(1);
}
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
const candidates = Array.isArray(data?.candidates) ? data.candidates : [];
if (candidates.length === 0) {
console.log('No plugin candidates.');
return;
}
for (const candidate of candidates) {
console.log(`${candidate.id}\t${candidate.status}\t${candidate.title}\t${candidate.draftPath ?? ''}`);
}
return;
}
const candidateId = args.find((a) => !a.startsWith('-') && a !== flags.project && a !== flags.action);
if (!candidateId) {
console.error(`candidate id is required for ${sub}`);
process.exit(2);
}
if (sub === 'draft') {
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(candidateId)}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const data = await resp.json().catch(() => null);
if (flags.json) {
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
} else if (resp.ok) {
console.log(`[candidate] draft: ${data.draftPath}`);
console.log(`[candidate] validation ok=${data.validation?.ok}`);
} else {
console.error(`[candidate] draft failed: ${data?.message ?? JSON.stringify(data)}`);
}
process.exit(resp.ok ? 0 : resp.status === 422 ? 4 : 1);
}
if (sub === 'dismiss') {
const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(candidateId)}/dismiss`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const data = await resp.json().catch(() => null);
if (flags.json) process.stdout.write(JSON.stringify(data, null, 2) + '\n');
else if (resp.ok) console.log(`[candidate] dismissed ${candidateId}`);
else console.error(`[candidate] dismiss failed: ${data?.message ?? JSON.stringify(data)}`);
process.exit(resp.ok ? 0 : 1);
}
console.error(`unknown subcommand: od plugin candidates ${sub}`);
process.exit(2);
}
// Phase 4 / spec §14.1 — `od plugin publish --to <catalog>`.
//
// Reads the installed plugin's manifest metadata (or the snapshot's
@@ -4154,6 +4234,8 @@ function printPluginHelp() {
(manifest parse + atom + ref checks).
od plugin pack <folder> [--out <path>] Build a .tgz archive of a plugin
folder for distribution.
od plugin candidates list --project <id>
List persisted skill-to-plugin candidates.
od plugin publish-repo <folder> Create/update the author's public
GitHub repo for a plugin folder.
od plugin open-design-pr <folder> Push a community-catalog branch and
+1
View File
@@ -100,5 +100,6 @@ export * from './scaffold.js';
export * from './gc.js';
export * from './resolve-snapshot.js';
export * from './snapshots.js';
export * from './skill-candidates.js';
export * from './trust.js';
export * from './until.js';
+24
View File
@@ -142,6 +142,30 @@ export function migratePlugins(db: SqliteDb): void {
CREATE INDEX IF NOT EXISTS idx_genui_proj_surface ON genui_surfaces(project_id, surface_id);
CREATE INDEX IF NOT EXISTS idx_genui_conv_surface ON genui_surfaces(conversation_id, surface_id);
CREATE INDEX IF NOT EXISTS idx_genui_run ON genui_surfaces(run_id);
CREATE TABLE IF NOT EXISTS skill_plugin_candidates (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
run_id TEXT,
conversation_id TEXT,
assistant_message_id TEXT,
fingerprint TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
title TEXT NOT NULL,
description TEXT NOT NULL,
confidence REAL NOT NULL,
source_refs_json TEXT NOT NULL,
provenance_json TEXT NOT NULL,
draft_path TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
dismissed_at INTEGER,
UNIQUE(project_id, fingerprint),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_skill_plugin_candidates_project
ON skill_plugin_candidates(project_id, status, created_at DESC);
`);
const marketplaceCols = db.prepare(`PRAGMA table_info(plugin_marketplaces)`).all() as DbRow[];
+391
View File
@@ -0,0 +1,391 @@
import crypto from 'node:crypto';
import path from 'node:path';
import fs from 'node:fs/promises';
import type Database from 'better-sqlite3';
import type {
SkillPluginCandidate,
SkillPluginCandidateSourceRef,
} from '@open-design/contracts';
import { OPEN_DESIGN_PLUGIN_SPEC_VERSION } from '@open-design/contracts';
import { validatePluginFolder, flattenValidationDiagnostics } from './validate.js';
type SqliteDb = Database.Database;
type DbRow = Record<string, unknown>;
const MAX_SOURCE_BYTES = 96_000;
const SAFE_SLUG = /^[a-z0-9][a-z0-9._-]*$/;
export interface DetectSkillPluginCandidateInput {
projectId: string;
runId?: string | null;
conversationId?: string | null;
assistantMessageId?: string | null;
message?: unknown;
attachments?: unknown;
projectRoot: string;
now?: number;
}
export interface SkillPluginDraftGenerationResult {
ok: boolean;
candidate: SkillPluginCandidate;
draftPath: string;
folder: string;
validation: {
ok: boolean;
diagnostics: Array<{ severity: 'error' | 'warning' | 'info'; code: string; message: string }>;
};
}
export async function detectSkillPluginCandidate(input: DetectSkillPluginCandidateInput): Promise<Omit<SkillPluginCandidate, 'id' | 'createdAt' | 'updatedAt' | 'status'> & { fingerprint: string } | null> {
const refs: SkillPluginCandidateSourceRef[] = [];
const seen = new Set<string>();
const addRef = (ref: SkillPluginCandidateSourceRef) => {
const key = `${ref.kind}:${ref.value}`;
if (seen.has(key)) return;
seen.add(key);
refs.push(ref);
};
const message = typeof input.message === 'string' ? input.message : '';
for (const rel of referencedMarkdownPaths(message)) {
const ref = await readCandidateFile(input.projectRoot, rel);
if (ref) addRef(ref);
}
if (Array.isArray(input.attachments)) {
for (const raw of input.attachments) {
if (typeof raw !== 'string') continue;
const ref = await readCandidateFile(input.projectRoot, raw);
if (ref) addRef(ref);
}
}
for (const url of referencedPluginUrls(message)) {
addRef({
kind: 'url',
value: url,
label: url,
reason: 'Referenced repository or plugin-like URL.',
});
}
const eligible = refs.filter((ref) => isEligibleSourceRef(ref));
if (eligible.length === 0) return null;
const primary = eligible[0]!;
const title = deriveCandidateTitle(primary);
const description = deriveCandidateDescription(primary);
const fingerprint = sha256(eligible.map((ref) => `${ref.kind}:${ref.value}:${ref.content ? sha256(ref.content) : ''}`).join('\n'));
return {
projectId: input.projectId,
runId: input.runId ?? null,
conversationId: input.conversationId ?? null,
assistantMessageId: input.assistantMessageId ?? null,
title,
description,
confidence: primary.value.endsWith('SKILL.md') || primary.value.includes('/SKILL.md') ? 0.95 : 0.78,
sourceRefs: eligible,
provenance: {
summary: `Detected from ${eligible.map((ref) => ref.label || ref.value).join(', ')} after a successful run.`,
detectedAt: input.now ?? Date.now(),
},
draftPath: null,
fingerprint,
};
}
export function insertSkillPluginCandidate(
db: SqliteDb,
candidate: Omit<SkillPluginCandidate, 'id' | 'createdAt' | 'updatedAt' | 'status'> & { fingerprint: string },
now = Date.now(),
): SkillPluginCandidate | null {
const existing = db.prepare(
`SELECT * FROM skill_plugin_candidates WHERE project_id = ? AND fingerprint = ?`,
).get(candidate.projectId, candidate.fingerprint) as DbRow | undefined;
if (existing) return rowToCandidate(existing);
const id = crypto.randomUUID();
db.prepare(
`INSERT INTO skill_plugin_candidates
(id, project_id, run_id, conversation_id, assistant_message_id, fingerprint, status,
title, description, confidence, source_refs_json, provenance_json, draft_path,
created_at, updated_at, dismissed_at)
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
).run(
id,
candidate.projectId,
candidate.runId ?? null,
candidate.conversationId ?? null,
candidate.assistantMessageId ?? null,
candidate.fingerprint,
candidate.title,
candidate.description,
candidate.confidence,
JSON.stringify(candidate.sourceRefs),
JSON.stringify(candidate.provenance),
candidate.draftPath ?? null,
now,
now,
);
return getSkillPluginCandidate(db, id);
}
export function listSkillPluginCandidates(db: SqliteDb, projectId: string, includeDismissed = false): SkillPluginCandidate[] {
const rows = db.prepare(
`SELECT * FROM skill_plugin_candidates
WHERE project_id = ? ${includeDismissed ? '' : `AND status != 'dismissed'`}
ORDER BY created_at DESC`,
).all(projectId) as DbRow[];
return rows.map(rowToCandidate);
}
export function getSkillPluginCandidate(db: SqliteDb, id: string): SkillPluginCandidate | null {
const row = db.prepare(`SELECT * FROM skill_plugin_candidates WHERE id = ?`).get(id) as DbRow | undefined;
return row ? rowToCandidate(row) : null;
}
export function dismissSkillPluginCandidate(db: SqliteDb, projectId: string, id: string, now = Date.now()): SkillPluginCandidate | null {
const result = db.prepare(
`UPDATE skill_plugin_candidates
SET status = 'dismissed', dismissed_at = ?, updated_at = ?
WHERE project_id = ? AND id = ?`,
).run(now, now, projectId, id);
if (result.changes === 0) return null;
return getSkillPluginCandidate(db, id);
}
export async function generateSkillPluginDraft(
db: SqliteDb,
projectRoot: string,
projectId: string,
id: string,
now = Date.now(),
): Promise<SkillPluginDraftGenerationResult | null> {
const candidate = getSkillPluginCandidate(db, id);
if (!candidate || candidate.projectId !== projectId || candidate.status === 'dismissed') return null;
const slug = uniqueSlug(slugify(candidate.title || 'skill-plugin'));
const draftPath = `plugin-source/${slug}`;
const folder = path.join(projectRoot, ...draftPath.split('/'));
await fs.mkdir(path.join(folder, 'references'), { recursive: true });
await fs.writeFile(path.join(folder, 'SKILL.md'), synthesizeSkill(candidate), 'utf8');
await fs.writeFile(path.join(folder, 'open-design.json'), JSON.stringify(buildManifest(slug, candidate), null, 2) + '\n', 'utf8');
await fs.writeFile(path.join(folder, 'references', 'provenance.json'), JSON.stringify({
candidateId: candidate.id,
projectId: candidate.projectId,
runId: candidate.runId,
conversationId: candidate.conversationId,
provenance: candidate.provenance,
sourceRefs: candidate.sourceRefs.map((ref) => ({ ...ref, content: undefined })),
}, null, 2) + '\n', 'utf8');
let copied = 0;
for (const ref of candidate.sourceRefs) {
if (ref.kind !== 'file' || typeof ref.content !== 'string') continue;
if (Buffer.byteLength(ref.content, 'utf8') > MAX_SOURCE_BYTES) continue;
copied += 1;
await fs.writeFile(path.join(folder, 'references', `source-${copied}-${path.basename(ref.value) || 'source.md'}`), ref.content, 'utf8');
}
const validationResult = await validatePluginFolder({ folder });
const diagnostics = flattenValidationDiagnostics(validationResult).map((d) => ({
severity: d.severity,
code: d.code,
message: d.message,
}));
db.prepare(
`UPDATE skill_plugin_candidates
SET draft_path = ?, updated_at = ?
WHERE id = ?`,
).run(draftPath, now, id);
const updated = getSkillPluginCandidate(db, id) ?? candidate;
return {
ok: validationResult.ok,
candidate: updated,
draftPath,
folder,
validation: { ok: validationResult.ok, diagnostics },
};
}
function rowToCandidate(row: DbRow): SkillPluginCandidate {
return {
id: String(row.id),
projectId: String(row.project_id),
runId: nullableString(row.run_id),
conversationId: nullableString(row.conversation_id),
assistantMessageId: nullableString(row.assistant_message_id),
title: String(row.title ?? ''),
description: String(row.description ?? ''),
confidence: Number(row.confidence ?? 0),
status: row.status === 'dismissed' ? 'dismissed' : 'active',
sourceRefs: parseJsonArray(row.source_refs_json),
provenance: parseJsonObject(row.provenance_json, { summary: '', detectedAt: Number(row.created_at ?? Date.now()) }) as SkillPluginCandidate['provenance'],
draftPath: nullableString(row.draft_path),
createdAt: Number(row.created_at ?? 0),
updatedAt: Number(row.updated_at ?? 0),
dismissedAt: nullableNumber(row.dismissed_at),
};
}
function nullableString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function nullableNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function parseJsonArray(value: unknown): SkillPluginCandidateSourceRef[] {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function parseJsonObject(value: unknown, fallback: Record<string, unknown>): Record<string, unknown> {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : null;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
function referencedMarkdownPaths(message: string): string[] {
const out = new Set<string>();
const re = /(?:^|[\s('"`])(@?[\w./ -]*(?:SKILL\.md|[A-Za-z0-9._-]+\.md))(?:$|[\s)'"`,])/gu;
for (const match of message.matchAll(re)) {
const value = String(match[1] ?? '').replace(/^@/, '').trim();
if (value && !/^https?:\/\//iu.test(value)) out.add(value);
}
return Array.from(out);
}
function referencedPluginUrls(message: string): string[] {
const out = new Set<string>();
const re = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\/[^\s)'"`<>]*)?/gu;
for (const match of message.matchAll(re)) {
const url = match[0];
if (isExplicitSkillPluginUrl(url)) out.add(url);
}
return Array.from(out);
}
async function readCandidateFile(projectRoot: string, rel: string): Promise<SkillPluginCandidateSourceRef | null> {
const normalized = rel.split(/[\\/]+/u).filter(Boolean);
if (normalized.length === 0 || normalized.some((seg) => seg === '..')) return null;
const abs = path.join(projectRoot, ...normalized);
let stat;
try {
stat = await fs.stat(abs);
} catch {
return null;
}
if (!stat.isFile()) return null;
const base = path.basename(abs);
if (base !== 'SKILL.md' && !base.endsWith('.md')) return null;
if (stat.size > MAX_SOURCE_BYTES) {
return { kind: 'file', value: normalized.join('/'), label: normalized.join('/'), size: stat.size, reason: 'Source file is too large to copy safely.' };
}
const content = await fs.readFile(abs, 'utf8');
return { kind: 'file', value: normalized.join('/'), label: normalized.join('/'), content, size: stat.size, copied: true };
}
function isEligibleSourceRef(ref: SkillPluginCandidateSourceRef): boolean {
if (ref.kind === 'url') return isExplicitSkillPluginUrl(ref.value);
if (path.basename(ref.value) === 'SKILL.md') return true;
const content = ref.content ?? '';
if (!content) return false;
const hasSkillSignal = /(^|\n)#{1,3}\s*(When to use|Usage|Workflow|Inputs|Outputs|Instructions|Capabilities)\b/iu.test(content)
|| /\b(skill|agent skill|reusable workflow|use this skill)\b/iu.test(content);
const hasSubstance = content.trim().length >= 160;
return hasSkillSignal && hasSubstance;
}
function isExplicitSkillPluginUrl(value: string): boolean {
let url: URL;
try {
url = new URL(value);
} catch {
return false;
}
if (url.hostname !== 'github.com') return false;
return /\/(?:SKILL\.md|open-design\.json)$/u.test(decodeURIComponent(url.pathname));
}
function deriveCandidateTitle(ref: SkillPluginCandidateSourceRef): string {
const heading = ref.content?.match(/^#\s+(.+)$/mu)?.[1]?.trim();
if (heading) return heading.slice(0, 80);
const base = path.basename(ref.value).replace(/\.md$/iu, '');
return titleize(base === 'SKILL' ? path.basename(path.dirname(ref.value)) || 'Skill plugin' : base);
}
function deriveCandidateDescription(ref: SkillPluginCandidateSourceRef): string {
const paragraph = ref.content
?.split(/\n{2,}/u)
.map((part) => part.trim())
.find((part) => part && !part.startsWith('#'));
if (paragraph) return paragraph.replace(/\s+/gu, ' ').slice(0, 220);
return ref.kind === 'url'
? 'This repo looks like it could work as a plugin.'
: 'Reusable skill material detected from a project file.';
}
function synthesizeSkill(candidate: SkillPluginCandidate): string {
const source = candidate.sourceRefs.find((ref) => ref.content)?.content?.trim();
if (source) return `${source}\n\n## Provenance\n\nFormalized by Open Design from candidate ${candidate.id}.\n`;
return [
`# ${candidate.title}`,
'',
candidate.description,
'',
'## When to use',
'',
'Use this skill when the workflow described by the source material should be repeated inside Open Design.',
'',
'## Workflow',
'',
'- Review the provenance in `references/provenance.json`.',
'- Follow the linked source material conservatively.',
'- Ask for clarification when the source material is incomplete.',
'',
'## Provenance',
'',
`Formalized by Open Design from candidate ${candidate.id}.`,
'',
].join('\n');
}
function buildManifest(slug: string, candidate: SkillPluginCandidate) {
return {
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
specVersion: OPEN_DESIGN_PLUGIN_SPEC_VERSION,
name: slug,
title: candidate.title,
version: '0.1.0',
description: candidate.description,
od: {
kind: 'skill',
taskKind: 'new-generation',
context: {
skills: [{ path: './SKILL.md' }],
},
capabilities: ['prompt:inject'],
},
};
}
function slugify(value: string): string {
const slug = value.toLowerCase().replace(/[^a-z0-9._-]+/gu, '-').replace(/(^[-._]+|[-._]+$)/gu, '');
return SAFE_SLUG.test(slug) ? slug : 'skill-plugin';
}
function uniqueSlug(slug: string): string {
return `${slug}-${Date.now().toString(36)}`;
}
function titleize(value: string): string {
return value.replace(/[-_.]+/gu, ' ').replace(/\b\w/gu, (c) => c.toUpperCase()).trim() || 'Skill plugin';
}
function sha256(value: string): string {
return crypto.createHash('sha256').update(value).digest('hex');
}
+203
View File
@@ -92,13 +92,18 @@ import {
applyPlugin,
buildConnectorProbe,
defaultBundledRoot,
detectSkillPluginCandidate,
dismissSkillPluginCandidate,
doctorPlugin,
FIRST_PARTY_ATOMS,
generateSkillPluginDraft,
getInstalledPlugin,
getSnapshot,
installFromLocalFolder,
installPlugin,
insertSkillPluginCandidate,
isDiffReviewSurfaceId,
listSkillPluginCandidates,
listInstalledPlugins,
listIterationsForRun,
MissingInputError,
@@ -2009,6 +2014,81 @@ function reconcileAssistantMessageOnRunEnd(db, runs, run) {
});
}
function detectSkillPluginCandidateOnRunSuccess(db, runs, run, input, projectRoot) {
if (!run.projectId || !run.conversationId) return;
void runs
.wait(run)
.then(async (finalStatus) => {
if (finalStatus.status !== 'succeeded') return;
const detected = await detectSkillPluginCandidate({
projectId: run.projectId,
runId: run.id,
conversationId: run.conversationId,
assistantMessageId: null,
message: input?.message ?? input?.currentPrompt,
attachments: input?.attachments,
projectRoot,
});
const candidate = detected ? insertSkillPluginCandidate(db, detected) : null;
if (!candidate || candidate.status === 'dismissed') return;
upsertSkillPluginCandidateAssistantMessage(db, run, candidate);
})
.catch((err) => {
console.warn('[plugins] skill candidate detection failed', err);
});
}
export function upsertSkillPluginCandidateAssistantMessage(db, run, candidate) {
const currentMessagePosition = run.assistantMessageId
? (db.prepare(`SELECT position FROM messages WHERE id = ?`).get(run.assistantMessageId)?.position ?? null)
: null;
const existingMessagePosition = candidate.assistantMessageId
? (db.prepare(`SELECT position FROM messages WHERE id = ?`).get(candidate.assistantMessageId)?.position ?? null)
: null;
if (
typeof currentMessagePosition === 'number' &&
typeof existingMessagePosition === 'number' &&
existingMessagePosition > currentMessagePosition
) {
return null;
}
const canReuseExistingMessage =
candidate.assistantMessageId &&
candidate.assistantMessageId !== run.assistantMessageId &&
typeof existingMessagePosition === 'number';
const messageId = canReuseExistingMessage ? candidate.assistantMessageId : randomUUID();
if (
candidate.assistantMessageId &&
candidate.assistantMessageId !== messageId &&
candidate.assistantMessageId !== run.assistantMessageId
) {
db.prepare(`DELETE FROM messages WHERE id = ?`).run(candidate.assistantMessageId);
}
const now = Date.now();
upsertMessage(db, run.conversationId, {
id: messageId,
role: 'assistant',
content: `Open Design found reusable skill material that can become a plugin: ${candidate.title}`,
agentId: run.agentId ?? undefined,
events: [{
kind: 'plugin_candidate',
candidateId: candidate.id,
title: candidate.title,
description: candidate.description,
confidence: candidate.confidence,
draftPath: candidate.draftPath ?? null,
}],
createdAt: now,
endedAt: now,
});
db.prepare(
`UPDATE skill_plugin_candidates
SET assistant_message_id = ?, updated_at = ?
WHERE id = ?`,
).run(messageId, now, candidate.id);
return messageId;
}
function persistRunEventToAssistantMessage(db, run, event, data) {
if (!run.assistantMessageId) return;
const persisted = runSseEventToPersistedAgentEvent(event, data);
@@ -8611,6 +8691,120 @@ export async function startServer({
}
});
app.get('/api/projects/:id/plugin-candidates', (req, res) => {
try {
const project = getProject(db, req.params.id);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
const includeDismissed = req.query.includeDismissed === 'true';
res.json({ candidates: listSkillPluginCandidates(db, req.params.id, includeDismissed) });
} catch (err) {
res.status(400).json({ error: String(err?.message || err) });
}
});
app.post('/api/projects/:id/plugin-candidates/:candidateId/dismiss', (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
const candidate = dismissSkillPluginCandidate(db, req.params.id, req.params.candidateId);
if (!candidate) {
sendApiError(res, 404, 'NOT_FOUND', 'plugin candidate not found');
return;
}
if (candidate.assistantMessageId) {
db.prepare(`DELETE FROM messages WHERE id = ?`).run(candidate.assistantMessageId);
}
res.json({ ok: true, candidate });
});
app.post('/api/projects/:id/plugin-candidates/:candidateId/draft', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const project = getProject(db, req.params.id);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
const projectRoot = resolveProjectDir(PROJECTS_DIR, req.params.id, project.metadata);
const result = await generateSkillPluginDraft(db, projectRoot, req.params.id, req.params.candidateId);
if (!result) {
sendApiError(res, 404, 'NOT_FOUND', 'plugin candidate not found');
return;
}
res.status(result.ok ? 200 : 422).json(result);
} catch (err) {
res.status(400).json({ ok: false, message: String(err?.message || err) });
}
});
app.post('/api/projects/:id/plugin-candidates/:candidateId/share-tasks', async (req, res) => {
if (!isLocalSameOrigin(req, resolvedPort)) {
return res.status(403).json({ error: 'cross-origin request rejected' });
}
try {
const project = getProject(db, req.params.id);
if (!project) {
sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
return;
}
const body = req.body && typeof req.body === 'object' ? req.body : {};
const action = body.action === 'publish-github' || body.action === 'contribute-open-design'
? body.action
: null;
if (!action) {
sendApiError(res, 400, 'BAD_REQUEST', 'plugin share action is required');
return;
}
const projectRoot = resolveProjectDir(PROJECTS_DIR, req.params.id, project.metadata);
const draft = await generateSkillPluginDraft(db, projectRoot, req.params.id, req.params.candidateId);
if (!draft) {
sendApiError(res, 404, 'NOT_FOUND', 'plugin candidate not found');
return;
}
if (!draft.validation.ok) {
res.status(422).json({
ok: false,
code: 'plugin-draft-invalid',
message: 'Generated plugin draft is invalid.',
draft,
});
return;
}
const taskId = randomUUID();
const task = createPluginShareTask(taskId, req.params.id, {
action,
path: draft.draftPath,
});
task.status = 'running';
notifyPluginShareTaskWaiters(task);
void runPluginShareTask(task, draft.folder).catch((err) => {
task.status = 'failed';
task.error = {
code: 'plugin-share-task-failed',
message: String(err?.message || err),
log: [String(err?.stack || err?.message || err)],
};
task.endedAt = Date.now();
notifyPluginShareTaskWaiters(task);
});
res.status(202).json({
taskId,
action,
path: draft.draftPath,
status: task.status,
startedAt: task.startedAt,
draft,
});
} catch (err) {
res.status(400).json({ ok: false, message: String(err?.message || err) });
}
});
app.post('/api/projects/:id/plugins/contribute-open-design', async (req, res) => {
try {
const project = getProject(db, req.params.id);
@@ -11777,6 +11971,15 @@ export async function startServer({
});
}
reconcileAssistantMessageOnRunEnd(db, design.runs, run);
if (run.projectId && run.conversationId) {
try {
const project = getProject(db, run.projectId);
const projectRoot = resolveProjectDir(PROJECTS_DIR, run.projectId, project?.metadata);
detectSkillPluginCandidateOnRunSuccess(db, design.runs, run, req.body || {}, projectRoot);
} catch (err) {
console.warn('[plugins] skill candidate hook setup failed', err);
}
}
design.runs.start(run, () => startChatRun(meta, run));
// Analytics v2: emit run_created (daemon-side authoritative) and
@@ -0,0 +1,281 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
closeDatabase,
insertConversation,
insertProject,
listMessages,
openDatabase,
upsertMessage,
} from '../src/db.js';
import {
detectSkillPluginCandidate,
dismissSkillPluginCandidate,
generateSkillPluginDraft,
insertSkillPluginCandidate,
listSkillPluginCandidates,
} from '../src/plugins/skill-candidates.js';
import { upsertSkillPluginCandidateAssistantMessage } from '../src/server.js';
let tmpDir: string;
let projectRoot: string;
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'od-skill-plugin-candidates-'));
projectRoot = path.join(tmpDir, 'project');
await mkdir(projectRoot, { recursive: true });
});
afterEach(async () => {
closeDatabase();
await rm(tmpDir, { recursive: true, force: true });
});
describe('skill plugin candidates', () => {
it('detects an explicit SKILL.md and generates a valid draft', async () => {
await writeFile(
path.join(projectRoot, 'SKILL.md'),
[
'# Research brief skill',
'',
'Use this skill when a reusable research workflow should collect sources, compare claims, and produce a concise brief.',
'',
'## Workflow',
'',
'- Read the supplied source material.',
'- Extract durable steps.',
'- Return a structured brief.',
'',
].join('\n'),
'utf8',
);
const db = openDatabase(tmpDir, { dataDir: path.join(tmpDir, 'data') });
insertProject(db, {
id: 'proj_1',
name: 'Candidate project',
skillId: null,
designSystemId: null,
pendingPrompt: null,
metadata: { kind: 'prototype' },
createdAt: 1,
updatedAt: 1,
});
const detected = await detectSkillPluginCandidate({
projectId: 'proj_1',
runId: 'run_1',
conversationId: 'conv_1',
message: 'Please use @SKILL.md for this run.',
projectRoot,
now: 10,
});
expect(detected?.title).toBe('Research brief skill');
const candidate = insertSkillPluginCandidate(db, detected!);
expect(candidate?.sourceRefs[0]?.value).toBe('SKILL.md');
expect(listSkillPluginCandidates(db, 'proj_1')).toHaveLength(1);
const result = await generateSkillPluginDraft(db, projectRoot, 'proj_1', candidate!.id, 20);
expect(result?.ok).toBe(true);
expect(result?.draftPath).toMatch(/^plugin-source\/research-brief-skill-/);
const manifest = JSON.parse(await readFile(path.join(result!.folder, 'open-design.json'), 'utf8'));
expect(manifest.od.kind).toBe('skill');
await expect(readFile(path.join(result!.folder, 'references', 'provenance.json'), 'utf8'))
.resolves.toContain(candidate!.id);
});
it('does not detect generic prompt heading blocks', async () => {
const detected = await detectSkillPluginCandidate({
projectId: 'proj_1',
message: [
'## Instructions',
'Follow these steps.',
'## Workflow',
'Make a page.',
'## Constraints',
'Keep it simple.',
].join('\n'),
projectRoot,
});
expect(detected).toBeNull();
});
it('only detects GitHub URLs that point at explicit skill artifacts', async () => {
await expect(detectSkillPluginCandidate({
projectId: 'proj_1',
message: 'Look at https://github.com/foo/bar for context.',
projectRoot,
})).resolves.toBeNull();
await expect(detectSkillPluginCandidate({
projectId: 'proj_1',
message: 'The implementation is discussed at https://github.com/foo/bar/pull/123.',
projectRoot,
})).resolves.toBeNull();
const detected = await detectSkillPluginCandidate({
projectId: 'proj_1',
message: 'Use https://github.com/foo/bar/blob/main/SKILL.md for this run.',
projectRoot,
});
expect(detected?.sourceRefs[0]?.value).toBe('https://github.com/foo/bar/blob/main/SKILL.md');
});
it('dismisses only the matching project candidate', async () => {
const db = openDatabase(tmpDir, { dataDir: path.join(tmpDir, 'data') });
for (const id of ['proj_1', 'proj_2']) {
insertProject(db, {
id,
name: id,
skillId: null,
designSystemId: null,
pendingPrompt: null,
metadata: { kind: 'prototype' },
createdAt: 1,
updatedAt: 1,
});
}
const base = {
runId: null,
conversationId: null,
assistantMessageId: null,
title: 'Reusable Skill',
description: 'A reusable skill.',
confidence: 0.9,
sourceRefs: [{ kind: 'url' as const, value: 'https://github.com/acme/skill' }],
provenance: { summary: 'test', detectedAt: 1 },
draftPath: null,
};
const a = insertSkillPluginCandidate(db, { ...base, projectId: 'proj_1', fingerprint: 'a' })!;
insertSkillPluginCandidate(db, { ...base, projectId: 'proj_2', fingerprint: 'b' });
dismissSkillPluginCandidate(db, 'proj_1', a.id, 30);
expect(listSkillPluginCandidates(db, 'proj_1')).toHaveLength(0);
expect(listSkillPluginCandidates(db, 'proj_2')).toHaveLength(1);
expect(listSkillPluginCandidates(db, 'proj_1', true)[0]?.status).toBe('dismissed');
});
it('does not dismiss or expose a candidate from another project', () => {
const db = openDatabase(tmpDir, { dataDir: path.join(tmpDir, 'data') });
for (const id of ['proj_1', 'proj_2']) {
insertProject(db, {
id,
name: id,
skillId: null,
designSystemId: null,
pendingPrompt: null,
metadata: { kind: 'prototype' },
createdAt: 1,
updatedAt: 1,
});
}
insertConversation(db, {
id: 'conv_1',
projectId: 'proj_1',
title: 'Candidate conversation',
createdAt: 1,
updatedAt: 1,
});
upsertMessage(db, 'conv_1', {
id: 'assistant_card_1',
role: 'assistant',
content: 'plugin candidate',
createdAt: 1,
endedAt: 1,
});
const candidate = insertSkillPluginCandidate(db, {
projectId: 'proj_1',
runId: null,
conversationId: 'conv_1',
assistantMessageId: 'assistant_card_1',
title: 'Reusable Skill',
description: 'A reusable skill.',
confidence: 0.9,
sourceRefs: [{ kind: 'file', value: 'SKILL.md' }],
provenance: { summary: 'test', detectedAt: 1 },
fingerprint: 'fingerprint_1',
draftPath: null,
})!;
const dismissed = dismissSkillPluginCandidate(db, 'proj_2', candidate.id, 30);
expect(dismissed).toBeNull();
expect(listSkillPluginCandidates(db, 'proj_1')).toHaveLength(1);
expect(listSkillPluginCandidates(db, 'proj_1', true)[0]?.status).toBe('active');
expect(listMessages(db, 'conv_1').map((message) => message.id)).toContain('assistant_card_1');
});
it('reuses and reanchors an existing candidate assistant message', () => {
const db = openDatabase(tmpDir, { dataDir: path.join(tmpDir, 'data') });
insertProject(db, {
id: 'proj_1',
name: 'Candidate project',
skillId: null,
designSystemId: null,
pendingPrompt: null,
metadata: { kind: 'prototype' },
createdAt: 1,
updatedAt: 1,
});
insertConversation(db, {
id: 'conv_1',
projectId: 'proj_1',
title: 'Candidate conversation',
createdAt: 1,
updatedAt: 1,
});
upsertMessage(db, 'conv_1', {
id: 'assistant_1',
role: 'assistant',
content: 'first run',
createdAt: 1,
endedAt: 1,
});
const candidate = insertSkillPluginCandidate(db, {
projectId: 'proj_1',
runId: 'run_1',
conversationId: 'conv_1',
assistantMessageId: null,
title: 'Reusable Skill',
description: 'A reusable skill.',
confidence: 0.9,
sourceRefs: [{ kind: 'file', value: 'SKILL.md' }],
provenance: { summary: 'test', detectedAt: 1 },
fingerprint: 'fingerprint_1',
draftPath: null,
})!;
const firstCardId = upsertSkillPluginCandidateAssistantMessage(db, {
id: 'run_1',
conversationId: 'conv_1',
assistantMessageId: 'assistant_1',
agentId: 'agent_1',
}, candidate);
upsertMessage(db, 'conv_1', {
id: 'assistant_2',
role: 'assistant',
content: 'second run',
createdAt: 2,
endedAt: 2,
});
const reloadedCandidate = listSkillPluginCandidates(db, 'proj_1')[0]!;
const secondCardId = upsertSkillPluginCandidateAssistantMessage(db, {
id: 'run_2',
conversationId: 'conv_1',
assistantMessageId: 'assistant_2',
agentId: 'agent_1',
}, reloadedCandidate);
expect(secondCardId).toBe(firstCardId);
expect(listMessages(db, 'conv_1').filter((message) =>
message.events?.some((event: { kind?: string }) => event.kind === 'plugin_candidate'),
)).toHaveLength(1);
expect(listSkillPluginCandidates(db, 'proj_1')[0]?.assistantMessageId).toBe(firstCardId);
});
});
@@ -104,6 +104,177 @@ function ActionNoticeView({ notice }: { notice: ActionNotice | null }) {
);
}
type SkillPluginCandidateBlock = Extract<Block, { kind: "plugin-candidate" }>;
function SkillPluginCandidateCard({
block,
projectId,
onDismissed,
onRequestOpenFile,
}: {
block: SkillPluginCandidateBlock;
projectId: string | null;
onDismissed: (candidateId: string) => void;
onRequestOpenFile?: (name: string) => void;
}) {
const t = useT();
const [busy, setBusy] = useState<null | "draft" | "publish" | "contribute" | "dismiss">(null);
const [notice, setNotice] = useState<ActionNotice | null>(null);
const disabled = !projectId || busy !== null;
const description =
block.description === "Reusable skill material detected from a repository link." ||
block.description === "This repo looks like it could work as a plugin."
? t("skillPluginCandidate.repoDescription")
: block.description || t("skillPluginCandidate.repoDescription");
async function post(path: string, body: Record<string, unknown> = {}) {
const resp = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await resp.json().catch(() => null);
if (!resp.ok) {
const message =
data?.message ??
(typeof data?.error === "string" ? data.error : data?.error?.message) ??
resp.statusText;
throw new Error(message || "Plugin candidate action failed.");
}
return data;
}
async function createDraft() {
if (!projectId) return;
setBusy("draft");
setNotice(null);
try {
const data = await post(
`/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(block.candidateId)}/draft`,
);
const draftPath = String(data?.draftPath ?? "");
if (data?.validation?.ok === false) {
setNotice({ message: "Draft created with validation issues." });
} else if (draftPath) {
const install = await post(
`/api/projects/${encodeURIComponent(projectId)}/plugins/install-folder`,
{ path: draftPath },
);
if (install?.ok === false) {
setNotice({ message: install?.message ?? "Plugin draft created, but install failed." });
} else {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("open-design:plugins-changed"));
}
setNotice({ message: install?.message ?? "Plugin draft created and added to My plugins." });
}
} else {
setNotice({ message: "Plugin draft created." });
}
if (draftPath && onRequestOpenFile) onRequestOpenFile(`${draftPath}/open-design.json`);
} catch (err) {
setNotice({ message: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(null);
}
}
async function share(action: "publish-github" | "contribute-open-design") {
if (!projectId) return;
setBusy(action === "publish-github" ? "publish" : "contribute");
setNotice(null);
try {
const data = await post(
`/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(block.candidateId)}/share-tasks`,
{ action },
);
setNotice({
message:
action === "publish-github"
? `GitHub publish task started for ${data?.path ?? "the draft"}.`
: `Open Design contribution task started for ${data?.path ?? "the draft"}.`,
});
} catch (err) {
setNotice({ message: err instanceof Error ? err.message : String(err) });
} finally {
setBusy(null);
}
}
async function dismiss() {
if (!projectId) return;
setBusy("dismiss");
try {
await post(
`/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(block.candidateId)}/dismiss`,
);
onDismissed(block.candidateId);
} catch (err) {
setNotice({ message: err instanceof Error ? err.message : String(err) });
setBusy(null);
}
}
return (
<div className="plugin-action-panel" data-testid={`skill-plugin-candidate-${block.candidateId}`}>
<div className="plugin-action-card">
<div className="plugin-action-card__body">
<div className="plugin-action-card__title">
<Icon name="sparkles" size={14} />
<span>{block.title}</span>
</div>
<p className="plugin-action-card__description">
{description}
</p>
<div className="plugin-action-card__actions">
<button
type="button"
className="plugin-action-button plugin-action-button--primary"
disabled={disabled}
onClick={() => void createDraft()}
>
<Icon name={busy === "draft" ? "spinner" : "plus"} size={13} />
<span>{busy === "draft" ? "Creating..." : t("skillPluginCandidate.createForMe")}</span>
</button>
<button
type="button"
className="plugin-action-button"
disabled={disabled}
onClick={() => void share("contribute-open-design")}
>
<Icon name={busy === "contribute" ? "spinner" : "share"} size={13} />
<span>{busy === "contribute" ? "Starting..." : t("skillPluginCandidate.contributeToMain")}</span>
</button>
<button
type="button"
className="plugin-action-button"
disabled={disabled}
onClick={() => void share("publish-github")}
>
<Icon name={busy === "publish" ? "spinner" : "github"} size={13} />
<span>{busy === "publish" ? "Starting..." : t("skillPluginCandidate.publishRepo")}</span>
</button>
<button
type="button"
className="plugin-action-button"
disabled={disabled}
onClick={() => void dismiss()}
>
<Icon name={busy === "dismiss" ? "spinner" : "close"} size={13} />
<span>{t("skillPluginCandidate.dismiss")}</span>
</button>
</div>
{notice ? (
<div className="plugin-action-card__notice" role="status">
<ActionNoticeView notice={notice} />
</div>
) : null}
</div>
</div>
</div>
);
}
interface Props {
message: ChatMessage;
streaming: boolean;
@@ -299,6 +470,9 @@ export function AssistantMessage({
const [locallySubmitted, setLocallySubmitted] = useState<Set<string>>(
() => new Set()
);
const [dismissedCandidateIds, setDismissedCandidateIds] = useState<Set<string>>(
() => new Set()
);
// Route interactive tool answers (currently AskUserQuestion) back to the
// still-open stream-json child via the daemon. We resolve to `true` on
// success so the card can flip into its answered state; on `false` (run
@@ -376,6 +550,24 @@ export function AssistantMessage({
/>
);
}
if (b.kind === "plugin-candidate") {
if (dismissedCandidateIds.has(b.candidateId)) return null;
return (
<SkillPluginCandidateCard
key={i}
block={b}
projectId={projectId}
onDismissed={(candidateId) =>
setDismissedCandidateIds((prev) => {
const next = new Set(prev);
next.add(candidateId);
return next;
})
}
onRequestOpenFile={onRequestOpenFile}
/>
);
}
if (b.kind === "status")
return <StatusPill key={i} label={b.label} detail={b.detail} />;
return null;
@@ -1877,6 +2069,14 @@ type Block =
| { kind: "text"; text: string }
| { kind: "thinking"; text: string }
| { kind: "tool-group"; items: ToolItem[] }
| {
kind: "plugin-candidate";
candidateId: string;
title: string;
description?: string | undefined;
confidence?: number | undefined;
draftPath?: string | null | undefined;
}
| { kind: "status"; label: string; detail?: string | undefined };
/**
@@ -1961,6 +2161,17 @@ function buildBlocks(events: AgentEvent[]): Block[] {
continue;
}
if (ev.kind === "tool_result") continue;
if (ev.kind === "plugin_candidate") {
out.push({
kind: "plugin-candidate",
candidateId: ev.candidateId,
title: ev.title,
description: ev.description,
confidence: ev.confidence,
draftPath: ev.draftPath,
});
continue;
}
if (ev.kind === "status") {
if (
ev.label === "streaming" ||
+5
View File
@@ -608,6 +608,11 @@ export const en: Dict = {
'pluginCard.publishTitle': 'Publish plugin as a GitHub repository',
'pluginCard.contributeAria': 'Contribute {title} to Open Design',
'pluginCard.contributeTitle': 'Contribute plugin to Open Design with a pull request',
'skillPluginCandidate.createForMe': 'Create for me',
'skillPluginCandidate.contributeToMain': 'Contribute to main',
'skillPluginCandidate.publishRepo': 'Publish repo',
'skillPluginCandidate.dismiss': 'Dismiss',
'skillPluginCandidate.repoDescription': 'This repo looks like it could work as a plugin.',
'integrations.kicker': 'Integration',
'integrations.lede': 'Connect external systems, bring MCP tools into your agent loop, and use Open Design from other IDEs, scripts, and automations.',
'integrations.agentReady': 'Agent-ready',
+5
View File
@@ -608,6 +608,11 @@ export const zhCN: Dict = {
'pluginCard.publishTitle': '将插件发布为 GitHub 仓库',
'pluginCard.contributeAria': '将 {title} 贡献到 Open Design',
'pluginCard.contributeTitle': '通过 pull request 将插件贡献到 Open Design',
'skillPluginCandidate.createForMe': '帮我创建',
'skillPluginCandidate.contributeToMain': '贡献到主仓库',
'skillPluginCandidate.publishRepo': '发布仓库',
'skillPluginCandidate.dismiss': '忽略',
'skillPluginCandidate.repoDescription': '这个仓库看起来可以做成插件。',
'integrations.kicker': '集成',
'integrations.lede': '连接外部系统,把 MCP 工具带入智能体循环,并在其它 IDE、脚本和自动化中使用 Open Design。',
'integrations.agentReady': '智能体就绪',
+5
View File
@@ -1972,4 +1972,9 @@ export const zhTW: Dict = {
'pluginCard.publishTitle': '將外掛發布為 GitHub 儲存庫',
'pluginCard.contributeAria': '將 {title} 貢獻至 Open Design',
'pluginCard.contributeTitle': '透過 pull request 將外掛貢獻至 Open Design',
'skillPluginCandidate.createForMe': '幫我建立',
'skillPluginCandidate.contributeToMain': '貢獻到主倉庫',
'skillPluginCandidate.publishRepo': '發布倉庫',
'skillPluginCandidate.dismiss': '忽略',
'skillPluginCandidate.repoDescription': '這個倉庫看起來可以做成外掛。',
};
+5
View File
@@ -918,6 +918,11 @@ export interface Dict {
'pluginCard.publishTitle': string;
'pluginCard.contributeAria': string;
'pluginCard.contributeTitle': string;
'skillPluginCandidate.createForMe': string;
'skillPluginCandidate.contributeToMain': string;
'skillPluginCandidate.publishRepo': string;
'skillPluginCandidate.dismiss': string;
'skillPluginCandidate.repoDescription': string;
'integrations.kicker': string;
'integrations.lede': string;
'integrations.agentReady': string;
+8
View File
@@ -246,6 +246,14 @@ export type PersistedAgentEvent =
}
| { kind: 'tool_use'; id: string; name: string; input: unknown }
| { kind: 'tool_result'; toolUseId: string; content: string; isError: boolean }
| {
kind: 'plugin_candidate';
candidateId: string;
title: string;
description?: string;
confidence?: number;
draftPath?: string | null;
}
| { kind: 'usage'; inputTokens?: number; outputTokens?: number; costUsd?: number; durationMs?: number }
| { kind: 'raw'; line: string };
@@ -0,0 +1,72 @@
import type { PluginShareAction } from '../plugins/share-actions.js';
export type SkillPluginCandidateSourceKind = 'file' | 'url';
export type SkillPluginCandidateStatus = 'active' | 'dismissed';
export interface SkillPluginCandidateSourceRef {
kind: SkillPluginCandidateSourceKind;
value: string;
label?: string;
content?: string;
size?: number;
copied?: boolean;
reason?: string;
}
export interface SkillPluginCandidate {
id: string;
projectId: string;
runId?: string | null;
conversationId?: string | null;
assistantMessageId?: string | null;
title: string;
description: string;
confidence: number;
status: SkillPluginCandidateStatus;
sourceRefs: SkillPluginCandidateSourceRef[];
provenance: {
summary: string;
detectedAt: number;
};
draftPath?: string | null;
createdAt: number;
updatedAt: number;
dismissedAt?: number | null;
}
export interface SkillPluginCandidateListResponse {
candidates: SkillPluginCandidate[];
}
export interface SkillPluginCandidateDismissResponse {
ok: true;
candidate: SkillPluginCandidate;
}
export interface SkillPluginDraftValidationDiagnostic {
severity: 'error' | 'warning' | 'info';
code: string;
message: string;
}
export interface SkillPluginDraftResult {
ok: boolean;
candidate: SkillPluginCandidate;
draftPath: string;
folder: string;
validation: {
ok: boolean;
diagnostics: SkillPluginDraftValidationDiagnostic[];
};
}
export interface SkillPluginCandidateDraftResponse extends SkillPluginDraftResult {}
export interface SkillPluginCandidateShareTaskResponse {
taskId: string;
action: PluginShareAction;
path: string;
status: 'queued' | 'running' | 'done' | 'failed';
startedAt: number;
draft: SkillPluginDraftResult;
}
+1
View File
@@ -18,6 +18,7 @@ export * from './api/live-artifacts.js';
export * from './api/mcp.js';
export * from './api/memory.js';
export * from './api/orbit.js';
export * from './api/plugin-candidates.js';
export * from './api/providerModels.js';
export * from './api/projects.js';
export * from './api/proxy.js';