Files
jsc-anima/hooks/skillgen.ts
T

141 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const PLUGIN = 'jsc-anima'
export const SKILL_PREFIX = 'anima-'
export const SKILL_DIR = 'skills'
export const GUIDE_SKILL = 'jsc-anima'
export const LIST_START = '<!-- skills:start -->'
export const LIST_END = '<!-- skills:end -->'
export type Parsed = { name: string; description: string; body: string }
export const slugify = (s: string): string =>
s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
export const commandOf = (skill: string): string => `/${PLUGIN}:${SKILL_PREFIX}${skill}`
// 指令第一個字是技能名稱,其餘是描述。
export const parseArgs = (args: string): { skill: string; description: string } | undefined => {
const m = args.trim().match(/^(\S+)\s+([\s\S]+)$/)
if (!m) return undefined
const skill = slugify(m[1]!)
return skill ? { skill, description: m[2]!.trim() } : undefined
}
export const parseSkillMd = (md: string): Parsed | undefined => {
const m = md.trim().match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
if (!m) return undefined
const field = (k: string) => m[1]!.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'))?.[1]?.trim().replace(/^["']|["']$/g, '')
return { name: field('name') ?? '', description: field('description') ?? '', body: m[2]!.trim() }
}
// 回傳錯誤訊息清單,空陣列代表通過。
export const validateSkill = (md: string, skill: string): string[] => {
const p = parseSkillMd(md)
if (!p) return ['缺少 frontmatter(--- 包住的 name 與 description)']
const errs: string[] = []
if (p.name !== `${SKILL_PREFIX}${skill}`) errs.push(`name 必須是 ${SKILL_PREFIX}${skill},實際為「${p.name}」`)
if (!p.description) errs.push('description 不可為空')
if (p.body.length < 20) errs.push('內文過短')
return errs
}
export const bumpVersion = (v: string, kind: 'minor' | 'patch'): string => {
const [a = 0, b = 0, c = 0] = v.split('.').map(n => Number(n) || 0)
return kind === 'minor' ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`
}
export type SkillEntry = { skill: string; description: string }
export const parseEntries = (readme: string): SkillEntry[] => {
const block = readme.split(LIST_START)[1]?.split(LIST_END)[0] ?? ''
const out: SkillEntry[] = []
for (const line of block.split('\n')) {
const m = line.match(/^- `\/jsc-anima:anima-([^`\s]+)[^`]*` — (.*)$/)
if (m) out.push({ skill: m[1]!, description: m[2]! })
}
return out
}
export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] =>
[...list.filter(x => x.skill !== e.skill), e].sort((a, b) => a.skill.localeCompare(b.skill))
// 固定的使用說明:重新產生技能 README 時要保留,不隨技能列表被覆蓋。
const USAGE_SECTIONS = [
'## 角色與萃取',
'',
'- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。',
'- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。',
'',
'## 待辦事項',
'',
'啟動角色後可用 `/jsc-anima:anima-todo`(全域一份,記錄類型、時間、任務與所屬專案):',
'',
'- `add <類型> <YYYY-MM-DD> <HH:MM> <任務>`、`update <編號> …`、`list`、`done <編號>`',
'- 類型預設:提醒、開發、研究、維護;`type <名稱>` 新增自訂類型。',
'- 範例:`/jsc-anima:anima-todo add 提醒 2026-10-16 09:00 開會`',
'- session 開著時,到點由角色提醒(可在 /config 關閉);輸入 `run <編號>` 確認後才執行,結果由角色告知。',
'',
'## 知識庫(wiki)',
'',
'啟動角色後可用:',
'',
'- `/jsc-anima:anima-wiki <領域> <主題> [出處]`(下一行起為內容):寫入 Gitea wiki,每次一個中文 commit,並更新 `Home` 目錄與 `_Sidebar`。領域與主題只能用英數與底線。',
'- `/jsc-anima:<領域>-<主題>`:讀取知識,指令在啟動時依 wiki 目錄註冊。',
'',
'讀寫都在 wiki,本機只做 10 分鐘快取,離線時沿用快取。認證優先讀 tea 登入,其次 `GITEA_TOKEN`,token 不寫入任何檔案。',
'',
]
// 技能 README:名稱 /jsc-anima:jsc-anima、技能列表(完整可複製指令)與範例。
export const renderGuide = (list: SkillEntry[], example?: { skill: string; input: string }): string => {
const ex = example ?? { skill: list[0]?.skill ?? 'example', input: '' }
return [
`# /${PLUGIN}:${GUIDE_SKILL}`,
'',
'動漫助理的使用說明。先用 `/jsc-anima:{作品代號}-{角色代號}` 啟動角色,再使用下列技能。',
'',
'## 技能列表',
'',
LIST_START,
...list.map(e => `- \`${commandOf(e.skill)}\` — ${e.description}`),
LIST_END,
'',
'## 範例',
'',
'```',
`${commandOf(ex.skill)}${ex.input ? ' ' + ex.input : ''}`,
'```',
'',
'產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。',
'',
...USAGE_SECTIONS,
].join('\n')
}
export const guideSkillMd = (): string =>
`---\nname: ${GUIDE_SKILL}\ndescription: jsc-anima 動漫助理的使用說明與技能列表\n---\n\n請讀取同目錄的 README.md 並依內容回答使用者。\n`
export const setReadmeVersion = (readme: string, version: string): string => {
const line = `目前版本:${version}`
return /^目前版本:.*$/m.test(readme)
? readme.replace(/^目前版本:.*$/m, line)
: readme.replace(/^(# jsc-anima\n)/m, `$1\n${line}\n`)
}
export const setPluginVersion = (json: string, version: string): string =>
json.replace(/("version":\s*")[^"]*(")/, `$1${version}$2`)
export const generatePrompt = (skill: string, description: string): string =>
[
`請撰寫一個 Claude 技能,技能名稱 ${SKILL_PREFIX}${skill},用途:${description}`,
'只輸出 JSON(不要程式碼圍欄),格式:{"skill":"<SKILL.md 全文,含 frontmatter name 與 description,內文用繁體中文>","example":"<一筆範例輸入>"}',
`frontmatter 的 name 必須是 ${SKILL_PREFIX}${skill}。`,
].join('\n')
export const parseGenerated = (text: string): { skill: string; example: string } | undefined => {
try {
const j = JSON.parse(text.trim().replace(/^```(?:json)?\n?|\n?```$/g, ''))
if (typeof j.skill === 'string' && typeof j.example === 'string') return { skill: j.skill, example: j.example }
} catch {}
return undefined
}