feat(agent-presets): give a preset a name and a description

A picker showed directory names, so the settings page could only ever list
`standard` / `core-web` / `cordis` and hope the reader knew what they meant.
A preset may now publish display text in an optional `preset.yml` beside
its composition, and the section renders cards — name, description, and the
one in use — instead of rows.

The file carries display text ONLY. `id` is the directory name and `trust`
comes from the root a preset was discovered under, so neither is writable
there: otherwise a locally authored preset could name itself into the
shipped set. It is a separate file because a composition is a top-level list
of plugin rows — YAML cannot carry sibling keys beside it, and a fake
metadata row would hand the Loader something to load.

Every read failure degrades to no metadata; absent, malformed, wrongly
typed, and blank all mean the same thing and the picker falls back to the
id. Presentation is not capability: a preset whose name is broken still
mounts.

The editor gained name and description fields above the YAML, and clearing
both removes the file rather than storing a blank name.
This commit is contained in:
Yichen Jiang
2026-08-05 16:30:14 +08:00
parent 5b93f48f78
commit 7281615d44
27 changed files with 593 additions and 68 deletions

View File

@@ -54,6 +54,19 @@ A row's **package name** resolves from the host composition, not from the preset
A **relative** path still resolves from the preset's own directory, so a preset's own plugin files and skill directories travel with it.
### Display metadata
A preset may publish display text in an optional `preset.yml` beside its composition:
```yaml
name: 极简模式
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
```
It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load.
Every read failure degrades to no metadata — absent, malformed, wrongly typed, or blank all mean the same thing, and a picker falls back to the id. Presentation is not capability: a preset with a broken name still mounts.
## Config
| Field | Default | Meaning |

View File

@@ -54,6 +54,19 @@ agent 工厂的 `setup(agentCtx)` 钩子是唯一受支持的调用点。只有
**相对**路径仍从 preset 自身的目录解析,因此 preset 自带的插件文件与 skill 目录会随它一同迁移。
### 展示用元信息
preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本:
```yaml
name: 极简模式
description: 只向模型呈现 bash 与 str_replace_editor,适合 benchmark 与最小复现。
```
它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。
任何读取失败都退化为「没有元信息」——缺失、格式错误、类型不对、内容为空,含义相同,选择器回退到 id。展示不是能力:名字坏掉的 preset 依然能挂载。
## 配置
| 字段 | 默认值 | 含义 |

View File

@@ -14,6 +14,7 @@ import { entryListSchema } from '@cordisjs/plugin-include'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { expandHomePath } from '@deepseek-ai/dsh-paths'
import { COMPOSITION_FILE } from './discovery.ts'
import { METADATA_FILE, renderPresetMetadata, type PresetMetadata } from './metadata.ts'
import type { AgentPreset, PresetRoot } from './types.ts'
/**
@@ -111,6 +112,7 @@ export async function readComposition(preset: AgentPreset): Promise<string> {
* @param roots - the configured roots; the first `user` one receives the write.
* @param id - the preset id, which becomes its directory name.
* @param content - the composition text.
* @param metadata - display name and description; clearing both removes the file.
* @returns the absolute path written.
* @throws when the id is unusable, the content is not an entry list, or the
* deployment has no writable root.
@@ -119,6 +121,7 @@ export async function writeComposition(
roots: readonly PresetRoot[],
id: string,
content: string,
metadata: PresetMetadata = {},
): Promise<string> {
if (!PRESET_ID.test(id)) throw new InvalidPresetIdError(id)
assertComposition(content)
@@ -127,6 +130,16 @@ export async function writeComposition(
// Owner-only: a composition names the plugins a session runs, so it carries
// the same weight as the settings document beside it.
await writeFileAtomic(path, content, { mode: 0o600, dirMode: 0o700 })
// Display text lands after the composition, and only when there is any: a
// preset with no name should carry no metadata file rather than an empty
// one. Clearing both fields therefore removes the file.
const rendered = renderPresetMetadata(metadata)
const metadataPath = join(dir, METADATA_FILE)
if (rendered === undefined) {
await rm(metadataPath, { force: true })
} else {
await writeFileAtomic(metadataPath, rendered, { mode: 0o600, dirMode: 0o700 })
}
return path
}

View File

@@ -1,6 +1,7 @@
/**
* Filesystem discovery of agent presets. A preset is a directory holding
* {@link COMPOSITION_FILE}; the directory name is the preset id. Discovery
* {@link COMPOSITION_FILE}, optionally beside a {@link METADATA_FILE} carrying
* its display text; the directory name is the preset id. Discovery
* re-reads the roots on every call so a preset authored while the process is
* running is visible without a restart.
* @module @deepseek-ai/dsh-agent-presets/discovery
@@ -9,6 +10,7 @@
import { readdir, stat } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { expandHomePath } from '@deepseek-ai/dsh-paths'
import { readPresetMetadata } from './metadata.ts'
import type { AgentPreset, PresetRoot } from './types.ts'
/** The composition file that makes a directory a preset. */
@@ -51,9 +53,13 @@ export async function scanRoot(root: PresetRoot): Promise<AgentPreset[]> {
const found: AgentPreset[] = []
for (const child of children) {
if (!child.isDirectory()) continue
const path = join(dir, child.name, COMPOSITION_FILE)
const directory = join(dir, child.name)
const path = join(directory, COMPOSITION_FILE)
if (!await isFile(path)) continue
found.push({ id: child.name, trust: root.trust, path })
// Display text only, and never fatal: a preset with unreadable metadata
// still mounts, it just shows its id.
const metadata = await readPresetMetadata(directory)
found.push({ id: child.name, trust: root.trust, path, ...metadata })
}
return found.sort((left, right) => left.id.localeCompare(right.id))
}

View File

@@ -16,6 +16,7 @@ import z from 'schemastery'
import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
import { discoverPresets } from './discovery.ts'
import { deleteComposition, readComposition, writeComposition } from './authoring.ts'
import type { PresetMetadata } from './metadata.ts'
import { mountPreset, serviceForAgent, unmountPresetFor } from './mount.ts'
import { PresetNotWritableError } from './authoring.ts'
import { UnknownPresetError, type AgentPreset, type Config } from './types.ts'
@@ -35,6 +36,9 @@ export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
})
export { COMPOSITION_FILE, discoverPresets, scanRoot } from './discovery.ts'
export {
METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata,
} from './metadata.ts'
export {
inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
unmountPresetFor, type PresetMount,
@@ -171,17 +175,18 @@ export class AgentPresets extends Service {
* names a missing plugin still fails at the next session that selects it.
* @param id - the preset id, which becomes its directory name.
* @param content - the composition text.
* @param metadata - display name and description; clearing both removes the file.
* @throws when the id is unusable, the text is not an entry list, or the
* deployment configures no writable root.
*/
async write(id: string, content: string): Promise<void> {
async write(id: string, content: string, metadata: PresetMetadata = {}): Promise<void> {
// A shipped preset belongs to the deployment: overwriting it would remove
// the known-good composition a broken local one is compared against.
const existing = (await this.list()).find(preset => preset.id === id)
if (existing !== undefined && existing.trust !== 'user') {
throw new PresetNotWritableError(id, 'it ships with the deployment')
}
await writeComposition(this.config.roots, id, content)
await writeComposition(this.config.roots, id, content, metadata)
}
/**

View File

@@ -0,0 +1,93 @@
/**
* A preset's display metadata: the name and description a picker shows.
*
* It lives in its own file because the composition is a top-level list of
* plugin rows — YAML cannot carry sibling keys beside it, and faking a
* metadata row would hand the Loader something to load. Keeping it separate
* also keeps the composition exactly what its name says: a Cordis file the
* loader owns and the cordis preset can author.
*
* The file carries display text ONLY. `id` is the directory name and `trust`
* comes from the root a preset was discovered under, so neither is writable
* here — otherwise a locally authored preset could claim to be a shipped one.
*
* Every read failure degrades to no metadata. A preset whose display text is
* missing, malformed, or unreadable still mounts: presentation is not a
* capability, and a broken name must never become an agent that cannot start.
* @module @deepseek-ai/dsh-agent-presets/metadata
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import yaml from 'js-yaml'
/** The optional display-metadata file beside a preset's composition. */
export const METADATA_FILE = 'preset.yml'
/** Display text a preset may publish about itself. */
export interface PresetMetadata {
/** Human-facing name; falls back to the preset id when absent. */
readonly name?: string
/** One sentence on what this preset is for. */
readonly description?: string
}
/** A non-empty trimmed string, or undefined for anything else. */
function text(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed === '' ? undefined : trimmed
}
/**
* Read one preset directory's display metadata.
*
* Absent, unparsable, and wrongly-shaped files are all the same answer —
* empty metadata — because the caller renders a picker, not a diagnostic.
* @param directory - the preset directory.
* @returns the display text the preset published, possibly empty.
*/
export async function readPresetMetadata(directory: string): Promise<PresetMetadata> {
let raw: string
try {
raw = await readFile(join(directory, METADATA_FILE), 'utf8')
} catch {
// Absent is the common case: metadata is optional and most presets,
// including every one authored by duplicating another, carry none.
return {}
}
let parsed: unknown
try {
parsed = yaml.load(raw)
} catch {
// Malformed display text is not worth failing discovery over; the picker
// falls back to the id, and the composition still mounts.
return {}
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
const record = parsed as Record<string, unknown>
const name = text(record.name)
const description = text(record.description)
return {
...name === undefined ? {} : { name },
...description === undefined ? {} : { description },
}
}
/**
* Render display metadata as the file's contents.
*
* Absent fields are omitted rather than written empty, so a preset with no
* description does not ship a key that reads as an intentional blank.
* @param metadata - the display text to store.
* @returns the YAML document, or undefined when there is nothing to store.
*/
export function renderPresetMetadata(metadata: PresetMetadata): string | undefined {
const name = text(metadata.name)
const description = text(metadata.description)
if (name === undefined && description === undefined) return undefined
return yaml.dump({
...name === undefined ? {} : { name },
...description === undefined ? {} : { description },
}, { lineWidth: -1 })
}

View File

@@ -15,6 +15,10 @@ export interface AgentPreset {
readonly trust: PresetTrust
/** Absolute path of the preset's agent composition file. */
readonly path: string
/** Display name from the preset's own metadata; absent falls back to {@link id}. */
readonly name?: string
/** One sentence on what this preset is for, when it published one. */
readonly description?: string
}
/** One directory scanned for preset subdirectories. */

View File

@@ -13,7 +13,9 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { beforeEach, describe, expect, it } from 'vitest'
import AgentPresets, { COMPOSITION_FILE, assertComposition } from '@deepseek-ai/dsh-agent-presets'
import AgentPresets, {
COMPOSITION_FILE, METADATA_FILE, assertComposition,
} from '@deepseek-ai/dsh-agent-presets'
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n'
@@ -92,6 +94,38 @@ describe('authoring a preset', () => {
})
})
describe('display metadata beside a composition', () => {
it('stores the name and description the author supplied', async () => {
await ctx.agentPresets.write('mine', VALID, { name: '我的模式', description: '只做检索。' })
expect(await readFile(join(userRoot, 'mine', METADATA_FILE), 'utf8'))
.toContain('name: 我的模式')
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
expect(listed).toMatchObject({ name: '我的模式', description: '只做检索。' })
})
it('removes the file when both fields are cleared', async () => {
await ctx.agentPresets.write('mine', VALID, { name: '我的模式' })
await ctx.agentPresets.write('mine', VALID, {})
// An empty metadata document would read as an intentional blank name;
// absence is what "this preset publishes no display text" looks like.
expect(existsSync(join(userRoot, 'mine', METADATA_FILE))).toBe(false)
expect((await ctx.agentPresets.list()).find(preset => preset.id === 'mine')?.name).toBeUndefined()
})
it('keeps a composition mountable when its metadata is unreadable', async () => {
await ctx.agentPresets.write('mine', VALID)
await writeFile(join(userRoot, 'mine', METADATA_FILE), 'name: [unclosed\n')
// Presentation is not capability: discovery still yields the preset.
const listed = (await ctx.agentPresets.list()).find(preset => preset.id === 'mine')
expect(listed?.name).toBeUndefined()
expect(await ctx.agentPresets.resolve('mine')).toMatchObject({ id: 'mine' })
})
})
describe('deleting a preset', () => {
it('removes a locally authored one', async () => {
await ctx.agentPresets.write('mine', VALID)

View File

@@ -0,0 +1,99 @@
/**
* Display metadata is presentation, never capability: every way of getting it
* wrong degrades to "this preset has no display text" rather than to a
* preset that cannot be discovered or mounted. It also cannot carry identity
* — `id` is the directory and `trust` is the root, so neither is readable
* from the file a user can write.
*/
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts'
/** A preset directory holding exactly the given metadata text. */
async function presetDir(content?: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-'))
await mkdir(dir, { recursive: true })
if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content)
return dir
}
describe('reading display metadata', () => {
it('reads a name and a description', async () => {
const dir = await presetDir('name: 标准模式\ndescription: 完整的编码 agent。\n')
expect(await readPresetMetadata(dir)).toEqual({ name: '标准模式', description: '完整的编码 agent。' })
})
it('treats an absent file as no metadata', async () => {
// The common case: every preset authored by duplicating another starts
// without one, and a picker simply falls back to the id.
expect(await readPresetMetadata(await presetDir())).toEqual({})
})
it('treats malformed YAML as no metadata', async () => {
const dir = await presetDir('name: [unclosed\n')
// Display text is not worth failing discovery over — the composition
// beside it still mounts.
expect(await readPresetMetadata(dir)).toEqual({})
})
it.each([
['a list', '- name: x\n'],
['a scalar', 'just a string\n'],
['an empty document', ''],
])('treats %s as no metadata', async (_label, content) => {
expect(await readPresetMetadata(await presetDir(content))).toEqual({})
})
it('ignores fields that are not text', async () => {
const dir = await presetDir('name: 42\ndescription:\n nested: true\n')
expect(await readPresetMetadata(dir)).toEqual({})
})
it('ignores blank text rather than showing an empty name', async () => {
const dir = await presetDir('name: " "\ndescription: ""\n')
expect(await readPresetMetadata(dir)).toEqual({})
})
it('trims surrounding whitespace', async () => {
const dir = await presetDir('name: " 极简模式 "\n')
expect(await readPresetMetadata(dir)).toEqual({ name: '极简模式' })
})
it('cannot carry identity or trust', async () => {
const dir = await presetDir('name: mine\nid: standard\ntrust: system\n')
// A locally authored preset writing `trust: system` must not become a
// shipped one; identity comes from the directory and the root it sits in.
expect(await readPresetMetadata(dir)).toEqual({ name: 'mine' })
})
})
describe('rendering display metadata', () => {
it('round-trips through a read', async () => {
const rendered = renderPresetMetadata({ name: '创造模式', description: '可以改自己的组装。' })
const dir = await presetDir(rendered)
expect(await readPresetMetadata(dir)).toEqual({ name: '创造模式', description: '可以改自己的组装。' })
})
it('omits an absent field rather than writing it blank', () => {
expect(renderPresetMetadata({ name: '极简模式' })).toBe('name: 极简模式\n')
// Description without a name is legal too: the picker falls back to the id.
expect(renderPresetMetadata({ description: '只做检索。' })).toBe('description: 只做检索。\n')
})
it('renders nothing when there is nothing to store', () => {
// Clearing both fields removes the file; an empty document would read as
// an intentional blank name.
expect(renderPresetMetadata({})).toBeUndefined()
expect(renderPresetMetadata({ name: ' ', description: '' })).toBeUndefined()
})
})