diff --git a/plugins/anime-companion/hooks/register.tsx b/plugins/anime-companion/hooks/register.tsx index da79fb4..3c135d3 100644 --- a/plugins/anime-companion/hooks/register.tsx +++ b/plugins/anime-companion/hooks/register.tsx @@ -1,4 +1,15 @@ import type { Register } from 'claude-code' -// 骨架:載入後尚無 hook。人格卡注入與強度見 W7、W8(議題 #8、#9)。 -export const register: Register = () => {} +import { resolveConfig } from './config' +import { buildRules, SECTION_ID } from './rules' + +// 依強度與稱呼把規則段落加到系統提示最後;人格卡與檢索見 W5~W7。 +export const register: Register = (on, options) => { + const config = resolveConfig(options) + on('prompt.compose', async ($, e, next) => { + const result = await next(e) + const text = buildRules(config) + if (text === null) return result + return { sections: [...result.sections, { id: SECTION_ID, text, scope: 'session' }] } + }) +} diff --git a/plugins/anime-companion/hooks/rules.test.ts b/plugins/anime-companion/hooks/rules.test.ts new file mode 100644 index 0000000..1e51c72 --- /dev/null +++ b/plugins/anime-companion/hooks/rules.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { resolveConfig } from './config' +import { buildRules, SECTION_ID } from './rules' + +const cfg = (o: Record = {}) => resolveConfig({ defaultCharacter: 'sao-asuna', ...o }) + +describe('buildRules', () => { + test('強度 0 與未選角色不注入', () => { + expect(buildRules(cfg({ intensity: '0' }))).toBeNull() + expect(buildRules(resolveConfig({}))).toBeNull() + }) + + test('強度 1 只換稱呼與句尾,沒有氣泡與旁白', () => { + const t = buildRules(cfg({ intensity: '1' }))! + expect(t).toContain('只換稱呼與句尾') + expect(t).not.toContain('前後各一個氣泡') + expect(t).not.toContain('最前面再加一行動作旁白') + }) + + test('強度 2 前後各一個氣泡,無旁白', () => { + const t = buildRules(cfg())! + expect(t).toContain('前後各一個氣泡') + expect(t).toContain('最多一兩句') + expect(t).toContain('不加動作旁白') + }) + + test('強度 3 加動作旁白', () => { + const t = buildRules(cfg({ intensity: '3' }))! + expect(t).toContain('前後各一個氣泡') + expect(t).toContain('最前面再加一行動作旁白') + expect(t).not.toContain('不加動作旁白') + }) + + test('稱呼依角色,設定可覆寫', () => { + expect(buildRules(cfg())!).toContain('「桐人」') + expect(buildRules(cfg(), 'sao-suguha')!).toContain('「哥哥」') + expect(buildRules(cfg({ address: '阿桐' }))!).toContain('「阿桐」') + }) + + test('各強度都保留安全規則:先用一句清楚的話,需確認時省略收尾氣泡', () => { + for (const intensity of ['1', '2', '3']) { + const t = buildRules(cfg({ intensity }))! + expect(t).toContain('第一句用中性、清楚的話') + expect(t).toContain('不加收尾的對話氣泡') + } + }) + + test('角色語氣不進程式碼、commit 與檔案內容', () => { + expect(buildRules(cfg())!).toContain('程式碼、commit 訊息與檔案內容保持中性') + }) +}) + +describe('prompt.compose hook', () => { + const input = { model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } + const base = { sections: [{ id: 'intro', text: 'x', scope: 'shared' as const }] } + + test('在原有段落後加入規則段落', { options: { defaultCharacter: 'sao-asuna', intensity: '2' } }, async ($, on) => { + on('prompt.compose', ($, e, next) => base) + const r = await $.prompt.compose(input) + expect(r.sections.at(-1)?.id).toBe(SECTION_ID) + expect(r.sections.at(-1)?.scope).toBe('session') + expect(r.sections[0]?.id).toBe('intro') + }) + + test('強度 0 不加段落', { options: { defaultCharacter: 'sao-asuna', intensity: '0' } }, async ($, on) => { + on('prompt.compose', ($, e, next) => base) + const r = await $.prompt.compose(input) + expect(r.sections.some(s => s.id === SECTION_ID)).toBe(false) + }) +}) diff --git a/plugins/anime-companion/hooks/rules.ts b/plugins/anime-companion/hooks/rules.ts new file mode 100644 index 0000000..c521fcc --- /dev/null +++ b/plugins/anime-companion/hooks/rules.ts @@ -0,0 +1,49 @@ +import { CHARACTERS, type CharacterCode } from './characters' +import { addressFor, type Config, type Intensity } from './config' + +export const SECTION_ID = 'anime-companion:rules' + +// 任何強度(含 3)都適用:先把該說的事用一句清楚的話說完,再加角色語氣。 +const SAFETY = [ + '錯誤、風險與需要使用者確認的事,第一句用中性、清楚的話直接說完(不帶角色語氣、不被旁白蓋過),之後才可加角色語氣。', + '危險操作需要確認時,不加收尾的對話氣泡與角色語氣的收尾,只留清楚的問句。', +] + +const SCOPE = + '角色語氣只套在對使用者說的話;程式碼、commit 訊息與檔案內容保持中性,不得出現角色口吻。' + +function levelRules(name: string, intensity: 1 | 2 | 3): string[] { + switch (intensity) { + case 1: + return ['只換稱呼與句尾語氣;不加口癖、吐槽、對話氣泡或動作旁白。'] + case 2: + return [ + `加入口癖與簡短吐槽,每則回覆最多一兩句角色化的話。`, + `角色化的話放進對話氣泡(引用區塊,標示角色名,如「> **${name}**:…」),內容前後各一個氣泡,正文放在兩個氣泡之間。`, + '不加動作旁白,不做常駐的狀態列。', + ] + case 3: + return [ + '完整演出角色:口癖、吐槽、情緒都可充分表現。', + `角色化的話放進對話氣泡(引用區塊,標示角色名,如「> **${name}**:…」),內容前後各一個氣泡,正文放在兩個氣泡之間。`, + '最前面再加一行動作旁白(斜體,如「*動作描寫*」)。', + '不做常駐的狀態列。', + ] + } +} + +// 依強度與稱呼組成注入系統提示的規則段落;強度 0 或未選角色時回傳 null(不注入)。 +export function buildRules(config: Config, character: CharacterCode | null = config.character): string | null { + if (character === null || config.intensity === 0) return null + const { name } = CHARACTERS[character] + const intensity = config.intensity as Exclude + return [ + `你現在以《刀劍神域》的「${name}」的個性與語氣與使用者對話,同時照常協助使用者做事。`, + `稱呼使用者為「${addressFor(config, character)}」。`, + SCOPE, + ...levelRules(name, intensity), + ...SAFETY, + ] + .map(line => `- ${line}`) + .join('\n') +}