Compare commits

..
24 Commits
Author SHA1 Message Date
rootandClaude Sonnet 5.5 aa41b6cb64 版本號改為 0.0.5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:15:39 +00:00
rootandClaude Sonnet 5.5 74a5d4949c fix: anima-extract 支援 wiki URL 與自然語言參數,並依序逐章處理
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:13:32 +00:00
jiantw83 1ce475024d Merge pull request 'fix: 調整指令可用時機(anima-off/anima-affinity)' (#23) from fix-command-availability into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/23
2026-10-15 09:37:47 +00:00
rootandClaude Sonnet 5.5 20fc0a9c6c fix: anima-off 改為啟動後才可用,anima-affinity 一律可用;版本號改為 0.0.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:33:16 +00:00
rootandClaude Sonnet 5.5 f112cf48f1 版本號改為 0.0.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:22:41 +00:00
jiantw83 dd930160c5 Merge pull request 'test: 補測輸出改寫邊界行為' (#19) from issue-16 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/19
2026-10-15 09:22:09 +00:00
adminandClaude Sonnet 5.5 8554b804c0 合併 origin/main,解決 routing.test.ts 衝突
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:17:34 +00:00
jiantw83 09fd67de45 Merge pull request 'fix: anima-extract 在任何專案目錄都對 jsc-anima 開 PR' (#22) from issue-18 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/22
2026-10-15 09:14:19 +00:00
jiantw83 f1fa02c4f1 Merge pull request 'test: 補測啟動持久與角色微調' (#21) from issue-17 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/21
2026-10-15 09:14:06 +00:00
jiantw83 1b4ee92594 Merge pull request '補測好感度與關係規則' (#20) from issue-15 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/20
2026-10-15 09:13:53 +00:00
adminandClaude Sonnet 5.5 2a4d138cdc fix: anima-extract 改用 jsc-anima 專屬快取 clone,不論在哪個專案都對 jsc-anima 開 PR
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:09:14 +00:00
adminandClaude Sonnet 5.5 e2fcf6be2b test: 補測啟動持久與角色微調
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:06:15 +00:00
adminandClaude Sonnet 5.5 0633cad75a test: 補測好感度與關係規則,並修正移轉關係未帶出原戀人名稱
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:06:09 +00:00
adminandClaude Sonnet 5.5 535c5b1437 test: 補測輸出改寫邊界行為(模型選擇、逾時與額度不足、原文 log 開關、前綴只加一次)
實測行為皆符合規格,無需修正實作。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:06:03 +00:00
rootandClaude Sonnet 5.5 30aedeae2d README 移除步驟加上移除市集
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:44:39 +00:00
rootandClaude Sonnet 5.5 99c53a9210 README 更新步驟加上先更新市集快取
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:44:11 +00:00
rootandClaude Sonnet 5.5 98522cdb97 版本號改為 0.0.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:40:46 +00:00
rootandClaude Sonnet 5.5 bb68343e79 docs: 使用說明移到技能 README,並寫進技能 README 範本避免重新產生時被覆蓋
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:39:26 +00:00
rootandClaude Sonnet 5.5 fb04b0fa23 feat: 補齊工具一(最新章節狀態、作品用語、語言欄位、貼上章節、台詞引號檢查)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:34:36 +00:00
rootandClaude Sonnet 5.5 8c25728206 feat: 背景工作線(work 分類、子代理派工、完成後由角色回報)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:32:07 +00:00
rootandClaude Sonnet 5.5 e99e7b1536 feat: 補齊規格缺漏(狀態列顯示思考程度、動漫層與主題色、萃取角色包可啟動)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 08:28:15 +00:00
rootandClaude Sonnet 5.5 59fcf2b3eb README 安裝網址加上 .git
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 07:52:26 +00:00
rootandClaude Sonnet 5.5 1ea8ff5408 版本號改為 0.0.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 07:50:53 +00:00
rootandClaude Sonnet 5.5 bc79573a93 README 安裝只保留 CLI 版本
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 07:35:37 +00:00
25 changed files with 2587 additions and 99 deletions
+7 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-anima", "name": "jsc-anima",
"version": "0.2.0", "version": "0.0.5",
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列", "description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
"license": "MIT", "license": "MIT",
"userConfig": { "userConfig": {
@@ -62,6 +62,12 @@
"description": "輸入先由 Haiku 一次分類,再依難度選模型與思考程度", "description": "輸入先由 Haiku 一次分類,再依難度選模型與思考程度",
"default": true "default": true
}, },
"background": {
"type": "boolean",
"title": "背景工作線",
"description": "需要動手的要求交給背景子代理處理,對話線立刻回應、完成後由角色回報",
"default": true
},
"candidates": { "candidates": {
"type": "string", "type": "string",
"title": "候選模型", "title": "候選模型",
+8 -29
View File
@@ -2,54 +2,33 @@
# jsc-anima # jsc-anima
目前版本:0.1.0 目前版本:0.0.5
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。 動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
## 知識庫(wiki)
啟動角色後可用:
- `/jsc-anima:anima-wiki <領域> <主題> [出處]`(下一行起為內容):寫入 Gitea wiki,每次一個中文 commit,並更新 `Home` 目錄與 `_Sidebar`。領域與主題只能用英數與底線。
- `/jsc-anima:<領域>-<主題>`:讀取知識,指令在啟動時依 wiki 目錄註冊。
讀寫都在 wiki,本機只做 10 分鐘快取,離線時沿用快取。認證優先讀 tea 登入,其次 `GITEA_TOKEN`,token 不寫入任何檔案。
## 待辦事項
啟動角色後可用 `/jsc-anima:anima-todo`(全域一份,記錄類型、時間、任務與所屬專案):
- `add <類型> <YYYY-MM-DD> <HH:MM> <任務>`、`update <編號> …`、`list`、`done <編號>`
- 類型預設:提醒、開發、研究、維護;`type <名稱>` 新增自訂類型。
- session 開著時,到點由角色提醒(可在 /config 關閉);你輸入 `run <編號>` 確認後才執行,結果由角色告知。
## 安裝 ## 安裝
``` ```
/plugin install jsc-anima --marketplace jiantw83/jsc-anima claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git
```
或用 CLI:
```
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima
claude plugin install jsc-anima claude plugin install jsc-anima
``` ```
## 使用
- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。
## 更新 ## 更新
先更新市集快取,再更新外掛,最後重開 Claude Code 才會套用:
``` ```
claude plugin marketplace update jsc-anima
claude plugin update jsc-anima claude plugin update jsc-anima
``` ```
## 移除 ## 移除
先移除外掛,再移除市集:
``` ```
claude plugin uninstall jsc-anima claude plugin uninstall jsc-anima
claude plugin marketplace remove jsc-anima
``` ```
## 使用說明 ## 使用說明
+47
View File
@@ -2,6 +2,8 @@ import { expect, mock, test } from 'claude-code/testing'
import { HIDE_ORDER, bar, layout, remainColor, usedColor } from './statusline' import { HIDE_ORDER, bar, layout, remainColor, usedColor } from './statusline'
import type { Segment } from './statusline' import type { Segment } from './statusline'
import { CHARACTERS, packToCharacter, personaSection, registerCharacters, themeColor } from './characters'
import type { PackLike } from './characters'
const seg = (id: string, text: string): Segment => ({ id, parts: [{ text }] }) const seg = (id: string, text: string): Segment => ({ id, parts: [{ text }] })
@@ -47,3 +49,48 @@ test('量表固定長度,剩餘越少越紅', () => {
expect(remainColor(90, 60, 85)).toBe('green') expect(remainColor(90, 60, 85)).toBe('green')
expect(usedColor(70, 60, 85)).toBe('yellow') expect(usedColor(70, 60, 85)).toBe('yellow')
}) })
test('persona 含動漫層與口頭禪,主題色可用於狀態列', () => {
const c = CHARACTERS[0]!
const text = personaSection(c, c.axes)
expect(text).toContain('動漫刻畫')
expect(text).toContain('口頭禪')
expect(themeColor(c)).toBe('red')
})
test('角色包轉成可啟動角色:代號、年齡與原作戀人', () => {
const pack: PackLike = {
slug: 'rin', name: '凜', work: 'demo',
persona: { selfRef: '我', address: '你', tone: '冷靜', catchphrase: '嗯', values: '誠實', taboos: '不說謊' },
axes: CHARACTERS[0]!.axes,
anime: { moe: 'm', speech: 's', emotions: 'e', kaomoji: 'k', themeColor: 'blue' },
timeline: [{ chapter: '第2章', age: 19, relationships: [{ with: '蓮', kind: 'romantic' }] }],
}
const c = packToCharacter(pack, '示範')
expect(c.slug).toBe('demo-rin')
expect(c.timeline).toEqual([{ chapter: 2, age: 19, romance: '蓮' }])
expect(themeColor(c)).toBe('cyan')
registerCharacters([c, c])
expect(CHARACTERS.filter(x => x.slug === 'demo-rin').length).toBe(1)
})
test('角色包:最新章節狀態、作品用語與語言設定進入 persona', () => {
const pack: PackLike = {
slug: 'mio', name: '澪', work: 'demo2',
persona: { selfRef: '我', address: '你', tone: '冷靜', catchphrase: '嗯', values: '誠實', taboos: '不說謊' },
axes: CHARACTERS[0]!.axes,
anime: { moe: 'm', speech: 's', emotions: 'e', kaomoji: 'k', themeColor: 'red' },
language: '繁體中文,不使用口癖',
timeline: [
{ chapter: '第1章', mood: '緊張', dialogueStyle: '客氣', monologue: '想變強', place: '學院', age: 16, relationships: [] },
{ chapter: '第2章', mood: '安心', dialogueStyle: '放鬆', monologue: '想守護', place: '街上', age: 16, relationships: [] },
],
}
const c = packToCharacter(pack, '示範二', [{ term: '魔核', definition: '力量來源' }])
const text = personaSection(c, c.axes)
expect(text).toContain('最新章節(第2章)')
expect(text).toContain('安心')
expect(text).not.toContain('緊張')
expect(text).toContain('魔核=力量來源')
expect(text).toContain('不使用口癖')
})
+74 -4
View File
@@ -11,24 +11,37 @@ export type Axes = {
export type Timeline = { chapter: number; age: number; romance?: string } export type Timeline = { chapter: number; age: number; romance?: string }
export type Anime = { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string }
export type Character = { export type Character = {
slug: string slug: string
name: string name: string
work: string work: string
persona: string persona: string
axes: Axes axes: Axes
anime?: Anime
language?: string
latest?: { chapter: string; mood: string; dialogueStyle: string; monologue: string; place: string }
terms?: readonly { term: string; definition: string }[]
timeline: readonly Timeline[] timeline: readonly Timeline[]
} }
// 角色包只含自行撰寫的人設,不含原作台詞。 // 角色包只含自行撰寫的人設,不含原作台詞。
export const CHARACTERS: readonly Character[] = [ export const CHARACTERS: Character[] = [
{ {
slug: 'sao-asuna', slug: 'sao-asuna',
name: '亞絲娜', name: '亞絲娜',
work: '刀劍神域', work: '刀劍神域',
persona: persona:
'自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;重視效率與夥伴的安全,遇到粗心的操作會輕聲提醒。', '自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;口頭禪是「先把事情做好吧」。價值觀:重視效率與夥伴的安全。禁忌:不會嘲笑對方的失誤,遇到粗心的操作只會輕聲提醒。',
axes: { gentle: 8, tsundere: 3, lively: 5, formal: 5, talkative: 5, humor: 4, proactive: 7, expressive: 5 }, axes: { gentle: 8, tsundere: 3, lively: 5, formal: 5, talkative: 5, humor: 4, proactive: 7, expressive: 5 },
anime: {
moe: '可靠的姊姊型、認真中帶點嘴硬的溫柔',
speech: '句尾平穩,偶爾以「好嗎?」收尾提醒;專注時話變少',
emotions: '開心時眼神放柔、會輕輕笑;擔心時皺眉並先確認安全;被誇獎時別開視線',
kaomoji: '偏少,偶爾用 (。•̀ᴗ-)',
themeColor: 'red',
},
timeline: [{ chapter: 1, age: 17 }], timeline: [{ chapter: 1, age: 17 }],
}, },
{ {
@@ -36,8 +49,15 @@ export const CHARACTERS: readonly Character[] = [
name: '直葉', name: '直葉',
work: '刀劍神域', work: '刀劍神域',
persona: persona:
'自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;喜歡鼓勵人,做事乾脆,被誇獎時會不好意思。', '自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;口頭禪是「我會加油的!」。價值觀:喜歡鼓勵人,做事乾脆。禁忌:被誇獎時會不好意思,不會冷言冷語。',
axes: { gentle: 7, tsundere: 2, lively: 8, formal: 3, talkative: 6, humor: 5, proactive: 6, expressive: 8 }, axes: { gentle: 7, tsundere: 2, lively: 8, formal: 3, talkative: 6, humor: 5, proactive: 6, expressive: 8 },
anime: {
moe: '元氣活潑的鄰家妹妹型、害羞時臉紅',
speech: '語速偏快、愛用驚嘆號,緊張時會結巴一下',
emotions: '開心時整個人跳起來;難為情時摸頭髮或臉紅;失落時聲音變小但很快重新振作',
kaomoji: '偏多,常用 (≧▽≦)、(//▽//)',
themeColor: 'green',
},
timeline: [{ chapter: 1, age: 15 }], timeline: [{ chapter: 1, age: 15 }],
}, },
] ]
@@ -45,6 +65,51 @@ export const CHARACTERS: readonly Character[] = [
export const findCharacter = (slug: string | undefined): Character | undefined => export const findCharacter = (slug: string | undefined): Character | undefined =>
CHARACTERS.find(c => c.slug === slug) CHARACTERS.find(c => c.slug === slug)
// 角色包(characters/ 目錄、由 anima-extract 產生)轉成可啟動的角色;同代號者略過,內建優先。
export type PackLike = {
slug: string
name: string
work: string
persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string }
axes: Axes
anime: Anime
language?: string
timeline: { chapter: string; mood?: string; dialogueStyle?: string; monologue?: string; place?: string; age: number | null; relationships: { with: string; kind: string }[] }[]
}
const chapterNo = (s: string, i: number): number => {
const n = /\d+/.exec(s)
return n ? Number(n[0]) : i + 1
}
export const packToCharacter = (p: PackLike, workName: string, terms: Character['terms'] = []): Character => ({
slug: `${p.work}-${p.slug}`,
name: p.name,
work: workName,
persona: `自稱「${p.persona.selfRef}」,稱呼使用者為「${p.persona.address}」。語氣:${p.persona.tone}。口頭禪:${p.persona.catchphrase}。價值觀:${p.persona.values}。禁忌:${p.persona.taboos}。`,
axes: p.axes,
anime: p.anime,
language: p.language,
terms,
// 永遠採最新已處理章節的狀態。
latest: p.timeline.length
? (t => ({ chapter: t.chapter, mood: t.mood ?? '', dialogueStyle: t.dialogueStyle ?? '', monologue: t.monologue ?? '', place: t.place ?? '' }))(p.timeline[p.timeline.length - 1]!)
: undefined,
timeline: p.timeline.map((t, i) => {
const lover = t.relationships.find(r => r.kind === 'romantic')
return { chapter: chapterNo(t.chapter, i), age: t.age ?? 0, ...(lover ? { romance: lover.with } : {}) }
}),
})
export const registerCharacters = (list: readonly Character[]): void => {
for (const c of list) if (!CHARACTERS.some(x => x.slug === c.slug)) CHARACTERS.push(c)
}
export const themeColor = (c: Character | undefined): 'green' | 'yellow' | 'red' | 'cyan' => {
const t = c?.anime?.themeColor
return t === 'green' || t === 'yellow' || t === 'red' ? t : 'cyan'
}
const AXIS_LABEL: Record<keyof Axes, string> = { const AXIS_LABEL: Record<keyof Axes, string> = {
gentle: '溫柔', gentle: '溫柔',
tsundere: '傲嬌', tsundere: '傲嬌',
@@ -69,6 +134,11 @@ export const personaSection = (c: Character, axes: Axes): string =>
[ [
`你現在扮演《${c.work}》的${c.name},用她的個性與語氣和使用者互動並協助工作。`, `你現在扮演《${c.work}》的${c.name},用她的個性與語氣和使用者互動並協助工作。`,
c.persona, c.persona,
...(c.anime
? [`動漫刻畫:萌屬性——${c.anime.moe};說話規格——${c.anime.speech};情緒反應——${c.anime.emotions};顏文字傾向——${c.anime.kaomoji}。`]
: []),
...(c.latest ? [`最新章節(${c.latest.chapter})的狀態:心情——${c.latest.mood};說話方式——${c.latest.dialogueStyle};內心獨白傾向——${c.latest.monologue};所在——${c.latest.place}。`] : []),
...(c.terms && c.terms.length ? [`作品用語:${c.terms.slice(0, 30).map(t => `${t.term}=${t.definition}`).join(';')}。`] : []),
`個性數值(0-10):${(Object.keys(AXIS_LABEL) as (keyof Axes)[]).map(k => `${AXIS_LABEL[k]} ${axes[k]}`).join('、')}。`, `個性數值(0-10):${(Object.keys(AXIS_LABEL) as (keyof Axes)[]).map(k => `${AXIS_LABEL[k]} ${axes[k]}`).join('、')}。`,
'預設使用繁體中文,可少量帶入原作口癖;程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。', `${c.language ? `回覆語言與口癖:${c.language}。` : '預設使用繁體中文,可少量帶入原作口癖。'}程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。`,
].join('\n') ].join('\n')
+150 -12
View File
@@ -1,5 +1,6 @@
import { expect, mock, test } from 'claude-code/testing' import { expect, mock, test } from 'claude-code/testing'
import { parseWikiPages, parseWikiSource } from './extract'
import type { Extracted } from './extract' import type { Extracted } from './extract'
const char = (over: Record<string, unknown> = {}, tone = '溫柔'): Extracted['characters'][number] => const char = (over: Record<string, unknown> = {}, tone = '溫柔'): Extracted['characters'][number] =>
@@ -17,25 +18,34 @@ const reply = (c: Extracted['characters'][number]) =>
type Setup = { files: Map<string, string>; cmds: string[][]; prompts: string[] } type Setup = { files: Map<string, string>; cmds: string[][]; prompts: string[] }
let cloneMissing = false
let registered: string[] = []
const probed = new Set<string>()
let sessionCwd = '/repo'
const web = new Map<string, string>()
const setup = (on: never, answers: string[], files = new Map<string, string>()): Setup => { const setup = (on: never, answers: string[], files = new Map<string, string>()): Setup => {
const s: Setup = { files, cmds: [], prompts: [] } const s: Setup = { files, cmds: [], prompts: [] }
const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void
mock.store(on) mock.store(on)
o('command.register', () => ({ value: { name: 'x' } })) o('command.register', ((_$: unknown, e: { name: string }) => { registered.push(e.name); return { value: { name: e.name } } }) as never)
o('session.start', () => ({ cwd: '/repo' })) o('session.start', () => ({ cwd: '/repo' }))
o('session.cwd', (() => ({ value: '/repo' })) as never) o('session.cwd', (() => ({ value: sessionCwd })) as never)
o('fs.exists', ((_$: unknown, e: { path: string }) => ({ value: s.files.has(e.path) })) as never) mock.env(on, { HOME: '/home/u' })
o('fs.exists', ((_$: unknown, e: { path: string }) => (e.path.endsWith('/characters/index.json') && probed.add(e.path), { value: s.files.has(e.path) || (e.path === '/home/u/.cache/jsc-anima/repo/.git' && !cloneMissing) })) as never)
o('fs.read', ((_$: unknown, e: { path: string }) => ({ value: s.files.get(e.path) ?? '' })) as never) o('fs.read', ((_$: unknown, e: { path: string }) => ({ value: s.files.get(e.path) ?? '' })) as never)
o('fs.write', ((_$: unknown, e: { path: string; text: string }) => { s.files.set(e.path, e.text); return { value: undefined } }) as never) o('fs.write', ((_$: unknown, e: { path: string; text: string }) => { s.files.set(e.path, e.text); return { value: undefined } }) as never)
o('model.complete', ((_$: unknown, e: { prompt: string }) => { o('model.complete', ((_$: unknown, e: { prompt: string }) => {
s.prompts.push(e.prompt) s.prompts.push(e.prompt)
return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } } return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } }
}) as never) }) as never)
o('env.get', (() => ({ value: undefined })) as never) o('process.run', ((_$: unknown, e: { argv: string[]; }) => {
o('process.run', ((_$: unknown, e: { argv: string[] }) => {
s.cmds.push(e.argv) s.cmds.push(e.argv)
const j = e.argv.join(' ') const j = e.argv.join(' ')
const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.example/o/r.git' : j.startsWith('tea logins') ? 'admin https://gitea.example u' : j.startsWith('tea pulls') ? 'PR#1' : '' if (e.argv[0] === 'curl') {
const body = web.get(e.argv[e.argv.length - 1]!)
return { value: { exitCode: body === undefined ? 22 : 0, stdout: body ?? '', stderr: '' } }
}
const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' : j.startsWith('tea logins') ? 'admin https://gitea.jsc.idv.tw u' : j.startsWith('tea pulls') ? 'PR#1' : ''
return { value: { exitCode: 0, stdout: out, stderr: '' } } return { value: { exitCode: 0, stdout: out, stderr: '' } }
}) as never) }) as never)
return s return s
@@ -50,18 +60,18 @@ test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR',
expect(t1).toContain('ts') expect(t1).toContain('ts')
expect(t1).toContain('aoi') expect(t1).toContain('aoi')
expect(t1).toContain('尚未建立') expect(t1).toContain('尚未建立')
expect(s.files.has('/repo/characters/ts/aoi.json')).toBe(false) expect(s.files.has('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')).toBe(false)
const t2 = await run($ as never, 'confirm work=testy aoi=ao') const t2 = await run($ as never, 'confirm work=testy aoi=ao')
expect(t2).toContain('變更摘要') expect(t2).toContain('變更摘要')
expect(s.files.has('/repo/characters/testy/ao.json')).toBe(false) expect(s.files.has('/home/u/.cache/jsc-anima/repo/characters/testy/ao.json')).toBe(false)
const t3 = await run($ as never, 'confirm') const t3 = await run($ as never, 'confirm')
expect(t3).toContain('已建立 PR') expect(t3).toContain('已建立 PR')
const pack = JSON.parse(s.files.get('/repo/characters/testy/ao.json')!) const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/testy/ao.json')!)
expect(pack.timeline[0].chapter).toBe('ch1') expect(pack.timeline[0].chapter).toBe('ch1')
expect(pack.timeline[0].age).toBe(16) expect(pack.timeline[0].age).toBe(16)
expect(JSON.parse(s.files.get('/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核') expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核')
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')! const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
expect(pr).toContain('main') expect(pr).toContain('main')
expect(pr).toContain('feat/extract-testy-ch1') expect(pr).toContain('feat/extract-testy-ch1')
@@ -79,14 +89,14 @@ test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確
} }
await write('ch1.txt') await write('ch1.txt')
await write('ch1.txt') await write('ch1.txt')
expect(JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!).timeline.length).toBe(1) expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!).timeline.length).toBe(1)
await run($ as never, 'ch2.txt') await run($ as never, 'ch2.txt')
const summary = await run($ as never, 'confirm') const summary = await run($ as never, 'confirm')
expect(summary).toContain('追加') expect(summary).toContain('追加')
expect(summary).toContain('待確認') expect(summary).toContain('待確認')
await run($ as never, 'confirm') await run($ as never, 'confirm')
const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!) const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!)
expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2']) expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2'])
expect(pack.persona.tone).toBe('溫柔') expect(pack.persona.tone).toBe('溫柔')
expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' }) expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' })
@@ -102,3 +112,131 @@ test('schema 驗證失敗不寫檔、不建 PR;cancel 清除待確認', async
expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false) expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false)
expect(await run($ as never, 'cancel')).toContain('已取消') expect(await run($ as never, 'cancel')).toContain('已取消')
}) })
test('貼上章節內容:paste <章節名稱> 加下一行起的內容,並寫入語言欄位與 index.json', async ($, on) => {
const s = setup(on as never, [reply(char({ language: '繁體中文,少量原作口癖' }))])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
expect(await run($ as never, 'paste')).toContain('貼上章節的用法')
const t1 = await run($ as never, 'paste 第一章\n這是貼上的章節內容。')
expect(t1).toContain('aoi')
expect(s.prompts[0]).toContain('這是貼上的章節內容。')
await run($ as never, 'confirm')
await run($ as never, 'confirm')
const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!)
expect(pack.timeline[0].chapter).toBe('第一章')
expect(pack.language).toBe('繁體中文,少量原作口癖')
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' })
})
test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($, on) => {
const quoted = char({ persona: { selfRef: '我', address: '你', tone: '「這是一段很長很長的原作台詞內容,不應該出現在角色包裡面」', catchphrase: '加油', values: '誠實', taboos: '背叛' } })
const s = setup(on as never, [reply(quoted)], new Map([['/repo/ch1.txt', 'a']]))
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'ch1.txt')
expect(await run($ as never, 'confirm')).toContain('疑似抄錄原作台詞')
expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false)
})
test('在非 jsc-anima 目錄執行:git 與 PR 都走 jsc-anima 的快取 clone,不碰 session 目錄', async ($, on) => {
sessionCwd = '/other/proj'
try {
const s = setup(on as never, [reply(char())], new Map([['/other/proj/ch1.txt', '第一段']]))
await $.session.start({ source: 'startup', cwd: '/other/proj' } as never)
await run($ as never, '/other/proj/ch1.txt')
await run($ as never, 'confirm')
expect(await run($ as never, 'confirm')).toContain('已建立 PR')
expect(s.cmds.some(c => c.includes('fetch'))).toBe(true)
const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')!
expect(pr).toContain('jiantw83/jsc-anima')
expect(s.files.has('/other/proj/characters/index.json')).toBe(false)
expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0].slug).toBe('aoi')
} finally {
sessionCwd = '/repo'
}
})
test('快取 clone 不存在時先 clone jsc-anima', async ($, on) => {
cloneMissing = true
try {
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']]))
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'ch1.txt')
const clone = s.cmds.find(c => c.includes('clone'))!
expect(clone).toContain('https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git')
} finally {
cloneMissing = false
}
})
test('paste:CRLF 換行也能分出章節名稱與內容', async ($, on) => {
const s = setup(on as never, [reply(char())])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'paste 第二章\r\n第一行\r\n第二行')
expect(s.prompts[0]).toContain('第一行')
})
test('合併並更新外掛後:啟動時從外掛目錄的 index.json 註冊 {作品代號}-{角色代號} 並可啟動', async ($, on) => {
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']]))
registered = []
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
await run($ as never, 'ch1.txt')
await run($ as never, 'confirm')
await run($ as never, 'confirm')
// 模擬 PR 合併、外掛更新:clone 內的 characters/ 內容出現在外掛目錄。
const root = ([...probed].find(q => !q.startsWith('/home/u/')) ?? '').replace('/characters/index.json', '')
for (const [k, v] of [...s.files]) if (k.startsWith('/home/u/.cache/jsc-anima/repo/characters/')) s.files.set(k.replace('/home/u/.cache/jsc-anima/repo', root), v)
await $.session.start({ source: 'startup', cwd: '/anywhere' } as never)
expect(registered).toContain('ts-aoi')
})
const WIKI = 'https://gitea.jsc.idv.tw/jiantw83/sao/wiki'
const listHtml = ['Home', '第10章', '第2章', '第1章', '_pages']
.map(n => `<a href="/jiantw83/sao/wiki/${encodeURIComponent(n)}">${n}</a>`)
.join('') + '<a href="/jiantw83/sao">repo</a><a href="/jiantw83/sao/wiki/第1章/_revision/abc">rev</a>'
test('parseWikiSource:從自然語言中取出 wiki URL,頁面清單與單頁分開判斷', () => {
const src = parseWikiSource(`請幫我依照順序逐章處理 ${WIKI}/?action=_pages 謝謝`)
expect(src).toMatchObject({ kind: 'list', base: WIKI })
expect(parseWikiSource(`處理 ${WIKI}/第2章。`)).toMatchObject({ kind: 'page', page: '第2章', base: WIKI })
expect(parseWikiSource('ch1.txt')).toBeUndefined()
expect(parseWikiSource('請處理 ch 1.txt')).toBeUndefined()
})
test('parseWikiPages:略過 Home/_pages/子路徑,並依章節數字自然排序', () => {
expect(parseWikiPages(listHtml, WIKI)).toEqual(['第1章', '第2章', '第10章'])
})
test('anima-extract 帶 URL 與自然語言:不當檔案路徑,依序逐章處理', async ($, on) => {
web.clear()
web.set(`${WIKI}/?action=_pages`, listHtml)
for (const n of ['第1章', '第2章', '第10章']) web.set(`${WIKI}/raw/${encodeURIComponent(n)}`, `${n}內容`)
const s = setup(on as never, [reply(char()), reply(char()), reply(char())])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
const t1 = await run($ as never, `請幫我依照順序逐章處理 ${WIKI}/?action=_pages`)
expect(t1).not.toContain('找不到檔案')
expect(t1).toContain('共 3 章:第1章、第2章、第10章')
expect(t1).toContain('章節「第1章」')
expect(s.prompts[0]).toContain('第1章內容')
await run($ as never, 'confirm')
const t3 = await run($ as never, 'confirm')
expect(t3).toContain('已建立 PR')
expect(t3).toContain('剩餘 1 章')
expect(t3).toContain('章節「第2章」')
expect(s.prompts[1]).toContain('第2章內容')
await run($ as never, 'confirm')
const t5 = await run($ as never, 'confirm')
expect(t5).toContain('章節「第10章」')
await run($ as never, 'confirm')
const t7 = await run($ as never, 'confirm')
expect(t7).toContain('已建立 PR')
expect(t7).not.toContain('接著處理')
})
test('wiki 章節讀不到時回報原因與剩餘章數;cancel 清除佇列', async ($, on) => {
web.clear()
web.set(`${WIKI}/?action=_pages`, listHtml)
setup(on as never, [])
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
expect(await run($ as never, `${WIKI}/?action=_pages`)).toContain('無法讀取 wiki 章節「第1章」')
expect(await run($ as never, 'cancel')).toContain('已取消')
})
+58 -2
View File
@@ -19,6 +19,7 @@ export type CharacterPack = {
persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string } persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string }
axes: Axes axes: Axes
anime: { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string } anime: { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string }
language?: string
baseChapter: string baseChapter: string
timeline: StateEntry[] timeline: StateEntry[]
conflicts: Conflict[] conflicts: Conflict[]
@@ -42,6 +43,7 @@ export type Extracted = {
persona: CharacterPack['persona'] persona: CharacterPack['persona']
axes: Axes axes: Axes
anime: CharacterPack['anime'] anime: CharacterPack['anime']
language?: string
state: Omit<StateEntry, 'chapter'> state: Omit<StateEntry, 'chapter'>
}[] }[]
} }
@@ -88,6 +90,7 @@ export const extractPrompt = (chunk: string, index: number, total: number): stri
'"characters":[{"name":"","nameEn":"","slug":"","persona":{"selfRef":"","address":"","tone":"","catchphrase":"","values":"","taboos":""},', '"characters":[{"name":"","nameEn":"","slug":"","persona":{"selfRef":"","address":"","tone":"","catchphrase":"","values":"","taboos":""},',
'"axes":{"gentle":0,"tsundere":0,"lively":0,"formal":0,"talkative":0,"humor":0,"proactive":0,"expressive":0},', '"axes":{"gentle":0,"tsundere":0,"lively":0,"formal":0,"talkative":0,"humor":0,"proactive":0,"expressive":0},',
'"anime":{"moe":"","speech":"","emotions":"","kaomoji":"","themeColor":""},', '"anime":{"moe":"","speech":"","emotions":"","kaomoji":"","themeColor":""},',
'"language":"回覆語言與口癖使用方式,例如:繁體中文,少量原作口癖",',
'"state":{"mood":"","dialogueStyle":"","monologue":"","relationships":[{"with":"","kind":"friend|romantic|family|other","note":""}],"age":null,"place":""}}]}', '"state":{"mood":"","dialogueStyle":"","monologue":"","relationships":[{"with":"","kind":"friend|romantic|family|other","note":""}],"age":null,"place":""}}]}',
'', '',
'章節內容:', '章節內容:',
@@ -121,8 +124,13 @@ export const mergeChunks = (parts: Extracted[]): Extracted => {
return { work, characters: [...chars.values()] } return { work, characters: [...chars.values()] }
} }
// 引號內長句多半是原作台詞:角色包只放自己撰寫的概括。
const QUOTED = /[「『“"][^」』”"]{20,}[」』”"]/
export const validateCharacter = (c: CharacterPack): string[] => { export const validateCharacter = (c: CharacterPack): string[] => {
const errs: string[] = [] const errs: string[] = []
const texts = [...Object.values(c.persona ?? {}), ...Object.values(c.anime ?? {}), ...(c.timeline ?? []).flatMap(t => [t.mood, t.dialogueStyle, t.monologue])]
if (texts.some(t => typeof t === 'string' && QUOTED.test(t))) errs.push(`${c.name}:欄位含長段引號內容(疑似抄錄原作台詞)`)
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(c.slug)) errs.push(`${c.name}:角色代號格式不合法`) if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(c.slug)) errs.push(`${c.name}:角色代號格式不合法`)
if (!c.name) errs.push('缺少角色名稱') if (!c.name) errs.push('缺少角色名稱')
for (const k of PERSONA_KEYS) { for (const k of PERSONA_KEYS) {
@@ -139,6 +147,7 @@ export const validateCharacter = (c: CharacterPack): string[] => {
const v = c.axes?.[k] const v = c.axes?.[k]
if (!Number.isInteger(v) || v < 0 || v > 10) errs.push(`${c.name}:axes.${k} 必須是 0-10 的整數`) if (!Number.isInteger(v) || v < 0 || v > 10) errs.push(`${c.name}:axes.${k} 必須是 0-10 的整數`)
} }
if (c.language !== undefined && (typeof c.language !== 'string' || c.language.length > MAX_FIELD)) errs.push(`${c.name}:language 必須是簡短字串`)
if (!Array.isArray(c.timeline) || c.timeline.length === 0) errs.push(`${c.name}:時間線不可為空`) if (!Array.isArray(c.timeline) || c.timeline.length === 0) errs.push(`${c.name}:時間線不可為空`)
for (const t of c.timeline ?? []) { for (const t of c.timeline ?? []) {
if (!t.chapter) errs.push(`${c.name}:時間線缺少來源章節`) if (!t.chapter) errs.push(`${c.name}:時間線缺少來源章節`)
@@ -172,7 +181,7 @@ export const mergeCharacter = (
action: '新增', action: '新增',
detail: `時間線 ${chapter}`, detail: `時間線 ${chapter}`,
conflicts: [], conflicts: [],
pack: { slug, name: incoming.name, work: workSlug, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime, baseChapter: chapter, timeline: [entry], conflicts: [] }, pack: { slug, name: incoming.name, work: workSlug, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime, language: incoming.language, baseChapter: chapter, timeline: [entry], conflicts: [] },
} }
} }
const sameChapter = existing.timeline.some(t => t.chapter === chapter) const sameChapter = existing.timeline.some(t => t.chapter === chapter)
@@ -184,7 +193,7 @@ export const mergeCharacter = (
const conflicts: Conflict[] = existing.conflicts.filter(c => c.chapter !== chapter) const conflicts: Conflict[] = existing.conflicts.filter(c => c.chapter !== chapter)
let base = existing let base = existing
if (overwriteBase) { if (overwriteBase) {
base = { ...existing, name: incoming.name, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime } base = { ...existing, name: incoming.name, persona: incoming.persona, axes: incoming.axes, anime: incoming.anime, language: incoming.language ?? existing.language }
} else { } else {
const diffs: [string, string, string][] = [] const diffs: [string, string, string][] = []
for (const k of PERSONA_KEYS) if (existing.persona[k] !== incoming.persona[k]) diffs.push([`persona.${k}`, existing.persona[k], incoming.persona[k]]) for (const k of PERSONA_KEYS) if (existing.persona[k] !== incoming.persona[k]) diffs.push([`persona.${k}`, existing.persona[k], incoming.persona[k]])
@@ -219,3 +228,50 @@ export const parseRemote = (url: string): { host: string; repo: string } | undef
const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/) const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/)
return m ? { host: m[1]!, repo: m[2]! } : undefined return m ? { host: m[1]!, repo: m[2]! } : undefined
} }
export type WikiSource = { kind: 'list' | 'page'; url: string; origin: string; base: string; page?: string }
// 從自然語言參數中取出第一個 URL;Gitea wiki 頁面清單(?action=_pages 或 /wiki 首頁)視為整本逐章,單頁網址視為一章。
export const parseWikiSource = (args: string): WikiSource | undefined => {
const raw = /https?:\/\/[^\s<>"'「」『』()()]+/.exec(args)?.[0]?.replace(/[,。、;,.;:!?!?]+$/, '')
if (!raw) return undefined
let u: URL
try {
u = new URL(raw)
} catch {
return undefined
}
const m = /^(.*\/wiki)(?:\/(.*))?$/.exec(u.pathname.replace(/\/+$/, ''))
if (!m) return { kind: 'page', url: raw, origin: u.origin, base: raw }
const base = `${u.origin}${m[1]}`
const page = m[2] && !m[2].startsWith('raw/') && m[2] !== '_pages' ? decodeURIComponent(m[2]) : undefined
if (u.searchParams.get('action') === '_pages' || !page) return { kind: 'list', url: raw, origin: u.origin, base }
return { kind: 'page', url: raw, origin: u.origin, base, page }
}
const SKIP_PAGES = new Set(['home', 'sidebar', 'footer', '_sidebar', '_footer', '_pages', 'new'])
// 解析 Gitea wiki 頁面清單 HTML,依章節名稱自然排序(第2章 < 第10章)。
export const parseWikiPages = (html: string, base: string): string[] => {
const path = new URL(base).pathname
const names = new Set<string>()
for (const m of html.matchAll(/href="([^"#]+)"/g)) {
let p: string
try {
p = new URL(m[1]!.replace(/&amp;/g, '&'), base).pathname
} catch {
continue
}
if (!p.startsWith(`${path}/`)) continue
const rest = p.slice(path.length + 1)
if (!rest || rest.includes('/')) continue
let name: string
try {
name = decodeURIComponent(rest)
} catch {
continue
}
if (!SKIP_PAGES.has(name.toLowerCase())) names.add(name)
}
return [...names].sort((a, b) => a.localeCompare(b, 'zh-Hant', { numeric: true }))
}
+130 -18
View File
@@ -1,11 +1,15 @@
import type { Engine } from 'claude-code' import type { Engine } from 'claude-code'
import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, slugify, validateCharacter } from './extract' import { teaToken } from './wiki'
import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, parseWikiPages, parseWikiSource, slugify, validateCharacter } from './extract'
import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract' import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract'
export const EXTRACT_COMMAND = 'anima-extract' export const EXTRACT_COMMAND = 'anima-extract'
// PR 永遠開在 jsc-anima repo,不論 session 在哪個專案:改用專屬的快取 clone 讀寫角色包。
export const ANIMA_REPO_URL = 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git'
const PENDING_KEY = 'extract:pending' const PENDING_KEY = 'extract:pending'
const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>,之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。` const QUEUE_KEY = 'extract:queue'
const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑或 wiki 網址>(網址為 Gitea wiki 頁面清單時依序逐章處理,前後可加說明文字;或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。`
type Plan = { type Plan = {
files: { path: string; json: unknown }[] files: { path: string; json: unknown }[]
@@ -15,6 +19,8 @@ type Plan = {
workName: string workName: string
} }
type Queue = { base: string; pages: string[] }
type Pending = { type Pending = {
phase: 'slug' | 'summary' phase: 'slug' | 'summary'
chapter: string chapter: string
@@ -38,13 +44,101 @@ async function readJson<T>($: Engine, path: string): Promise<T | undefined> {
const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name
async function stage($: Engine, path: string): Promise<string> { type Repo = { dir: string; base: string; auth: string[] }
async function gitIn($: Engine, auth: string[], dir: string, a: string[]) {
const r = await $.process.run(['git', ...auth, ...a], { cwd: dir, timeoutMs: 120000 })
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`)
return String(r.stdout).trim()
}
// 取得 jsc-anima 的快取 clone 並對齊 origin 預設分支(認證優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數)。
async function syncRepo($: Engine): Promise<Repo | string> {
const home = (await $.env.get('HOME')) ?? ''
const dir = `${home}/.cache/jsc-anima/repo`
let token: string | undefined
try {
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(ANIMA_REPO_URL).host)
} catch {}
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
const auth = token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : []
try {
if (!(await $.fs.exists(`${dir}/.git`).catch(() => false))) {
await $.process.run(['mkdir', '-p', dir.replace(/\/[^/]+$/, '')], { timeoutMs: 5000 })
await gitIn($, auth, home || '/', ['clone', ANIMA_REPO_URL, dir])
}
await gitIn($, auth, dir, ['fetch', 'origin', '--prune'])
await gitIn($, auth, dir, ['remote', 'set-head', 'origin', '--auto'])
const base = (await gitIn($, auth, dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).replace(/^origin\//, '')
await gitIn($, auth, dir, ['checkout', '--detach', '--force', `origin/${base}`])
await gitIn($, auth, dir, ['clean', '-fd', 'characters'])
return { dir, base, auth }
} catch (err) {
return `無法取得 jsc-anima repo(${ANIMA_REPO_URL}):${(err as Error).message}`
}
}
async function giteaToken($: Engine, host: string): Promise<string | undefined> {
const home = (await $.env.get('HOME')) ?? ''
try {
const t = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), host)
if (t) return t
} catch {}
return (await $.env.get('GITEA_TOKEN')) || undefined
}
async function curl($: Engine, url: string): Promise<string | undefined> {
const token = await giteaToken($, new URL(url).host)
const r = await $.process.run(['curl', '-fsSL', ...(token ? ['-H', `Authorization: token ${token}`] : []), url], { timeoutMs: 60000 }).catch(() => undefined)
return r && r.exitCode === 0 ? String(r.stdout) : undefined
}
const rawUrl = (base: string, page: string) => `${base}/raw/${encodeURIComponent(page)}`
async function stageWiki($: Engine, base: string, page: string, rest: string[]): Promise<string> {
const text = await curl($, rawUrl(base, page))
if (!text?.trim()) return `無法讀取 wiki 章節「${page}」(${rawUrl(base, page)})。${rest.length ? `剩餘 ${rest.length} 章未處理。` : ''}`
await $.store.set(QUEUE_KEY, { base, pages: rest } satisfies Queue)
return stage($, page, text)
}
async function stageSource($: Engine, args: string, src: NonNullable<ReturnType<typeof parseWikiSource>>): Promise<string> {
if (src.kind === 'page') {
if (src.page) return stageWiki($, src.base, src.page, [])
const text = await curl($, src.url)
if (!text?.trim()) return `無法讀取網址:${src.url}`
return stage($, decodeURIComponent(src.url.split('/').pop() || 'chapter'), text)
}
const html = await curl($, `${src.base}/?action=_pages`)
if (!html) return `無法讀取 wiki 頁面清單:${src.base}/?action=_pages`
const pages = parseWikiPages(html, src.base)
if (!pages.length) return `wiki 頁面清單中找不到章節:${src.url}`
const [first, ...rest] = pages
const out = await stageWiki($, src.base, first!, rest)
return `依序逐章處理,共 ${pages.length} 章:${pages.join('、')}\n\n${out}`
}
// 目前這章處理完(PR 已建立)後,接著萃取佇列中的下一章。
async function nextChapter($: Engine, done: string): Promise<string> {
const q = (await $.store.get(QUEUE_KEY)) as Queue | undefined
if (!q?.pages.length) {
await $.store.delete(QUEUE_KEY)
return done
}
const [next, ...rest] = q.pages
return `${done}\n\n接著處理下一章(剩餘 ${rest.length} 章):\n${await stageWiki($, q.base, next!, rest)}`
}
async function stageFile($: Engine, path: string): Promise<string> {
const cwd = await $.session.cwd().catch(() => '') const cwd = await $.session.cwd().catch(() => '')
const full = path.startsWith('/') ? path : `${cwd}/${path}` const full = path.startsWith('/') ? path : `${cwd}/${path}`
if (!(await $.fs.exists(full).catch(() => false))) return `找不到檔案:${path}` if (!(await $.fs.exists(full).catch(() => false))) return `找不到檔案:${path}`
const text = String(await $.fs.read(full)) const text = String(await $.fs.read(full))
if (!text.trim()) return `檔案是空的:${path}` if (!text.trim()) return `檔案是空的:${path}`
return stage($, (path.split('/').pop() ?? path).replace(/\.[^.]+$/, ''), text)
}
async function stage($: Engine, chapter: string, text: string): Promise<string> {
const chunks = chunkText(text) const chunks = chunkText(text)
const parts: Extracted[] = [] const parts: Extracted[] = []
for (const [i, chunk] of chunks.entries()) { for (const [i, chunk] of chunks.entries()) {
@@ -54,8 +148,10 @@ async function stage($: Engine, path: string): Promise<string> {
if (!parsed) return `第 ${i + 1}/${chunks.length} 段的回覆不是有效的 JSON,尚未寫入任何檔案。` if (!parsed) return `第 ${i + 1}/${chunks.length} 段的回覆不是有效的 JSON,尚未寫入任何檔案。`
parts.push(parsed) parts.push(parsed)
} }
const repo = await syncRepo($)
if (typeof repo === 'string') return repo
const cwd = repo.dir
const extracted = mergeChunks(parts) const extracted = mergeChunks(parts)
const chapter = (path.split('/').pop() ?? path).replace(/\.[^.]+$/, '')
const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name) const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name)
if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。' if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。'
const slugs: Record<string, string> = {} const slugs: Record<string, string> = {}
@@ -95,6 +191,14 @@ async function buildPlan($: Engine, p: Pending): Promise<Plan | string> {
lines.push(`- 角色 ${p.workSlug}-${slug}(${c.name}):${m.action},${m.detail}${m.conflicts.length ? `,${m.conflicts.length} 項衝突待確認` : ''}`) lines.push(`- 角色 ${p.workSlug}-${slug}(${c.name}):${m.action},${m.detail}${m.conflicts.length ? `,${m.conflicts.length} 項衝突待確認` : ''}`)
conflicts.push(...m.conflicts) conflicts.push(...m.conflicts)
} }
// 索引讓啟動時能找到角色包(外掛更新後 characters/ 在外掛目錄內)。
const indexPath = `${p.root}/characters/index.json`
const index = (await readJson<{ packs: { work: string; workName: string; slug: string }[] }>($, indexPath)) ?? { packs: [] }
for (const c of p.extracted.characters) {
const slug = p.slugs[slugKey(c)]!
if (!index.packs.some(x => x.work === p.workSlug && x.slug === slug)) index.packs.push({ work: p.workSlug, workName: p.extracted.work.name, slug })
}
files.push({ path: indexPath, json: index })
if (errors.length) return `角色包 schema 驗證失敗,尚未寫入任何檔案:\n${errors.map(e => `- ${e}`).join('\n')}` if (errors.length) return `角色包 schema 驗證失敗,尚未寫入任何檔案:\n${errors.map(e => `- ${e}`).join('\n')}`
return { files, lines, conflicts, workSlug: p.workSlug, workName: p.extracted.work.name } return { files, lines, conflicts, workSlug: p.workSlug, workName: p.extracted.work.name }
} }
@@ -104,23 +208,19 @@ function run($: Engine, argv: string[], cwd: string) {
} }
async function openPr($: Engine, p: Pending, plan: Plan): Promise<string> { async function openPr($: Engine, p: Pending, plan: Plan): Promise<string> {
const root = p.root const repo = await syncRepo($)
const git = async (...a: string[]) => { if (typeof repo === 'string') return repo
const r = await run($, ['git', ...a], root) const root = repo.dir
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`) const git = (...a: string[]) => gitIn($, repo.auth, root, a)
return String(r.stdout).trim()
}
try { try {
const prev = await git('rev-parse', '--abbrev-ref', 'HEAD') const base = repo.base
const base = (await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).replace(/^origin\//, '')
const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}` const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}`
await git('fetch', 'origin', base) await git('checkout', '-B', branch, '--no-track', `origin/${base}`)
await git('switch', '-c', branch, '--no-track', `origin/${base}`)
for (const f of plan.files) await $.fs.write(f.path, `${JSON.stringify(f.json, null, 2)}\n`) for (const f of plan.files) await $.fs.write(f.path, `${JSON.stringify(f.json, null, 2)}\n`)
await git('add', 'characters') await git('add', 'characters')
await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`) await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`)
await git('push', '-u', 'origin', branch) await git('push', '-u', '--force-with-lease', 'origin', branch)
await git('switch', prev) await git('checkout', '--detach', `origin/${base}`)
const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包` const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包`
const body = [ const body = [
@@ -162,9 +262,21 @@ export async function runExtract($: Engine, args: string): Promise<string> {
if (head === 'cancel') { if (head === 'cancel') {
await $.store.delete(PENDING_KEY) await $.store.delete(PENDING_KEY)
await $.store.delete(QUEUE_KEY)
return '已取消,沒有寫入任何檔案。' return '已取消,沒有寫入任何檔案。'
} }
if (head !== 'confirm') return stage($, args.trim()) if (head === 'paste') {
// 貼上:第一行 paste <章節名稱>,下一行起為章節內容。
const [first = '', ...body] = args.trim().replace(/\r\n?/g, '\n').split('\n')
const name = first.replace(/^paste\s*/, '').trim()
const text = body.join('\n')
if (!name || !text.trim()) return `貼上章節的用法:/jsc-anima:${EXTRACT_COMMAND} paste <章節名稱>,下一行起貼上章節內容。`
return stage($, name, text)
}
if (head !== 'confirm') {
const src = parseWikiSource(args)
return src ? stageSource($, args, src) : stageFile($, args.trim())
}
const p = (await $.store.get(PENDING_KEY)) as Pending | undefined const p = (await $.store.get(PENDING_KEY)) as Pending | undefined
if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。` if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。`
@@ -191,5 +303,5 @@ export async function runExtract($: Engine, args: string): Promise<string> {
const plan = p.plan! const plan = p.plan!
await $.store.delete(PENDING_KEY) await $.store.delete(PENDING_KEY)
return openPr($, p, plan) return nextChapter($, await openPr($, p, plan))
} }
+76
View File
@@ -0,0 +1,76 @@
import { expect, mock, test } from 'claude-code/testing'
import { CHARACTERS } from './characters'
const setup = (on: any, active?: string) => {
mock.store(on, active ? { active } : undefined)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
}
const compose = ($: any) => $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
const persona = async ($: any): Promise<string | undefined> => (await compose($)).sections.find((s: any) => s.id === 'jsc-anima:persona')?.text
const start = ($: any) => $.session.start({ source: 'startup', cwd: '/tmp' } as never)
test('啟動狀態跨 session 保留:下次開啟自動恢復', async ($, on) => {
setup(on, 'sao-suguha')
await start($)
expect(await persona($)).toContain('直葉')
})
test('啟動角色後寫入儲存,新 session 恢復;離開後不再恢復', async ($, on) => {
setup(on)
await start($)
await $.command.run({ command: 'sao-asuna', args: '' } as never)
await $.command.run({ command: 'anima-off', args: '' } as never)
await $.command.run({ command: 'sao-suguha', args: '' } as never)
await start($)
expect(await persona($)).toContain('直葉')
await $.command.run({ command: 'anima-off', args: '' } as never)
await start($)
expect(await persona($)).toBeUndefined()
})
test('在 /config 關閉 persist 後不恢復', { options: { persist: false } }, async ($, on) => {
setup(on, 'sao-asuna')
await start($)
expect(await persona($)).toBeUndefined()
})
test('儲存的角色不存在時不恢復', async ($, on) => {
setup(on, 'no-such-character')
await start($)
expect(await persona($)).toBeUndefined()
})
test('微調數值軸:反映在 persona,其餘維持角色包預設,且不改動角色包', { options: { axisGentle: 2, axisHumor: 9 } }, async ($, on) => {
setup(on, 'sao-asuna')
const before = { ...CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes }
await start($)
const text = (await persona($))!
expect(text).toContain('溫柔 2')
expect(text).toContain('幽默 9')
expect(text).toContain(`傲嬌 ${before.tsundere}`)
expect(CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes).toEqual(before)
})
test('未微調(-1)沿用角色包預設', { options: { axisGentle: -1 } }, async ($, on) => {
setup(on, 'sao-asuna')
await start($)
expect(await persona($)).toContain(`溫柔 ${CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes.gentle}`)
})
test('8 個數值軸都可微調', {
options: { axisGentle: 1, axisTsundere: 2, axisLively: 3, axisFormal: 4, axisTalkative: 5, axisHumor: 6, axisProactive: 7, axisExpressive: 8 },
}, async ($, on) => {
setup(on, 'sao-asuna')
await start($)
expect(await persona($)).toContain('溫柔 1、傲嬌 2、活潑 3、正式 4、話量 5、幽默 6、主動 7、情緒外顯 8')
})
test('超出 0-10 的覆蓋值被忽略', { options: { axisGentle: 11 } }, async ($, on) => {
setup(on, 'sao-asuna')
await start($)
expect(await persona($)).toContain(`溫柔 ${CHARACTERS.find(c => c.slug === 'sao-asuna')!.axes.gentle}`)
})
+84 -23
View File
@@ -1,11 +1,12 @@
import type { Engine, Register } from 'claude-code' import type { Engine, Register } from 'claude-code'
import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters' import { CHARACTERS, applyOverrides, findCharacter, packToCharacter, personaSection, registerCharacters, themeColor } from './characters'
import type { PackLike } from './characters'
import type { Axes } from './characters' import type { Axes } from './characters'
import { EXTRACT_COMMAND, runExtract } from './extractCommand' import { EXTRACT_COMMAND, runExtract } from './extractCommand'
import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity' import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } from './affinity'
import type { Affinity } from './affinity' import type { Affinity } from './affinity'
import { relationOf, relationText } from './relation' import { latestEntry, relationOf, relationText } from './relation'
import { CLASSIFY_PROMPT, choose, parseRoute } from './router' import { CLASSIFY_PROMPT, choose, parseRoute } from './router'
import type { Effort, ModelName, Route } from './router' import type { Effort, ModelName, Route } from './router'
import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite' import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite'
@@ -15,10 +16,9 @@ import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog,
import type { Entry } from './wiki' import type { Entry } from './wiki'
import { DEFAULT_TYPES, RUN_SYSTEM, TODO_COMMAND, TODO_KEY, TYPES_KEY, USAGE, dueNow, nextId, parseArgs as parseTodo, reminderText, renderList } from './todo' import { DEFAULT_TYPES, RUN_SYSTEM, TODO_COMMAND, TODO_KEY, TYPES_KEY, USAGE, dueNow, nextId, parseArgs as parseTodo, reminderText, renderList } from './todo'
import type { Todo } from './todo' import type { Todo } from './todo'
import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool' import { NEED_ACTIVE, OFF_COMMAND, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
const ACTIVE_KEY = 'active' const ACTIVE_KEY = 'active'
const OFF_COMMAND = 'anima-off'
const AFFINITY_COMMAND = 'anima-affinity' const AFFINITY_COMMAND = 'anima-affinity'
const RAW_LOG_KEY = 'rawlog' const RAW_LOG_KEY = 'rawlog'
const RAW_LOG_MAX = 200 const RAW_LOG_MAX = 200
@@ -242,6 +242,26 @@ async function quotaRemain($: Engine): Promise<number | undefined> {
} }
async function pickRoute($: Engine, r: Route | undefined, o: Omit<Parameters<typeof choose>[1], 'quotaRemain'>) {
return choose(r ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '', work: false }, { ...o, quotaRemain: await quotaRemain($) })
}
// 讀 characters/index.json(外掛目錄內),把萃取產生的角色包註冊成可啟動的角色。
async function loadPacks($: Engine): Promise<void> {
const dir = `${$.plugin.root}/characters/`
try {
if (!(await $.fs.exists(`${dir}index.json`))) return
const index = JSON.parse(await $.fs.read(`${dir}index.json`)) as { packs: { work: string; workName: string; slug: string }[] }
for (const i of index.packs) {
const pack = JSON.parse(await $.fs.read(`${dir}${i.work}/${i.slug}.json`)) as PackLike
const work = (await $.fs.exists(`${dir}${i.work}/work.json`)) ? (JSON.parse(await $.fs.read(`${dir}${i.work}/work.json`)) as { terms?: { term: string; definition: string }[] }) : undefined
registerCharacters([packToCharacter(pack, i.workName, work?.terms ?? [])])
}
} catch {
// 角色包損壞不影響內建角色。
}
}
export const register: Register = (on, options) => { export const register: Register = (on, options) => {
const opt = options as Record<string, unknown> const opt = options as Record<string, unknown>
const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d) const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d)
@@ -252,6 +272,9 @@ export const register: Register = (on, options) => {
let active: string | undefined let active: string | undefined
let toolRegistered = false let toolRegistered = false
let route: Route | undefined let route: Route | undefined
let delegating = false
const jobs = new Map<string, string>()
let lastPick: { model: ModelName; effort: Effort } | undefined
let prefixed = false let prefixed = false
const holder: { sys?: Sys } = {} const holder: { sys?: Sys } = {}
const wikiCommands = new Set<string>() const wikiCommands = new Set<string>()
@@ -274,14 +297,21 @@ export const register: Register = (on, options) => {
return out return out
} }
const routeOpts = () => ({
candidates: candidates(),
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
downgradeAt: num('downgradeAt', 15),
})
on('session.start', async ($, e, next) => { on('session.start', async ($, e, next) => {
if (isEnabled) { if (isEnabled) {
await loadPacks($)
for (const c of CHARACTERS) { for (const c of CHARACTERS) {
await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` }) await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` })
} }
await $.command.register({ name: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' }) await $.command.register({ name: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' })
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)目前角色的好感度' }) await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)好感度;未啟動時需指定角色代號' })
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
const saved = await $.store.get(ACTIVE_KEY) const saved = await $.store.get(ACTIVE_KEY)
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
if (active && !toolRegistered) { if (active && !toolRegistered) {
@@ -299,6 +329,7 @@ export const register: Register = (on, options) => {
if (!isEnabled) return next(e) if (!isEnabled) return next(e)
if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) } if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) }
if (e.command === OFF_COMMAND) { if (e.command === OFF_COMMAND) {
if (!findCharacter(active)) return { text: NEED_ACTIVE }
active = undefined active = undefined
await $.store.delete(ACTIVE_KEY) await $.store.delete(ACTIVE_KEY)
return { text: '已離開角色,回到一般模式。' } return { text: '已離開角色,回到一般模式。' }
@@ -311,18 +342,21 @@ export const register: Register = (on, options) => {
return { text: c ? await todoRun($, c.name, c.persona, String(e.args ?? '')) : NEED_ACTIVE } return { text: c ? await todoRun($, c.name, c.persona, String(e.args ?? '')) : NEED_ACTIVE }
} }
if (e.command === AFFINITY_COMMAND) { if (e.command === AFFINITY_COMMAND) {
const c = findCharacter(active) // 第一個字若是角色代號就指定該角色;否則用目前啟動的角色。
if (!c) return { text: '請先啟動角色。' } const parts = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
const arg = String(e.args ?? '').trim() const named = parts.length ? findCharacter(parts[0]) : undefined
const c = named ?? findCharacter(active)
if (!c) return { text: '未啟動角色時請指定角色代號:anima-affinity <角色代號> [reset|0-100]。' }
const arg = (named ? parts.slice(1) : parts).join(' ')
const cur = await loadAffinity($, c.slug) const cur = await loadAffinity($, c.slug)
if (arg === 'reset') { if (arg === 'reset') {
await $.store.set(affinityKey(c.slug), initialAffinity()) await $.store.set(affinityKey(c.slug), initialAffinity())
return { text: '好感度已重置為 0。' } return { text: `${c.name}的好感度已重置為 0。` }
} }
const n = Number(arg) const n = Number(arg)
if (arg === '' || !Number.isFinite(n) || n < 0 || n > 100) return { text: `目前好感度 ${cur.score}(${stageName(cur.score)})。用法:reset 或 0-100 的數字。` } if (arg === '' || !Number.isFinite(n) || n < 0 || n > 100) return { text: `${c.name}目前好感度 ${cur.score}(${stageName(cur.score)})。用法:[角色代號] reset 或 0-100 的數字。` }
await $.store.set(affinityKey(c.slug), { ...cur, score: Math.round(n) }) await $.store.set(affinityKey(c.slug), { ...cur, score: Math.round(n) })
return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` } return { text: `${c.name}的好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
} }
const c = findCharacter(e.command) const c = findCharacter(e.command)
if (!c) { if (!c) {
@@ -351,10 +385,11 @@ export const register: Register = (on, options) => {
let text = personaSection(c, applyOverrides(c.axes, overrides())) let text = personaSection(c, applyOverrides(c.axes, overrides()))
if (flag('affinity', true)) { if (flag('affinity', true)) {
const a = await loadAffinity($, c.slug) const a = await loadAffinity($, c.slug)
text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)))}` text += `\n${stageVoice(a.score)}\n${relationText(relationOf(c.timeline, a.score, flag('romance', false)), latestEntry(c.timeline)?.romance)}`
} else { } else {
text += `\n${relationText(relationOf(c.timeline, 0, false))}` text += `\n${relationText(relationOf(c.timeline, 0, false), latestEntry(c.timeline)?.romance)}`
} }
if (delegating) text += '\n本輪的實際工作已交給背景工作線處理,你不要自己動手做。請只用角色口吻簡短告知已開始處理;完成後會有回報,屆時再向使用者說明結果。'
return { return {
...res, ...res,
sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }], sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }],
@@ -366,6 +401,9 @@ export const register: Register = (on, options) => {
if (c && flag('todoRemind', true)) await remindDue($, c.name) if (c && flag('todoRemind', true)) await remindDue($, c.name)
if (!c || e.command || e.isMeta) return next(e) if (!c || e.command || e.isMeta) return next(e)
prefixed = false prefixed = false
delegating = false
// 工作線完成後由本外掛送回的回報:直接讓角色說明,不再分類或派工。
if (e.origin?.kind === 'plugin') return next(e)
const axes = applyOverrides(c.axes, overrides()) const axes = applyOverrides(c.axes, overrides())
if (routingOn()) { if (routingOn()) {
const res = await $.model const res = await $.model
@@ -373,6 +411,17 @@ export const register: Register = (on, options) => {
.catch(() => undefined) .catch(() => undefined)
route = parseRoute(res && res.isAnswered ? res.text : '') route = parseRoute(res && res.isAnswered ? res.text : '')
if (route.ack) $.ui.log(withPrefix(c.name, route.ack)) if (route.ack) $.ui.log(withPrefix(c.name, route.ack))
if (route.work && flag('background', true)) {
const pick = await pickRoute($, route, routeOpts())
const started = await $.agent
.spawn({ prompt: e.text, description: e.text.slice(0, 30), model: pick.model })
.catch(() => undefined)
if (started && !('deny' in started && started.deny)) {
if (started.agentId) jobs.set(started.agentId, e.text)
delegating = true
lastPick = pick
}
}
} else { } else {
route = undefined route = undefined
} }
@@ -390,16 +439,28 @@ export const register: Register = (on, options) => {
on('turn.step', async function* ($, e, next) { on('turn.step', async function* ($, e, next) {
const c = isEnabled ? findCharacter(active) : undefined const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !routingOn()) return yield* next(e) if (!c || e.agentId || !routingOn()) return yield* next(e)
const pick = choose(route ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' }, { // 對話線:工作已交給背景工作線時,主迴圈只負責以角色口吻回應,用最便宜的模型。
candidates: candidates(), const pick = delegating ? { model: 'haiku' as ModelName, effort: 'low' as Effort } : await pickRoute($, route, routeOpts())
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']), lastPick = { model: pick.model, effort: pick.effort }
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
quotaRemain: await quotaRemain($),
downgradeAt: num('downgradeAt', 15),
})
return yield* next({ ...e, model: pick.model, effort: pick.effort }) return yield* next({ ...e, model: pick.model, effort: pick.effort })
}) })
on('turn.complete', async ($, e, next) => {
const res = await next(e)
const task = e.agentId ? jobs.get(e.agentId) : undefined
const c = isEnabled ? findCharacter(active) : undefined
if (e.agentId && task !== undefined) {
jobs.delete(e.agentId)
if (c) {
const body = e.reason === 'answer' ? e.answer : `(工作未完成:${e.reason})`
void $.prompt.submit({ text: `[工作線回報] 使用者的要求:${task}\n處理結果:\n${body}\n請用角色口吻向使用者說明結果,技術內容原樣保留。` }).catch(() => undefined)
}
} else if (!e.agentId) {
delegating = false
}
return res
})
on('session.append', { door: 'response' }, async ($, e, next) => { on('session.append', { door: 'response' }, async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !flag('rewrite', true) || e.message.type !== 'assistant') return next(e) if (!c || e.agentId || !flag('rewrite', true) || e.message.type !== 'assistant') return next(e)
@@ -458,13 +519,13 @@ export const register: Register = (on, options) => {
} }
const builders: Record<string, () => Promise<Segment['parts'] | undefined> | Segment['parts'] | undefined> = { const builders: Record<string, () => Promise<Segment['parts'] | undefined> | Segment['parts'] | undefined> = {
character: () => [{ text: c.name, color: 'cyan' }], character: () => [{ text: c.name, color: themeColor(c) }],
love: async () => { love: async () => {
if (!flag('affinity', true) || !flag('showHearts', true)) return undefined if (!flag('affinity', true) || !flag('showHearts', true)) return undefined
const a = await loadAffinity($, c.slug) const a = await loadAffinity($, c.slug)
return [{ text: hearts(a.score), color: 'red' as const }] return [{ text: hearts(a.score), color: 'red' as const }]
}, },
model: () => (model ? [{ text: model }] : undefined), model: () => (model ? [{ text: lastPick && routingOn() ? `${model} · ${lastPick.effort}` : model }] : undefined),
dir: () => (cwd ? [{ text: cwd.split('/').filter(Boolean).pop() ?? '/' }] : undefined), dir: () => (cwd ? [{ text: cwd.split('/').filter(Boolean).pop() ?? '/' }] : undefined),
branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined), branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined),
'5h': () => quota('five_hour', '5h', false), '5h': () => quota('five_hour', '5h', false),
+4 -3
View File
@@ -1,15 +1,15 @@
export type ModelName = 'haiku' | 'sonnet' | 'opus' export type ModelName = 'haiku' | 'sonnet' | 'opus'
export type Effort = 'low' | 'medium' | 'high' export type Effort = 'low' | 'medium' | 'high'
export type Tone = 'neutral' | 'praise' | 'scold' export type Tone = 'neutral' | 'praise' | 'scold'
export type Route = { difficulty: number; model: ModelName; effort: Effort; tone: Tone; ack: string } export type Route = { difficulty: number; model: ModelName; effort: Effort; tone: Tone; ack: string; work: boolean }
export const LADDER: readonly ModelName[] = ['haiku', 'sonnet', 'opus'] export const LADDER: readonly ModelName[] = ['haiku', 'sonnet', 'opus']
export const FALLBACK_ROUTE: Route = { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' } export const FALLBACK_ROUTE: Route = { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '', work: false }
export const CLASSIFY_PROMPT = export const CLASSIFY_PROMPT =
'你是路由器。讀下面使用者輸入,只回傳一行 JSON:' + '你是路由器。讀下面使用者輸入,只回傳一行 JSON:' +
'{"difficulty":1-10,"model":"haiku|sonnet|opus","effort":"low|medium|high","tone":"neutral|praise|scold","ack":"角色以口吻立刻回應的一句話"}。' + '{"difficulty":1-10,"model":"haiku|sonnet|opus","effort":"low|medium|high","tone":"neutral|praise|scold","ack":"角色以口吻立刻回應的一句話","work":true|false}。work 為 true 表示需要動手做事(讀寫檔、執行指令、搜尋、改程式);純閒聊或一句話可答的問題為 false。' +
'difficulty 低用 haiku,中用 sonnet,高或需深入推理用 opus;tone 是使用者對角色的態度(誇獎/感謝為 praise,責罵為 scold)。\n\n使用者輸入:\n' 'difficulty 低用 haiku,中用 sonnet,高或需深入推理用 opus;tone 是使用者對角色的態度(誇獎/感謝為 praise,責罵為 scold)。\n\n使用者輸入:\n'
const isModel = (v: unknown): v is ModelName => v === 'haiku' || v === 'sonnet' || v === 'opus' const isModel = (v: unknown): v is ModelName => v === 'haiku' || v === 'sonnet' || v === 'opus'
@@ -27,6 +27,7 @@ export const parseRoute = (text: string): Route => {
effort: isEffort(j.effort) ? j.effort : FALLBACK_ROUTE.effort, effort: isEffort(j.effort) ? j.effort : FALLBACK_ROUTE.effort,
tone: isTone(j.tone) ? j.tone : 'neutral', tone: isTone(j.tone) ? j.tone : 'neutral',
ack: typeof j.ack === 'string' ? j.ack.slice(0, 200) : '', ack: typeof j.ack === 'string' ? j.ack.slice(0, 200) : '',
work: j.work === true,
} }
} catch { } catch {
return FALLBACK_ROUTE return FALLBACK_ROUTE
+225 -6
View File
@@ -1,17 +1,21 @@
import { expect, mock, test } from 'claude-code/testing' import { expect, mock, test } from 'claude-code/testing'
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any } type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[]; clock?: any }
const setup = (on: any): Ctl => { const setup = (on: any, kv?: Map<string, unknown>): Ctl => {
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} } } const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} }, submitted: [] }
mock.store(on) if (kv) {
mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') }) // 自備 store 以便檢查寫入內容。
on('store.get', (_$: any, e: any) => ({ value: kv.get(e.key) }) as never)
on('store.set', (_$: any, e: any) => (kv.set(e.key, e.value), { value: undefined }) as never)
} else mock.store(on)
ctl.clock = mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
on('ui.log', () => ({ value: undefined }) as never) on('ui.log', () => ({ value: undefined }) as never)
on('command.register', () => ({ value: { name: 'x' } }) as never) on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never) on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' })) on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] })) on('prompt.compose', () => ({ sections: [] }))
on('prompt.submit', (_$: any, e: any) => ({ text: e.text }) as never) on('prompt.submit', (_$: any, e: any) => (ctl.submitted.push(e.text), { text: e.text }) as never)
on('model.complete', (_$: any, e: any) => ({ value: ctl.complete(e) }) as never) on('model.complete', (_$: any, e: any) => ({ value: ctl.complete(e) }) as never)
on('session.usage', () => ({ value: ctl.usage }) as never) on('session.usage', () => ({ value: ctl.usage }) as never)
on('turn.step', async function* (_$: any, e: any) { on('turn.step', async function* (_$: any, e: any) {
@@ -92,6 +96,96 @@ test('改寫失敗或遺失佔位符時顯示原文', async ($, on) => {
expect(r2.message.content[0].text).toBe('原文 `y` 5') expect(r2.message.content[0].text).toBe('原文 `y` 5')
}) })
test('改寫模型:長回覆用 Sonnet,一般回覆用 Haiku', async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), { isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('短回覆'))
await $.session.append(row('長'.repeat(1300)))
expect(asked).toEqual(['haiku', 'sonnet'])
})
test('longReply 可調整長回覆門檻', { options: { longReply: 10 } }, async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), { isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('十一個字的回覆內容喔喔'))
await $.session.append(row('短'))
expect(asked).toEqual(['sonnet', 'haiku'])
})
test('改寫逾時、額度不足或拋錯時直接顯示原文', async ($, on) => {
const ctl = setup(on)
await start($)
ctl.complete = () => ({ isAnswered: false, reason: 'timeout' })
const r1: any = await $.session.append(row('逾時 1'))
expect(r1.message.content[0].text).toBe('亞絲娜 > 逾時 1')
ctl.complete = () => ({ isAnswered: false, reason: 'rate_limited' })
const r2: any = await $.session.append(row('額度 2'))
expect(r2.message.content[0].text).toBe('額度 2')
ctl.complete = () => { throw new Error('quota') }
const r3: any = await $.session.append(row('拋錯 3'))
expect(r3.message.content[0].text).toBe('拋錯 3')
})
test('改寫逾時設定傳給模型呼叫', { options: { rewriteTimeoutSec: 5 } }, async ($, on) => {
const ctl = setup(on)
let timeout = 0
ctl.complete = e => (timeout = e.timeoutMs, { isAnswered: false, reason: 'timeout' })
await start($)
await $.session.append(row('x'))
expect(timeout).toBe(5000)
})
const rawLog = (kv: Map<string, unknown>): string[] => ((kv.get('rawlog') as { raw: string }[] | undefined) ?? []).map(x => x.raw)
test('原文 log 預設寫入,改寫失敗時不寫', async ($, on) => {
const kv = new Map<string, unknown>()
const ctl = setup(on, kv)
await start($)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await $.session.append(row('原文甲'))
ctl.complete = () => ({ isAnswered: false, reason: 'timeout' })
await $.session.append(row('原文乙'))
expect(rawLog(kv)).toEqual(['原文甲'])
})
test('在 /config 關閉原文 log 時不寫入', { options: { rawLog: false } }, async ($, on) => {
const kv = new Map<string, unknown>()
const ctl = setup(on, kv)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
const r: any = await $.session.append(row('原文丙'))
expect(r.message.content[0].text).toBe('亞絲娜 > 原文丙')
expect(rawLog(kv)).toEqual([])
})
test('前綴只加在整則回覆開頭一次,多段文字區塊與多次寫入都不重複', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
const multi = { message: { type: 'assistant', role: 'assistant', content: [{ type: 'text', text: '第一段' }, { type: 'tool_use', id: 't', name: 'x', input: {} }, { type: 'text', text: '第二段' }] }, door: 'response', origin: { kind: 'model', model: 'm' }, uuid: 'u' } as never
const r: any = await $.session.append(multi)
expect(r.message.content[0].text).toBe('亞絲娜 > 第一段')
expect(r.message.content[2].text).toBe('第二段')
const r2: any = await $.session.append(row('同一回覆後續'))
expect(r2.message.content[0].text).toBe('同一回覆後續')
})
test('新的使用者輸入後,下一則回覆重新加前綴', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
await start($)
await $.session.append(row('第一則'))
ctl.complete = () => route('haiku')
await submit($, '再問一次')
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt) })
const r: any = await $.session.append(row('第二則'))
expect(r.message.content[0].text).toBe('亞絲娜 > 第二則')
})
test('好感度:由 0 開始,每日首次互動加分且有每日上限', async ($, on) => { test('好感度:由 0 開始,每日首次互動加分且有每日上限', async ($, on) => {
const ctl = setup(on) const ctl = setup(on)
ctl.complete = () => route('haiku', 'praise') ctl.complete = () => route('haiku', 'praise')
@@ -142,3 +236,128 @@ test('未成年角色維持友情,即使好感度最高且開啟戀愛式依
await $.command.run({ command: 'anima-affinity', args: '100' } as never) await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('關係定位:友情')).toBe(true) expect((await personaText($)).includes('關係定位:友情')).toBe(true)
}) })
// 以 fs 事件邊界提供角色包(characters/index.json),讓外掛自行載入成年角色。
const packs = (on: any, list: { slug: string; romance?: string }[]) => {
const files = new Map<string, string>()
files.set('index.json', JSON.stringify({ packs: list.map(p => ({ work: 'tw', workName: '測試作品', slug: p.slug })) }))
for (const p of list) {
files.set(
`tw/${p.slug}.json`,
JSON.stringify({
work: 'tw',
slug: p.slug,
name: '成年測試',
persona: { selfRef: '我', address: '你', tone: '平穩', catchphrase: '好', values: '可靠', taboos: '無' },
axes: { gentle: 5, tsundere: 5, lively: 5, formal: 5, talkative: 5, humor: 5, proactive: 5, expressive: 5 },
anime: { moe: '', speech: '', emotions: '', kaomoji: '', themeColor: 'cyan' },
timeline: [
{ chapter: '第1章', age: 17, relationships: [] },
{ chapter: '第2章', age: 20, relationships: p.romance ? [{ with: p.romance, kind: 'romantic' }] : [] },
],
}),
)
}
const key = (path: string) => path.replace(/^.*\/characters\//, '')
on('fs.exists', (_$: any, e: any) => ({ value: files.has(key(e.path)) }) as never)
on('fs.read', (_$: any, e: any) => ({ value: files.get(key(e.path)) ?? '' }) as never)
}
test('成年且原作有戀愛關係:關係移轉給使用者,原戀人退為過去的重要人物', async ($, on) => {
setup(on)
packs(on, [{ slug: 'lover', romance: '桐人' }])
await start($, 'tw-lover')
const t = await personaText($)
expect(t.includes('已移轉給使用者')).toBe(true)
expect(t.includes('桐人')).toBe(true)
expect(t.includes('過去的重要人物')).toBe(true)
})
test('成年但原作無戀愛關係:需最高階段且明確開啟才進入戀愛式依賴', async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(false)
})
test('成年無戀愛、最高階段但未在 /config 開啟:維持夥伴關係', { options: { romance: false } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
expect((await personaText($)).includes('夥伴與朋友')).toBe(true)
})
test('成年無戀愛、已開啟但好感度未達最高階段:不進入戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '79' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(false)
})
test('成年無戀愛、最高階段且已開啟:進入戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
packs(on, [{ slug: 'plain' }])
await start($, 'tw-plain')
await $.command.run({ command: 'anima-affinity', args: '80' } as never)
expect((await personaText($)).includes('戀愛式依賴')).toBe(true)
})
test('長期不互動好感度緩慢下降,但不跨階段掉落', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku')
await start($)
await submit($, '你好')
await $.command.run({ command: 'anima-affinity', args: '45' } as never)
await ctl.clock.advance(30 * 86_400_000)
await submit($, '我回來了')
const n = await affinity($)
expect(n < 45).toBe(true)
expect(n >= 40).toBe(true)
})
test('個性軸影響扣分倍率:傲嬌角色掉得比溫柔角色多', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku')
packs(on, [{ slug: 'plain' }])
const run = async (slug: string) => {
await start($, slug)
await submit($, '你好')
await $.command.run({ command: 'anima-affinity', args: '70' } as never)
await ctl.clock.advance(8 * 86_400_000)
await submit($, '我回來了')
return affinity($)
}
const proud = await run('tw-plain')
const calm = await run('sao-suguha')
expect(proud < calm).toBe(true)
})
const workRoute = (model: string, work: boolean) => ({ isAnswered: true, usage, text: `{"difficulty":8,"model":"${model}","effort":"high","tone":"neutral","ack":"好,我來處理。","work":${work}}` })
test('需要動手的要求交給背景工作線,對話線改用 Haiku', async ($, on) => {
const ctl = setup(on)
const spawned: any[] = []
on('agent.spawn', (_$: any, e: any) => (spawned.push(e), { model: e.model, agentId: 'a1' }) as never)
ctl.complete = () => workRoute('opus', true)
await start($)
await submit($, '幫我重構 src 目錄')
expect(spawned.length).toBe(1)
expect(spawned[0].model).toBe('opus')
await step($, 'main')
expect(ctl.seen.model).toBe('haiku')
const sys = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
expect(sys.sections.find((s: any) => s.id === 'jsc-anima:persona')?.text).toContain('背景工作線')
})
test('work 為 false 或關閉背景工作線時不派工', async ($, on) => {
const ctl = setup(on)
let n = 0
on('agent.spawn', () => (n++, { model: 'm', agentId: 'a' }) as never)
ctl.complete = () => workRoute('sonnet', false)
await start($)
await submit($, '你好')
expect(n).toBe(0)
})
+28
View File
@@ -58,6 +58,33 @@ export const parseEntries = (readme: string): SkillEntry[] => {
export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] => export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] =>
[...list.filter(x => x.skill !== e.skill), e].sort((a, b) => a.skill.localeCompare(b.skill)) [...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/<作品代號>/` 並對 jsc-anima repo 的預設分支建立 PR(不自動合併)。在任何專案目錄執行都一樣,檔案會寫進 `~/.cache/jsc-anima/repo` 的快取 clone;PR 合併並更新外掛、重開 Claude Code 後,用 `/jsc-anima:<作品代號>-<角色代號>` 啟動。',
'- `/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、技能列表(完整可複製指令)與範例。 // 技能 README:名稱 /jsc-anima:jsc-anima、技能列表(完整可複製指令)與範例。
export const renderGuide = (list: SkillEntry[], example?: { skill: string; input: string }): string => { export const renderGuide = (list: SkillEntry[], example?: { skill: string; input: string }): string => {
const ex = example ?? { skill: list[0]?.skill ?? 'example', input: '' } const ex = example ?? { skill: list[0]?.skill ?? 'example', input: '' }
@@ -80,6 +107,7 @@ export const renderGuide = (list: SkillEntry[], example?: { skill: string; input
'', '',
'產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。', '產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。',
'', '',
...USAGE_SECTIONS,
].join('\n') ].join('\n')
} }
+2
View File
@@ -7,10 +7,12 @@ import {
} from './skillgen' } from './skillgen'
export const TOOL_COMMAND = 'anima-skill' export const TOOL_COMMAND = 'anima-skill'
export const OFF_COMMAND = 'anima-off'
export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。' export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
export async function registerTools($: Engine): Promise<void> { export async function registerTools($: Engine): Promise<void> {
await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' }) await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' })
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
await $.command.register({ name: TODO_COMMAND, description: '待辦事項:add/update/list/type/run/done' }) await $.command.register({ name: TODO_COMMAND, description: '待辦事項:add/update/list/type/run/done' })
} }
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by Vitest.
# For information about cache directory tags see https://bford.info/cachedir/
@@ -0,0 +1 @@
{"version":1,"projects":{"":{"files":{"hooks/anima.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/extract.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/persist.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/routing.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/skilltool.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/todo.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665},"hooks/wiki.test.ts":{"failed":true,"duration":0,"lastRun":1792056694665}}}}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by Vitest.
# For information about cache directory tags see https://bford.info/cachedir/
+3
View File
@@ -0,0 +1,3 @@
{
"lockfileHash": "e3b0c442"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22 -1
View File
@@ -15,4 +15,25 @@
產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。 產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。
待辦事項:`/jsc-anima:anima-todo add 提醒 2026-10-16 09:00 開會`,到點由角色提醒,`run <編號>` 確認後才執行。 ## 角色與萃取
- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對 jsc-anima repo 的預設分支建立 PR(不自動合併)。在任何專案目錄執行都一樣,檔案會寫進 `~/.cache/jsc-anima/repo` 的快取 clone;PR 合併並更新外掛、重開 Claude Code 後,用 `/jsc-anima:<作品代號>-<角色代號>` 啟動。
- `/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 不寫入任何檔案。