Files
goodbuddy/src/main/capabilities/capability-service.ts
T

2078 lines
63 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto'
import { unzipSync } from 'fflate'
import {
lstat,
mkdir,
readdir,
readFile,
realpath,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { basename, dirname, extname, join } from 'node:path'
import { parse as parseYaml } from 'yaml'
import { z } from 'zod'
import {
browserProfileIdSchema,
browserProfileNameSchema,
browserProfilesSummarySchema,
builtinMcpAssignmentsSchema,
builtinMcpServerIdSchema,
builtinMcpServerStateSummarySchema,
capabilityDiagnosticReportSchema,
capabilityAssignmentsSchema,
computerCapabilityConfigSummarySchema,
computerCapabilityIdSchema,
mcpServerIdSchema,
mcpServerInputSchema,
mcpServerSummarySchema,
obsidianSettingsSchema,
type ObsidianSettings,
runtimeTargetSchema,
skillIdSchema,
skillSummarySchema,
webSearchCapabilitySchema,
type CapabilityAssignments,
type CapabilityDiagnosticReport,
type CapabilitySnapshot,
type BrowserProfilesSummary,
type BuiltinMcpServerId,
type ComputerCapabilityId,
type McpServerInput,
type McpServerSummary,
type RuntimeTarget,
type SkillSummary
} from '../../shared/capability-contracts'
import type { SettingsWarning } from '../../shared/settings-warning-contracts'
import {
assertSupportedSettingsVersion,
isolateCorruptSettingsFile,
isMissingFileError,
type SettingsFileOperations,
UnsupportedSettingsVersionError,
writeJsonFileAtomically
} from '../settings-file-utils'
import {
decryptSettingsCredential,
encryptedSettingsCredentialSchema,
encryptSettingsCredential,
type SettingsCredentialCipher
} from '../settings-credential-cipher'
import {
BrowserProfileService,
FileBrowserProfileStore,
type BrowserProfileState
} from './browser-profile-service'
import {
CapabilityDiagnostics,
type CapabilityDiagnosticCheck
} from './capability-diagnostics'
import {
computerCapabilityCatalog,
getComputerCapability,
isComputerCapabilitySupported,
type ComputerCapabilityImplementationKind
} from './computer-capability-catalog'
const MAX_SKILL_FILE_BYTES = 2 * 1024 * 1024
const MAX_SKILL_PACKAGE_BYTES = 10 * 1024 * 1024
const MAX_SKILL_PACKAGE_FILES = 128
const MAX_SKILL_DEPTH = 6
const MAX_SKILL_DISCOVERY_DEPTH = 4
const DEFAULT_DISABLED_BUILTIN_SKILL_IDS = new Set([
'product-marketing',
'product-evidence',
'product-presentation'
])
const MAX_SKILL_DISCOVERY_RESULTS = 64
const MAX_SKILL_INSTRUCTION_CHARACTERS = 262_144
const SKILL_DISCOVERY_IGNORED_DIRECTORIES = new Set([
'node_modules',
'__pycache__',
'__MACOSX'
])
// Block scalars in SKILL.md frontmatter carry newlines that would break the
// single-line summary surfaces the renderer and runtimes rely on.
function collapsedText(maximum: number): z.ZodType<string> {
return z
.string()
.transform((value) => value.replace(/\s+/gu, ' ').trim())
.pipe(z.string().min(1).max(maximum))
}
const skillMetadataSchema = z.object({
id: skillIdSchema.optional(),
name: collapsedText(80),
description: collapsedText(500),
version: collapsedText(32).optional(),
tags: z.array(collapsedText(32)).max(12).default([])
})
const skillStateSchema = z
.object({
enabled: z.boolean(),
assignments: capabilityAssignmentsSchema
})
.strict()
const builtinMcpServerStateSchema = z
.object({
enabled: z.boolean(),
assignments: builtinMcpAssignmentsSchema
})
.strict()
const legacyBuiltinMcpServerStatesSchema = z
.object({
'knowledge-base': builtinMcpServerStateSchema,
'magic-notes': builtinMcpServerStateSchema,
'goodbuddy-config': builtinMcpServerStateSchema
})
.strict()
const builtinMcpServerStatesSchema =
legacyBuiltinMcpServerStatesSchema.extend({
'story-graph': builtinMcpServerStateSchema.extend({ assignments: capabilityAssignmentsSchema }).default({ enabled: true, assignments: ['model', 'opencode', 'continue', 'deepseek-harness'] }),
'builtin-browser': builtinMcpServerStateSchema,
obsidian: builtinMcpServerStateSchema.default({
enabled: false,
assignments: ['model', 'opencode', 'continue']
})
})
const encryptedSecretSchema =
encryptedSettingsCredentialSchema.optional()
const storedMcpCommonShape = {
id: mcpServerIdSchema,
name: z.string(),
description: z.string(),
enabled: z.boolean(),
allowDynamicTools: z.boolean().default(false),
assignments: capabilityAssignmentsSchema,
credential: encryptedSecretSchema
}
const storedMcpServerSchema = z.discriminatedUnion('transport', [
z
.object({
...storedMcpCommonShape,
transport: z.literal('stdio'),
command: z.string(),
args: z.array(z.string())
})
.strict(),
z
.object({
...storedMcpCommonShape,
transport: z.literal('http'),
url: z.string()
})
.strict(),
z
.object({
...storedMcpCommonShape,
transport: z.literal('sse'),
url: z.string()
})
.strict()
])
const storedCapabilitiesV1Schema = z
.object({
version: z.literal(1),
skills: z.record(skillIdSchema, skillStateSchema),
mcpServers: z.array(storedMcpServerSchema).max(64)
})
.strict()
const computerCapabilityStateSchema = z
.object({
enabled: z.boolean(),
browserProfileId: browserProfileIdSchema.nullable()
})
.strict()
const storedCapabilitiesV2Schema = z
.object({
version: z.literal(2),
skills: z.record(skillIdSchema, skillStateSchema),
mcpServers: z.array(storedMcpServerSchema).max(64),
computerCapabilities: z
.object({
'host-browser-control': computerCapabilityStateSchema,
'linux-desktop-control': computerCapabilityStateSchema
})
.strict()
})
.strict()
const webSearchStateSchema = z
.object({
enabled: z.boolean()
})
.strict()
const storedCapabilitiesV3Schema = z
.object({
version: z.literal(3),
skills: z.record(skillIdSchema, skillStateSchema),
mcpServers: z.array(storedMcpServerSchema).max(64),
webSearch: webSearchStateSchema,
computerCapabilities: z
.object({
'host-browser-control': computerCapabilityStateSchema,
'linux-desktop-control': computerCapabilityStateSchema
})
.strict()
})
.strict()
const storedCapabilitiesV4Schema = storedCapabilitiesV3Schema.extend({
version: z.literal(4)
})
const storedCapabilitiesV5Schema = storedCapabilitiesV4Schema.extend({
version: z.literal(5),
builtinMcpServers: legacyBuiltinMcpServerStatesSchema
})
const storedCapabilitiesSchema = storedCapabilitiesV5Schema.extend({
version: z.literal(6),
builtinMcpServers: builtinMcpServerStatesSchema,
obsidian: obsidianSettingsSchema.default({ vaultPath: '' })
})
type StoredCapabilitiesV1 = z.infer<typeof storedCapabilitiesV1Schema>
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
type StoredMcpServer = z.infer<typeof storedMcpServerSchema>
const secretPayloadSchema = z
.object({
version: z.literal(1),
serverId: mcpServerIdSchema,
secret: z.string()
})
.strict()
export type CapabilityCipher = SettingsCredentialCipher
export type ResolvedMcpServer = McpServerSummary & {
secret?: string
}
export type RuntimeSkillPackage = {
id: string
directory: string
digest?: string
}
export type RuntimeSkillContext = {
instructions: string
packages: RuntimeSkillPackage[]
}
export type SkillImportInspection = {
sourcePath: string
digest: string
skills: Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
}
export type CapabilityServiceOptions = Readonly<{
getSavedModelProfiles?: () => Promise<ReadonlyArray<{
id: string
name: string
protocol: string
allowConversationInvocation?: boolean
}>>
platform?: NodeJS.Platform
architecture?: string
electronTarget?: boolean
browserProfiles?: BrowserProfileService
diagnostics?: CapabilityDiagnostics
availableComputerCapabilityImplementations?: readonly ComputerCapabilityImplementationKind[]
settingsFileOperations?: Partial<SettingsFileOperations>
}>
function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabilities'] {
return {
'host-browser-control': {
enabled: true,
browserProfileId: null
},
'linux-desktop-control': {
enabled: false,
browserProfileId: null
}
}
}
function defaultBuiltinMcpServerStates(
browserEnabled = true
): StoredCapabilities['builtinMcpServers'] {
const defaultState = (): z.infer<typeof builtinMcpServerStateSchema> => ({
enabled: true,
assignments: ['model', 'opencode', 'continue']
})
return {
'knowledge-base': defaultState(),
'story-graph': { enabled: true, assignments: ['model', 'opencode', 'continue', 'deepseek-harness'] },
'magic-notes': defaultState(),
'goodbuddy-config': defaultState(),
obsidian: { ...defaultState(), enabled: false },
'builtin-browser': {
enabled: browserEnabled,
assignments: ['model', 'opencode', 'continue']
}
}
}
function emptyStoredCapabilities(
webSearchEnabled = true
): StoredCapabilities {
return {
version: 6,
obsidian: { vaultPath: '' },
skills: {},
builtinMcpServers: defaultBuiltinMcpServerStates(),
mcpServers: [],
webSearch: { enabled: webSearchEnabled },
computerCapabilities: defaultComputerCapabilityStates()
}
}
function defaultSkillState(
skill?: Pick<SkillSummary, 'id' | 'source'>
): z.infer<typeof skillStateSchema> {
return {
enabled:
skill?.source !== 'builtin' ||
!DEFAULT_DISABLED_BUILTIN_SKILL_IDS.has(skill.id),
assignments: [
'model',
'opencode',
'continue',
'deepseek-harness'
]
}
}
async function readSkill(
directoryPath: string,
source: SkillSummary['source'],
expectedId: string | null = basename(directoryPath)
): Promise<Omit<SkillSummary, 'enabled' | 'assignments'>> {
const filePath = join(directoryPath, 'SKILL.md')
const file = await stat(filePath)
if (!file.isFile() || file.size > MAX_SKILL_FILE_BYTES) {
throw new Error(`${basename(directoryPath)} 的 SKILL.md 无效或过大`)
}
const content = await readFile(filePath, 'utf8')
return parseSkillContent(
content,
basename(directoryPath),
source,
expectedId
)
}
function parseSkillContent(
content: string,
displayName: string,
source: SkillSummary['source'],
expectedId: string | null
): Omit<SkillSummary, 'enabled' | 'assignments'> {
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]+)$/u.exec(content)
if (!match?.[1] || !match[2]?.trim()) {
throw new Error(`${displayName} 的 SKILL.md 格式无效`)
}
const metadata = skillMetadataSchema.parse(parseYaml(match[1]))
// Standard SKILL.md files identify the skill by `name`; GoodBuddy packages
// add an explicit `id` alongside a human-readable `name`.
const identifier = skillIdSchema.safeParse(metadata.id ?? metadata.name)
if (!identifier.success) {
throw new Error(
`${displayName} 的 SKILL.md 缺少可用的 Skill ID,请提供小写连字符格式的 id 或 name`
)
}
if (expectedId !== null && identifier.data !== expectedId) {
throw new Error(`Skill ID 必须与目录名一致:${identifier.data}`)
}
return skillSummarySchema
.omit({ enabled: true, assignments: true })
.parse({
...metadata,
id: identifier.data,
source,
digest: createHash('sha256').update(content).digest('hex')
})
}
async function listSkills(
root: string,
source: SkillSummary['source']
): Promise<Array<Omit<SkillSummary, 'enabled' | 'assignments'>>> {
let entries
try {
entries = await readdir(root, { withFileTypes: true })
} catch (error) {
if (isMissingFileError(error)) {
return []
}
throw error
}
return Promise.all(
entries
.filter(
(entry) =>
entry.isDirectory() && !entry.name.startsWith('.')
)
.map((entry) => readSkill(join(root, entry.name), source))
)
}
async function pathExists(candidate: string): Promise<boolean> {
return stat(candidate)
.then(() => true)
.catch(() => false)
}
// Mirrors the conventional layout where the first directory containing
// SKILL.md is the skill root, so users can pick a suite directory that holds
// many skills instead of one package at a time.
async function discoverSkillDirectories(root: string): Promise<string[]> {
if (await pathExists(join(root, 'SKILL.md'))) {
return [root]
}
const found: string[] = []
const walk = async (current: string, depth: number): Promise<void> => {
if (depth > MAX_SKILL_DISCOVERY_DEPTH || found.length > MAX_SKILL_DISCOVERY_RESULTS) {
return
}
let entries
try {
entries = await readdir(current, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.name.startsWith('.') ||
SKILL_DISCOVERY_IGNORED_DIRECTORIES.has(entry.name)
) {
continue
}
const child = join(current, entry.name)
if (await pathExists(join(child, 'SKILL.md'))) {
found.push(child)
continue
}
await walk(child, depth + 1)
}
}
await walk(root, 0)
if (found.length > MAX_SKILL_DISCOVERY_RESULTS) {
throw new Error(
`所选目录包含的 Skill 超过 ${MAX_SKILL_DISCOVERY_RESULTS} 个,请选择更精确的目录`
)
}
return found.sort((left, right) => left.localeCompare(right))
}
type SkillPackageFile = {
relativePath: string
contents: Buffer
}
async function captureSkillPackage(
sourceRoot: string
): Promise<SkillPackageFile[]> {
let fileCount = 0
let totalBytes = 0
const files: SkillPackageFile[] = []
const captureDirectory = async (
source: string,
relativeRoot: string,
depth: number
): Promise<void> => {
if (depth > MAX_SKILL_DEPTH) {
throw new Error('Skill 目录层级超过安全限制')
}
const entries = (await readdir(source, { withFileTypes: true })).sort(
(left, right) => left.name.localeCompare(right.name)
)
for (const entry of entries) {
const sourcePath = join(source, entry.name)
const relativePath = relativeRoot
? `${relativeRoot}/${entry.name}`
: entry.name
const details = await lstat(sourcePath)
if (details.isSymbolicLink()) {
throw new Error('Skill 包不能包含符号链接')
}
if (details.isDirectory()) {
await captureDirectory(sourcePath, relativePath, depth + 1)
continue
}
if (!details.isFile()) {
throw new Error('Skill 包只能包含普通文件和目录')
}
const contents = await readFile(sourcePath)
if (contents.byteLength !== details.size) {
throw new Error('Skill 内容在读取期间已发生变化,请重试')
}
fileCount += 1
totalBytes += contents.byteLength
if (
fileCount > MAX_SKILL_PACKAGE_FILES ||
contents.byteLength > MAX_SKILL_FILE_BYTES ||
totalBytes > MAX_SKILL_PACKAGE_BYTES
) {
throw new Error('Skill 包大小或文件数量超过安全限制')
}
files.push({ relativePath, contents })
}
}
await captureDirectory(sourceRoot, '', 0)
return files.sort((left, right) =>
left.relativePath.localeCompare(right.relativePath)
)
}
function digestSkillFiles(files: readonly SkillPackageFile[]): string {
const packageHash = createHash('sha256')
for (const file of files) {
packageHash.update(
`file\0${file.relativePath}\0${file.contents.byteLength}\0`
)
packageHash.update(file.contents)
packageHash.update('\0')
}
return packageHash.digest('hex')
}
async function writeSkillPackageFiles(
files: readonly SkillPackageFile[],
targetRoot: string
): Promise<void> {
await mkdir(targetRoot, { recursive: true })
for (const file of files) {
const targetPath = join(targetRoot, ...file.relativePath.split('/'))
await mkdir(dirname(targetPath), { recursive: true })
await writeFile(targetPath, file.contents, { mode: 0o600 })
}
}
async function copySkillPackage(
sourceRoot: string,
targetRoot: string
): Promise<string> {
const files = await captureSkillPackage(sourceRoot)
await writeSkillPackageFiles(files, targetRoot)
return digestSkillFiles(files)
}
async function digestSkillPackage(sourceRoot: string): Promise<string> {
return digestSkillFiles(await captureSkillPackage(sourceRoot))
}
function parseSkillZipPath(path: string): string[] {
const normalized = path.replaceAll('\\', '/')
const withoutTrailingSlash = normalized.replace(/\/+$/u, '')
if (
!withoutTrailingSlash ||
normalized.startsWith('/') ||
/^[a-z]:/iu.test(normalized)
) {
throw new Error('Skill ZIP 包含不安全路径')
}
const segments = withoutTrailingSlash.split('/')
if (
segments.some(
(segment) =>
!segment ||
segment === '.' ||
segment === '..' ||
segment.length > 255 ||
[...segment].some((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127
})
) ||
normalized.length > 512
) {
throw new Error('Skill ZIP 包含不安全路径')
}
return segments
}
function isIgnoredSkillZipPath(segments: readonly string[]): boolean {
return (
segments[0] === '__MACOSX' ||
segments.at(-1) === '.DS_Store'
)
}
type ParsedSkillZip = {
directoryName?: string
files: SkillPackageFile[]
}
async function parseSkillZip(archivePath: string): Promise<ParsedSkillZip> {
const archiveDetails = await stat(archivePath)
if (
!archiveDetails.isFile() ||
archiveDetails.size > MAX_SKILL_PACKAGE_BYTES
) {
throw new Error('Skill ZIP 文件无效或过大')
}
const archiveBytes = await readFile(archivePath)
const selectedPaths = new Map<string, string[]>()
const normalizedPaths = new Set<string>()
let fileCount = 0
let totalBytes = 0
let files: Record<string, Uint8Array>
try {
files = unzipSync(archiveBytes, {
filter: (file) => {
const segments = parseSkillZipPath(file.name)
if (
file.name.endsWith('/') ||
isIgnoredSkillZipPath(segments)
) {
return false
}
const normalizedPath = segments.join('/').toLowerCase()
if (normalizedPaths.has(normalizedPath)) {
throw new Error('Skill ZIP 包含重复文件路径')
}
normalizedPaths.add(normalizedPath)
fileCount += 1
totalBytes += file.originalSize
if (
fileCount > MAX_SKILL_PACKAGE_FILES ||
file.originalSize > MAX_SKILL_FILE_BYTES ||
totalBytes > MAX_SKILL_PACKAGE_BYTES
) {
throw new Error('Skill ZIP 大小或文件数量超过安全限制')
}
selectedPaths.set(file.name, segments)
return true
}
})
} catch (error) {
if (error instanceof Error && error.message.startsWith('Skill ZIP')) {
throw error
}
throw new Error('Skill ZIP 文件无效或不受支持', {
cause: error
})
}
const skillEntries = [...selectedPaths.entries()].filter(
([, segments]) => segments.at(-1) === 'SKILL.md'
)
if (skillEntries.length !== 1) {
throw new Error('Skill ZIP 必须且只能包含一个 SKILL.md')
}
const packageRoot = skillEntries[0]![1].slice(0, -1)
const packageRootKey = packageRoot
.map((segment) => segment.toLowerCase())
for (const segments of selectedPaths.values()) {
const belongsToPackage = packageRootKey.every(
(segment, index) => segments[index]?.toLowerCase() === segment
)
if (!belongsToPackage || segments.length <= packageRoot.length) {
throw new Error('Skill ZIP 只能包含一个 Skill 包')
}
if (segments.length - packageRoot.length - 1 > MAX_SKILL_DEPTH) {
throw new Error('Skill ZIP 目录层级超过安全限制')
}
}
return {
...(packageRoot.at(-1)
? { directoryName: packageRoot.at(-1) }
: {}),
files: Object.entries(files)
.flatMap(([archiveName, contents]) => {
const segments = selectedPaths.get(archiveName)
if (!segments) {
return []
}
return [
{
relativePath: segments.slice(packageRoot.length).join('/'),
contents: Buffer.from(contents)
}
]
})
.sort((left, right) =>
left.relativePath.localeCompare(right.relativePath)
)
}
}
export class CapabilityService {
private readonly getSavedModelProfiles: NonNullable<CapabilityServiceOptions['getSavedModelProfiles']>
private state?: StoredCapabilities
private loadPromise?: Promise<StoredCapabilities>
private warnings: SettingsWarning[] = []
private updateQueue: Promise<void> = Promise.resolve()
private readonly platform: NodeJS.Platform
private readonly architecture: string
private readonly electronTarget: boolean
private readonly browserProfiles: BrowserProfileService
private readonly diagnostics: CapabilityDiagnostics
private readonly settingsFileOperations?: Partial<SettingsFileOperations>
private readonly availableComputerCapabilityImplementations: ReadonlySet<ComputerCapabilityImplementationKind>
constructor(
private readonly filePath: string,
private readonly builtinSkillsRoot: string,
private readonly importedSkillsRoot: string,
private readonly cipher: CapabilityCipher,
options: CapabilityServiceOptions = {}
) {
this.getSavedModelProfiles =
options.getSavedModelProfiles ?? (async () => [])
this.platform = options.platform ?? process.platform
this.architecture = options.architecture ?? process.arch
this.electronTarget =
options.electronTarget ?? Boolean(process.versions.electron)
this.availableComputerCapabilityImplementations = new Set(
options.availableComputerCapabilityImplementations ?? [
'managed-browser-driver'
]
)
this.settingsFileOperations = options.settingsFileOperations
this.browserProfiles =
options.browserProfiles ??
new BrowserProfileService(
new FileBrowserProfileStore(
join(dirname(this.filePath), 'browser-profiles')
)
)
const checks: CapabilityDiagnosticCheck[] = [
{
id: 'browser-executable',
run: async () =>
this.electronTarget
? {
status: 'available',
summary: 'GoodBuddy 的受管 Electron 浏览器核心可用。'
}
: {
status: 'unavailable',
summary: '当前进程不是受支持的 Electron 桌面目标。',
remedy: '请从 GoodBuddy 桌面应用运行此诊断。'
}
},
{
id: 'managed-profile-root',
run: async () => {
await this.browserProfiles.getSnapshot()
return {
status: 'available',
summary: '内置浏览器隔离存储可用。'
}
}
}
]
this.diagnostics =
options.diagnostics ?? new CapabilityDiagnostics(checks)
}
private load(): Promise<StoredCapabilities> {
if (this.state) {
return Promise.resolve(this.state)
}
if (this.loadPromise) {
return this.loadPromise
}
const pending = this.loadUncached()
const tracked = pending.catch((error: unknown) => {
if (this.loadPromise === tracked) {
this.loadPromise = undefined
this.state = undefined
}
throw error
})
this.loadPromise = tracked
return tracked
}
private async loadUncached(): Promise<StoredCapabilities> {
let loaded: StoredCapabilities
let shouldPersist = false
try {
const raw = JSON.parse(await readFile(this.filePath, 'utf8')) as unknown
assertSupportedSettingsVersion(raw, 6, (version) =>
`当前 GoodBuddy 不支持能力设置版本 ${version},请升级应用后重试`
)
const version = z
.object({
version: z.union([
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6)
])
})
.passthrough()
.parse(raw).version
if (version === 1) {
const legacy: StoredCapabilitiesV1 =
storedCapabilitiesV1Schema.parse(raw)
loaded = {
version: 6,
obsidian: { vaultPath: '' },
skills: legacy.skills,
builtinMcpServers: defaultBuiltinMcpServerStates(),
mcpServers: legacy.mcpServers,
webSearch: { enabled: true },
computerCapabilities: defaultComputerCapabilityStates()
}
shouldPersist = true
} else if (version === 2) {
const legacy = storedCapabilitiesV2Schema.parse(raw)
loaded = {
...legacy,
version: 6,
obsidian: { vaultPath: '' },
builtinMcpServers: defaultBuiltinMcpServerStates(
legacy.computerCapabilities['host-browser-control'].enabled
),
webSearch: { enabled: true }
}
shouldPersist = true
} else if (version === 3) {
const legacy = storedCapabilitiesV3Schema.parse(raw)
loaded = {
...legacy,
version: 6,
obsidian: { vaultPath: '' },
builtinMcpServers: defaultBuiltinMcpServerStates(
legacy.computerCapabilities['host-browser-control'].enabled
)
}
shouldPersist = true
} else if (version === 4) {
const legacy = storedCapabilitiesV4Schema.parse(raw)
loaded = {
...legacy,
version: 6,
obsidian: { vaultPath: '' },
builtinMcpServers: defaultBuiltinMcpServerStates(
legacy.computerCapabilities['host-browser-control'].enabled
)
}
shouldPersist = true
} else if (version === 5) {
const legacy = storedCapabilitiesV5Schema.parse(raw)
loaded = {
...legacy,
version: 6,
obsidian: { vaultPath: '' },
builtinMcpServers: {
'story-graph': defaultBuiltinMcpServerStates()['story-graph'],
obsidian: defaultBuiltinMcpServerStates().obsidian,
...legacy.builtinMcpServers,
'builtin-browser': {
enabled:
legacy.computerCapabilities['host-browser-control'].enabled,
assignments: ['model', 'opencode', 'continue']
}
}
}
shouldPersist = true
} else {
loaded = storedCapabilitiesSchema.parse(raw)
}
} catch (error) {
if (error instanceof UnsupportedSettingsVersionError) {
throw error
}
if (isMissingFileError(error)) {
loaded = emptyStoredCapabilities()
} else {
await isolateCorruptSettingsFile(
this.filePath,
'能力设置已损坏且无法隔离',
Date.now,
this.settingsFileOperations
)
this.warnings = [{ code: 'capability-settings-recovered' }]
loaded = emptyStoredCapabilities(false)
shouldPersist = true
}
}
this.state = storedCapabilitiesSchema.parse(loaded)
await this.validateBrowserProfileReferences(this.state)
if (shouldPersist) {
await this.persist(this.state)
}
return this.state
}
private async validateBrowserProfileReferences(
state: StoredCapabilities
): Promise<void> {
const profiles = await this.browserProfiles.getSnapshot()
const profileIds = new Set(profiles.profiles.map((profile) => profile.id))
for (const capability of computerCapabilityCatalog) {
const profileId =
state.computerCapabilities[capability.id].browserProfileId
if (profileId && !profileIds.has(profileId)) {
throw new Error('电脑控制能力引用了不存在的浏览器配置')
}
if (capability.id !== 'host-browser-control' && profileId) {
throw new Error('此电脑控制能力不支持浏览器配置')
}
}
}
private queue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.updateQueue.then(operation)
this.updateQueue = result.then(
() => undefined,
() => undefined
)
return result
}
private async persist(state: StoredCapabilities): Promise<void> {
const validated = storedCapabilitiesSchema.parse(state)
await writeJsonFileAtomically(
this.filePath,
validated,
this.settingsFileOperations
)
this.state = validated
}
private clearRecoveryWarnings(): void {
this.warnings = this.warnings.filter(
(warning) => warning.code !== 'capability-settings-recovered'
)
}
private async persistUserChange(
state: StoredCapabilities
): Promise<void> {
await this.persist(state)
this.clearRecoveryWarnings()
}
private async getSkillCatalog(): Promise<
Array<Omit<SkillSummary, 'enabled' | 'assignments'>>
> {
const [builtins, imported] = await Promise.all([
listSkills(this.builtinSkillsRoot, 'builtin'),
listSkills(this.importedSkillsRoot, 'imported')
])
const builtinIds = new Set(builtins.map((skill) => skill.id))
const catalog = [
...builtins,
...imported.filter((skill) => !builtinIds.has(skill.id))
]
if (catalog.length > 256) {
throw new Error('Skill 数量超过 256 个安全限制')
}
return catalog
}
private toMcpSummary(server: StoredMcpServer): McpServerSummary {
const { credential, ...configuration } = server
return mcpServerSummarySchema.parse({
...configuration,
secretConfigured: Boolean(credential)
})
}
async getSnapshot(): Promise<CapabilitySnapshot> {
const [state, catalog, browserProfileState, savedModelProfiles] = await Promise.all([
this.load(),
this.getSkillCatalog(),
this.browserProfiles.getSnapshot(),
this.getSavedModelProfiles()
])
const imageProfiles = savedModelProfiles
.filter((profile) =>
profile.protocol === 'openai-images-generations' &&
profile.allowConversationInvocation === true
)
.map(({ id, name }) => ({ id, name }))
return {
imageGeneration: {
modelProfiles: imageProfiles,
assignments: imageProfiles.length > 0
? ['model', 'opencode', 'continue', 'deepseek-harness']
: []
},
obsidian: { ...state.obsidian },
skills: catalog
.map((skill) => ({
...skill,
...(state.skills[skill.id] ?? defaultSkillState(skill))
}))
.sort((left, right) =>
left.source === right.source
? left.name.localeCompare(right.name, 'zh-CN')
: left.source === 'builtin'
? -1
: 1
),
builtinMcpServers: builtinMcpServerIdSchema.options.map((id) =>
builtinMcpServerStateSummarySchema.parse({
id,
...state.builtinMcpServers[id]
})
),
mcpServers: state.mcpServers.map((server) =>
this.toMcpSummary(server)
),
webSearch: webSearchCapabilitySchema.parse({
provider: 'exa',
enabled: state.webSearch.enabled,
availableIn: ['ask', 'execute'],
tools: ['web_search', 'web_fetch']
}),
computerCapabilities: computerCapabilityCatalog.map((capability) =>
computerCapabilityConfigSummarySchema.parse({
id: capability.id,
name: capability.name,
description: capability.description,
enabled:
capability.id === 'host-browser-control'
? state.builtinMcpServers['builtin-browser'].enabled
: state.computerCapabilities[capability.id].enabled,
supported: isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
),
browserProfileId:
state.computerCapabilities[capability.id].browserProfileId,
riskSummary: capability.riskSummary
})
),
browserProfiles: this.toBrowserProfilesSummary(browserProfileState),
...(this.warnings.length > 0
? { warnings: [...this.warnings] }
: {})
}
}
async getConfigurationDigest(): Promise<string> {
const state = await this.load()
const sanitized = {
...state,
mcpServers: state.mcpServers.map((server) => ({
...server,
credentialConfigured: Boolean(server.credential),
credential: undefined
}))
}
return createHash('sha256')
.update(JSON.stringify(sanitized))
.digest('hex')
}
async getObsidianSettings(): Promise<ObsidianSettings> {
return { ...(await this.load()).obsidian }
}
updateObsidianSettings(input: ObsidianSettings): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const obsidian = obsidianSettingsSchema.parse(input)
const state = await this.load()
await this.persistUserChange({ ...state, obsidian })
return this.getSnapshot()
})
}
async getWebSearchCapabilityStatus(): Promise<{ enabled: boolean }> {
const state = await this.load()
return { enabled: state.webSearch.enabled }
}
setWebSearchEnabled(enabled: boolean): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const state = await this.load()
await this.persistUserChange({
...state,
webSearch: { enabled }
})
return this.getSnapshot()
})
}
async getComputerCapabilityStatus(
capabilityId: ComputerCapabilityId
): Promise<{ enabled: boolean; supported: boolean }> {
const id = computerCapabilityIdSchema.parse(capabilityId)
const capability = getComputerCapability(id)
const state = await this.load()
return {
enabled:
id === 'host-browser-control'
? state.builtinMcpServers['builtin-browser'].enabled
: state.computerCapabilities[id].enabled,
supported: isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
)
}
}
private toBrowserProfilesSummary(
state: BrowserProfileState
): BrowserProfilesSummary {
return browserProfilesSummarySchema.parse({
profiles: state.profiles.map((profile) => ({
id: profile.id,
name: profile.name,
mode: profile.mode
})),
defaultProfileId: state.defaultProfileId
})
}
setComputerCapabilityEnabled(
capabilityId: ComputerCapabilityId,
enabled: boolean
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = computerCapabilityIdSchema.parse(capabilityId)
const capability = getComputerCapability(id)
if (
enabled &&
!isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
)
) {
throw new Error('当前操作系统或处理器架构不支持此能力')
}
if (enabled) {
const report = await this.diagnoseComputerCapabilityState(id, true)
if (report.status === 'unavailable') {
throw new Error('能力诊断不可用,未启用此能力')
}
}
const state = await this.load()
await this.persistUserChange({
...state,
...(id === 'host-browser-control'
? {
builtinMcpServers: {
...state.builtinMcpServers,
'builtin-browser': {
...state.builtinMcpServers['builtin-browser'],
enabled
}
}
}
: {}),
computerCapabilities: {
...state.computerCapabilities,
[id]: {
...state.computerCapabilities[id],
enabled
}
}
})
return this.getSnapshot()
})
}
setComputerCapabilityBrowserProfile(
capabilityId: ComputerCapabilityId,
browserProfileId: string | null
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = computerCapabilityIdSchema.parse(capabilityId)
const profileId = browserProfileIdSchema.nullable().parse(
browserProfileId
)
if (id !== 'host-browser-control' && profileId) {
throw new Error('此电脑控制能力不支持浏览器配置')
}
const profiles = await this.browserProfiles.getSnapshot()
if (
profileId &&
!profiles.profiles.some((profile) => profile.id === profileId)
) {
throw new Error('浏览器配置不存在')
}
const state = await this.load()
const previousProfileId =
state.computerCapabilities[id].browserProfileId
if (profileId === previousProfileId) {
return this.getSnapshot()
}
const reference = { kind: 'capability' as const, id }
if (profileId) {
try {
await this.browserProfiles.addReference(profileId, reference)
} catch (error) {
try {
await this.browserProfiles.removeReference(profileId, reference)
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'浏览器配置引用添加失败,且补偿清理未完成',
{ cause: compensationError }
)
}
throw error
}
}
const nextState: StoredCapabilities = {
...state,
computerCapabilities: {
...state.computerCapabilities,
[id]: {
...state.computerCapabilities[id],
browserProfileId: profileId
}
}
}
try {
await this.persistUserChange(nextState)
} catch (error) {
if (profileId) {
try {
await this.browserProfiles.removeReference(profileId, reference)
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'电脑控制配置保存失败,且新增引用补偿清理未完成',
{ cause: compensationError }
)
}
}
throw error
}
if (previousProfileId) {
try {
await this.browserProfiles.removeReference(
previousProfileId,
reference
)
} catch (error) {
try {
await this.browserProfiles.addReference(
previousProfileId,
reference
)
await this.persistUserChange(state)
if (profileId) {
await this.browserProfiles.removeReference(
profileId,
reference
)
}
} catch (compensationError) {
throw new AggregateError(
[error, compensationError],
'旧浏览器配置引用移除失败,且回滚未完成',
{ cause: compensationError }
)
}
throw error
}
}
return this.getSnapshot()
})
}
async diagnoseComputerCapability(
capabilityId: ComputerCapabilityId
): Promise<CapabilityDiagnosticReport> {
const id = computerCapabilityIdSchema.parse(capabilityId)
const state = await this.load()
return this.diagnoseComputerCapabilityState(
id,
id === 'host-browser-control'
? state.builtinMcpServers['builtin-browser'].enabled
: state.computerCapabilities[id].enabled
)
}
private async diagnoseComputerCapabilityState(
id: ComputerCapabilityId,
enabled: boolean
): Promise<CapabilityDiagnosticReport> {
if (
id === 'host-browser-control' &&
enabled &&
!this.electronTarget
) {
return capabilityDiagnosticReportSchema.parse({
capabilityId: id,
status: 'unavailable',
checkedAt: new Date().toISOString(),
checks: [
{
id: 'electron-target',
status: 'unavailable',
summary: '当前进程不是受支持的 Electron 桌面目标。',
remedy: '请从 GoodBuddy 桌面应用运行此诊断。'
}
]
})
}
return capabilityDiagnosticReportSchema.parse(
await this.diagnostics.diagnose({
capabilityId: id,
enabled,
platform: this.platform,
architecture: this.architecture,
availableImplementationKinds:
this.availableComputerCapabilityImplementations
})
)
}
createBrowserProfile(name: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.createProfile(
browserProfileNameSchema.parse(name)
)
this.clearRecoveryWarnings()
return this.getSnapshot()
})
}
renameBrowserProfile(
profileId: string,
name: string
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.renameProfile(
browserProfileIdSchema.parse(profileId),
browserProfileNameSchema.parse(name)
)
this.clearRecoveryWarnings()
return this.getSnapshot()
})
}
setDefaultBrowserProfile(profileId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.setDefaultProfile(
browserProfileIdSchema.parse(profileId)
)
this.clearRecoveryWarnings()
return this.getSnapshot()
})
}
removeBrowserProfile(profileId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
await this.browserProfiles.deleteProfile(
browserProfileIdSchema.parse(profileId)
)
this.clearRecoveryWarnings()
return this.getSnapshot()
})
}
private async stageSkillDirectory(
sourceDirectory: string,
expectedId: string | null | undefined,
expectedPackageDigest?: string
): Promise<{
skill: Omit<SkillSummary, 'enabled' | 'assignments'>
temporaryPath: string
targetPath: string
}> {
const temporaryPath = join(
this.importedSkillsRoot,
`.import-${randomUUID()}`
)
try {
const skill = await readSkill(
sourceDirectory,
'imported',
expectedId
)
const builtins = await listSkills(this.builtinSkillsRoot, 'builtin')
if (builtins.some((item) => item.id === skill.id)) {
throw new Error('导入的 Skill ID 与内置 Skill 冲突')
}
const targetPath = join(this.importedSkillsRoot, skill.id)
if (await pathExists(targetPath)) {
throw new Error('同名 Skill 已导入,请先删除后重试')
}
const copiedDigest = await copySkillPackage(
sourceDirectory,
temporaryPath
)
if (
expectedPackageDigest !== undefined &&
copiedDigest !== expectedPackageDigest
) {
throw new Error(
'Skill 内容在确认后已发生变化,请重新生成导入计划'
)
}
await readSkill(temporaryPath, 'imported', skill.id)
return { skill, temporaryPath, targetPath }
} catch (error) {
await rm(temporaryPath, { recursive: true, force: true })
throw error
}
}
private async importSkillDirectory(
sourceDirectory: string,
expectedId: string | null | undefined,
initialState: z.infer<typeof skillStateSchema> = defaultSkillState(),
expectedPackageDigest?: string
): Promise<string> {
const staged = await this.stageSkillDirectory(
sourceDirectory,
expectedId,
expectedPackageDigest
)
try {
await rename(staged.temporaryPath, staged.targetPath)
const state = await this.load()
await this.persistUserChange({
...state,
skills: {
...state.skills,
[staged.skill.id]: skillStateSchema.parse(initialState)
}
})
return staged.skill.id
} catch (error) {
await rm(staged.temporaryPath, { recursive: true, force: true })
throw error
}
}
async inspectSkillImport(
sourcePath: string
): Promise<SkillImportInspection> {
const canonicalSource = await realpath(sourcePath)
const sourceDetails = await stat(canonicalSource)
const isDirectory = sourceDetails.isDirectory()
const isZip =
sourceDetails.isFile() &&
extname(canonicalSource).toLowerCase() === '.zip'
if (!isDirectory && !isZip) {
throw new Error('所选 Skill 路径必须是目录或 .zip 文件')
}
let directories: string[]
let expectedId: string | null | undefined
let zipFiles: SkillPackageFile[] | undefined
if (isZip) {
const parsedZip = await parseSkillZip(canonicalSource)
zipFiles = parsedZip.files
directories = []
expectedId = parsedZip.directoryName ?? null
} else {
directories = await discoverSkillDirectories(canonicalSource)
if (directories.length === 0) {
throw new Error(
'所选目录及其子目录中没有找到 SKILL.md,请选择 Skill 目录或包含多个 Skill 的目录'
)
}
}
const skills = isZip
? [
parseSkillContent(
zipFiles
?.find((file) => file.relativePath === 'SKILL.md')
?.contents.toString('utf8') ?? '',
expectedId ?? 'Skill ZIP',
'imported',
expectedId ?? null
)
]
: await Promise.all(
directories.map((directory) =>
readSkill(
directory,
'imported',
directories.length === 1 ? undefined : null
)
)
)
const ids = new Set(skills.map((skill) => skill.id))
if (ids.size !== skills.length) {
throw new Error('所选目录包含重复的 Skill ID')
}
const [builtins, imported] = await Promise.all([
listSkills(this.builtinSkillsRoot, 'builtin'),
listSkills(this.importedSkillsRoot, 'imported')
])
const unavailableIds = new Set([
...builtins.map((skill) => skill.id),
...imported.map((skill) => skill.id)
])
const conflict = skills.find((skill) => unavailableIds.has(skill.id))
if (conflict) {
throw new Error(
builtins.some((skill) => skill.id === conflict.id)
? `导入的 Skill ID 与内置 Skill 冲突:${conflict.id}`
: `同名 Skill 已导入:${conflict.id}`
)
}
const packageDigests = isZip
? [digestSkillFiles(zipFiles ?? [])]
: await Promise.all(
directories.map((directory) => digestSkillPackage(directory))
)
const digest = createHash('sha256')
.update(
skills
.map((skill, index) => ({
id: skill.id,
digest: packageDigests[index]
}))
.sort((left, right) => left.id.localeCompare(right.id))
.map((item) => `${item.id}\0${item.digest}`)
.join('\0')
)
.digest('hex')
return {
sourcePath: canonicalSource,
digest,
skills
}
}
importSkill(
sourcePath: string,
expectedDigest?: string,
initialState?: {
enabled: boolean
assignments: CapabilityAssignments
}
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
let inspectedPackageDigests: string[] | undefined
if (expectedDigest !== undefined) {
const inspection = await this.inspectSkillImport(sourcePath)
if (inspection.digest !== expectedDigest) {
throw new Error(
'Skill 内容在确认后已发生变化,请重新生成导入计划'
)
}
const inspectedSource = await realpath(sourcePath)
const inspectedDetails = await stat(inspectedSource)
if (inspectedDetails.isDirectory()) {
inspectedPackageDigests = await Promise.all(
(await discoverSkillDirectories(inspectedSource)).map(
(directory) => digestSkillPackage(directory)
)
)
}
}
const canonicalSource = await realpath(sourcePath)
const sourceDetails = await stat(canonicalSource)
const isDirectory = sourceDetails.isDirectory()
const isZip =
sourceDetails.isFile() &&
extname(canonicalSource).toLowerCase() === '.zip'
if (!isDirectory && !isZip) {
throw new Error('所选 Skill 路径必须是目录或 .zip 文件')
}
await mkdir(this.importedSkillsRoot, { recursive: true })
if (isZip) {
const extractPath = join(
this.importedSkillsRoot,
`.extract-${randomUUID()}`
)
try {
const parsedZip = await parseSkillZip(canonicalSource)
if (expectedDigest !== undefined) {
const skillContent = parsedZip.files
.find((file) => file.relativePath === 'SKILL.md')
?.contents.toString('utf8') ?? ''
const skill = parseSkillContent(
skillContent,
parsedZip.directoryName ?? 'Skill ZIP',
'imported',
parsedZip.directoryName ?? null
)
const currentDigest = createHash('sha256')
.update(
`${skill.id}\0${digestSkillFiles(parsedZip.files)}`
)
.digest('hex')
if (currentDigest !== expectedDigest) {
throw new Error(
'Skill 内容在确认后已发生变化,请重新生成导入计划'
)
}
}
await writeSkillPackageFiles(parsedZip.files, extractPath)
await this.importSkillDirectory(
extractPath,
parsedZip.directoryName ?? null,
initialState,
digestSkillFiles(parsedZip.files)
)
} finally {
await rm(extractPath, { recursive: true, force: true })
}
return this.getSnapshot()
}
const directories = await discoverSkillDirectories(canonicalSource)
if (directories.length === 0) {
throw new Error(
'所选目录及其子目录中没有找到 SKILL.md,请选择 Skill 目录或包含多个 Skill 的目录'
)
}
if (
expectedDigest !== undefined &&
inspectedPackageDigests?.length !== directories.length
) {
throw new Error(
'Skill 内容在确认后已发生变化,请重新生成导入计划'
)
}
const stagedSkills: Array<{
skill: Omit<SkillSummary, 'enabled' | 'assignments'>
temporaryPath: string
targetPath: string
}> = []
for (const [index, directory] of directories.entries()) {
try {
// A suite directory may nest skills below its own name, so the
// directory name is only authoritative for a single-skill import.
const staged = await this.stageSkillDirectory(
directory,
directories.length === 1 ? undefined : null,
expectedDigest !== undefined
? inspectedPackageDigests?.[index]
: undefined
)
stagedSkills.push(staged)
} catch (error) {
await Promise.allSettled(
stagedSkills.map((staged) =>
rm(staged.temporaryPath, { recursive: true, force: true })
)
)
throw error
}
}
const state = await this.load()
const nextSkills = { ...state.skills }
for (const staged of stagedSkills) {
nextSkills[staged.skill.id] = skillStateSchema.parse(
initialState ?? defaultSkillState()
)
}
const installedPaths: string[] = []
try {
for (const staged of stagedSkills) {
await rename(staged.temporaryPath, staged.targetPath)
installedPaths.push(staged.targetPath)
}
await this.persistUserChange({ ...state, skills: nextSkills })
} catch (error) {
await Promise.allSettled(
[
...stagedSkills.map((staged) => staged.temporaryPath),
...installedPaths
].map((path) => rm(path, { recursive: true, force: true }))
)
throw error
}
return this.getSnapshot()
})
}
removeSkill(skillId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = skillIdSchema.parse(skillId)
const imported = await listSkills(this.importedSkillsRoot, 'imported')
if (!imported.some((skill) => skill.id === id)) {
throw new Error('只能删除已导入的 Skill')
}
await rm(join(this.importedSkillsRoot, id), {
recursive: true,
force: false
})
const state = await this.load()
const skills = { ...state.skills }
delete skills[id]
await this.persistUserChange({ ...state, skills })
return this.getSnapshot()
})
}
setSkillEnabled(
skillId: string,
enabled: boolean
): Promise<CapabilitySnapshot> {
return this.updateSkillState(skillId, { enabled })
}
setSkillAssignments(
skillId: string,
assignments: CapabilityAssignments
): Promise<CapabilitySnapshot> {
return this.updateSkillState(skillId, {
assignments: capabilityAssignmentsSchema.parse(assignments)
})
}
setBuiltinMcpServerEnabled(
serverId: BuiltinMcpServerId,
enabled: boolean
): Promise<CapabilitySnapshot> {
return this.updateBuiltinMcpServerState(serverId, { enabled })
}
setBuiltinMcpServerAssignments(
serverId: BuiltinMcpServerId,
assignments: CapabilityAssignments
): Promise<CapabilitySnapshot> {
return this.updateBuiltinMcpServerState(serverId, {
assignments: (serverId === 'story-graph' ? capabilityAssignmentsSchema : builtinMcpAssignmentsSchema).parse(assignments)
})
}
private updateBuiltinMcpServerState(
serverId: BuiltinMcpServerId,
update: Partial<z.infer<typeof builtinMcpServerStateSchema>>
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = builtinMcpServerIdSchema.parse(serverId)
const state = await this.load()
if (id === 'builtin-browser' && update.enabled === true) {
const capability = getComputerCapability('host-browser-control')
if (
!isComputerCapabilitySupported(
capability,
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
)
) {
throw new Error('当前操作系统或处理器架构不支持此能力')
}
const report = await this.diagnoseComputerCapabilityState(
'host-browser-control',
true
)
if (report.status === 'unavailable') {
throw new Error('能力诊断不可用,未启用此能力')
}
}
await this.persistUserChange({
...state,
...(id === 'builtin-browser' && update.enabled !== undefined
? {
computerCapabilities: {
...state.computerCapabilities,
'host-browser-control': {
...state.computerCapabilities['host-browser-control'],
enabled: update.enabled
}
}
}
: {}),
builtinMcpServers: {
...state.builtinMcpServers,
[id]: {
...state.builtinMcpServers[id],
...update
}
}
})
return this.getSnapshot()
})
}
private updateSkillState(
skillId: string,
update: Partial<z.infer<typeof skillStateSchema>>
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = skillIdSchema.parse(skillId)
const catalog = await this.getSkillCatalog()
const skill = catalog.find((item) => item.id === id)
if (!skill) {
throw new Error('Skill 不存在')
}
const state = await this.load()
await this.persistUserChange({
...state,
skills: {
...state.skills,
[id]: {
...(state.skills[id] ?? defaultSkillState(skill)),
...update
}
}
})
return this.getSnapshot()
})
}
saveMcpServer(
serverId: string | undefined,
input: McpServerInput
): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const value = mcpServerInputSchema.parse(input)
const state = await this.load()
const id = serverId ? mcpServerIdSchema.parse(serverId) : randomUUID()
const existing = state.mcpServers.find((server) => server.id === id)
if (serverId && !existing) {
throw new Error('MCP Server 不存在')
}
if (!existing && state.mcpServers.length >= 64) {
throw new Error('MCP Server 数量不能超过 64 个')
}
if (
existing &&
value.secret.action === 'keep' &&
existing.transport !== 'stdio' &&
value.transport !== 'stdio' &&
existing.url !== value.url &&
existing.credential
) {
throw new Error('MCP 地址已更改,请重新输入或清除访问令牌')
}
if (value.transport === 'stdio' && value.secret.action === 'replace') {
throw new Error('stdio MCP 不支持 Bearer Token')
}
let credential =
value.secret.action === 'keep' ? existing?.credential : undefined
if (value.secret.action === 'replace') {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,MCP 访问令牌未保存')
}
credential = encryptSettingsCredential(this.cipher, {
version: 1,
serverId: id,
secret: value.secret.value
})
}
const stored: StoredMcpServer =
value.transport === 'stdio'
? {
id,
name: value.name,
description: value.description,
enabled: value.enabled,
allowDynamicTools: value.allowDynamicTools,
assignments: value.assignments,
transport: 'stdio',
command: value.command,
args: value.args
}
: {
id,
name: value.name,
description: value.description,
enabled: value.enabled,
allowDynamicTools: value.allowDynamicTools,
assignments: value.assignments,
credential,
transport: value.transport,
url: new URL(value.url).toString()
}
const nextServers = existing
? state.mcpServers.map((server) =>
server.id === id ? stored : server
)
: [...state.mcpServers, stored]
await this.persistUserChange({ ...state, mcpServers: nextServers })
return this.getSnapshot()
})
}
removeMcpServer(serverId: string): Promise<CapabilitySnapshot> {
return this.queue(async () => {
const id = mcpServerIdSchema.parse(serverId)
const state = await this.load()
if (!state.mcpServers.some((server) => server.id === id)) {
throw new Error('MCP Server 不存在')
}
await this.persistUserChange({
...state,
mcpServers: state.mcpServers.filter((server) => server.id !== id)
})
return this.getSnapshot()
})
}
async getResolvedMcpServer(serverId: string): Promise<ResolvedMcpServer> {
const id = mcpServerIdSchema.parse(serverId)
const state = await this.load()
const server = state.mcpServers.find((item) => item.id === id)
if (!server) {
throw new Error('MCP Server 不存在')
}
let secret: string | undefined
if (server.credential) {
if (!this.cipher.isAvailable()) {
throw new Error('系统安全存储不可用,无法读取 MCP 访问令牌')
}
try {
const payload = secretPayloadSchema.parse(
decryptSettingsCredential(this.cipher, server.credential)
)
if (payload.serverId === id) {
secret = payload.secret
}
} catch {
throw new Error('MCP 访问令牌无法解密,请重新配置')
}
}
return {
...this.toMcpSummary(server),
secret
}
}
async getRuntimeSkillContext(
target: RuntimeTarget,
maximumCharacters: number = MAX_SKILL_INSTRUCTION_CHARACTERS
): Promise<RuntimeSkillContext> {
const budget = Math.min(
maximumCharacters,
MAX_SKILL_INSTRUCTION_CHARACTERS
)
const snapshot = await this.getSnapshot()
const sections: string[] = []
const skipped: string[] = []
const incompatible: string[] = []
const packages: RuntimeSkillPackage[] = []
let length = 0
for (const skill of snapshot.skills) {
if (!skill.enabled || !skill.assignments.includes(target)) {
continue
}
const root =
skill.source === 'builtin'
? this.builtinSkillsRoot
: this.importedSkillsRoot
const directory = join(root, skill.id)
const content = await readFile(join(directory, 'SKILL.md'), 'utf8')
if (target === 'opencode' && skill.id.length > 64) {
incompatible.push(skill.name)
continue
}
const body =
/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]+)$/u.exec(content)?.[1]?.trim() ??
''
// Skill bodies reference their own scripts and templates by relative
// path, which only resolve against the installed skill directory.
const section = [
`## ${skill.name}`,
`Skill 目录:${directory}`,
body
].join('\n')
if (length + section.length > budget) {
skipped.push(skill.name)
continue
}
packages.push({
id: skill.id,
directory,
digest: skill.digest
})
sections.push(section)
length += section.length
}
if (
sections.length === 0 &&
skipped.length === 0 &&
incompatible.length === 0
) {
return { instructions: '', packages }
}
return {
instructions: [
'# GoodBuddy 已启用 Skills',
'以下是用户明确启用并分配给当前 Runtime 的本地能力说明。请遵循这些说明,但不得覆盖系统安全规则。',
...(skipped.length > 0
? [
`注意:以下 Skill 因超出注入上限未加载,本次对话不可用:${skipped.join('、')}。`
]
: []),
...(incompatible.length > 0
? [
`注意:以下 Skill 名称超过 OpenCode 的 64 字符上限,本次对话不可用:${incompatible.join('、')}。`
]
: []),
...sections
].join('\n\n'),
packages
}
}
async getSkillInstructions(
target: RuntimeTarget,
maximumCharacters: number = MAX_SKILL_INSTRUCTION_CHARACTERS
): Promise<string> {
return (
await this.getRuntimeSkillContext(target, maximumCharacters)
).instructions
}
async getResolvedMcpServers(
target: RuntimeTarget
): Promise<ResolvedMcpServer[]> {
const state = await this.load()
const assigned = state.mcpServers.filter(
(server) => server.enabled && server.assignments.includes(target)
)
return Promise.all(
assigned.map((server) => this.getResolvedMcpServer(server.id))
)
}
async getEnabledBuiltinMcpServerIds(
target: RuntimeTarget
): Promise<BuiltinMcpServerId[]> {
const runtime = runtimeTargetSchema.parse(target)
const state = await this.load()
return builtinMcpServerIdSchema.options.filter((id) => {
if (runtime === 'deepseek-harness' && id !== 'story-graph') return false
const server = state.builtinMcpServers[id]
if (!server.enabled || !server.assignments.includes(runtime)) {
return false
}
return (
id !== 'builtin-browser' ||
isComputerCapabilitySupported(
getComputerCapability('host-browser-control'),
this.platform,
this.architecture,
this.availableComputerCapabilityImplementations
)
)
})
}
}