From e4d344beca0c3f95cf2461504d062b0482c6f5db Mon Sep 17 00:00:00 2001 From: QAyong Date: Mon, 28 Sep 2026 00:37:30 +0800 Subject: [PATCH 1/3] perf(buddy): cache skill metadata during chat prep --- .../service/src/skills/SkillPackageCache.ts | 9 +- apps/buddy/service/src/skills/SkillService.ts | 146 +++++++++++++++--- .../__tests__/SkillPackageCache.spec.ts | 18 +++ .../src/skills/__tests__/SkillService.spec.ts | 25 ++- .../__tests__/skillInstallation.spec.ts | 3 + apps/buddy/service/src/skills/skillFiles.ts | 1 + apps/buddy/shared/skills/skillApi.ts | 2 + .../composer/useComposerContextOptions.ts | 7 +- docs/specs/feature-004-skill-lazy-loading.md | 122 +++++++++++++++ 9 files changed, 303 insertions(+), 30 deletions(-) create mode 100644 docs/specs/feature-004-skill-lazy-loading.md diff --git a/apps/buddy/service/src/skills/SkillPackageCache.ts b/apps/buddy/service/src/skills/SkillPackageCache.ts index 77e5f1d8..82acb71f 100644 --- a/apps/buddy/service/src/skills/SkillPackageCache.ts +++ b/apps/buddy/service/src/skills/SkillPackageCache.ts @@ -3,7 +3,7 @@ import type { LoadedSkill } from './skillFiles' import { createHash } from 'node:crypto' import { lstat, readdir } from 'node:fs/promises' import { basename, dirname, join, relative } from 'node:path' -import { MAX_SKILL_BYTES, MAX_SKILL_FILES, MAX_SKILL_PACKAGE_BYTES, readSkill, requireSkillPath, SkillError } from './skillFiles' +import { MAX_SKILL_BYTES, MAX_SKILL_FILES, MAX_SKILL_PACKAGE_BYTES, readSkill, readSkillDocument, requireSkillPath, SkillError } from './skillFiles' export type ResolvedSkill = Omit @@ -31,6 +31,13 @@ export class SkillPackageCache { return loading } + async loadMetadata(filePath: string, allowedRoot: string): Promise { + const document = await readSkillDocument(filePath, allowedRoot) + if (!document.description) + throw new SkillError('SKILL_INVALID') + return { ...document, revision: document.referenceRevision } + } + clear() { this.#packages.clear() } diff --git a/apps/buddy/service/src/skills/SkillService.ts b/apps/buddy/service/src/skills/SkillService.ts index bd014e85..b1efd3e0 100644 --- a/apps/buddy/service/src/skills/SkillService.ts +++ b/apps/buddy/service/src/skills/SkillService.ts @@ -42,9 +42,16 @@ interface SkillServiceOptions { } interface Candidate { + allowedRoot: string | null entry: LocalSkill loaded: ResolvedSkill | null priority: number + referenceRevision: string +} + +interface ResolvedCatalog { + candidates: Candidate[] + catalog: LocalSkillCatalog } interface ImportPreview { @@ -59,7 +66,8 @@ export class SkillService { readonly #previews = new Map() readonly #inspector: SkillInspector readonly #packages = new SkillPackageCache() - readonly #resolutions = new Map>() + readonly #resolutions = new Map>() + readonly #resolved = new Map() #mutation: Promise = Promise.resolve() constructor(options: SkillServiceOptions) { @@ -84,18 +92,25 @@ export class SkillService { } async list(spaceId: string | null): Promise { + this.#invalidateResolutions(spaceId) return (await this.#resolve(spaceId)).catalog } async loadForSpace(spaceId: string | null): Promise { - const { catalog, candidates } = await this.#resolve(spaceId) + const { catalog, candidates } = await this.#resolve(spaceId, true) const effective = candidates.filter(candidate => isSkillAvailable(candidate.entry)) + const revision = createHash('sha256').update(JSON.stringify(effective.map(candidate => ({ + id: candidate.entry.id, + revision: candidate.referenceRevision, + status: candidate.entry.status, + enabled: candidate.entry.enabled, + })))).digest('hex') return { diagnostics: catalog.diagnostics, paths: effective.map(candidate => candidate.entry.filePath), readRoots: [...new Set(effective.map(candidate => dirname(candidate.entry.filePath)))], - references: effective.map(candidate => reference(candidate.entry)), - revision: catalog.revision, + references: effective.map(candidate => reference(candidate.entry, candidate.referenceRevision)), + revision, skills: effective.map(candidate => candidate.entry).sort((a, b) => a.name.localeCompare(b.name)), } } @@ -103,21 +118,38 @@ export class SkillService { async materializeForSpace(spaceId: string | null, selections: readonly (string | SkillReference)[]): Promise { if (!selections.length) return [] - const { candidates } = await this.#resolve(spaceId) + const { candidates } = await this.#resolve(spaceId, true) const selected = new Map() for (const selection of selections) { const name = typeof selection === 'string' ? selection : selection.name const candidate = candidates.find(item => item.entry.name === name && isSkillAvailable(item.entry)) - if (!candidate?.loaded) + if (!candidate?.allowedRoot) throw new SkillError('SKILL_NOT_FOUND') - if (typeof selection !== 'string' && (selection.id !== candidate.entry.id || selection.revision !== candidate.entry.revision)) + let loaded: ResolvedSkill + try { + loaded = await this.#packages.load(candidate.entry.filePath, candidate.allowedRoot) + } + catch (error) { + this.#invalidateResolutions(spaceId) + throw error + } + if (loaded.referenceRevision !== candidate.referenceRevision) { + this.#invalidateResolutions(spaceId) throw new SkillError('SKILL_CHANGED') + } + if (typeof selection !== 'string' && ( + selection.id !== candidate.entry.id + || (selection.revision !== candidate.referenceRevision && selection.revision !== loaded.revision) + || (selection.packageRevision && selection.packageRevision !== loaded.revision) + )) { + throw new SkillError('SKILL_CHANGED') + } selected.set(name, { name, - body: candidate.loaded.body, + body: loaded.body, filePath: candidate.entry.filePath, - baseDirectory: candidate.loaded.baseDirectory, - reference: reference(candidate.entry), + baseDirectory: loaded.baseDirectory, + reference: reference(candidate.entry, candidate.referenceRevision, loaded.revision), }) } return [...selected.values()] @@ -321,20 +353,29 @@ export class SkillService { async dispose() { await this.#mutation.catch(() => {}) await Promise.allSettled([...this.#resolutions.values()]) + this.#resolved.clear() this.#packages.clear() await Promise.all([...this.#previews.keys()].map(id => this.discard(id))) } - #resolve(spaceId: string | null) { + #resolve(spaceId: string | null, lightweight = false) { const space = this.#requireSpace(spaceId) const scope = JSON.stringify(space?.primaryDirectory ?? null) - const key = JSON.stringify([spaceId, scope, this.#options.repository.list()]) + const cacheId = this.#resolutionCacheId(spaceId, lightweight) + const key = JSON.stringify([spaceId, scope, this.#options.repository.list(), lightweight]) + const cached = this.#resolved.get(cacheId) + if (cached?.key === key) + return Promise.resolve(cached.result) const pending = this.#resolutions.get(key) if (pending) return pending - const resolving = this.#resolveCatalog(spaceId).then((result) => { + const resolving = this.#resolveCatalog(spaceId, lightweight).then((result) => { if (JSON.stringify(this.#requireSpace(spaceId)?.primaryDirectory ?? null) !== scope) throw new SkillError('SKILL_CHANGED') + const currentKey = this.#resolutionKey(spaceId, lightweight) + this.#resolved.set(cacheId, { key: currentKey, result }) + if (!lightweight) + this.#resolved.set(this.#resolutionCacheId(spaceId, true), { key: this.#resolutionKey(spaceId, true), result }) return result }).finally(() => { if (this.#resolutions.get(key) === resolving) @@ -344,7 +385,21 @@ export class SkillService { return resolving } - async #resolveCatalog(spaceId: string | null) { + #resolutionCacheId(spaceId: string | null, lightweight: boolean) { + return JSON.stringify([spaceId, lightweight]) + } + + #invalidateResolutions(spaceId: string | null) { + this.#resolved.delete(this.#resolutionCacheId(spaceId, false)) + this.#resolved.delete(this.#resolutionCacheId(spaceId, true)) + } + + #resolutionKey(spaceId: string | null, lightweight: boolean) { + const space = this.#requireSpace(spaceId) + return JSON.stringify([spaceId, JSON.stringify(space?.primaryDirectory ?? null), this.#options.repository.list(), lightweight]) + } + + async #resolveCatalog(spaceId: string | null, lightweight = false) { const space = this.#requireSpace(spaceId) const diagnostics: Array<{ code: 'SKILL_INVALID' | 'SKILL_NAME_COLLISION' | 'SKILL_PATH_OUTSIDE_SOURCE' | 'SKILL_SOURCE_UNREADABLE', message: string, path?: string }> = [] const candidates: Candidate[] = [] @@ -358,15 +413,18 @@ export class SkillService { : []), ] const discovered = new Map() + const discoveredRoots = new Map() for (const source of sources) { try { const root = await requireSkillPath(source.allowedRoot, source.root) for (const path of await discoverSkillFiles(root, source.kind !== 'application', path => diagnostics.push({ code: 'SKILL_PATH_OUTSIDE_SOURCE', message: 'Skill source is outside the allowed folder or cannot be read.', path }))) { try { - const loaded = await this.#packages.load(path, root) + const loaded = lightweight + ? await this.#packages.loadMetadata(path, root) + : await this.#packages.load(path, root) const id = skillIdentity(source.kind === 'application' ? `application:${loaded.name}` : `${source.kind}:${path}`) if (source.kind === 'directory') { - candidates.push({ loaded, priority: 1, entry: { + candidates.push({ allowedRoot: root, loaded, priority: 1, referenceRevision: loaded.referenceRevision, entry: { id, name: loaded.name, description: loaded.description, @@ -377,6 +435,7 @@ export class SkillService { status: loaded.manualOnly ? 'manual_only' : 'available', shadowedBy: null, revision: loaded.revision, + referenceRevision: loaded.referenceRevision, filePath: path, origin: null, canRemove: false, @@ -390,7 +449,7 @@ export class SkillService { if (!existing && records.some(record => record.name === loaded.name && record.managedBy === source.kind && record.spaceId === null)) continue const now = new Date().toISOString() - if (!existing || existing.path !== loaded.path || existing.revision !== loaded.revision) { + if (!lightweight && (!existing || existing.path !== loaded.path || existing.revision !== loaded.revision)) { this.#options.repository.save({ id, name: loaded.name, @@ -406,13 +465,40 @@ export class SkillService { }) } discovered.set(id, loaded) + discoveredRoots.set(id, root) + if (lightweight && !existing) { + candidates.push({ + allowedRoot: root, + loaded, + priority: source.kind === 'application' ? 3 : 4, + referenceRevision: loaded.referenceRevision, + entry: { + id, + name: loaded.name, + description: loaded.description, + source: 'global', + spaceId: null, + managedBy: source.kind, + enabled: true, + status: loaded.manualOnly ? 'manual_only' : 'available', + shadowedBy: null, + revision: loaded.revision, + referenceRevision: loaded.referenceRevision, + filePath: loaded.path, + origin: { kind: source.kind === 'application' ? 'application' : 'directory', location: root }, + canRemove: false, + canUpdate: false, + busy: false, + }, + }) + } } } catch { diagnostics.push({ code: 'SKILL_INVALID', message: 'Skill metadata or bundled resources are invalid.', path }) if (source.kind === 'directory') { const name = basename(path) === 'SKILL.md' ? basename(dirname(path)) : basename(path, '.md') - candidates.push({ loaded: null, priority: 1, entry: { + candidates.push({ allowedRoot: root, loaded: null, priority: 1, referenceRevision: 'invalid', entry: { id: skillIdentity(`directory:${path}`), name, description: '', @@ -423,6 +509,7 @@ export class SkillService { status: 'invalid', shadowedBy: null, revision: 'invalid', + referenceRevision: 'invalid', filePath: path, origin: null, canRemove: false, @@ -441,22 +528,32 @@ export class SkillService { } for (const record of this.#options.repository.list().filter(record => !record.spaceId || record.spaceId === spaceId)) { let loaded = discovered.get(record.id) ?? null + let allowedRoot = discoveredRoots.get(record.id) ?? null if (record.managedBy === 'user') { try { + allowedRoot = this.#options.paths.skillsDirectory(record.spaceId) await requireSkillPath(this.#options.paths.root, record.path) - loaded = await this.#packages.load(record.path, this.#options.paths.skillsDirectory(record.spaceId)) + loaded = lightweight + ? await this.#packages.loadMetadata(record.path, allowedRoot) + : await this.#packages.load(record.path, allowedRoot) if (loaded.name !== record.name) loaded = null } - catch { loaded = null } + catch { + loaded = null + allowedRoot = null + } } candidates.push({ + allowedRoot, loaded, priority: record.spaceId ? 2 : record.managedBy === 'external' ? 4 : 3, + referenceRevision: loaded?.referenceRevision ?? record.revision, entry: { id: record.id, name: record.name, description: loaded?.description ?? record.description, + referenceRevision: loaded?.referenceRevision ?? record.revision, source: record.spaceId ? 'space' : 'global', spaceId: record.spaceId, managedBy: record.managedBy, @@ -489,6 +586,7 @@ export class SkillService { } async #currentSkill(spaceId: string | null, id: string) { + this.#invalidateResolutions(spaceId) const { catalog } = await this.#resolve(spaceId) const skill = catalog.skills.find(skill => skill.id === id) if (!skill) @@ -540,8 +638,12 @@ export class SkillService { } } -function reference(skill: LocalSkill): SkillReference { - return { id: skill.id, name: skill.name, revision: skill.revision } +function reference( + skill: LocalSkill, + revision = skill.referenceRevision ?? skill.revision, + packageRevision?: string, +): SkillReference { + return { id: skill.id, name: skill.name, revision, ...(packageRevision ? { packageRevision } : {}) } } export function formatBuddySkillPrompt(skill: BuddyMaterializedSkill): string { diff --git a/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts b/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts index 49468370..09011574 100644 --- a/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts +++ b/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts @@ -15,6 +15,24 @@ afterEach(async () => { }) describe('skillPackageCache', () => { + it('loads skill metadata without traversing bundled resources', async () => { + const f = await fixture() + await rm(join(f.root, 'references'), { recursive: true }) + let nested = f.root + for (let depth = 0; depth < 18; depth++) { + nested = join(nested, `level-${depth}`) + await mkdir(nested) + } + await writeFile(join(nested, 'guide.md'), 'resource') + + const metadata = await f.cache.loadMetadata(f.path, f.root) + + expect(metadata.name).toBe('workflow') + expect(metadata.description).toBe('A local workflow') + expect(metadata.referenceRevision).toBeTruthy() + await expect(f.cache.load(f.path, f.root)).rejects.toMatchObject({ code: 'SKILL_TOO_LARGE' }) + }) + it('shares a complete revision across concurrent loads and reuses unchanged packages without reading content', async () => { const f = await fixture() const expected = await readSkill(f.path, f.root) diff --git a/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts b/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts index 651381ea..1c94fbf2 100644 --- a/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts +++ b/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts @@ -48,7 +48,7 @@ describe('skillService', () => { expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'SKILL_NAME_COLLISION' })) }) - it('changes the session resource revision when trusted skill content changes', async () => { + it('reuses lightweight metadata and invalidates it when a selected skill document changes', async () => { const fixture = await createFixture() await writeSkill(fixture.global, 'mutable', 'first revision') @@ -57,7 +57,12 @@ describe('skillService', () => { const second = await fixture.service.loadForSpace(null) expect(first.paths).toEqual(second.paths) - expect(first.revision).not.toBe(second.revision) + expect(second.revision).toBe(first.revision) + await expect(fixture.service.materializeForSpace(null, first.references)).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) + + const third = await fixture.service.loadForSpace(null) + expect(third.revision).not.toBe(first.revision) + expect((await fixture.service.materializeForSpace(null, third.references))[0]?.body).toBe('# mutable') }) it('unloads revoked Space skills and rejects symlink escapes', async () => { @@ -84,18 +89,26 @@ describe('skillService', () => { expect((await fixture.service.list(null)).skills).toEqual([]) }) - it('rejects a queued reference immediately after a supporting resource changes', async () => { + it('does not make supporting resource changes part of the lightweight session revision', async () => { const fixture = await createFixture() await writeSkill(fixture.global, 'mutable', 'unchanged entry') const resource = join(fixture.global, 'mutable', 'guide.md') await writeFile(resource, 'version one') const first = await fixture.service.loadForSpace(null) + const listed = await fixture.service.list(null) + const listedSkill = listed.skills[0]! + const packageReference = { + id: listedSkill.id, + name: listedSkill.name, + revision: listedSkill.referenceRevision ?? listedSkill.revision, + packageRevision: listedSkill.revision, + } await writeFile(resource, 'version two') - await expect(fixture.service.materializeForSpace(null, first.references)).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) + await expect(fixture.service.materializeForSpace(null, [packageReference])).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) + expect((await fixture.service.materializeForSpace(null, first.references))[0]?.body).toBe('# mutable') const second = await fixture.service.loadForSpace(null) - expect(second.revision).not.toBe(first.revision) - expect((await fixture.service.materializeForSpace(null, second.references))[0]?.body).toBe('# mutable') + expect(second.revision).toBe(first.revision) }) it('rejects a pending resolution when the Space directory binding is cleared', async () => { diff --git a/apps/buddy/service/src/skills/__tests__/skillInstallation.spec.ts b/apps/buddy/service/src/skills/__tests__/skillInstallation.spec.ts index 6703d103..b3858647 100644 --- a/apps/buddy/service/src/skills/__tests__/skillInstallation.spec.ts +++ b/apps/buddy/service/src/skills/__tests__/skillInstallation.spec.ts @@ -157,6 +157,7 @@ describe('skill installation lifecycle', () => { expect((await f.service.list('space-a')).skills.find(skill => skill.id === global.id)?.status).toBe('shadowed') await f.service.setEnabled({ spaceId: 'space-a', id: local.id, revision: local.revision, enabled: true }) await writeFile(local.filePath, 'broken') + await f.service.list('space-a') expect((await f.service.loadForSpace('space-a')).skills).toEqual([]) expect((await f.service.list('space-a')).skills.find(skill => skill.id === local.id)?.status).toBe('invalid') }) @@ -216,8 +217,10 @@ describe('skill installation lifecycle', () => { expect((await f.service.materializeForSpace('space-a', [{ id: winner.id, name: winner.name, revision: winner.revision }]))[0]?.body).toBe('Directory instructions') await expect(f.service.materializeForSpace('space-a', [{ id: app.id, name: app.name, revision: app.revision }])).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) await writeFile(join(skillRoot, 'SKILL.md'), 'broken') + await f.service.list('space-a') expect((await f.service.loadForSpace('space-a')).skills).toEqual([]) await rm(skillRoot, { recursive: true }) + await f.service.list('space-a') expect((await f.service.loadForSpace('space-a')).skills.map(skill => skill.id)).toEqual([local.id]) }) diff --git a/apps/buddy/service/src/skills/skillFiles.ts b/apps/buddy/service/src/skills/skillFiles.ts index 60ccf0b8..ba16863f 100644 --- a/apps/buddy/service/src/skills/skillFiles.ts +++ b/apps/buddy/service/src/skills/skillFiles.ts @@ -139,6 +139,7 @@ export async function readSkillDocument(filePath: string, allowedRoot = dirname( hasDeclaredName: typeof frontmatter.name === 'string' && !!frontmatter.name.trim(), description: typeof frontmatter.description === 'string' ? frontmatter.description.trim() : '', content: text, + referenceRevision: createHash('sha256').update(content).digest('hex'), body: body.trim(), metadata: Object.entries(frontmatter).map(([name, value]) => ({ name, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2) ?? '' })), path, diff --git a/apps/buddy/shared/skills/skillApi.ts b/apps/buddy/shared/skills/skillApi.ts index 8da5e58d..eca571bc 100644 --- a/apps/buddy/shared/skills/skillApi.ts +++ b/apps/buddy/shared/skills/skillApi.ts @@ -8,6 +8,7 @@ export const skillReferenceSchema = z.object({ id: idSchema, name: z.string().min(1), revision: z.string().min(1), + packageRevision: z.string().min(1).optional(), }).strict() export type SkillReference = z.infer @@ -31,6 +32,7 @@ export const skillSchema = z.object({ status: z.enum(['available', 'manual_only', 'disabled', 'shadowed', 'invalid']), shadowedBy: idSchema.nullable(), revision: z.string(), + referenceRevision: z.string().optional(), filePath: z.string(), origin: skillOriginSchema.nullable(), canUpdate: z.boolean(), diff --git a/apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts b/apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts index 7c8f9442..ef20ec78 100644 --- a/apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts +++ b/apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts @@ -65,7 +65,12 @@ export function useComposerContextOptions(options: ComposerContextOptions) { path: null, value: skill.name, skillScope: skill.source, - skill: { id: skill.id, name: skill.name, revision: skill.revision }, + skill: { + id: skill.id, + name: skill.name, + revision: skill.referenceRevision ?? skill.revision, + packageRevision: skill.revision, + }, })), } } diff --git a/docs/specs/feature-004-skill-lazy-loading.md b/docs/specs/feature-004-skill-lazy-loading.md new file mode 100644 index 00000000..cac0d1d5 --- /dev/null +++ b/docs/specs/feature-004-skill-lazy-loading.md @@ -0,0 +1,122 @@ +# Spec-004:移除发送前的 Skills 全库扫描 + +**日期:** 2026-09-27 + +**状态:** 已按方案完成最小实现,验收清单待复核 + +**目标:** 普通消息发送和会话准备只发现技能并读取各自的 `SKILL.md`,不递归检查技能附属文件;完整包检查只在安装、更新、明确刷新技能列表或读取所选技能时进行。现有 AI 使用技能的方式保持不变。 + +## 1. 背景与问题定位 + +新会话准备会调用 `resolveBuddySessionResources()`,其中等待 `loadForSpace()` 完成。`SkillService` 随后遍历已发现的技能,并为每个技能调用 `SkillPackageCache.load()`。缓存检查函数 `inspectPackage()` 会递归遍历技能目录中的文件;缓存未命中时,`readSkill()` 还会读取整套技能文件。 + +因此,即使用户发送普通消息、没有选择技能,发送流程也可能先等整个技能库检查完。输入框手动选择技能时,`materializeForSpace()` 也会重新解析候选目录,可能再次处理未选中的技能。 + +涉及的现有调用点: + +- `apps/buddy/service/src/agent/sessions/BuddySessionBlueprintService.ts:128`:创建会话资源。 +- `apps/buddy/service/src/agent/resources/BuddySessionResources.ts:32-38`:并行等待技能目录与项目上下文。 +- `apps/buddy/service/src/skills/SkillService.ts:90-114`:准备技能列表及组装已选择技能的内容。 +- `apps/buddy/service/src/skills/SkillService.ts:347-367`:发现技能并逐个加载包。 +- `apps/buddy/service/src/skills/SkillPackageCache.ts:38-99`:缓存命中前递归检查包内文件;未命中则解析完整技能包。 +- `apps/buddy/service/src/skills/skillFiles.ts:78-100,151-163`:读取技能包文件并计算版本信息。 +- `apps/buddy/service/src/chat/ChatTurnService.ts:558-565,667-690,773-789`:发送前展开输入框中的技能选择或技能上下文。 + +## 2. 本次范围 + +本次只处理发送卡顿的直接原因:**会话准备只扫描技能目录并读取各个 `SKILL.md` 的必要信息,不遍历附属文件。**用户没有选择技能时,不应因为技能库中有大量附属文件而等待;用户显式选择技能时,只处理所选技能,不为此重新检查其他技能包。 + +本次保留现有技能目录、手动选择和模型使用技能的方式。技能实际被读取时仍执行必要的授权和路径安全检查;完整包检查放在安装、更新或用户明确要求检查时。 + +## 3. 不在本次范围 + +- 不新增 `load_skill`、`read_skill_resource` 等模型工具。 +- 不改变 AI 自动发现、选择或读取技能的方式。 +- 不把技能说明和附属文件改造成分阶段读取,也不改变手动选择技能后注入对话的现有语义。 +- 不改聊天界面、模型选择、消息展示或发送状态交互。 + +这些属于后续的 AI 技能使用体验改造,可以在确有需求时另行设计;它们不是消除本次全库扫描卡顿的前提。 + +## 4. 目标流程 + +```mermaid +sequenceDiagram + participant UI as 聊天界面 + participant Session as 会话准备 + participant Skills as 技能服务 + participant Model as 模型 + + UI->>Session: 创建会话并发送消息 + Session->>Skills: 获取技能目录 + Note over Skills: 不递归检查每个技能包的所有文件 + Skills-->>Session: 返回当前会话所需的技能信息 + Session-->>Model: 提供用户消息和现有技能上下文 + opt 用户显式选择技能 + Session->>Skills: 处理被选中的技能 + Note over Skills: 只校验和读取所选技能,不重新扫描其他技能包 + end +``` + +普通消息不再等待所有技能包的文件检查。显式选择技能仍按当前产品行为处理,只避免为了取出所选技能而重新扫描全库。 + +## 5. 方案 + +### 5.1 会话准备不递归检查技能包 + +- 首次会话准备发现技能目录并读取各个 `SKILL.md` 的元数据;不计算包内完整文件签名,也不读取附属文件。结果按空间缓存,后续发送复用。 +- 保留必要的基本格式检查、授权来源检查和路径规范化;不合法的目录条目记录诊断并跳过。 +- 技能包缓存不能在每次发送前通过遍历包内所有文件来确认命中。安装、更新、删除、启用状态变化会触发缓存更新;打开技能目录列表时也执行一次完整刷新。 + +### 5.2 用户选中技能时只处理该技能 + +- `materializeForSpace()` 使用会话准备阶段已有的技能索引或等效引用定位所选技能,不重新枚举并加载全部技能包。 +- 保留当前显式选择的交互语义。所选技能仍按现有方式提供给对话;本次不要求只读取 `SKILL.md` 或新增模型工具。 +- 读取所选技能时,仍验证其授权范围、规范化路径和必要的文件边界;不因读取一个技能而递归检查其他技能。 +- 技能引用使用 `SKILL.md` 内容版本;显式选择还携带完整包版本,以便发现选择后技能包被替换或修改。 + +### 5.3 技能变化与完整校验 + +- 完整包校验保留在技能导入、安装、更新或用户明确刷新技能目录时,不放在每轮发送的同步路径上。 +- 普通发送复用进程内缓存的技能目录。外部目录新增或删除的技能会在用户刷新技能目录或重启应用后被发现;本次不增加文件系统监听器。 +- 用户选中技能时仍重新校验该技能包。如果 `SKILL.md` 或授权路径自目录缓存后发生变化,返回明确的 `SKILL_CHANGED` 错误并失效目录缓存;不会静默切换到其他同名技能。 + +## 6. 预期改动范围 + +以现有实现为准,预计集中在技能服务和缓存逻辑: + +| 文件或模块 | 计划调整 | +|---|---| +| `apps/buddy/service/src/skills/SkillService.ts` | 让会话目录准备走轻量路径;显式选择技能时通过已有目录索引直接处理所选项 | +| `apps/buddy/service/src/skills/SkillPackageCache.ts` | 增加只读 `SKILL.md` 元数据的路径;完整包检查用于明确刷新或所选技能 | +| `apps/buddy/service/src/skills/skillFiles.ts` | 为 `SKILL.md` 计算轻量引用版本;保留所选技能的路径与文件安全检查 | +| `apps/buddy/service/src/agent/resources/BuddySessionResources.ts` | 如有必要,调整为传递轻量技能目录或会话内已有引用 | +| `apps/buddy/shared/skills/skillApi.ts` 和 `apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts` | 让手动选择同时携带轻量说明版本和完整包版本 | + +不新增 Agent 技能工具或新的技能调用协议。 + +## 7. 验收标准 + +- [ ] 不选择技能发送普通消息时,会话准备只读取技能元数据,不递归检查任一技能包的附属文件。 +- [ ] 会话准备耗时不随技能包附属文件总数成比例增加。 +- [ ] 手动选择一个技能时,只处理所选技能,不重新加载或校验其他技能包。 +- [ ] 所选技能仍受现有授权、路径穿越、软链接边界及文件类型限制保护。 +- [ ] 所选技能的说明或授权路径在会话期间变更或失效时返回明确错误,不静默切换到其他同名技能。 +- [ ] 完整技能包校验仍可在安装、更新或用户明确检查时执行。 + +## 8. 风险和取舍 + +- 外部技能目录的新增和删除不会在每轮普通发送时自动发现,需要刷新技能目录或重启应用。这是避免再次引入每轮全库扫描所接受的取舍。 +- 本次不解决“AI 是否能自动挑选技能”或“附属资料是否按需进入上下文”。如果后续需要这些能力,再单独评估模型工具、上下文和交互变化。 +- 为避免发送变慢而移除同步全库检查,不代表放弃安全校验:授权和路径边界仍在技能实际读取时检查,完整包检查仍可在安装或显式检查时运行。 + +## 9. 后续可选方向 + +如果未来要让 AI 更灵活地使用 Skills,可以另立方案评估:先提供技能名称和简介,AI 判断需要时再读取说明,并在需要时读取单个附属文件。Pi、Agent Skills 规范和 Codex 都介绍了类似的分层读取方式,但这不是本次性能修复的验收条件。 + +- [Pi Skills 文档](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md) +- [Agent Skills 规范](https://agentskills.io/specification) +- [Codex Skills 文档](https://developers.openai.com/plugins/concepts/skills) + +--- + +**状态说明:** 普通发送的轻量目录缓存、显式技能选择时的单技能校验,以及技能列表刷新路径已按本方案实现。当前 Windows 环境不能创建符号链接,因此依赖符号链接的安全测试未能运行通过;验收清单保留待在具备该权限的环境复核。 From b609c5161ac79d7d546ea287f8017c954d3a6892 Mon Sep 17 00:00:00 2001 From: QAyong Date: Mon, 28 Sep 2026 01:38:21 +0800 Subject: [PATCH 2/3] docs: translate skill loading spec to Chinese --- docs/specs/feature-004-skill-lazy-loading.md | 75 ++++++++++---------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/docs/specs/feature-004-skill-lazy-loading.md b/docs/specs/feature-004-skill-lazy-loading.md index cac0d1d5..91ac2039 100644 --- a/docs/specs/feature-004-skill-lazy-loading.md +++ b/docs/specs/feature-004-skill-lazy-loading.md @@ -1,41 +1,40 @@ -# Spec-004:移除发送前的 Skills 全库扫描 +# 规格 004:移除消息发送前的 Skills 全库扫描 **日期:** 2026-09-27 +**状态:** 已完成最小实现,验收清单待复核 -**状态:** 已按方案完成最小实现,验收清单待复核 - -**目标:** 普通消息发送和会话准备只发现技能并读取各自的 `SKILL.md`,不递归检查技能附属文件;完整包检查只在安装、更新、明确刷新技能列表或读取所选技能时进行。现有 AI 使用技能的方式保持不变。 +**目标:** 普通消息发送和会话准备阶段只发现技能并读取各自 `SKILL.md` 的必要信息,不递归检查技能附属文件。完整技能包检查仅在安装、更新、明确刷新技能列表或读取用户选中的技能时执行。保持现有 AI 使用技能的方式不变。 ## 1. 背景与问题定位 -新会话准备会调用 `resolveBuddySessionResources()`,其中等待 `loadForSpace()` 完成。`SkillService` 随后遍历已发现的技能,并为每个技能调用 `SkillPackageCache.load()`。缓存检查函数 `inspectPackage()` 会递归遍历技能目录中的文件;缓存未命中时,`readSkill()` 还会读取整套技能文件。 +新会话准备会调用 `resolveBuddySessionResources()`,并等待 `loadForSpace()` 完成。随后 `SkillService` 遍历已发现的技能,为每个技能调用 `SkillPackageCache.load()`。缓存检查函数 `inspectPackage()` 会递归遍历技能目录中的文件;缓存未命中时,`readSkill()` 还会读取整套技能文件。 -因此,即使用户发送普通消息、没有选择技能,发送流程也可能先等整个技能库检查完。输入框手动选择技能时,`materializeForSpace()` 也会重新解析候选目录,可能再次处理未选中的技能。 +因此,即使用户发送普通消息且没有选择技能,发送流程也可能先等待整个技能库检查完成。用户在输入框中手动选择技能时,`materializeForSpace()` 也会重新解析候选目录,可能再次处理未选中的技能。 涉及的现有调用点: - `apps/buddy/service/src/agent/sessions/BuddySessionBlueprintService.ts:128`:创建会话资源。 - `apps/buddy/service/src/agent/resources/BuddySessionResources.ts:32-38`:并行等待技能目录与项目上下文。 -- `apps/buddy/service/src/skills/SkillService.ts:90-114`:准备技能列表及组装已选择技能的内容。 -- `apps/buddy/service/src/skills/SkillService.ts:347-367`:发现技能并逐个加载包。 -- `apps/buddy/service/src/skills/SkillPackageCache.ts:38-99`:缓存命中前递归检查包内文件;未命中则解析完整技能包。 +- `apps/buddy/service/src/skills/SkillService.ts:90-114`:准备技能列表并组装已选择技能的内容。 +- `apps/buddy/service/src/skills/SkillService.ts:347-367`:发现技能并逐个加载技能包。 +- `apps/buddy/service/src/skills/SkillPackageCache.ts:38-99`:缓存命中前递归检查包内文件;未命中时解析完整技能包。 - `apps/buddy/service/src/skills/skillFiles.ts:78-100,151-163`:读取技能包文件并计算版本信息。 - `apps/buddy/service/src/chat/ChatTurnService.ts:558-565,667-690,773-789`:发送前展开输入框中的技能选择或技能上下文。 ## 2. 本次范围 -本次只处理发送卡顿的直接原因:**会话准备只扫描技能目录并读取各个 `SKILL.md` 的必要信息,不遍历附属文件。**用户没有选择技能时,不应因为技能库中有大量附属文件而等待;用户显式选择技能时,只处理所选技能,不为此重新检查其他技能包。 +本次只处理导致发送卡顿的直接原因:**会话准备阶段只扫描技能目录并读取各个 `SKILL.md` 的必要信息,不遍历附属文件。**用户没有选择技能时,不应因技能库中存在大量附属文件而等待;用户明确选择技能时,只处理所选技能,不为此重新检查其他技能包。 -本次保留现有技能目录、手动选择和模型使用技能的方式。技能实际被读取时仍执行必要的授权和路径安全检查;完整包检查放在安装、更新或用户明确要求检查时。 +本次保留现有技能目录、手动选择和模型使用技能的方式。实际读取技能时仍执行必要的授权和路径安全检查;完整技能包检查放在安装、更新或用户明确要求检查时执行。 ## 3. 不在本次范围 - 不新增 `load_skill`、`read_skill_resource` 等模型工具。 - 不改变 AI 自动发现、选择或读取技能的方式。 -- 不把技能说明和附属文件改造成分阶段读取,也不改变手动选择技能后注入对话的现有语义。 -- 不改聊天界面、模型选择、消息展示或发送状态交互。 +- 不将技能说明和附属文件改造成分阶段读取,也不改变手动选择技能后注入对话的现有语义。 +- 不改动聊天界面、模型选择、消息展示或发送状态交互。 -这些属于后续的 AI 技能使用体验改造,可以在确有需求时另行设计;它们不是消除本次全库扫描卡顿的前提。 +这些属于后续 AI 技能使用体验的改造,可在确有需求时另行设计;它们不是消除本次全库扫描卡顿的前提。 ## 4. 目标流程 @@ -51,44 +50,44 @@ sequenceDiagram Note over Skills: 不递归检查每个技能包的所有文件 Skills-->>Session: 返回当前会话所需的技能信息 Session-->>Model: 提供用户消息和现有技能上下文 - opt 用户显式选择技能 - Session->>Skills: 处理被选中的技能 + opt 用户明确选择技能 + Session->>Skills: 处理所选技能 Note over Skills: 只校验和读取所选技能,不重新扫描其他技能包 end ``` -普通消息不再等待所有技能包的文件检查。显式选择技能仍按当前产品行为处理,只避免为了取出所选技能而重新扫描全库。 +普通消息不再等待所有技能包的文件检查。用户明确选择技能时仍沿用当前产品行为,只避免为了取出所选技能而重新扫描全库。 ## 5. 方案 -### 5.1 会话准备不递归检查技能包 +### 5.1 会话准备阶段不递归检查技能包 -- 首次会话准备发现技能目录并读取各个 `SKILL.md` 的元数据;不计算包内完整文件签名,也不读取附属文件。结果按空间缓存,后续发送复用。 -- 保留必要的基本格式检查、授权来源检查和路径规范化;不合法的目录条目记录诊断并跳过。 -- 技能包缓存不能在每次发送前通过遍历包内所有文件来确认命中。安装、更新、删除、启用状态变化会触发缓存更新;打开技能目录列表时也执行一次完整刷新。 +- 首次准备会话时发现技能目录,并读取各个 `SKILL.md` 的元数据;不计算包内完整文件签名,也不读取附属文件。结果按空间缓存,后续发送复用。 +- 保留必要的基本格式检查、授权来源检查和路径规范化;记录诊断并跳过不合法的目录条目。 +- 技能包缓存不能在每次发送前通过遍历包内所有文件来确认命中。安装、更新、删除和启用状态变化会触发缓存更新;打开技能目录列表时也执行完整刷新。 ### 5.2 用户选中技能时只处理该技能 - `materializeForSpace()` 使用会话准备阶段已有的技能索引或等效引用定位所选技能,不重新枚举并加载全部技能包。 - 保留当前显式选择的交互语义。所选技能仍按现有方式提供给对话;本次不要求只读取 `SKILL.md` 或新增模型工具。 -- 读取所选技能时,仍验证其授权范围、规范化路径和必要的文件边界;不因读取一个技能而递归检查其他技能。 -- 技能引用使用 `SKILL.md` 内容版本;显式选择还携带完整包版本,以便发现选择后技能包被替换或修改。 +- 读取所选技能时,仍验证其授权范围、规范化路径和必要的文件边界;读取一个技能时不递归检查其他技能。 +- 技能引用使用 `SKILL.md` 内容版本;用户明确选择技能时还携带完整包版本,以便发现选择后技能包被替换或修改。 ### 5.3 技能变化与完整校验 -- 完整包校验保留在技能导入、安装、更新或用户明确刷新技能目录时,不放在每轮发送的同步路径上。 -- 普通发送复用进程内缓存的技能目录。外部目录新增或删除的技能会在用户刷新技能目录或重启应用后被发现;本次不增加文件系统监听器。 -- 用户选中技能时仍重新校验该技能包。如果 `SKILL.md` 或授权路径自目录缓存后发生变化,返回明确的 `SKILL_CHANGED` 错误并失效目录缓存;不会静默切换到其他同名技能。 +- 完整技能包校验保留在技能导入、安装、更新或用户明确刷新技能目录时执行,不放在每轮发送的同步路径上。 +- 普通发送复用进程内缓存的技能目录。外部目录中的技能新增或删除,会在用户刷新技能目录或重启应用后被发现;本次不增加文件系统监听器。 +- 用户选中技能时仍重新校验该技能包。如果 `SKILL.md` 或授权路径自目录缓存后发生变化,则返回明确的 `SKILL_CHANGED` 错误并使目录缓存失效;不会静默切换到其他同名技能。 ## 6. 预期改动范围 -以现有实现为准,预计集中在技能服务和缓存逻辑: +按现有实现,改动集中在技能服务和缓存逻辑: | 文件或模块 | 计划调整 | |---|---| -| `apps/buddy/service/src/skills/SkillService.ts` | 让会话目录准备走轻量路径;显式选择技能时通过已有目录索引直接处理所选项 | -| `apps/buddy/service/src/skills/SkillPackageCache.ts` | 增加只读 `SKILL.md` 元数据的路径;完整包检查用于明确刷新或所选技能 | -| `apps/buddy/service/src/skills/skillFiles.ts` | 为 `SKILL.md` 计算轻量引用版本;保留所选技能的路径与文件安全检查 | +| `apps/buddy/service/src/skills/SkillService.ts` | 让会话目录准备走轻量路径;用户选择技能时通过已有目录索引直接处理所选项 | +| `apps/buddy/service/src/skills/SkillPackageCache.ts` | 增加只读取 `SKILL.md` 元数据的路径;完整包检查用于明确刷新或读取所选技能 | +| `apps/buddy/service/src/skills/skillFiles.ts` | 为 `SKILL.md` 计算轻量引用版本;保留所选技能的路径和文件安全检查 | | `apps/buddy/service/src/agent/resources/BuddySessionResources.ts` | 如有必要,调整为传递轻量技能目录或会话内已有引用 | | `apps/buddy/shared/skills/skillApi.ts` 和 `apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts` | 让手动选择同时携带轻量说明版本和完整包版本 | @@ -96,22 +95,22 @@ sequenceDiagram ## 7. 验收标准 -- [ ] 不选择技能发送普通消息时,会话准备只读取技能元数据,不递归检查任一技能包的附属文件。 +- [ ] 不选择技能发送普通消息时,会话准备只读取技能元数据,不递归检查任何技能包的附属文件。 - [ ] 会话准备耗时不随技能包附属文件总数成比例增加。 - [ ] 手动选择一个技能时,只处理所选技能,不重新加载或校验其他技能包。 - [ ] 所选技能仍受现有授权、路径穿越、软链接边界及文件类型限制保护。 -- [ ] 所选技能的说明或授权路径在会话期间变更或失效时返回明确错误,不静默切换到其他同名技能。 -- [ ] 完整技能包校验仍可在安装、更新或用户明确检查时执行。 +- [ ] 所选技能的说明或授权路径在会话期间发生变化或失效时,返回明确错误,不静默切换到其他同名技能。 +- [ ] 安装、更新或用户明确检查时,仍可执行完整技能包校验。 ## 8. 风险和取舍 -- 外部技能目录的新增和删除不会在每轮普通发送时自动发现,需要刷新技能目录或重启应用。这是避免再次引入每轮全库扫描所接受的取舍。 -- 本次不解决“AI 是否能自动挑选技能”或“附属资料是否按需进入上下文”。如果后续需要这些能力,再单独评估模型工具、上下文和交互变化。 -- 为避免发送变慢而移除同步全库检查,不代表放弃安全校验:授权和路径边界仍在技能实际读取时检查,完整包检查仍可在安装或显式检查时运行。 +- 外部技能目录中的新增和删除不会在每轮普通发送时自动发现,需要刷新技能目录或重启应用。这是为避免每轮重新扫描全库而接受的取舍。 +- 本次不解决“AI 是否能自动挑选技能”或“附属资料是否按需进入上下文”。若后续需要这些能力,应单独评估模型工具、上下文和交互变化。 +- 为避免发送变慢而移除同步全库检查,不代表放弃安全校验:实际读取技能时仍检查授权和路径边界,安装或明确检查时仍可执行完整包检查。 ## 9. 后续可选方向 -如果未来要让 AI 更灵活地使用 Skills,可以另立方案评估:先提供技能名称和简介,AI 判断需要时再读取说明,并在需要时读取单个附属文件。Pi、Agent Skills 规范和 Codex 都介绍了类似的分层读取方式,但这不是本次性能修复的验收条件。 +若未来希望 AI 更灵活地使用 Skills,可另行评估分层读取方案:先提供技能名称和简介,由 AI 判断是否需要读取技能说明,并在需要时读取单个附属文件。Pi、Agent Skills 规范和 Codex 均介绍了类似方式,但这不是本次性能修复的验收条件。 - [Pi Skills 文档](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md) - [Agent Skills 规范](https://agentskills.io/specification) @@ -119,4 +118,4 @@ sequenceDiagram --- -**状态说明:** 普通发送的轻量目录缓存、显式技能选择时的单技能校验,以及技能列表刷新路径已按本方案实现。当前 Windows 环境不能创建符号链接,因此依赖符号链接的安全测试未能运行通过;验收清单保留待在具备该权限的环境复核。 +**状态说明:** 普通发送的轻量目录缓存、用户选择技能时的单技能校验,以及技能列表刷新路径已按本方案实现。当前 Windows 环境无法创建符号链接,因此依赖符号链接的安全测试尚未通过;验收清单待在具备相应权限的环境中复核。 From 0ec0acdc969ed17b82fbccf45a4eabdbccdd0144 Mon Sep 17 00:00:00 2001 From: shanyuhai123 <864299347@qq.com> Date: Mon, 28 Sep 2026 10:36:27 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(buddy):=20=E4=BF=AE=E5=A4=8D=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E7=BC=93=E5=AD=98=E6=A0=A1=E9=AA=8C=E4=B8=8E=E6=97=A7?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../electron/preload/local-chat/skills.ts | 2 +- apps/buddy/electron/shared/localChatApi.ts | 2 +- apps/buddy/service/src/BuddyService.ts | 1 + .../buddyService.integration.spec.ts | 2 +- .../buddyComposerInputBoundary.spec.ts | 2 +- .../execution/BuddyRunExecutionPlanner.ts | 13 +- .../__tests__/BuddyAgentRunner.spec.ts | 2 +- .../__tests__/BuddyTurnLauncher.spec.ts | 51 ++++++-- .../__tests__/inProcessExtensions.spec.ts | 8 +- .../__tests__/toolDiscoverySession.spec.ts | 2 +- .../agent/resources/BuddySessionResources.ts | 6 +- .../resources/createBuddyResourceLoader.ts | 22 +++- .../BuddySessionBlueprintService.spec.ts | 2 +- .../sessions/__tests__/cacheWarming.spec.ts | 2 +- .../__tests__/createBuddySession.spec.ts | 27 +++- .../src/agent/sessions/createBuddySession.ts | 2 +- .../__tests__/BuddyConversationTree.spec.ts | 2 +- .../buddy/service/src/chat/ChatTurnService.ts | 6 +- .../mcp/__tests__/McpDiscovery.spec.ts | 2 +- .../service/src/skills/SkillPackageCache.ts | 21 ++- apps/buddy/service/src/skills/SkillService.ts | 53 ++++++-- .../__tests__/SkillPackageCache.spec.ts | 20 +++ .../src/skills/__tests__/SkillService.spec.ts | 75 ++++++++++- apps/buddy/shared/skills/skillApi.ts | 2 +- .../composer/useComposerContextOptions.ts | 3 +- docs/specs/feature-004-skill-lazy-loading.md | 121 ------------------ 26 files changed, 268 insertions(+), 183 deletions(-) delete mode 100644 docs/specs/feature-004-skill-lazy-loading.md diff --git a/apps/buddy/electron/preload/local-chat/skills.ts b/apps/buddy/electron/preload/local-chat/skills.ts index 0a0ac4b7..1926194a 100644 --- a/apps/buddy/electron/preload/local-chat/skills.ts +++ b/apps/buddy/electron/preload/local-chat/skills.ts @@ -4,7 +4,7 @@ import { LOCAL_CHAT_IPC_CHANNELS } from '../../shared/localChatApi' export function createSkillsApi(): Pick { return { skills: Object.freeze({ - list: spaceId => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsList, { spaceId: spaceId ?? null }), + list: (spaceId, metadataOnly) => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsList, { spaceId: spaceId ?? null, metadataOnly }), get: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsGet, { ...input }), listFiles: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsListFiles, { ...input }), readFile: input => ipcRenderer.invoke(LOCAL_CHAT_IPC_CHANNELS.skillsReadFile, { ...input }), diff --git a/apps/buddy/electron/shared/localChatApi.ts b/apps/buddy/electron/shared/localChatApi.ts index b7e3cf67..4d88cc21 100644 --- a/apps/buddy/electron/shared/localChatApi.ts +++ b/apps/buddy/electron/shared/localChatApi.ts @@ -317,7 +317,7 @@ export interface LocalChatApi { update: (input: LocalSpaceUpdateInput) => Promise } skills: { - list: (spaceId?: string | null) => Promise + list: (spaceId?: string | null, metadataOnly?: boolean) => Promise get: (input: { spaceId: string | null, id: string }) => Promise listFiles: (input: SkillDirectoryRequest) => Promise readFile: (input: SkillFileTarget) => Promise diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index caf3cfa3..53f09e4a 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -488,6 +488,7 @@ export async function startBuddyService( runInputs, runs, sessions: sessionBlueprints, + skills: skillService, }) const turnLauncher = new BuddyTurnLauncher({ lifecycle: runLifecycleService, diff --git a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts index 79699211..10f53c87 100644 --- a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts +++ b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts @@ -139,7 +139,7 @@ describe('buddy runtime cross-subsystem contract', () => { resources: { skillReadRoots: [], skillReferences: [], - approvedSkillPaths: [], + approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'resources-1', diff --git a/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts b/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts index 31c41d13..768447f4 100644 --- a/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts +++ b/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts @@ -617,7 +617,7 @@ async function createFixture(options: { resources: { skillReadRoots: [], skillReferences: [], - approvedSkillPaths: [], + approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: DIRECTORY_CONTEXT, revision: 'offline-s0', diff --git a/apps/buddy/service/src/agent/execution/BuddyRunExecutionPlanner.ts b/apps/buddy/service/src/agent/execution/BuddyRunExecutionPlanner.ts index 542077b9..d0f55b5b 100644 --- a/apps/buddy/service/src/agent/execution/BuddyRunExecutionPlanner.ts +++ b/apps/buddy/service/src/agent/execution/BuddyRunExecutionPlanner.ts @@ -1,5 +1,6 @@ import type { AttachmentService } from '../../attachments/AttachmentService' import type { ProviderExecutionModelResolver } from '../../providers/ProviderExecutionModelResolver' +import type { SkillService } from '../../skills/SkillService' import type { CommandRequestRepository } from '../../storage/commandRequestRepository' import type { ConversationRepository } from '../../storage/conversationRepository' import type { RunInputRepository } from '../../storage/runInputRepository' @@ -25,6 +26,7 @@ export interface BuddyRunExecutionPlannerOptions { runInputs: Pick runs: Pick sessions: Pick + skills: Pick } export class BuddyRunExecutionPlanner { @@ -88,10 +90,13 @@ export class BuddyRunExecutionPlanner { const input = this.#options.runInputs.findByRunId(run.id) if (!input?.prompt.trim()) throw new BuddyAgentRunError('RUN_INPUT_NOT_FOUND') - for (const item of input.contextItems) { - if (item.kind !== 'skill' || !item.skill) - continue - const selected = item.skill + const selections = input.contextItems.flatMap(item => item.kind === 'skill' ? [item.skill ?? item.value] : []) + for (const selection of selections) { + if (typeof selection !== 'string' && !session.resources.skillReferences.some(skill => skill.id === selection.id && skill.name === selection.name)) + throw new SkillError('SKILL_CHANGED') + } + const selectedSkills = await this.#options.skills.materializeForSpace(conversation.spaceId, selections) + for (const { reference: selected } of selectedSkills) { if (!session.resources.skillReferences.some(skill => skill.id === selected.id && skill.name === selected.name && skill.revision === selected.revision)) throw new SkillError('SKILL_CHANGED') } diff --git a/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts index b38d5e2b..5ed806aa 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts @@ -1483,7 +1483,7 @@ function emptyResources() { return { skillReadRoots: [], skillReferences: [], - approvedSkillPaths: [], + approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'resources-1', diff --git a/apps/buddy/service/src/agent/execution/__tests__/BuddyTurnLauncher.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/BuddyTurnLauncher.spec.ts index 1892d2a9..37438103 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/BuddyTurnLauncher.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/BuddyTurnLauncher.spec.ts @@ -5,12 +5,13 @@ import type { BuddyTurnHandle, StartBuddyTurnInput, } from '../turnTypes' -import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { createRunEventLog } from '../../../events/createRunEventLog' import { RunLifecycleService } from '../../../runs/RunLifecycleService' +import { SkillService } from '../../../skills/SkillService' import { prepareTestTurnRequest } from '../../../storage/__tests__/composerDraftTestFixture' import { BuddyDataPaths } from '../../../storage/BuddyDataPaths' import { createCommandRequestRepository } from '../../../storage/commandRequestRepository' @@ -18,6 +19,7 @@ import { createConversationRepository } from '../../../storage/conversationRepos import { openBuddyDatabase } from '../../../storage/database' import { createRunInputRepository } from '../../../storage/runInputRepository' import { createRunRepository } from '../../../storage/runRepository' +import { createSkillRepository } from '../../../storage/skillRepository' import { createSpaceRepository } from '../../../storage/spaceRepository' import { BuddySessionBlueprintService } from '../../sessions/BuddySessionBlueprintService' import { BuddyRunExecutionPlanner } from '../BuddyRunExecutionPlanner' @@ -40,6 +42,30 @@ describe('buddyTurnLauncher', () => { expect(fixture.runs.findById('run-1')?.status).toBe('queued') }) + it.each(['legacy', 'current'] as const)('launches an unchanged %s Skill reference and rejects later package changes', async (format) => { + const fixture = await createFixture() + const directory = join(fixture.root, 'agent', 'skills', 'writer') + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'SKILL.md'), '---\nname: writer\ndescription: Write text\n---\nWrite carefully.') + const resource = join(directory, 'reference.md') + await writeFile(resource, 'first version') + const [materialized] = await fixture.skills.materializeForSpace(null, ['writer']) + const selectedSkill = format === 'legacy' + ? { id: materialized!.reference.id, name: 'writer', revision: materialized!.reference.packageRevision! } + : materialized!.reference + fixture.prepareTurn({ spaceId: null, selectedSkill }) + + const plan = await fixture.planner.resolve('run-1') + expect(plan.kind).toBe('turn') + expect(plan.input.session.resources.skillReferences).toContainEqual(expect.objectContaining({ + id: selectedSkill.id, + revision: materialized!.reference.revision, + })) + await writeFile(resource, 'second version') + await expect(fixture.planner.resolve('run-1')).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) + expect(fixture.runs.findById('run-1')?.status).toBe('queued') + }) + it('rebuilds the executable turn from persisted run facts', async () => { const fixture = await createFixture() fixture.prepareTurn({ spaceId: null }) @@ -177,19 +203,16 @@ async function createFixture(options: { modelInput?: readonly ('text' | 'image') database, }) const lifecycle = new RunLifecycleService({ eventLog, repository: runs }) + const skills = new SkillService({ + agentDirectory: join(root, 'agent'), + paths, + repository: createSkillRepository(database), + spaces, + }) const blueprints = new BuddySessionBlueprintService({ conversationGrants: { listActive: () => [] }, paths, - skills: { - loadForSpace: async () => ({ - diagnostics: [], - readRoots: [], - references: [], - paths: [], - revision: 'skills-revision-1', - skills: [], - }), - }, + skills, spaces, }) const planner = new BuddyRunExecutionPlanner({ @@ -200,6 +223,7 @@ async function createFixture(options: { modelInput?: readonly ('text' | 'image') runInputs: createRunInputRepository(database), runs, sessions: blueprints, + skills, }) return { createLauncher(overrides: { @@ -222,7 +246,8 @@ async function createFixture(options: { modelInput?: readonly ('text' | 'image') planner, resolveInputReferences, paths, - prepareTurn({ spaceId }: { spaceId: string | null }) { + skills, + prepareTurn({ spaceId, selectedSkill = options.selectedSkill }: { spaceId: string | null, selectedSkill?: SkillReference }) { prepareTestTurnRequest(database, { attachmentBindings: [], branchId: 'branch-1', @@ -239,7 +264,7 @@ async function createFixture(options: { modelInput?: readonly ('text' | 'image') runId: 'run-1', runInput: { attachmentIds: [], - contextItems: options.selectedSkill ? [{ kind: 'skill', value: options.selectedSkill.name, skill: options.selectedSkill }] : [], + contextItems: selectedSkill ? [{ kind: 'skill', value: selectedSkill.name, skill: selectedSkill }] : [], prompt: 'Persisted prompt', reasoning: 'high', serviceTier: 'priority', diff --git a/apps/buddy/service/src/agent/extensions/__tests__/inProcessExtensions.spec.ts b/apps/buddy/service/src/agent/extensions/__tests__/inProcessExtensions.spec.ts index 08b72f78..80bec210 100644 --- a/apps/buddy/service/src/agent/extensions/__tests__/inProcessExtensions.spec.ts +++ b/apps/buddy/service/src/agent/extensions/__tests__/inProcessExtensions.spec.ts @@ -67,7 +67,7 @@ describe('buddy in-process Pi extensions', () => { resources: { skillReadRoots: [], skillReferences: [], - approvedSkillPaths: [], + approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'resources-1', @@ -124,7 +124,7 @@ describe('buddy in-process Pi extensions', () => { resources: { skillReadRoots: [], skillReferences: [], - approvedSkillPaths: [], + approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'resources-1', @@ -216,7 +216,7 @@ describe('buddy in-process Pi extensions', () => { resources: { skillReadRoots: [], skillReferences: [], - approvedSkillPaths: [], + approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'resources-1', @@ -370,7 +370,7 @@ describe('buddy in-process Pi extensions', () => { resources: { skillReadRoots: [], skillReferences: [], - approvedSkillPaths: [], + approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'resources-1', diff --git a/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolDiscoverySession.spec.ts b/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolDiscoverySession.spec.ts index 3fdfeb70..3132f349 100644 --- a/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolDiscoverySession.spec.ts +++ b/apps/buddy/service/src/agent/extensions/discovery/__tests__/toolDiscoverySession.spec.ts @@ -55,7 +55,7 @@ async function fixture() { model, modelRuntime: runtime, inProcessExtensions: [extension, discovery.extension], - resources: { skillReadRoots: [], skillReferences: [], approvedSkillPaths: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, + resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, } return { root, options, runtime, model } } diff --git a/apps/buddy/service/src/agent/resources/BuddySessionResources.ts b/apps/buddy/service/src/agent/resources/BuddySessionResources.ts index 080dcc93..aab10747 100644 --- a/apps/buddy/service/src/agent/resources/BuddySessionResources.ts +++ b/apps/buddy/service/src/agent/resources/BuddySessionResources.ts @@ -1,4 +1,4 @@ -import type { SkillReference } from '../../../../shared/skills/skillApi' +import type { LocalSkill, SkillReference } from '../../../../shared/skills/skillApi' import type { SkillService } from '../../skills/SkillService' import type { BoundedContextFilesResult } from './loadBoundedContextFiles' import { createHash } from 'node:crypto' @@ -6,7 +6,7 @@ import { createHash } from 'node:crypto' import { loadBoundedContextFiles } from './loadBoundedContextFiles' export interface BuddySessionResources { - approvedSkillPaths: readonly string[] + approvedSkills: readonly LocalSkill[] skillReadRoots: readonly string[] skillReferences: readonly SkillReference[] context: BoundedContextFilesResult @@ -55,7 +55,7 @@ export async function resolveBuddySessionResources( hash.update('\0') } return { - approvedSkillPaths: skills.paths, + approvedSkills: skills.skills, skillReadRoots: skills.readRoots, skillReferences: skills.references, context, diff --git a/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts b/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts index c77e1234..cf2d096d 100644 --- a/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts +++ b/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts @@ -1,9 +1,11 @@ import type { SettingsManager } from '@earendil-works/pi-coding-agent' import type { BuddyApprovalPolicy } from '../../../../shared/permissions/approvalPolicy' import type { BuddyExecutionProfile } from '../../../../shared/permissions/executionProfile' +import type { LocalSkill } from '../../../../shared/skills/skillApi' import type { BuddyInputReferenceV1 } from '../context/BuddyInputReference' import type { BuddyInProcessExtension } from '../extensions/BuddyInProcessExtension' import type { BoundedContextFile } from './loadBoundedContextFiles' +import { dirname } from 'node:path' import process from 'node:process' import { @@ -18,7 +20,7 @@ import { createBuddySystemPrompt } from './createBuddySystemPrompt' export interface CreateBuddyResourceLoaderOptions { getPendingInput?: () => BuddyInputReferenceV1 | null - approvedSkillPaths: readonly string[] + approvedSkills: readonly LocalSkill[] agentDir: string approvalPolicy: BuddyApprovalPolicy boundedContextFiles: readonly BoundedContextFile[] @@ -56,7 +58,7 @@ export async function createBuddyResourceLoader( const loader = new DefaultResourceLoader({ additionalExtensionPaths: [], additionalPromptTemplatePaths: [], - additionalSkillPaths: [...options.approvedSkillPaths], + additionalSkillPaths: [], additionalThemePaths: [], agentDir: options.agentDir, agentsFilesOverride: () => ({ agentsFiles: [...options.boundedContextFiles] }), @@ -67,6 +69,22 @@ export async function createBuddyResourceLoader( noExtensions: true, noPromptTemplates: true, noSkills: true, + skillsOverride: () => ({ + skills: options.approvedSkills.map(skill => ({ + name: skill.name, + description: skill.description, + filePath: skill.filePath, + baseDir: dirname(skill.filePath), + disableModelInvocation: skill.status === 'manual_only', + sourceInfo: { + path: skill.filePath, + source: 'lexora', + scope: skill.source === 'directory' ? 'project' : 'user', + origin: 'top-level', + }, + })), + diagnostics: [], + }), noThemes: true, settingsManager: options.settingsManager ?? createBuddySettingsManager(), systemPrompt, diff --git a/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionBlueprintService.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionBlueprintService.spec.ts index 2b74a6df..ad012690 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionBlueprintService.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/BuddySessionBlueprintService.spec.ts @@ -51,7 +51,7 @@ describe('buddySessionBlueprintService', () => { { canonicalRoot: spaceRoot, grantId: 'directory-1', kind: 'workspace' as const, root: spaceRoot }, { canonicalRoot: scratchRoot, grantId: 'space-1', kind: 'workspace' as const, root: scratchRoot }, ], - resources: { skillReadRoots: [], skillReferences: [], approvedSkillPaths: ['/skills/space/SKILL.md'] }, + resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [] }, scratchRoot, space: { additionalDirectoryBindings: [], diff --git a/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts index 16a1d562..e1eee9fc 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts @@ -65,7 +65,7 @@ describe('buddy cache warming', () => { model, modelRuntime: runtime, thinkingLevel: 'off', - resources: { skillReadRoots: [], skillReferences: [], approvedSkillPaths: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, + resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, inProcessExtensions: [{ name: 'lexora-warm-fixture', factory(pi) { diff --git a/apps/buddy/service/src/agent/sessions/__tests__/createBuddySession.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/createBuddySession.spec.ts index 53d371d9..49e8ec1f 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/createBuddySession.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/createBuddySession.spec.ts @@ -1,10 +1,15 @@ -import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { InMemoryCredentialStore } from '@earendil-works/pi-ai' import { ModelRuntime, SessionManager } from '@earendil-works/pi-coding-agent' import { afterEach, describe, expect, it } from 'vitest' import { createImageGenerationCapability } from '../../../images/imageGenerationExtension' +import { SkillService } from '../../../skills/SkillService' +import { BuddyDataPaths } from '../../../storage/BuddyDataPaths' +import { openBuddyDatabase } from '../../../storage/database' +import { createSkillRepository } from '../../../storage/skillRepository' +import { createSpaceRepository } from '../../../storage/spaceRepository' import { createToolDiscoveryCapability } from '../../extensions/discovery/toolDiscoveryExtension' import { createBuddySession as createPreparedBuddySession } from '../createBuddySession' @@ -76,6 +81,21 @@ describe('createBuddySession', () => { const skillPath = join(directory, 'SKILL.md') await writeFile(skillPath, '---\nname: sample-workflow\ndescription: Inspect sample workflow inputs\n---\nBODY_ONLY_ON_DEMAND\nSee reference.md when needed.\n') await writeFile(join(directory, 'reference.md'), 'REFERENCE_ONLY_ON_DEMAND') + const database = openBuddyDatabase({ databasePath: ':memory:' }) + const skills = new SkillService({ + agentDirectory: join(root, 'agent'), + builtinSkillsDirectories: [join(root, 'skills')], + paths: new BuddyDataPaths(root), + repository: createSkillRepository(database), + spaces: createSpaceRepository(database), + }) + const approved = await skills.loadForSpace(null) + await skills.dispose() + database.close() + const outside = join(root, 'outside.md') + await writeFile(outside, '---\nname: sample-workflow\ndescription: OUTSIDE_METADATA\n---\nOUTSIDE_BODY') + await rm(skillPath) + await symlink(outside, skillPath) const modelRuntime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false }) const model = modelRuntime.getModels()[0]! const result = await createBuddySession({ @@ -88,7 +108,7 @@ describe('createBuddySession', () => { ...createRuntimeOptions(), model, modelRuntime, - resources: { ...emptyResources(), skillReadRoots: [], skillReferences: [], approvedSkillPaths: [skillPath] }, + resources: { ...emptyResources(), skillReadRoots: [], skillReferences: [], approvedSkills: approved.skills }, }) try { expect(result.session.systemPrompt).toContain('') @@ -98,6 +118,7 @@ describe('createBuddySession', () => { expect(result.session.systemPrompt).toContain('Use the read tool to load a skill') expect(result.session.systemPrompt).not.toContain('BODY_ONLY_ON_DEMAND') expect(result.session.systemPrompt).not.toContain('REFERENCE_ONLY_ON_DEMAND') + expect(result.session.systemPrompt).not.toContain('OUTSIDE_METADATA') } finally { await result.shutdown('quit') @@ -238,7 +259,7 @@ function emptyResources() { return { skillReadRoots: [], skillReferences: [], - approvedSkillPaths: [], + approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty', diff --git a/apps/buddy/service/src/agent/sessions/createBuddySession.ts b/apps/buddy/service/src/agent/sessions/createBuddySession.ts index 8d9ac646..dbffa646 100644 --- a/apps/buddy/service/src/agent/sessions/createBuddySession.ts +++ b/apps/buddy/service/src/agent/sessions/createBuddySession.ts @@ -220,7 +220,7 @@ async function createConfiguredBuddySession( const settingsManager = createBuddySettingsManager() const resourceLoader = await createBuddyResourceLoader({ getPendingInput: options.getPendingInput, - approvedSkillPaths: [...options.resources.approvedSkillPaths], + approvedSkills: [...options.resources.approvedSkills], agentDir: runtime.agentDir, approvalPolicy: options.approvalPolicy, boundedContextFiles: context.agentsFiles, diff --git a/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts b/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts index 5ac56e64..09b01dfd 100644 --- a/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts +++ b/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts @@ -261,7 +261,7 @@ async function createFixture(recovery?: BuddySessionRecoveryService['create']) { }), }, }) - const sessionOptions = (branchId: string) => ({ agentDir: join(root, 'agent'), branchId, canonicalRoot: root, conversationId: 'conversation', conversationsDirectory: join(root, 'conversations'), cwd: root, approvalPolicy: 'policy' as const, executionProfile: 'workspace_write' as const, inProcessExtensions: [], model, modelRuntime, resources: { skillReadRoots: [], skillReferences: [], approvedSkillPaths: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'test' } }) + const sessionOptions = (branchId: string) => ({ agentDir: join(root, 'agent'), branchId, canonicalRoot: root, conversationId: 'conversation', conversationsDirectory: join(root, 'conversations'), cwd: root, approvalPolicy: 'policy' as const, executionProfile: 'workspace_write' as const, inProcessExtensions: [], model, modelRuntime, resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'test' } }) return { root, tree, diff --git a/apps/buddy/service/src/chat/ChatTurnService.ts b/apps/buddy/service/src/chat/ChatTurnService.ts index b06d3c41..e5681b61 100644 --- a/apps/buddy/service/src/chat/ChatTurnService.ts +++ b/apps/buddy/service/src/chat/ChatTurnService.ts @@ -298,7 +298,7 @@ export class ChatTurnService { async validatePreparedInput(input: PrepareTurnRequestInput, validateSkills = true): Promise { if (validateSkills) - await this.#validateSkillItems(input.spaceId, input.runInput.contextItems) + input.runInput.contextItems = await this.#validateSkillItems(input.spaceId, input.runInput.contextItems) await this.#options.inputValidation.validate({ conversationId: input.conversationId, branchId: input.branchId, @@ -617,7 +617,9 @@ export class ChatTurnService { async #validateSkillItems(spaceId: string | null, items: readonly RunInputRecord['contextItems'][number][]) { const selections = items.filter(item => item.kind === 'skill').map(item => item.skill ?? item.value) - await this.#options.skills.materializeForSpace(spaceId, selections) + const loaded = await this.#options.skills.materializeForSpace(spaceId, selections) + const references = new Map(loaded.map(skill => [skill.name, skill.reference])) + return items.map(item => item.kind === 'skill' ? { ...item, skill: references.get(item.value)! } : item) } #resolveConversationSpace(conversation: ConversationRecord): SpaceRecord | null { diff --git a/apps/buddy/service/src/connectors/mcp/__tests__/McpDiscovery.spec.ts b/apps/buddy/service/src/connectors/mcp/__tests__/McpDiscovery.spec.ts index bb24dfb5..babee22a 100644 --- a/apps/buddy/service/src/connectors/mcp/__tests__/McpDiscovery.spec.ts +++ b/apps/buddy/service/src/connectors/mcp/__tests__/McpDiscovery.spec.ts @@ -129,7 +129,7 @@ it('advertises enabled MCP capabilities, discovers Chinese queries, and calls th model, modelRuntime: runtime, inProcessExtensions: [mcp.extension, discovery.extension, policy], - resources: { skillReadRoots: [], skillReferences: [], approvedSkillPaths: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, + resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, } const preview = await createIsolatedBuddyContextSnapshot(options) created = await createIsolatedBuddySession(options) diff --git a/apps/buddy/service/src/skills/SkillPackageCache.ts b/apps/buddy/service/src/skills/SkillPackageCache.ts index 82acb71f..265a8e51 100644 --- a/apps/buddy/service/src/skills/SkillPackageCache.ts +++ b/apps/buddy/service/src/skills/SkillPackageCache.ts @@ -16,6 +16,7 @@ const MAX_CACHED_PACKAGES = 256 export class SkillPackageCache { readonly #packages = new Map() + readonly #documents = new Map() readonly #pending = new Map>() async load(filePath: string, allowedRoot: string): Promise { @@ -32,14 +33,30 @@ export class SkillPackageCache { } async loadMetadata(filePath: string, allowedRoot: string): Promise { - const document = await readSkillDocument(filePath, allowedRoot) + const path = await requireSkillPath(allowedRoot, filePath) + const signature = fileSignature(path, await lstat(path, { bigint: true })) + const cached = this.#documents.get(path) + if (cached?.signature === signature) { + this.#documents.delete(path) + this.#documents.set(path, cached) + return cached.skill + } + this.#documents.delete(path) + const document = await readSkillDocument(path, allowedRoot) if (!document.description) throw new SkillError('SKILL_INVALID') - return { ...document, revision: document.referenceRevision } + if (fileSignature(path, await lstat(path, { bigint: true })) !== signature) + throw new SkillError('SKILL_CHANGED') + const skill = { ...document, revision: document.referenceRevision } + this.#documents.set(path, { signature, skill }) + while (this.#documents.size > MAX_CACHED_PACKAGES) + this.#documents.delete(this.#documents.keys().next().value!) + return skill } clear() { this.#packages.clear() + this.#documents.clear() } async #load(path: string, allowedRoot: string): Promise { diff --git a/apps/buddy/service/src/skills/SkillService.ts b/apps/buddy/service/src/skills/SkillService.ts index b1efd3e0..dca09091 100644 --- a/apps/buddy/service/src/skills/SkillService.ts +++ b/apps/buddy/service/src/skills/SkillService.ts @@ -6,7 +6,7 @@ import type { SpaceRepository } from '../storage/spaceRepository' import type { LoadedSkill } from './skillFiles' import type { ResolvedSkill } from './SkillPackageCache' import { createHash, randomUUID } from 'node:crypto' -import { mkdir, readdir, rm } from 'node:fs/promises' +import { mkdir, readdir, realpath, rm } from 'node:fs/promises' import { basename, dirname, join, relative } from 'node:path' import { isSkillAvailable, skillsRpc } from '../../../shared/skills/skillApi' import { registerRuntimeRequest } from '../rpc/runtimeRequest' @@ -69,6 +69,7 @@ export class SkillService { readonly #resolutions = new Map>() readonly #resolved = new Map() #mutation: Promise = Promise.resolve() + #generation = 0 constructor(options: SkillServiceOptions) { this.#options = options @@ -91,9 +92,9 @@ export class SkillService { } } - async list(spaceId: string | null): Promise { + async list(spaceId: string | null, metadataOnly = false): Promise { this.#invalidateResolutions(spaceId) - return (await this.#resolve(spaceId)).catalog + return (await this.#resolve(spaceId, metadataOnly)).catalog } async loadForSpace(spaceId: string | null): Promise { @@ -119,6 +120,7 @@ export class SkillService { if (!selections.length) return [] const { candidates } = await this.#resolve(spaceId, true) + const state = this.#resolutionKey(spaceId, true) const selected = new Map() for (const selection of selections) { const name = typeof selection === 'string' ? selection : selection.name @@ -152,6 +154,8 @@ export class SkillService { reference: reference(candidate.entry, candidate.referenceRevision, loaded.revision), }) } + if (this.#resolutionKey(spaceId, true) !== state) + throw new SkillError('SKILL_CHANGED') return [...selected.values()] } @@ -358,24 +362,32 @@ export class SkillService { await Promise.all([...this.#previews.keys()].map(id => this.discard(id))) } - #resolve(spaceId: string | null, lightweight = false) { + async #resolve(spaceId: string | null, lightweight = false): Promise { const space = this.#requireSpace(spaceId) const scope = JSON.stringify(space?.primaryDirectory ?? null) const cacheId = this.#resolutionCacheId(spaceId, lightweight) - const key = JSON.stringify([spaceId, scope, this.#options.repository.list(), lightweight]) + const state = this.#resolutionKey(spaceId, lightweight) + const generation = this.#generation + const key = JSON.stringify([state, generation]) const cached = this.#resolved.get(cacheId) - if (cached?.key === key) - return Promise.resolve(cached.result) + if (cached?.key === key && await this.#isCatalogCurrent(cached.result)) { + if (this.#resolutionKey(spaceId, lightweight) !== state) + throw new SkillError('SKILL_CHANGED') + if (this.#generation === generation) + return cached.result + } + if (this.#generation !== generation) + return this.#resolve(spaceId, lightweight) const pending = this.#resolutions.get(key) if (pending) return pending const resolving = this.#resolveCatalog(spaceId, lightweight).then((result) => { if (JSON.stringify(this.#requireSpace(spaceId)?.primaryDirectory ?? null) !== scope) throw new SkillError('SKILL_CHANGED') - const currentKey = this.#resolutionKey(spaceId, lightweight) - this.#resolved.set(cacheId, { key: currentKey, result }) - if (!lightweight) - this.#resolved.set(this.#resolutionCacheId(spaceId, true), { key: this.#resolutionKey(spaceId, true), result }) + if (lightweight && this.#resolutionKey(spaceId, lightweight) !== state) + throw new SkillError('SKILL_CHANGED') + if (lightweight && this.#generation === generation) + this.#resolved.set(cacheId, { key, result }) return result }).finally(() => { if (this.#resolutions.get(key) === resolving) @@ -390,10 +402,27 @@ export class SkillService { } #invalidateResolutions(spaceId: string | null) { + this.#generation++ this.#resolved.delete(this.#resolutionCacheId(spaceId, false)) this.#resolved.delete(this.#resolutionCacheId(spaceId, true)) } + async #isCatalogCurrent(result: ResolvedCatalog): Promise { + for (const candidate of result.candidates) { + if (!candidate.loaded || !candidate.allowedRoot) + continue + try { + if (await realpath(candidate.allowedRoot) !== candidate.allowedRoot) + return false + const document = await this.#packages.loadMetadata(candidate.entry.filePath, candidate.allowedRoot) + if (document.path !== candidate.loaded.path || document.referenceRevision !== candidate.referenceRevision) + return false + } + catch { return false } + } + return true + } + #resolutionKey(spaceId: string | null, lightweight: boolean) { const space = this.#requireSpace(spaceId) return JSON.stringify([spaceId, JSON.stringify(space?.primaryDirectory ?? null), this.#options.repository.list(), lightweight]) @@ -653,7 +682,7 @@ export function formatBuddySkillPrompt(skill: BuddyMaterializedSkill): string { export function registerSkillServiceRpc(rpc: RuntimeRequestRegistrar, service: SkillService): () => void { const stops = [ - registerRuntimeRequest(rpc, skillsRpc.list, input => service.list(input.spaceId)), + registerRuntimeRequest(rpc, skillsRpc.list, input => service.list(input.spaceId, input.metadataOnly)), registerRuntimeRequest(rpc, skillsRpc.get, input => service.get(input.spaceId, input.id)), registerRuntimeRequest(rpc, skillsRpc.listFiles, input => service.listFiles(input)), registerRuntimeRequest(rpc, skillsRpc.readFile, input => service.readFile(input)), diff --git a/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts b/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts index 09011574..5a44abc6 100644 --- a/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts +++ b/apps/buddy/service/src/skills/__tests__/SkillPackageCache.spec.ts @@ -33,6 +33,26 @@ describe('skillPackageCache', () => { await expect(f.cache.load(f.path, f.root)).rejects.toMatchObject({ code: 'SKILL_TOO_LARGE' }) }) + it('reuses unchanged document bytes while detecting same-size edits and path escapes', async () => { + const f = await fixture() + const first = await f.cache.loadMetadata(f.path, f.root) + const read = vi.spyOn(boundedFile, 'readBoundedFile').mockRejectedValue(new Error('Unchanged content must not be read')) + expect(await f.cache.loadMetadata(f.path, f.root)).toEqual(first) + read.mockRestore() + const metadata = await stat(f.path) + await writeFile(f.path, first.content.replace('A local workflow', 'A newer workflow')) + await utimes(f.path, metadata.atime, metadata.mtime) + const changed = await f.cache.loadMetadata(f.path, f.root) + expect(changed.description).toBe('A newer workflow') + expect(changed.referenceRevision).not.toBe(first.referenceRevision) + const outside = await mkdtemp(join(tmpdir(), 'buddy-metadata-outside-')) + directories.push(outside) + await writeFile(join(outside, 'SKILL.md'), first.content) + await rm(f.path) + await symlink(join(outside, 'SKILL.md'), f.path) + await expect(f.cache.loadMetadata(f.path, f.root)).rejects.toMatchObject({ code: 'SKILL_INVALID' }) + }) + it('shares a complete revision across concurrent loads and reuses unchanged packages without reading content', async () => { const f = await fixture() const expected = await readSkill(f.path, f.root) diff --git a/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts b/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts index 1c94fbf2..00a4b3e3 100644 --- a/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts +++ b/apps/buddy/service/src/skills/__tests__/SkillService.spec.ts @@ -2,8 +2,9 @@ import type { DatabaseSync } from 'node:sqlite' import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as boundedFile from '../../../../platform/filesystem/boundedFile' import { BuddyDataPaths } from '../../storage/BuddyDataPaths' import { openBuddyDatabase } from '../../storage/database' import { createSkillRepository } from '../../storage/skillRepository' @@ -15,6 +16,7 @@ const directories: string[] = [] const now = '2026-08-14T00:00:00.000Z' afterEach(async () => { + vi.restoreAllMocks() for (const database of databases.splice(0)) database.close() await Promise.all(directories.splice(0).map(path => rm(path, { force: true, recursive: true }))) @@ -48,7 +50,7 @@ describe('skillService', () => { expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'SKILL_NAME_COLLISION' })) }) - it('reuses lightweight metadata and invalidates it when a selected skill document changes', async () => { + it('refreshes changed skill documents without accepting an older selection', async () => { const fixture = await createFixture() await writeSkill(fixture.global, 'mutable', 'first revision') @@ -57,7 +59,7 @@ describe('skillService', () => { const second = await fixture.service.loadForSpace(null) expect(first.paths).toEqual(second.paths) - expect(second.revision).toBe(first.revision) + expect(second.revision).not.toBe(first.revision) await expect(fixture.service.materializeForSpace(null, first.references)).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) const third = await fixture.service.loadForSpace(null) @@ -65,6 +67,73 @@ describe('skillService', () => { expect((await fixture.service.materializeForSpace(null, third.references))[0]?.body).toBe('# mutable') }) + it.each(['document', 'source'] as const)('rejects a cached %s replaced by a symlink outside its source', async (target) => { + const fixture = await createFixture() + const root = join(fixture.trustedSpace, '.agents', 'skills') + await writeSkill(root, 'trusted', 'trusted metadata') + fixture.spaces.create(spaceInput('space-trusted', fixture.trustedSpace)) + expect((await fixture.service.loadForSpace('space-trusted')).skills).toHaveLength(1) + const outside = join(fixture.root, 'outside') + await writeSkill(outside, 'trusted', 'outside metadata') + const replaced = target === 'document' ? join(root, 'trusted', 'SKILL.md') : root + const replacement = target === 'document' ? join(outside, 'trusted', 'SKILL.md') : outside + await rm(replaced, { recursive: true }) + await symlink(replacement, replaced, target === 'source' ? 'junction' : 'file') + + const current = await fixture.service.loadForSpace('space-trusted') + expect(current.skills).toEqual([]) + expect(current.readRoots).toEqual([]) + expect(current.diagnostics.length).toBeGreaterThan(0) + }) + + it('keeps discovery and the picker lightweight after a full management inspection', async () => { + const fixture = await createFixture() + await writeSkill(fixture.global, 'large', 'large skill') + let nested = join(fixture.global, 'large') + for (let depth = 0; depth < 18; depth++) { + nested = join(nested, 'nested') + await mkdir(nested) + } + await writeFile(join(nested, 'reference.md'), 'resource') + const initial = await fixture.service.loadForSpace(null) + expect(initial.skills.map(skill => skill.name)).toEqual(['large']) + await fixture.service.list(null) + const picker = await fixture.service.list(null, true) + expect(picker.skills.map(skill => skill.name)).toEqual(['large']) + expect((await fixture.service.loadForSpace(null)).revision).toBe(initial.revision) + await writeSkill(fixture.global, 'new-skill', 'new skill') + expect((await fixture.service.list(null, true)).skills.map(skill => skill.name)).toEqual(['large', 'new-skill']) + }) + + it('rejects revocation while a selected package is being materialized', async () => { + const fixture = await createFixture() + const root = join(fixture.trustedSpace, '.agents', 'skills') + await writeSkill(root, 'trusted', 'trusted skill') + const resource = join(root, 'trusted', 'reference.md') + await writeFile(resource, 'reference') + fixture.spaces.create(spaceInput('space-trusted', fixture.trustedSpace)) + const initial = await fixture.service.loadForSpace('space-trusted') + const read = boundedFile.readBoundedFile + vi.spyOn(boundedFile, 'readBoundedFile').mockImplementation(async (...args) => { + const content = await read(...args) + if (args[1] === resource) { + fixture.spaces.update({ + id: 'space-trusted', + name: 'Trusted', + memoryScope: 'space_only', + primaryDirectory: null, + additionalDirectories: [], + updatedAt: now, + event: { id: 'revoke-during-load', eventType: 'space.config.updated', spaceId: 'space-trusted', createdAt: now, payload: {} }, + }) + } + return content + }) + + await expect(fixture.service.materializeForSpace('space-trusted', initial.references)).rejects.toMatchObject({ code: 'SKILL_CHANGED' }) + expect((await fixture.service.loadForSpace('space-trusted')).skills).toEqual([]) + }) + it('unloads revoked Space skills and rejects symlink escapes', async () => { const fixture = await createFixture() const outside = join(fixture.root, 'outside') diff --git a/apps/buddy/shared/skills/skillApi.ts b/apps/buddy/shared/skills/skillApi.ts index eca571bc..56c96643 100644 --- a/apps/buddy/shared/skills/skillApi.ts +++ b/apps/buddy/shared/skills/skillApi.ts @@ -83,7 +83,7 @@ const previewSchema = z.object({ export type SkillInstallPreview = DeepReadonly> export const skillsRequestSchemas = { - skillScope: scopeSchema, + skillScope: scopeSchema.extend({ metadataOnly: z.boolean().optional() }), target: targetSchema, file: fileTargetSchema, directory: directoryRequestSchema, diff --git a/apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts b/apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts index ef20ec78..c6b9b4e3 100644 --- a/apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts +++ b/apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts @@ -32,7 +32,7 @@ export function useComposerContextOptions(options: ComposerContextOptions) { deepSearch, spaceId, } - const catalog = fileQuery === null ? await options.listSkills(spaceId) : null + const catalog = fileQuery === null ? await options.listSkills(spaceId, true) : null const sources = fileQuery === null ? null : await options.listSources(request) if (current !== scopeVersion) return { files: [], skills: [] } @@ -69,7 +69,6 @@ export function useComposerContextOptions(options: ComposerContextOptions) { id: skill.id, name: skill.name, revision: skill.referenceRevision ?? skill.revision, - packageRevision: skill.revision, }, })), } diff --git a/docs/specs/feature-004-skill-lazy-loading.md b/docs/specs/feature-004-skill-lazy-loading.md deleted file mode 100644 index 91ac2039..00000000 --- a/docs/specs/feature-004-skill-lazy-loading.md +++ /dev/null @@ -1,121 +0,0 @@ -# 规格 004:移除消息发送前的 Skills 全库扫描 - -**日期:** 2026-09-27 -**状态:** 已完成最小实现,验收清单待复核 - -**目标:** 普通消息发送和会话准备阶段只发现技能并读取各自 `SKILL.md` 的必要信息,不递归检查技能附属文件。完整技能包检查仅在安装、更新、明确刷新技能列表或读取用户选中的技能时执行。保持现有 AI 使用技能的方式不变。 - -## 1. 背景与问题定位 - -新会话准备会调用 `resolveBuddySessionResources()`,并等待 `loadForSpace()` 完成。随后 `SkillService` 遍历已发现的技能,为每个技能调用 `SkillPackageCache.load()`。缓存检查函数 `inspectPackage()` 会递归遍历技能目录中的文件;缓存未命中时,`readSkill()` 还会读取整套技能文件。 - -因此,即使用户发送普通消息且没有选择技能,发送流程也可能先等待整个技能库检查完成。用户在输入框中手动选择技能时,`materializeForSpace()` 也会重新解析候选目录,可能再次处理未选中的技能。 - -涉及的现有调用点: - -- `apps/buddy/service/src/agent/sessions/BuddySessionBlueprintService.ts:128`:创建会话资源。 -- `apps/buddy/service/src/agent/resources/BuddySessionResources.ts:32-38`:并行等待技能目录与项目上下文。 -- `apps/buddy/service/src/skills/SkillService.ts:90-114`:准备技能列表并组装已选择技能的内容。 -- `apps/buddy/service/src/skills/SkillService.ts:347-367`:发现技能并逐个加载技能包。 -- `apps/buddy/service/src/skills/SkillPackageCache.ts:38-99`:缓存命中前递归检查包内文件;未命中时解析完整技能包。 -- `apps/buddy/service/src/skills/skillFiles.ts:78-100,151-163`:读取技能包文件并计算版本信息。 -- `apps/buddy/service/src/chat/ChatTurnService.ts:558-565,667-690,773-789`:发送前展开输入框中的技能选择或技能上下文。 - -## 2. 本次范围 - -本次只处理导致发送卡顿的直接原因:**会话准备阶段只扫描技能目录并读取各个 `SKILL.md` 的必要信息,不遍历附属文件。**用户没有选择技能时,不应因技能库中存在大量附属文件而等待;用户明确选择技能时,只处理所选技能,不为此重新检查其他技能包。 - -本次保留现有技能目录、手动选择和模型使用技能的方式。实际读取技能时仍执行必要的授权和路径安全检查;完整技能包检查放在安装、更新或用户明确要求检查时执行。 - -## 3. 不在本次范围 - -- 不新增 `load_skill`、`read_skill_resource` 等模型工具。 -- 不改变 AI 自动发现、选择或读取技能的方式。 -- 不将技能说明和附属文件改造成分阶段读取,也不改变手动选择技能后注入对话的现有语义。 -- 不改动聊天界面、模型选择、消息展示或发送状态交互。 - -这些属于后续 AI 技能使用体验的改造,可在确有需求时另行设计;它们不是消除本次全库扫描卡顿的前提。 - -## 4. 目标流程 - -```mermaid -sequenceDiagram - participant UI as 聊天界面 - participant Session as 会话准备 - participant Skills as 技能服务 - participant Model as 模型 - - UI->>Session: 创建会话并发送消息 - Session->>Skills: 获取技能目录 - Note over Skills: 不递归检查每个技能包的所有文件 - Skills-->>Session: 返回当前会话所需的技能信息 - Session-->>Model: 提供用户消息和现有技能上下文 - opt 用户明确选择技能 - Session->>Skills: 处理所选技能 - Note over Skills: 只校验和读取所选技能,不重新扫描其他技能包 - end -``` - -普通消息不再等待所有技能包的文件检查。用户明确选择技能时仍沿用当前产品行为,只避免为了取出所选技能而重新扫描全库。 - -## 5. 方案 - -### 5.1 会话准备阶段不递归检查技能包 - -- 首次准备会话时发现技能目录,并读取各个 `SKILL.md` 的元数据;不计算包内完整文件签名,也不读取附属文件。结果按空间缓存,后续发送复用。 -- 保留必要的基本格式检查、授权来源检查和路径规范化;记录诊断并跳过不合法的目录条目。 -- 技能包缓存不能在每次发送前通过遍历包内所有文件来确认命中。安装、更新、删除和启用状态变化会触发缓存更新;打开技能目录列表时也执行完整刷新。 - -### 5.2 用户选中技能时只处理该技能 - -- `materializeForSpace()` 使用会话准备阶段已有的技能索引或等效引用定位所选技能,不重新枚举并加载全部技能包。 -- 保留当前显式选择的交互语义。所选技能仍按现有方式提供给对话;本次不要求只读取 `SKILL.md` 或新增模型工具。 -- 读取所选技能时,仍验证其授权范围、规范化路径和必要的文件边界;读取一个技能时不递归检查其他技能。 -- 技能引用使用 `SKILL.md` 内容版本;用户明确选择技能时还携带完整包版本,以便发现选择后技能包被替换或修改。 - -### 5.3 技能变化与完整校验 - -- 完整技能包校验保留在技能导入、安装、更新或用户明确刷新技能目录时执行,不放在每轮发送的同步路径上。 -- 普通发送复用进程内缓存的技能目录。外部目录中的技能新增或删除,会在用户刷新技能目录或重启应用后被发现;本次不增加文件系统监听器。 -- 用户选中技能时仍重新校验该技能包。如果 `SKILL.md` 或授权路径自目录缓存后发生变化,则返回明确的 `SKILL_CHANGED` 错误并使目录缓存失效;不会静默切换到其他同名技能。 - -## 6. 预期改动范围 - -按现有实现,改动集中在技能服务和缓存逻辑: - -| 文件或模块 | 计划调整 | -|---|---| -| `apps/buddy/service/src/skills/SkillService.ts` | 让会话目录准备走轻量路径;用户选择技能时通过已有目录索引直接处理所选项 | -| `apps/buddy/service/src/skills/SkillPackageCache.ts` | 增加只读取 `SKILL.md` 元数据的路径;完整包检查用于明确刷新或读取所选技能 | -| `apps/buddy/service/src/skills/skillFiles.ts` | 为 `SKILL.md` 计算轻量引用版本;保留所选技能的路径和文件安全检查 | -| `apps/buddy/service/src/agent/resources/BuddySessionResources.ts` | 如有必要,调整为传递轻量技能目录或会话内已有引用 | -| `apps/buddy/shared/skills/skillApi.ts` 和 `apps/buddy/src/modules/tasks/state/composer/useComposerContextOptions.ts` | 让手动选择同时携带轻量说明版本和完整包版本 | - -不新增 Agent 技能工具或新的技能调用协议。 - -## 7. 验收标准 - -- [ ] 不选择技能发送普通消息时,会话准备只读取技能元数据,不递归检查任何技能包的附属文件。 -- [ ] 会话准备耗时不随技能包附属文件总数成比例增加。 -- [ ] 手动选择一个技能时,只处理所选技能,不重新加载或校验其他技能包。 -- [ ] 所选技能仍受现有授权、路径穿越、软链接边界及文件类型限制保护。 -- [ ] 所选技能的说明或授权路径在会话期间发生变化或失效时,返回明确错误,不静默切换到其他同名技能。 -- [ ] 安装、更新或用户明确检查时,仍可执行完整技能包校验。 - -## 8. 风险和取舍 - -- 外部技能目录中的新增和删除不会在每轮普通发送时自动发现,需要刷新技能目录或重启应用。这是为避免每轮重新扫描全库而接受的取舍。 -- 本次不解决“AI 是否能自动挑选技能”或“附属资料是否按需进入上下文”。若后续需要这些能力,应单独评估模型工具、上下文和交互变化。 -- 为避免发送变慢而移除同步全库检查,不代表放弃安全校验:实际读取技能时仍检查授权和路径边界,安装或明确检查时仍可执行完整包检查。 - -## 9. 后续可选方向 - -若未来希望 AI 更灵活地使用 Skills,可另行评估分层读取方案:先提供技能名称和简介,由 AI 判断是否需要读取技能说明,并在需要时读取单个附属文件。Pi、Agent Skills 规范和 Codex 均介绍了类似方式,但这不是本次性能修复的验收条件。 - -- [Pi Skills 文档](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/skills.md) -- [Agent Skills 规范](https://agentskills.io/specification) -- [Codex Skills 文档](https://developers.openai.com/plugins/concepts/skills) - ---- - -**状态说明:** 普通发送的轻量目录缓存、用户选择技能时的单技能校验,以及技能列表刷新路径已按本方案实现。当前 Windows 环境无法创建符号链接,因此依赖符号链接的安全测试尚未通过;验收清单待在具备相应权限的环境中复核。