From 170a05f5d232158e96a8ea3e55dc95fdeb8d443a Mon Sep 17 00:00:00 2001 From: Siri-Ray <109605599+Siri-Ray@users.noreply.github.com> Date: Wed, 27 May 2026 16:26:00 +0800 Subject: [PATCH] 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 --- apps/daemon/src/cli.ts | 82 ++++ apps/daemon/src/plugins/index.ts | 1 + apps/daemon/src/plugins/persistence.ts | 24 ++ apps/daemon/src/plugins/skill-candidates.ts | 391 ++++++++++++++++++ apps/daemon/src/server.ts | 203 +++++++++ .../tests/skill-plugin-candidates.test.ts | 281 +++++++++++++ apps/web/src/components/AssistantMessage.tsx | 211 ++++++++++ apps/web/src/i18n/locales/en.ts | 5 + apps/web/src/i18n/locales/zh-CN.ts | 5 + apps/web/src/i18n/locales/zh-TW.ts | 5 + apps/web/src/i18n/types.ts | 5 + packages/contracts/src/api/chat.ts | 8 + .../contracts/src/api/plugin-candidates.ts | 72 ++++ packages/contracts/src/index.ts | 1 + 14 files changed, 1294 insertions(+) create mode 100644 apps/daemon/src/plugins/skill-candidates.ts create mode 100644 apps/daemon/tests/skill-plugin-candidates.test.ts create mode 100644 packages/contracts/src/api/plugin-candidates.ts diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 7f3ae51f00..1b86c1dd02 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -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 [--json] [--include-dismissed] + od plugin candidates draft --project [--json] + od plugin candidates dismiss --project [--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 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 `. // // 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 [--out ] Build a .tgz archive of a plugin folder for distribution. + od plugin candidates list --project + List persisted skill-to-plugin candidates. od plugin publish-repo Create/update the author's public GitHub repo for a plugin folder. od plugin open-design-pr Push a community-catalog branch and diff --git a/apps/daemon/src/plugins/index.ts b/apps/daemon/src/plugins/index.ts index 314c62031f..0bc5865d2d 100644 --- a/apps/daemon/src/plugins/index.ts +++ b/apps/daemon/src/plugins/index.ts @@ -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'; diff --git a/apps/daemon/src/plugins/persistence.ts b/apps/daemon/src/plugins/persistence.ts index cb392ba42c..d0aaac6044 100644 --- a/apps/daemon/src/plugins/persistence.ts +++ b/apps/daemon/src/plugins/persistence.ts @@ -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[]; diff --git a/apps/daemon/src/plugins/skill-candidates.ts b/apps/daemon/src/plugins/skill-candidates.ts new file mode 100644 index 0000000000..64ce5ea264 --- /dev/null +++ b/apps/daemon/src/plugins/skill-candidates.ts @@ -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; + +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 & { fingerprint: string } | null> { + const refs: SkillPluginCandidateSourceRef[] = []; + const seen = new Set(); + 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 & { 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 { + 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): Record { + 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(); + 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(); + 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 { + 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'); +} diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index e08490ce1e..2c93461e45 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -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 diff --git a/apps/daemon/tests/skill-plugin-candidates.test.ts b/apps/daemon/tests/skill-plugin-candidates.test.ts new file mode 100644 index 0000000000..3768efc439 --- /dev/null +++ b/apps/daemon/tests/skill-plugin-candidates.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index a74b4c4a69..708db93b55 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -104,6 +104,177 @@ function ActionNoticeView({ notice }: { notice: ActionNotice | null }) { ); } +type SkillPluginCandidateBlock = Extract; + +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); + const [notice, setNotice] = useState(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 = {}) { + 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 ( +
+
+
+
+ + {block.title} +
+

+ {description} +

+
+ + + + +
+ {notice ? ( +
+ +
+ ) : null} +
+
+
+ ); +} + interface Props { message: ChatMessage; streaming: boolean; @@ -299,6 +470,9 @@ export function AssistantMessage({ const [locallySubmitted, setLocallySubmitted] = useState>( () => new Set() ); + const [dismissedCandidateIds, setDismissedCandidateIds] = useState>( + () => 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 ( + + setDismissedCandidateIds((prev) => { + const next = new Set(prev); + next.add(candidateId); + return next; + }) + } + onRequestOpenFile={onRequestOpenFile} + /> + ); + } if (b.kind === "status") return ; 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" || diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 49ac1ba700..0a9f7afca4 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -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', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 50732a343e..c34622bf83 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -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': '智能体就绪', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 442af51e65..57e877a2f5 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -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': '這個倉庫看起來可以做成外掛。', }; diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index c9ce3b2f54..34803906de 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -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; diff --git a/packages/contracts/src/api/chat.ts b/packages/contracts/src/api/chat.ts index 5e5b6ba690..c83acaf3b1 100644 --- a/packages/contracts/src/api/chat.ts +++ b/packages/contracts/src/api/chat.ts @@ -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 }; diff --git a/packages/contracts/src/api/plugin-candidates.ts b/packages/contracts/src/api/plugin-candidates.ts new file mode 100644 index 0000000000..8510eb6e6b --- /dev/null +++ b/packages/contracts/src/api/plugin-candidates.ts @@ -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; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d8015c5c67..ce62222087 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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';