168 lines
6.0 KiB
TypeScript
168 lines
6.0 KiB
TypeScript
import { readFile, readdir } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
|
import {
|
|
BUNDLED_CODING_SKILL_IDS,
|
|
type BundledCodingSkillId,
|
|
} from '../../shared/coding-skills';
|
|
import type {
|
|
ProductCodingCommand,
|
|
ProductCodingSkill,
|
|
ProductPiCommandInput,
|
|
} from '../../shared/coding-product-tools';
|
|
|
|
export type {
|
|
ProductCodingCommand,
|
|
ProductCodingSkill,
|
|
ProductPiCommandInput,
|
|
} from '../../shared/coding-product-tools';
|
|
|
|
const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [
|
|
{ name: 'models', title: '切换模型', description: '切换当前会话后续轮次使用的模型', source: 'makelore' },
|
|
{ name: 'thinking', title: '思考强度', description: '设置当前会话的思考强度', source: 'makelore' },
|
|
{ name: 'compact', title: '压缩会话', description: '使用当前模型总结会话上下文', source: 'makelore' },
|
|
{ name: 'fork', title: '分叉会话', description: '从当前用户消息创建新的产品会话', source: 'makelore' },
|
|
{ name: 'recover', title: '恢复会话', description: '重新创建当前会话 worker 并恢复状态', source: 'makelore' },
|
|
] as const;
|
|
|
|
const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
|
|
export interface ProductCodingPluginSkillSource {
|
|
id: string;
|
|
directory: string;
|
|
/** Package-relative Skill entry; defaults to `SKILL.md`. */
|
|
entryPath?: string;
|
|
}
|
|
|
|
function frontmatterScalar(content: string, key: string): string | undefined {
|
|
const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
|
if (!block) return undefined;
|
|
const line = block.split(/\r?\n/).find((candidate) => candidate.startsWith(`${key}:`));
|
|
if (!line) return undefined;
|
|
const value = line.slice(key.length + 1).trim();
|
|
if ((value.startsWith('"') && value.endsWith('"'))
|
|
|| (value.startsWith("'") && value.endsWith("'"))) {
|
|
return value.slice(1, -1).trim() || undefined;
|
|
}
|
|
return value || undefined;
|
|
}
|
|
|
|
function defaultPluginSkillSources(bundledSkillsDir: string): ProductCodingPluginSkillSource[] {
|
|
const skillId = DATA_SERVICE_PLUGIN_DEFINITION.skills[0]?.id ?? 'data-service';
|
|
return [{
|
|
id: skillId,
|
|
directory: path.join(
|
|
path.dirname(path.resolve(bundledSkillsDir)),
|
|
'coding-plugins',
|
|
'data-service',
|
|
'skills',
|
|
'data-service',
|
|
),
|
|
}];
|
|
}
|
|
|
|
function selectedSkillIds(
|
|
value: readonly string[],
|
|
allIds: readonly string[],
|
|
): Set<string> {
|
|
const allowed = new Set<string>(allIds);
|
|
const selected = new Set<string>();
|
|
for (const raw of value) {
|
|
const id = raw.trim();
|
|
if (!allowed.has(id)) throw new Error(`Unknown bundled coding skill: ${id}`);
|
|
selected.add(id);
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function productSkillId(id: string): BundledCodingSkillId {
|
|
// ProductCodingSkill predates package-owned Skill ids. The registry is
|
|
// the trusted projection boundary, so the runtime value may contain a
|
|
// package Skill while the shared contract is migrated by the caller.
|
|
return id as BundledCodingSkillId;
|
|
}
|
|
|
|
async function listSkillEntries(
|
|
directory: string,
|
|
relative = '',
|
|
): Promise<Array<{ path: string; type: 'file' | 'directory' }>> {
|
|
const entries = await readdir(path.join(directory, relative), { withFileTypes: true });
|
|
const result: Array<{ path: string; type: 'file' | 'directory' }> = [];
|
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
const entryPath = relative ? path.posix.join(relative, entry.name) : entry.name;
|
|
if (entry.isDirectory()) {
|
|
result.push({ path: entryPath, type: 'directory' });
|
|
result.push(...await listSkillEntries(directory, entryPath));
|
|
} else if (entry.isFile()) {
|
|
result.push({ path: entryPath, type: 'file' });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export async function listProductCodingSkills(
|
|
bundledSkillsDir: string,
|
|
selectedIds: readonly string[] = [],
|
|
pluginSkillSources: readonly ProductCodingPluginSkillSource[] = defaultPluginSkillSources(bundledSkillsDir),
|
|
): Promise<ProductCodingSkill[]> {
|
|
const pluginIds = pluginSkillSources.map(({ id }) => id);
|
|
const allIds = [...BUNDLED_CODING_SKILL_IDS, ...pluginIds];
|
|
if (new Set(allIds).size !== allIds.length) {
|
|
throw new Error('Duplicate coding skill identifier');
|
|
}
|
|
const selected = selectedSkillIds(selectedIds, allIds);
|
|
const sources: ProductCodingPluginSkillSource[] = [
|
|
...BUNDLED_CODING_SKILL_IDS.map((id) => ({
|
|
id,
|
|
directory: path.join(bundledSkillsDir, id),
|
|
})),
|
|
...pluginSkillSources,
|
|
];
|
|
return await Promise.all(sources.map(async ({ id, directory, entryPath }) => {
|
|
const location = path.resolve(directory);
|
|
const content = await readFile(path.join(location, entryPath ?? 'SKILL.md'), 'utf8');
|
|
return {
|
|
id: productSkillId(id),
|
|
name: frontmatterScalar(content, 'name') ?? id,
|
|
description: frontmatterScalar(content, 'description') ?? '',
|
|
selected: selected.has(id),
|
|
location,
|
|
content,
|
|
entries: await listSkillEntries(location),
|
|
};
|
|
}));
|
|
}
|
|
|
|
export function buildProductCodingCommandCatalog(
|
|
skills: readonly ProductCodingSkill[],
|
|
piCommands: readonly ProductPiCommandInput[] = [],
|
|
): ProductCodingCommand[] {
|
|
const commands = [...MAKELORE_COMMANDS];
|
|
const used = new Set(commands.map((command) => command.name.toLocaleLowerCase()));
|
|
for (const command of piCommands) {
|
|
const name = command.name.trim();
|
|
const key = name.toLocaleLowerCase();
|
|
if (!COMMAND_NAME_PATTERN.test(name) || used.has(key)) continue;
|
|
used.add(key);
|
|
commands.push({
|
|
name,
|
|
title: name,
|
|
description: command.description?.trim() || 'Pi 命令',
|
|
source: 'pi',
|
|
});
|
|
}
|
|
for (const skill of skills) {
|
|
const key = skill.id.toLocaleLowerCase();
|
|
if (!skill.selected || used.has(key)) continue;
|
|
used.add(key);
|
|
commands.push({
|
|
name: skill.id,
|
|
title: skill.name,
|
|
description: skill.description || '项目技能',
|
|
source: 'skill',
|
|
skillId: skill.id,
|
|
});
|
|
}
|
|
return commands;
|
|
}
|