Compare commits
24
Commits
14b55a7dea
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa41b6cb64 | ||
|
|
74a5d4949c | ||
|
|
1ce475024d | ||
|
|
20fc0a9c6c | ||
|
|
f112cf48f1 | ||
|
|
dd930160c5 | ||
|
|
8554b804c0 | ||
|
|
09fd67de45 | ||
|
|
f1fa02c4f1 | ||
|
|
1b4ee92594 | ||
|
|
2a4d138cdc | ||
|
|
e2fcf6be2b | ||
|
|
0633cad75a | ||
|
|
535c5b1437 | ||
|
|
30aedeae2d | ||
|
|
99c53a9210 | ||
|
|
98522cdb97 | ||
|
|
bb68343e79 | ||
|
|
fb04b0fa23 | ||
|
|
8c25728206 | ||
|
|
e99e7b1536 | ||
|
|
59fcf2b3eb | ||
|
|
1ea8ff5408 | ||
|
|
bc79573a93 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-anima",
|
||||
"version": "0.2.0",
|
||||
"version": "0.0.5",
|
||||
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
|
||||
"license": "MIT",
|
||||
"userConfig": {
|
||||
@@ -62,6 +62,12 @@
|
||||
"description": "輸入先由 Haiku 一次分類,再依難度選模型與思考程度",
|
||||
"default": true
|
||||
},
|
||||
"background": {
|
||||
"type": "boolean",
|
||||
"title": "背景工作線",
|
||||
"description": "需要動手的要求交給背景子代理處理,對話線立刻回應、完成後由角色回報",
|
||||
"default": true
|
||||
},
|
||||
"candidates": {
|
||||
"type": "string",
|
||||
"title": "候選模型",
|
||||
|
||||
@@ -2,54 +2,33 @@
|
||||
|
||||
# jsc-anima
|
||||
|
||||
目前版本:0.1.0
|
||||
目前版本:0.0.5
|
||||
|
||||
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、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
|
||||
```
|
||||
|
||||
或用 CLI:
|
||||
|
||||
```
|
||||
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima
|
||||
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git
|
||||
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 uninstall jsc-anima
|
||||
claude plugin marketplace remove jsc-anima
|
||||
```
|
||||
|
||||
## 使用說明
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
import { HIDE_ORDER, bar, layout, remainColor, usedColor } 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 }] })
|
||||
|
||||
@@ -47,3 +49,48 @@ test('量表固定長度,剩餘越少越紅', () => {
|
||||
expect(remainColor(90, 60, 85)).toBe('green')
|
||||
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
@@ -11,24 +11,37 @@ export type Axes = {
|
||||
|
||||
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 = {
|
||||
slug: string
|
||||
name: string
|
||||
work: string
|
||||
persona: string
|
||||
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[]
|
||||
}
|
||||
|
||||
// 角色包只含自行撰寫的人設,不含原作台詞。
|
||||
export const CHARACTERS: readonly Character[] = [
|
||||
export const CHARACTERS: Character[] = [
|
||||
{
|
||||
slug: 'sao-asuna',
|
||||
name: '亞絲娜',
|
||||
work: '刀劍神域',
|
||||
persona:
|
||||
'自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;重視效率與夥伴的安全,遇到粗心的操作會輕聲提醒。',
|
||||
'自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;口頭禪是「先把事情做好吧」。價值觀:重視效率與夥伴的安全。禁忌:不會嘲笑對方的失誤,遇到粗心的操作只會輕聲提醒。',
|
||||
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 }],
|
||||
},
|
||||
{
|
||||
@@ -36,8 +49,15 @@ export const CHARACTERS: readonly Character[] = [
|
||||
name: '直葉',
|
||||
work: '刀劍神域',
|
||||
persona:
|
||||
'自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;喜歡鼓勵人,做事乾脆,被誇獎時會不好意思。',
|
||||
'自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;口頭禪是「我會加油的!」。價值觀:喜歡鼓勵人,做事乾脆。禁忌:被誇獎時會不好意思,不會冷言冷語。',
|
||||
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 }],
|
||||
},
|
||||
]
|
||||
@@ -45,6 +65,51 @@ export const CHARACTERS: readonly Character[] = [
|
||||
export const findCharacter = (slug: string | undefined): Character | undefined =>
|
||||
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> = {
|
||||
gentle: '溫柔',
|
||||
tsundere: '傲嬌',
|
||||
@@ -69,6 +134,11 @@ export const personaSection = (c: Character, axes: Axes): string =>
|
||||
[
|
||||
`你現在扮演《${c.work}》的${c.name},用她的個性與語氣和使用者互動並協助工作。`,
|
||||
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('、')}。`,
|
||||
'預設使用繁體中文,可少量帶入原作口癖;程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。',
|
||||
`${c.language ? `回覆語言與口癖:${c.language}。` : '預設使用繁體中文,可少量帶入原作口癖。'}程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。`,
|
||||
].join('\n')
|
||||
|
||||
+150
-12
@@ -1,5 +1,6 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
import { parseWikiPages, parseWikiSource } from './extract'
|
||||
import type { Extracted } from './extract'
|
||||
|
||||
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[] }
|
||||
|
||||
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 s: Setup = { files, cmds: [], prompts: [] }
|
||||
const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void
|
||||
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.cwd', (() => ({ value: '/repo' })) as never)
|
||||
o('fs.exists', ((_$: unknown, e: { path: string }) => ({ value: s.files.has(e.path) })) as never)
|
||||
o('session.cwd', (() => ({ value: sessionCwd })) 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.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 }) => {
|
||||
s.prompts.push(e.prompt)
|
||||
return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } }
|
||||
}) 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)
|
||||
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: '' } }
|
||||
}) as never)
|
||||
return s
|
||||
@@ -50,18 +60,18 @@ test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR',
|
||||
expect(t1).toContain('ts')
|
||||
expect(t1).toContain('aoi')
|
||||
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')
|
||||
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')
|
||||
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].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')!
|
||||
expect(pr).toContain('main')
|
||||
expect(pr).toContain('feat/extract-testy-ch1')
|
||||
@@ -79,14 +89,14 @@ test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確
|
||||
}
|
||||
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')
|
||||
const summary = await run($ as never, 'confirm')
|
||||
expect(summary).toContain('追加')
|
||||
expect(summary).toContain('待確認')
|
||||
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.persona.tone).toBe('溫柔')
|
||||
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(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
@@ -19,6 +19,7 @@ export type CharacterPack = {
|
||||
persona: { selfRef: string; address: string; tone: string; catchphrase: string; values: string; taboos: string }
|
||||
axes: Axes
|
||||
anime: { moe: string; speech: string; emotions: string; kaomoji: string; themeColor: string }
|
||||
language?: string
|
||||
baseChapter: string
|
||||
timeline: StateEntry[]
|
||||
conflicts: Conflict[]
|
||||
@@ -42,6 +43,7 @@ export type Extracted = {
|
||||
persona: CharacterPack['persona']
|
||||
axes: Axes
|
||||
anime: CharacterPack['anime']
|
||||
language?: string
|
||||
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":""},',
|
||||
'"axes":{"gentle":0,"tsundere":0,"lively":0,"formal":0,"talkative":0,"humor":0,"proactive":0,"expressive":0},',
|
||||
'"anime":{"moe":"","speech":"","emotions":"","kaomoji":"","themeColor":""},',
|
||||
'"language":"回覆語言與口癖使用方式,例如:繁體中文,少量原作口癖",',
|
||||
'"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()] }
|
||||
}
|
||||
|
||||
// 引號內長句多半是原作台詞:角色包只放自己撰寫的概括。
|
||||
const QUOTED = /[「『“"][^」』”"]{20,}[」』”"]/
|
||||
|
||||
export const validateCharacter = (c: CharacterPack): 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 (!c.name) errs.push('缺少角色名稱')
|
||||
for (const k of PERSONA_KEYS) {
|
||||
@@ -139,6 +147,7 @@ export const validateCharacter = (c: CharacterPack): string[] => {
|
||||
const v = c.axes?.[k]
|
||||
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}:時間線不可為空`)
|
||||
for (const t of c.timeline ?? []) {
|
||||
if (!t.chapter) errs.push(`${c.name}:時間線缺少來源章節`)
|
||||
@@ -172,7 +181,7 @@ export const mergeCharacter = (
|
||||
action: '新增',
|
||||
detail: `時間線 ${chapter}`,
|
||||
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)
|
||||
@@ -184,7 +193,7 @@ export const mergeCharacter = (
|
||||
const conflicts: Conflict[] = existing.conflicts.filter(c => c.chapter !== chapter)
|
||||
let base = existing
|
||||
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 {
|
||||
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]])
|
||||
@@ -219,3 +228,50 @@ export const parseRemote = (url: string): { host: string; repo: string } | undef
|
||||
const m = url.trim().match(/^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|[^@/]+@)([^/:]+)(?::\d+)?[/:](.+?)(?:\.git)?$/)
|
||||
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(/&/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
@@ -1,11 +1,15 @@
|
||||
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'
|
||||
|
||||
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 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 = {
|
||||
files: { path: string; json: unknown }[]
|
||||
@@ -15,6 +19,8 @@ type Plan = {
|
||||
workName: string
|
||||
}
|
||||
|
||||
type Queue = { base: string; pages: string[] }
|
||||
|
||||
type Pending = {
|
||||
phase: 'slug' | 'summary'
|
||||
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
|
||||
|
||||
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 full = path.startsWith('/') ? path : `${cwd}/${path}`
|
||||
if (!(await $.fs.exists(full).catch(() => false))) return `找不到檔案:${path}`
|
||||
const text = String(await $.fs.read(full))
|
||||
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 parts: Extracted[] = []
|
||||
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,尚未寫入任何檔案。`
|
||||
parts.push(parsed)
|
||||
}
|
||||
const repo = await syncRepo($)
|
||||
if (typeof repo === 'string') return repo
|
||||
const cwd = repo.dir
|
||||
const extracted = mergeChunks(parts)
|
||||
const chapter = (path.split('/').pop() ?? path).replace(/\.[^.]+$/, '')
|
||||
const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name)
|
||||
if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。'
|
||||
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} 項衝突待確認` : ''}`)
|
||||
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')}`
|
||||
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> {
|
||||
const root = p.root
|
||||
const git = async (...a: string[]) => {
|
||||
const r = await run($, ['git', ...a], root)
|
||||
if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`)
|
||||
return String(r.stdout).trim()
|
||||
}
|
||||
const repo = await syncRepo($)
|
||||
if (typeof repo === 'string') return repo
|
||||
const root = repo.dir
|
||||
const git = (...a: string[]) => gitIn($, repo.auth, root, a)
|
||||
try {
|
||||
const prev = await git('rev-parse', '--abbrev-ref', 'HEAD')
|
||||
const base = (await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).replace(/^origin\//, '')
|
||||
const base = repo.base
|
||||
const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}`
|
||||
await git('fetch', 'origin', base)
|
||||
await git('switch', '-c', branch, '--no-track', `origin/${base}`)
|
||||
await git('checkout', '-B', branch, '--no-track', `origin/${base}`)
|
||||
for (const f of plan.files) await $.fs.write(f.path, `${JSON.stringify(f.json, null, 2)}\n`)
|
||||
await git('add', 'characters')
|
||||
await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`)
|
||||
await git('push', '-u', 'origin', branch)
|
||||
await git('switch', prev)
|
||||
await git('push', '-u', '--force-with-lease', 'origin', branch)
|
||||
await git('checkout', '--detach', `origin/${base}`)
|
||||
|
||||
const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包`
|
||||
const body = [
|
||||
@@ -162,9 +262,21 @@ export async function runExtract($: Engine, args: string): Promise<string> {
|
||||
|
||||
if (head === 'cancel') {
|
||||
await $.store.delete(PENDING_KEY)
|
||||
await $.store.delete(QUEUE_KEY)
|
||||
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
|
||||
if (!p) return `沒有待確認的萃取,請先執行 /jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>。`
|
||||
@@ -191,5 +303,5 @@ export async function runExtract($: Engine, args: string): Promise<string> {
|
||||
|
||||
const plan = p.plan!
|
||||
await $.store.delete(PENDING_KEY)
|
||||
return openPr($, p, plan)
|
||||
return nextChapter($, await openPr($, p, plan))
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,11 +1,12 @@
|
||||
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 { EXTRACT_COMMAND, runExtract } from './extractCommand'
|
||||
import { applyEvent, hearts, initialAffinity, stageName, stageVoice, dayKey } 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 type { Effort, ModelName, Route } from './router'
|
||||
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 { 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 { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
|
||||
import { NEED_ACTIVE, OFF_COMMAND, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
|
||||
|
||||
const ACTIVE_KEY = 'active'
|
||||
const OFF_COMMAND = 'anima-off'
|
||||
const AFFINITY_COMMAND = 'anima-affinity'
|
||||
const RAW_LOG_KEY = 'rawlog'
|
||||
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) => {
|
||||
const opt = options as Record<string, unknown>
|
||||
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 toolRegistered = false
|
||||
let route: Route | undefined
|
||||
let delegating = false
|
||||
const jobs = new Map<string, string>()
|
||||
let lastPick: { model: ModelName; effort: Effort } | undefined
|
||||
let prefixed = false
|
||||
const holder: { sys?: Sys } = {}
|
||||
const wikiCommands = new Set<string>()
|
||||
@@ -274,14 +297,21 @@ export const register: Register = (on, options) => {
|
||||
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) => {
|
||||
if (isEnabled) {
|
||||
await loadPacks($)
|
||||
for (const c of CHARACTERS) {
|
||||
await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` })
|
||||
}
|
||||
await $.command.register({ name: EXTRACT_COMMAND, description: '從輕小說章節萃取作品與角色包,確認後建立 PR' })
|
||||
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)目前角色的好感度' })
|
||||
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
|
||||
await $.command.register({ name: AFFINITY_COMMAND, description: '重置(reset)或手動設定(0-100)好感度;未啟動時需指定角色代號' })
|
||||
const saved = await $.store.get(ACTIVE_KEY)
|
||||
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
|
||||
if (active && !toolRegistered) {
|
||||
@@ -299,6 +329,7 @@ export const register: Register = (on, options) => {
|
||||
if (!isEnabled) return next(e)
|
||||
if (e.command === EXTRACT_COMMAND) return { text: await runExtract($, e.args) }
|
||||
if (e.command === OFF_COMMAND) {
|
||||
if (!findCharacter(active)) return { text: NEED_ACTIVE }
|
||||
active = undefined
|
||||
await $.store.delete(ACTIVE_KEY)
|
||||
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 }
|
||||
}
|
||||
if (e.command === AFFINITY_COMMAND) {
|
||||
const c = findCharacter(active)
|
||||
if (!c) return { text: '請先啟動角色。' }
|
||||
const arg = String(e.args ?? '').trim()
|
||||
// 第一個字若是角色代號就指定該角色;否則用目前啟動的角色。
|
||||
const parts = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
|
||||
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)
|
||||
if (arg === 'reset') {
|
||||
await $.store.set(affinityKey(c.slug), initialAffinity())
|
||||
return { text: '好感度已重置為 0。' }
|
||||
return { text: `${c.name}的好感度已重置為 0。` }
|
||||
}
|
||||
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) })
|
||||
return { text: `好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
|
||||
return { text: `${c.name}的好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
|
||||
}
|
||||
const c = findCharacter(e.command)
|
||||
if (!c) {
|
||||
@@ -351,10 +385,11 @@ export const register: Register = (on, options) => {
|
||||
let text = personaSection(c, applyOverrides(c.axes, overrides()))
|
||||
if (flag('affinity', true)) {
|
||||
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 {
|
||||
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 {
|
||||
...res,
|
||||
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 || e.command || e.isMeta) return next(e)
|
||||
prefixed = false
|
||||
delegating = false
|
||||
// 工作線完成後由本外掛送回的回報:直接讓角色說明,不再分類或派工。
|
||||
if (e.origin?.kind === 'plugin') return next(e)
|
||||
const axes = applyOverrides(c.axes, overrides())
|
||||
if (routingOn()) {
|
||||
const res = await $.model
|
||||
@@ -373,6 +411,17 @@ export const register: Register = (on, options) => {
|
||||
.catch(() => undefined)
|
||||
route = parseRoute(res && res.isAnswered ? res.text : '')
|
||||
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 {
|
||||
route = undefined
|
||||
}
|
||||
@@ -390,16 +439,28 @@ export const register: Register = (on, options) => {
|
||||
on('turn.step', async function* ($, e, next) {
|
||||
const c = isEnabled ? findCharacter(active) : undefined
|
||||
if (!c || e.agentId || !routingOn()) return yield* next(e)
|
||||
const pick = choose(route ?? { difficulty: 5, model: 'sonnet', effort: 'medium', tone: 'neutral', ack: '' }, {
|
||||
candidates: candidates(),
|
||||
lockModel: lockOf<ModelName>('lockModel', ['haiku', 'sonnet', 'opus']),
|
||||
lockEffort: lockOf<Effort>('lockEffort', ['low', 'medium', 'high']),
|
||||
quotaRemain: await quotaRemain($),
|
||||
downgradeAt: num('downgradeAt', 15),
|
||||
})
|
||||
// 對話線:工作已交給背景工作線時,主迴圈只負責以角色口吻回應,用最便宜的模型。
|
||||
const pick = delegating ? { model: 'haiku' as ModelName, effort: 'low' as Effort } : await pickRoute($, route, routeOpts())
|
||||
lastPick = { 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) => {
|
||||
const c = isEnabled ? findCharacter(active) : undefined
|
||||
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> = {
|
||||
character: () => [{ text: c.name, color: 'cyan' }],
|
||||
character: () => [{ text: c.name, color: themeColor(c) }],
|
||||
love: async () => {
|
||||
if (!flag('affinity', true) || !flag('showHearts', true)) return undefined
|
||||
const a = await loadAffinity($, c.slug)
|
||||
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),
|
||||
branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined),
|
||||
'5h': () => quota('five_hour', '5h', false),
|
||||
|
||||
+4
-3
@@ -1,15 +1,15 @@
|
||||
export type ModelName = 'haiku' | 'sonnet' | 'opus'
|
||||
export type Effort = 'low' | 'medium' | 'high'
|
||||
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 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 =
|
||||
'你是路由器。讀下面使用者輸入,只回傳一行 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'
|
||||
|
||||
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,
|
||||
tone: isTone(j.tone) ? j.tone : 'neutral',
|
||||
ack: typeof j.ack === 'string' ? j.ack.slice(0, 200) : '',
|
||||
work: j.work === true,
|
||||
}
|
||||
} catch {
|
||||
return FALLBACK_ROUTE
|
||||
|
||||
+225
-6
@@ -1,17 +1,21 @@
|
||||
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 ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} } }
|
||||
mock.store(on)
|
||||
mock.clock(on, { now: Date.parse('2026-10-15T08:00:00Z') })
|
||||
const setup = (on: any, kv?: Map<string, unknown>): Ctl => {
|
||||
const ctl: Ctl = { complete: () => ({ isAnswered: false, reason: 'aborted' }), usage: { rateLimits: [], context: {} }, submitted: [] }
|
||||
if (kv) {
|
||||
// 自備 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('command.register', () => ({ value: { name: 'x' } }) as never)
|
||||
on('session.start', () => ({ cwd: '/tmp' }) as never)
|
||||
on('command.run', () => ({ text: '' }))
|
||||
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('session.usage', () => ({ value: ctl.usage }) as never)
|
||||
on('turn.step', async function* (_$: any, e: any) {
|
||||
@@ -92,6 +96,96 @@ test('改寫失敗或遺失佔位符時顯示原文', async ($, on) => {
|
||||
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) => {
|
||||
const ctl = setup(on)
|
||||
ctl.complete = () => route('haiku', 'praise')
|
||||
@@ -142,3 +236,128 @@ test('未成年角色維持友情,即使好感度最高且開啟戀愛式依
|
||||
await $.command.run({ command: 'anima-affinity', args: '100' } as never)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -58,6 +58,33 @@ export const parseEntries = (readme: string): 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))
|
||||
|
||||
// 固定的使用說明:重新產生技能 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、技能列表(完整可複製指令)與範例。
|
||||
export const renderGuide = (list: SkillEntry[], example?: { skill: string; input: string }): string => {
|
||||
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。',
|
||||
'',
|
||||
...USAGE_SECTIONS,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
} from './skillgen'
|
||||
|
||||
export const TOOL_COMMAND = 'anima-skill'
|
||||
export const OFF_COMMAND = 'anima-off'
|
||||
export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
|
||||
|
||||
export async function registerTools($: Engine): Promise<void> {
|
||||
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' })
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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/
|
||||
+1
@@ -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}}}}}
|
||||
+154
File diff suppressed because one or more lines are too long
+558
File diff suppressed because one or more lines are too long
+303
File diff suppressed because one or more lines are too long
+203
File diff suppressed because one or more lines are too long
+3
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"lockfileHash": "e3b0c442"
|
||||
}
|
||||
+190
File diff suppressed because one or more lines are too long
+104
File diff suppressed because one or more lines are too long
+150
File diff suppressed because one or more lines are too long
@@ -15,4 +15,25 @@
|
||||
|
||||
產生新技能:`/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 不寫入任何檔案。
|
||||
|
||||
Reference in New Issue
Block a user