mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '智能体就绪',
|
||||
|
||||
@@ -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': '這個倉庫看起來可以做成外掛。',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user