Compare commits

..
39 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
jiantw83 14b55a7dea Merge pull request 'feat: 階段 6 工具四:待辦事項' (#14) from issue-7 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/14
2026-10-15 07:34:35 +00:00
adminandClaude Sonnet 5.5 2139f7dd01 feat: 階段 6 工具四待辦事項(全域待辦、類型自訂、到點提醒、確認後執行並回報)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 07:31:09 +00:00
jiantw83 dd4f2cb294 Merge pull request 'feat: 階段 5 工具三 wiki 知識庫' (#10) from issue-6 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/10
2026-10-15 07:29:11 +00:00
adminandClaude Sonnet 5.5 d4c9f78560 merge: 合併 origin/main,保留 wiki 工具與階段 2–4 功能
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:46:15 +00:00
jiantw83 3cd553e70d Merge pull request 'feat: 階段 4 工具二:助理技能' (#12) from issue-5 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/12
2026-10-15 06:31:48 +00:00
jiantw83 acb0c118d4 Merge pull request 'feat: 階段 3 工具一 anima-extract' (#11) from issue-4 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/11
2026-10-15 06:31:38 +00:00
adminandClaude Sonnet 5.5 16618321c4 合併 origin/main,保留路由/好感度與工具二
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:26:44 +00:00
adminandClaude Sonnet 5.5 540146cc8f merge: 合併 origin/main,解決 register.tsx 衝突
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:26:31 +00:00
jiantw83 5fabce206d Merge pull request 'feat: 階段 2 路由、改寫與好感度' (#13) from issue-3 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/13
2026-10-15 06:23:00 +00:00
adminandClaude Sonnet 5.5 5bec18b44b feat: 階段 2 路由、改寫與好感度
- Haiku 一次分類(難度、模型、思考程度、語氣),角色立刻回應
- turn.step 覆寫模型與思考程度,額度低於門檻自動降一級,支援手動鎖定
- 主模型回覆改寫為角色口吻,程式碼/路徑/指令/數字/URL 原樣保留,失敗回原文並另存原文 log
- 好感度規則、五階段、不跨階段掉落、個性倍率、狀態列愛心、/jsc-anima:anima-affinity
- 年齡與關係規則(未成年友情、成年移轉、戀愛式依賴需明確開啟)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:16:22 +00:00
adminandClaude Sonnet 5.5 8f2138f77a feat: 階段 4 工具二(助理技能產生、驗證實跑、技能 README 與版本號、PR)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:15:13 +00:00
adminandClaude Sonnet 5.5 e681e11ae9 feat: 工具一 anima-extract,從輕小說章節產生角色包並建立 PR
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:15:09 +00:00
adminandClaude Sonnet 5.5 0c525d07b2 feat: 階段 5 wiki 知識庫(讀寫、10 分鐘快取、動態註冊指令)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:15:03 +00:00
jiantw83 c890059a50 Merge pull request 'feat: 階段 1 骨架' (#9) from issue-2 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-anima/pulls/9
2026-10-15 06:11:22 +00:00
adminandClaude Sonnet 5.5 7873828bc3 feat: 階段 1 骨架(插件、角色啟動/離開、persona、/config、狀態列)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 06:07:37 +00:00
39 changed files with 5168 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"name": "jsc-anima",
"owner": {
"name": "jsc"
},
"plugins": [
{
"name": "jsc-anima",
"source": "./",
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列"
}
],
"description": "jsc 的動漫助理插件"
}
+219
View File
@@ -0,0 +1,219 @@
{
"name": "jsc-anima",
"version": "0.0.5",
"description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
"license": "MIT",
"userConfig": {
"enabled": {
"type": "boolean",
"title": "啟用助理",
"description": "關閉後停止所有 persona 注入與狀態列",
"default": true
},
"persist": {
"type": "boolean",
"title": "跨 session 保留啟動",
"description": "下次開啟時自動恢復上次的角色",
"default": true
},
"statusbar": {
"type": "boolean",
"title": "顯示狀態列",
"description": "輸入框上方的單行狀態列",
"default": true
},
"fields": {
"type": "string",
"title": "狀態列欄位與順序",
"description": "逗號分隔:character,love,model,dir,branch,5h,7d,ctx,sys",
"default": "character,love,model,dir,branch,5h,7d,ctx,sys"
},
"barStyle": {
"type": "string",
"title": "量表樣式",
"description": "block 或 dot",
"options": [
"block",
"dot"
],
"default": "block"
},
"warnAt": {
"type": "number",
"title": "黃色門檻 (%)",
"description": "使用率達此值轉黃",
"default": 60
},
"dangerAt": {
"type": "number",
"title": "紅色門檻 (%)",
"description": "使用率達此值轉紅",
"default": 85
},
"refreshSec": {
"type": "number",
"title": "系統資源更新秒數",
"description": "CPU/RAM/DSK 的取樣間隔",
"default": 10
},
"routing": {
"type": "boolean",
"title": "啟用模型路由",
"description": "輸入先由 Haiku 一次分類,再依難度選模型與思考程度",
"default": true
},
"background": {
"type": "boolean",
"title": "背景工作線",
"description": "需要動手的要求交給背景子代理處理,對話線立刻回應、完成後由角色回報",
"default": true
},
"candidates": {
"type": "string",
"title": "候選模型",
"description": "逗號分隔:haiku,sonnet,opus",
"default": "haiku,sonnet,opus"
},
"lockModel": {
"type": "string",
"title": "鎖定模型",
"description": "auto 為自動",
"options": [
"auto",
"haiku",
"sonnet",
"opus"
],
"default": "auto"
},
"lockEffort": {
"type": "string",
"title": "鎖定思考程度",
"description": "auto 為自動",
"options": [
"auto",
"low",
"medium",
"high"
],
"default": "auto"
},
"downgradeAt": {
"type": "number",
"title": "降級門檻 (剩餘 %)",
"description": "5h/7d 額度剩餘低於此值自動降一級",
"default": 15
},
"rewrite": {
"type": "boolean",
"title": "改寫輸出",
"description": "把主模型回覆改寫成角色口吻並取代顯示;失敗時顯示原文",
"default": true
},
"rawLog": {
"type": "boolean",
"title": "保存原文 log",
"description": "改寫前的原文另存本機",
"default": true
},
"longReply": {
"type": "number",
"title": "長回覆字數",
"description": "超過此字數改用 Sonnet 改寫",
"default": 1200
},
"rewriteTimeoutSec": {
"type": "number",
"title": "改寫逾時秒數",
"description": "逾時直接顯示原文",
"default": 20
},
"affinity": {
"type": "boolean",
"title": "啟用好感度",
"description": "好感度影響語氣、稱呼、害羞與主動程度,不影響工作品質",
"default": true
},
"showHearts": {
"type": "boolean",
"title": "狀態列顯示愛心",
"description": "在狀態列顯示好感度愛心量表",
"default": true
},
"romance": {
"type": "boolean",
"title": "允許戀愛式依賴",
"description": "僅對成年且原作無戀愛關係的角色,好感度到最高階段才生效",
"default": false
},
"axisGentle": {
"type": "number",
"title": "溫柔 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisTsundere": {
"type": "number",
"title": "傲嬌 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisLively": {
"type": "number",
"title": "活潑 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisFormal": {
"type": "number",
"title": "正式 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisTalkative": {
"type": "number",
"title": "話量 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisHumor": {
"type": "number",
"title": "幽默 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisProactive": {
"type": "number",
"title": "主動 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"axisExpressive": {
"type": "number",
"title": "情緒外顯 (0-10,-1 沿用角色包)",
"description": "覆蓋目前角色的數值軸,只存本機",
"default": -1
},
"todoRemind": {
"type": "boolean",
"title": "待辦到點提醒",
"description": "session 開著時,待辦到點由角色提醒;確認後才執行",
"default": true
},
"wikiUrl": {
"type": "string",
"title": "wiki 位址",
"description": "知識庫的 Gitea wiki git 位址;留空則由目前專案的 origin 推得(認證優先讀 tea 登入,其次 GITEA_TOKEN)",
"default": ""
},
"wikiCacheDir": {
"type": "string",
"title": "wiki 本機快取目錄",
"description": "10 分鐘 git 快取的位置;留空用 ~/.cache/jsc-anima/wiki",
"default": ""
}
},
"author": {
"name": "jsc"
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 jsc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+39
View File
@@ -1 +1,40 @@
<p align="center"><img src="icon.png" width="96" alt="jsc-anima"></p>
# jsc-anima
目前版本:0.0.5
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
## 安裝
```
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git
claude plugin install jsc-anima
```
## 更新
先更新市集快取,再更新外掛,最後重開 Claude Code 才會套用:
```
claude plugin marketplace update jsc-anima
claude plugin update jsc-anima
```
## 移除
先移除外掛,再移除市集:
```
claude plugin uninstall jsc-anima
claude plugin marketplace remove jsc-anima
```
## 使用說明
技能列表與範例見 [skills/jsc-anima/README.md](skills/jsc-anima/README.md)。
## 授權
[MIT](LICENSE)
+77
View File
@@ -0,0 +1,77 @@
import type { Axes } from './characters'
export type Affinity = { score: number; day: string; todayGain: number; streak: number; lastDay: string }
export type AffinityEvent = 'daily' | 'praise' | 'scold'
export const STAGES = [
{ min: 0, name: '陌生' },
{ min: 20, name: '熟悉' },
{ min: 40, name: '親近' },
{ min: 60, name: '信賴' },
{ min: 80, name: '特別' },
] as const
export const DAILY_CAP = 5
export const IDLE_GRACE_DAYS = 2
export const initialAffinity = (): Affinity => ({ score: 0, day: '', todayGain: 0, streak: 0, lastDay: '' })
export const clampScore = (n: number): number => Math.max(0, Math.min(100, Math.round(n)))
export const stageIndex = (score: number): number => {
let i = 0
for (let k = 0; k < STAGES.length; k++) if (score >= STAGES[k]!.min) i = k
return i
}
export const stageName = (score: number): string => STAGES[stageIndex(score)]!.name
export const stageFloor = (score: number): number => STAGES[stageIndex(score)]!.min
// 個性影響倍率:傲嬌(高)加分較慢、扣分較重;溫柔與情緒外顯(高)加分較快。
export const gainMultiplier = (a: Axes): number => Math.max(0.5, 1 + (a.gentle + a.expressive - 10) * 0.03 - a.tsundere * 0.04)
export const lossMultiplier = (a: Axes): number => 1 + a.tsundere * 0.05
export const dayKey = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
const daysBetween = (a: string, b: string): number => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000)
// 長期不互動緩慢下降(每天 −1),超過寬限日數才開始,且不跨階段掉落。
export const decay = (s: Affinity, today: string, axes: Axes): Affinity => {
if (!s.lastDay) return s
const idle = daysBetween(s.lastDay, today) - IDLE_GRACE_DAYS
if (idle <= 0) return s
const lost = Math.round(idle * lossMultiplier(axes))
return { ...s, score: Math.max(stageFloor(s.score), clampScore(s.score - lost)), lastDay: s.lastDay }
}
export const applyEvent = (s0: Affinity, event: AffinityEvent, axes: Axes, today: string): Affinity => {
let s = decay(s0, today, axes)
if (s.day !== today) s = { ...s, day: today, todayGain: 0 }
if (event === 'scold') return { ...s, score: Math.max(stageFloor(s.score), clampScore(s.score - 2 * lossMultiplier(axes))), lastDay: today }
let base = event === 'daily' ? 1 : 2
if (event === 'daily') {
const consecutive = s.lastDay && daysBetween(s.lastDay, today) === 1
s = { ...s, streak: consecutive ? s.streak + 1 : 1 }
if (s.streak >= 3) base += 1
}
const room = DAILY_CAP - s.todayGain
const gain = Math.max(0, Math.min(room, Math.round(base * gainMultiplier(axes))))
return { ...s, score: clampScore(s.score + gain), todayGain: s.todayGain + gain, lastDay: today }
}
export const hearts = (score: number): string => {
const n = Math.round(score / 20)
return '♥'.repeat(n) + '♡'.repeat(5 - n)
}
// 語氣由角色自然反映,不用系統式提示,也不用愧疚感或情緒勒索。
export const stageVoice = (score: number): string => {
const voices = [
'與使用者還很生疏:稱呼客氣、保持距離,語氣禮貌而不黏人。',
'與使用者漸漸熟悉:語氣放鬆一些,偶爾開小玩笑。',
'與使用者很親近:稱呼更隨意,會主動關心,被誇獎時容易害羞。',
'十分信賴使用者:語氣坦率溫暖,會主動分擔與提議。',
'對使用者來說是特別的存在:語氣親暱、體貼,但仍尊重使用者的自由。',
]
return `${voices[stageIndex(score)]}好感度只影響語氣、稱呼、害羞與主動程度,不影響技術內容、事實與安全回答的品質;不使用愧疚感或情緒勒索。`
}
+96
View File
@@ -0,0 +1,96 @@
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 }] })
test('啟動角色後 persona 注入系統提示', async ($, on) => {
mock.store(on)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
await $.command.run({ command: 'sao-asuna', args: '' } as never)
const res = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
expect(res.sections.some(s => s.id === 'jsc-anima:persona' && s.text.includes('亞絲娜'))).toBe(true)
})
test('切換角色與離開', async ($, on) => {
mock.store(on)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/tmp' }) as never)
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
await $.command.run({ command: 'sao-asuna', args: '' } as never)
await $.command.run({ command: 'sao-suguha', args: '' } as never)
const compose = () => $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
expect((await compose()).sections.find(s => s.id === 'jsc-anima:persona')?.text).toContain('直葉')
await $.command.run({ command: 'anima-off', args: '' } as never)
expect((await compose()).sections.some(s => s.id === 'jsc-anima:persona')).toBe(false)
})
test('狀態列:窄時依序整欄隱藏,不截斷', () => {
const all = [seg('character', 'AB'), seg('model', 'CD'), seg('dir', 'EF'), seg('branch', 'GH'), seg('5h', 'IJ'), seg('7d', 'KL'), seg('ctx', 'MN'), seg('sys', 'OP')]
expect(layout(all, 999).length).toBe(8)
expect(layout(all, 26).map(s => s.id)).not.toContain('sys')
const narrow = layout(all, 14).map(s => s.id)
expect(narrow).toEqual(['character', 'model', '5h', 'ctx'])
expect([...HIDE_ORDER]).toEqual(['sys', '7d', 'dir', 'branch'])
})
test('量表固定長度,剩餘越少越紅', () => {
expect(bar(0).length).toBe(bar(100).length)
expect(remainColor(10, 60, 85)).toBe('red')
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('不使用口癖')
})
+144
View File
@@ -0,0 +1,144 @@
export type Axes = {
gentle: number
tsundere: number
lively: number
formal: number
talkative: number
humor: number
proactive: number
expressive: number
}
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: 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 }],
},
{
slug: 'sao-suguha',
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 }],
},
]
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: '傲嬌',
lively: '活潑',
formal: '正式',
talkative: '話量',
humor: '幽默',
proactive: '主動',
expressive: '情緒外顯',
}
export const applyOverrides = (axes: Axes, over: Partial<Axes>): Axes => {
const out = { ...axes }
for (const k of Object.keys(over) as (keyof Axes)[]) {
const v = over[k]
if (typeof v === 'number' && v >= 0 && v <= 10) out[k] = v
}
return out
}
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('、')}。`,
`${c.language ? `回覆語言與口癖:${c.language}。` : '預設使用繁體中文,可少量帶入原作口癖。'}程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。`,
].join('\n')
+242
View File
@@ -0,0 +1,242 @@
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] =>
({
name: '小葵', nameEn: 'Aoi', slug: 'aoi',
persona: { selfRef: '我', address: '你', tone, catchphrase: '加油', values: '誠實', taboos: '背叛' },
axes: { gentle: 7, tsundere: 2, lively: 5, formal: 4, talkative: 5, humor: 3, proactive: 6, expressive: 5 },
anime: { moe: '認真', speech: '柔和', emotions: '會臉紅', kaomoji: '(^_^)', themeColor: '#88c' },
state: { mood: '緊張', dialogueStyle: '客氣', monologue: '想變強', relationships: [{ with: '阿零', kind: 'friend', note: '同伴' }], age: 16, place: '學院' },
...over,
}) as never
const reply = (c: Extracted['characters'][number]) =>
JSON.stringify({ work: { name: '測試物語', nameEn: 'Test Story', slug: 'ts', timeline: [{ time: '春', place: '學院' }], terms: [{ term: '魔核', definition: '力量來源' }] }, characters: [c] })
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', ((_$: unknown, e: { name: string }) => { registered.push(e.name); return { value: { name: e.name } } }) as never)
o('session.start', () => ({ cwd: '/repo' }))
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('process.run', ((_$: unknown, e: { argv: string[]; }) => {
s.cmds.push(e.argv)
const j = e.argv.join(' ')
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
}
const run = ($: never, args: string) => ($ as any).command.run({ command: 'anima-extract', args }).then((r: { text: string }) => r.text)
test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR', async ($, on) => {
const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', '第一段\n\n第二段']]))
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
const t1 = await run($ as never, 'ch1.txt')
expect(t1).toContain('ts')
expect(t1).toContain('aoi')
expect(t1).toContain('尚未建立')
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('/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('/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('/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')
expect(s.cmds.some(c => c[0] === 'git' && c[1] === 'commit' && c[3]!.startsWith('feat: '))).toBe(true)
})
test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確認', async ($, on) => {
const files = new Map([['/repo/ch1.txt', 'a'], ['/repo/ch2.txt', 'b']])
const s = setup(on as never, [reply(char()), reply(char()), reply(char({ state: { mood: '開心', dialogueStyle: '', monologue: '', relationships: [], age: 17, place: '' } }, '傲嬌'))], files)
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
const write = async (f: string) => {
await run($ as never, f)
await run($ as never, 'confirm')
return run($ as never, 'confirm')
}
await write('ch1.txt')
await write('ch1.txt')
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('/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: '待確認' })
})
test('schema 驗證失敗不寫檔、不建 PR;cancel 清除待確認', async ($, on) => {
const bad = char({ axes: { gentle: 99, tsundere: 2, lively: 5, formal: 4, talkative: 5, humor: 3, proactive: 6, expressive: 5 } })
const s = setup(on as never, [reply(bad)], 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('schema 驗證失敗')
expect(s.cmds.some(c => c[0] === 'tea')).toBe(false)
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('已取消')
})
+277
View File
@@ -0,0 +1,277 @@
import type { Axes } from './characters'
export type Relationship = { with: string; kind: 'friend' | 'romantic' | 'family' | 'other'; note: string }
export type StateEntry = {
chapter: string
mood: string
dialogueStyle: string
monologue: string
relationships: Relationship[]
age: number | null
place: string
}
export type CharacterPack = {
slug: string
name: string
work: string
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[]
}
export type Conflict = { field: string; existing: string; proposed: string; chapter: string; status: '待確認' }
export type WorkPack = {
slug: string
name: string
timeline: { chapter: string; time: string; place: string }[]
terms: { term: string; definition: string; chapter: string }[]
}
export type Extracted = {
work: { name: string; nameEn: string; slug: string; timeline: { time: string; place: string }[]; terms: { term: string; definition: string }[] }
characters: {
name: string
nameEn: string
slug: string
persona: CharacterPack['persona']
axes: Axes
anime: CharacterPack['anime']
language?: string
state: Omit<StateEntry, 'chapter'>
}[]
}
export const AXIS_KEYS: (keyof Axes)[] = ['gentle', 'tsundere', 'lively', 'formal', 'talkative', 'humor', 'proactive', 'expressive']
const PERSONA_KEYS = ['selfRef', 'address', 'tone', 'catchphrase', 'values', 'taboos'] as const
const ANIME_KEYS = ['moe', 'speech', 'emotions', 'kaomoji', 'themeColor'] as const
const MAX_FIELD = 400
export const CHUNK_SIZE = 4000
export const slugify = (s: string): string =>
s
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
export const chunkText = (text: string, size = CHUNK_SIZE): string[] => {
const out: string[] = []
let cur = ''
for (const para of text.split(/\n\s*\n/)) {
if (cur && cur.length + para.length + 2 > size) {
out.push(cur)
cur = ''
}
let p = para
while (p.length > size) {
out.push(p.slice(0, size))
p = p.slice(size)
}
cur = cur ? `${cur}\n\n${p}` : p
}
if (cur.trim()) out.push(cur)
return out
}
export const extractPrompt = (chunk: string, index: number, total: number): string =>
[
`以下是輕小說章節的第 ${index + 1}/${total} 段。請萃取環境(時間、地點、用語)與人物(心情、對話風格、獨白、關係、年齡)。`,
'規則:所有欄位用你自己的話撰寫並簡短概括,不得抄錄原作台詞;每個欄位不超過 200 字;axes 為 0-10 的整數。',
'slug 與 workSlug 使用官方英文名或羅馬拼音的小寫英數與連字號。',
'只輸出一個 JSON,不要其他文字,格式:',
'{"work":{"name":"","nameEn":"","slug":"","timeline":[{"time":"","place":""}],"terms":[{"term":"","definition":""}]},',
'"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":""}}]}',
'',
'章節內容:',
chunk,
].join('\n')
export const parseExtracted = (text: string): Extracted | undefined => {
const m = text.match(/\{[\s\S]*\}/)
if (!m) return undefined
try {
const v = JSON.parse(m[0]) as Extracted
return v && v.work && Array.isArray(v.characters) ? v : undefined
} catch {
return undefined
}
}
// 逐段結果合併:同一人物以英文名對應,後段的狀態蓋過前段(永遠採最新)。
export const mergeChunks = (parts: Extracted[]): Extracted => {
const first = parts[0]!
const work = { ...first.work, timeline: [...first.work.timeline], terms: [...first.work.terms] }
const chars = new Map<string, Extracted['characters'][number]>()
for (const p of parts) {
for (const t of p.work.terms ?? []) if (!work.terms.some(x => x.term === t.term)) work.terms.push(t)
for (const t of p.work.timeline ?? []) if (!work.timeline.some(x => x.time === t.time && x.place === t.place)) work.timeline.push(t)
for (const c of p.characters) {
const key = slugify(c.nameEn || c.slug || c.name) || c.name
chars.set(key, { ...(chars.get(key) ?? c), ...c, state: { ...(chars.get(key)?.state ?? c.state), ...c.state } })
}
}
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) {
const v = c.persona?.[k]
if (typeof v !== 'string') errs.push(`${c.name}:persona.${k} 必須是字串`)
else if (v.length > MAX_FIELD) errs.push(`${c.name}:persona.${k} 過長(疑似抄錄原文)`)
}
for (const k of ANIME_KEYS) {
const v = c.anime?.[k]
if (typeof v !== 'string') errs.push(`${c.name}:anime.${k} 必須是字串`)
else if (v.length > MAX_FIELD) errs.push(`${c.name}:anime.${k} 過長(疑似抄錄原文)`)
}
for (const k of AXIS_KEYS) {
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}:時間線缺少來源章節`)
if (t.age !== null && !(Number.isInteger(t.age) && t.age >= 0)) errs.push(`${c.name}:年齡必須是非負整數或 null`)
for (const f of [t.mood, t.dialogueStyle, t.monologue])
if (typeof f === 'string' && f.length > MAX_FIELD) errs.push(`${c.name}:時間線欄位過長(疑似抄錄原文)`)
}
return errs
}
export type MergeResult<T> = { pack: T; action: '新增' | '更新'; detail: string; conflicts: Conflict[] }
export const mergeCharacter = (
incoming: Extracted['characters'][number],
workSlug: string,
slug: string,
chapter: string,
existing: CharacterPack | undefined,
): MergeResult<CharacterPack> => {
const entry: StateEntry = {
chapter,
mood: incoming.state.mood ?? '',
dialogueStyle: incoming.state.dialogueStyle ?? '',
monologue: incoming.state.monologue ?? '',
relationships: incoming.state.relationships ?? [],
age: incoming.state.age ?? null,
place: incoming.state.place ?? '',
}
if (!existing) {
return {
action: '新增',
detail: `時間線 ${chapter}`,
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 timeline = sameChapter
? existing.timeline.map(t => (t.chapter === chapter ? entry : t))
: [...existing.timeline, entry]
// 重跑同一章節:該章節產生的基礎設定直接覆蓋;新章節若與既有基礎設定不同,標記待確認而不覆蓋。
const overwriteBase = existing.baseChapter === chapter
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, 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]])
for (const k of AXIS_KEYS) if (existing.axes[k] !== incoming.axes[k]) diffs.push([`axes.${k}`, String(existing.axes[k]), String(incoming.axes[k])])
for (const [field, a, b] of diffs) conflicts.push({ field, existing: a, proposed: b, chapter, status: '待確認' })
}
return {
action: '更新',
detail: `時間線 ${sameChapter ? '覆蓋' : '追加'} ${chapter}`,
conflicts: conflicts.filter(c => c.chapter === chapter),
pack: { ...base, timeline, conflicts },
}
}
export const mergeWork = (
incoming: Extracted['work'],
slug: string,
chapter: string,
existing: WorkPack | undefined,
): MergeResult<WorkPack> => {
const timeline = [...(existing?.timeline ?? []).filter(t => t.chapter !== chapter), ...(incoming.timeline ?? []).map(t => ({ chapter, ...t }))]
const terms = [...(existing?.terms ?? []).filter(t => t.chapter !== chapter), ...(incoming.terms ?? []).map(t => ({ chapter, ...t }))]
return {
action: existing ? '更新' : '新增',
detail: `時間線 ${incoming.timeline?.length ?? 0} 筆、用語 ${incoming.terms?.length ?? 0} 筆(${chapter})`,
conflicts: [],
pack: { slug, name: incoming.name, timeline, terms },
}
}
export const parseRemote = (url: string): { host: string; repo: string } | undefined => {
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(/&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 }))
}
+307
View File
@@ -0,0 +1,307 @@
import type { Engine } from 'claude-code'
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 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 }[]
lines: string[]
conflicts: Conflict[]
workSlug: string
workName: string
}
type Queue = { base: string; pages: string[] }
type Pending = {
phase: 'slug' | 'summary'
chapter: string
root: string
extracted: Extracted
workSlug: string
slugs: Record<string, string>
plan?: Plan
}
const dirOf = (root: string, work: string) => `${root}/characters/${work}`
async function readJson<T>($: Engine, path: string): Promise<T | undefined> {
if (!(await $.fs.exists(path).catch(() => false))) return undefined
try {
return JSON.parse(String(await $.fs.read(path))) as T
} catch {
return undefined
}
}
const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name
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()) {
const r = await $.model.complete({ model: 'sonnet', prompt: extractPrompt(chunk, i, chunks.length), timeoutMs: 180000 })
if (!r.isAnswered) return `第 ${i + 1}/${chunks.length} 段萃取失敗,尚未寫入任何檔案。`
const parsed = parseExtracted(r.text)
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 workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name)
if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。'
const slugs: Record<string, string> = {}
for (const c of extracted.characters) slugs[slugKey(c)] = slugKey(c)
await $.store.set(PENDING_KEY, { phase: 'slug', chapter, root: cwd, extracted, workSlug, slugs } satisfies Pending)
const existsWork = await $.fs.exists(`${dirOf(cwd, workSlug)}/work.json`).catch(() => false)
const lines = [
`已萃取《${extracted.work.name}》章節「${chapter}」(${chunks.length} 段),請確認代號:`,
`- 作品代號:${workSlug}(${existsWork ? '已建立,將更新' : '尚未建立,將新增'})`,
]
for (const c of extracted.characters) {
const s = slugs[slugKey(c)]!
const ex = await $.fs.exists(`${dirOf(cwd, workSlug)}/${s}.json`).catch(() => false)
lines.push(`- ${c.name}:${s}(${ex ? '已建立,將更新' : '尚未建立,將新增'})→ 啟動指令 /jsc-anima:${workSlug}-${s}`)
}
lines.push('', `代號沒問題請輸入 /jsc-anima:${EXTRACT_COMMAND} confirm;要修改請加 work=新代號 或 <原角色代號>=新代號,例如 confirm work=sao ${slugs[Object.keys(slugs)[0] ?? ''] ?? 'asuna'}=asu。`)
return lines.join('\n')
}
async function buildPlan($: Engine, p: Pending): Promise<Plan | string> {
const dir = dirOf(p.root, p.workSlug)
const files: Plan['files'] = []
const lines: string[] = []
const conflicts: Conflict[] = []
const errors: string[] = []
const work = mergeWork(p.extracted.work, p.workSlug, p.chapter, await readJson<WorkPack>($, `${dir}/work.json`))
files.push({ path: `${dir}/work.json`, json: work.pack })
lines.push(`- 作品 ${p.workSlug}:${work.action},${work.detail}`)
for (const c of p.extracted.characters) {
const slug = p.slugs[slugKey(c)]!
const m = mergeCharacter(c, p.workSlug, slug, p.chapter, await readJson<CharacterPack>($, `${dir}/${slug}.json`))
errors.push(...validateCharacter(m.pack))
files.push({ path: `${dir}/${slug}.json`, json: m.pack })
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 }
}
function run($: Engine, argv: string[], cwd: string) {
return $.process.run(argv, { cwd, timeoutMs: 60000 })
}
async function openPr($: Engine, p: Pending, plan: Plan): Promise<string> {
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 base = repo.base
const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}`
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', '--force-with-lease', 'origin', branch)
await git('checkout', '--detach', `origin/${base}`)
const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包`
const body = [
'由 /jsc-anima:anima-extract 產生。',
'',
...plan.lines,
...(plan.conflicts.length ? ['', '## 待確認的衝突', ...plan.conflicts.map(c => `- ${c.field}:既有「${c.existing}」,新章節提議「${c.proposed}」`)] : []),
'',
'角色包只含自行撰寫的人設與規則,不含原作台詞。',
].join('\n')
return await createPr($, root, base, branch, title, body)
} catch (err) {
return `寫檔或建立 PR 失敗:${(err as Error).message}`
}
}
// 認證優先讀 tea 登入,其次 GITEA_TOKEN;token 只放在子行程的環境變數,不寫進任何檔案。
async function createPr($: Engine, root: string, base: string, branch: string, title: string, body: string): Promise<string> {
const remote = parseRemote(String((await run($, ['git', 'remote', 'get-url', 'origin'], root)).stdout))
if (!remote) return '已推送分支,但無法解析 origin,請手動建立 PR。'
const logins = await run($, ['tea', 'logins', 'list', '--output', 'simple'], root).catch(() => undefined)
const login = logins?.exitCode === 0 ? String(logins.stdout).split('\n').map(l => l.trim().split(/\s+/)).find(f => f[1]?.includes(remote.host))?.[0] : undefined
if (login) {
const r = await run($, ['tea', 'pulls', 'create', '--login', login, '--repo', remote.repo, '--base', base, '--head', branch, '--title', title, '--description', body], root)
return r.exitCode === 0 ? `已建立 PR(未自動合併):\n${String(r.stdout).trim()}` : `已推送分支 ${branch},但 tea 建立 PR 失敗:${String(r.stderr).trim()}`
}
const token = await $.env.get('GITEA_TOKEN')
if (!token) return `已推送分支 ${branch},但找不到 tea 登入或 GITEA_TOKEN,請手動建立 PR。`
const r = await $.process.run(
['sh', '-c', `curl -sf -X POST "https://${remote.host}/api/v1/repos/${remote.repo}/pulls" -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" -d @-`],
{ cwd: root, env: { GITEA_TOKEN: token }, stdin: JSON.stringify({ title, body, base, head: branch }), timeoutMs: 30000 },
)
return r.exitCode === 0 ? `已建立 PR(未自動合併):${String(r.stdout).match(/"html_url":"([^"]+)"/)?.[1] ?? branch}` : `已推送分支 ${branch},但 API 建立 PR 失敗。`
}
export async function runExtract($: Engine, args: string): Promise<string> {
const [head = '', ...rest] = args.trim().split(/\s+/)
if (!head) return USAGE
if (head === 'cancel') {
await $.store.delete(PENDING_KEY)
await $.store.delete(QUEUE_KEY)
return '已取消,沒有寫入任何檔案。'
}
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} <章節檔案路徑>。`
if (p.phase === 'slug') {
for (const kv of rest) {
const [k, v] = kv.split('=')
const s = slugify(v ?? '')
if (!k || !s) continue
if (k === 'work') p.workSlug = s
else if (k in p.slugs) p.slugs[k] = s
}
const plan = await buildPlan($, p)
if (typeof plan === 'string') return plan
await $.store.set(PENDING_KEY, { ...p, phase: 'summary', plan } satisfies Pending)
return [
'變更摘要:',
...plan.lines,
...(plan.conflicts.length ? ['衝突(標記待確認,不覆蓋既有設定):', ...plan.conflicts.map(c => ` · ${c.field}:「${c.existing}」vs「${c.proposed}」`)] : []),
'',
`確認寫檔並對預設分支建立 PR 請再輸入 /jsc-anima:${EXTRACT_COMMAND} confirm,取消請輸入 cancel。`,
].join('\n')
}
const plan = p.plan!
await $.store.delete(PENDING_KEY)
return nextChapter($, await openPr($, p, plan))
}
+1
View File
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
+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}`)
})
+574
View File
@@ -0,0 +1,574 @@
import type { Engine, Register } from 'claude-code'
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 { 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'
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
import type { Segment } from './statusline'
import { CACHE_MS, addEntry, commandName, commitMessage, pageName, parseCatalog, parseWriteArgs, renderHome, renderPage, renderSidebar, teaToken, wikiUrlFromRemote } 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 type { Todo } from './todo'
import { NEED_ACTIVE, OFF_COMMAND, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
const ACTIVE_KEY = 'active'
const AFFINITY_COMMAND = 'anima-affinity'
const RAW_LOG_KEY = 'rawlog'
const RAW_LOG_MAX = 200
const WIKI_COMMAND = 'anima-wiki'
const SYNCED_KEY = 'wikiSyncedAt'
const AXIS_OPTION: Record<keyof Axes, string> = {
gentle: 'axisGentle',
tsundere: 'axisTsundere',
lively: 'axisLively',
formal: 'axisFormal',
talkative: 'axisTalkative',
humor: 'axisHumor',
proactive: 'axisProactive',
expressive: 'axisExpressive',
}
const SYS_SCRIPT =
'a=$(awk \'{print $1}\' /proc/loadavg); n=$(nproc); ' +
'echo "$a $n"; free | awk \'/Mem:/{printf "%d\\n", $3*100/$2}\'; df / | awk \'NR==2{gsub("%","",$5); print $5}\''
const resetText = (iso: string | undefined, long: boolean): string => {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const p = (n: number) => String(n).padStart(2, '0')
return long ? ` ${d.getMonth() + 1}/${d.getDate()}` : ` ${p(d.getHours())}:${p(d.getMinutes())}`
}
const fmtTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
type Sys = { at: number; cpu: number; ram: number; dsk: number }
async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Promise<Sys | undefined> {
const now = await $.clock.now()
if (holder.sys && now - holder.sys.at < refreshMs) return holder.sys
try {
const r = await $.process.run(['sh', '-c', SYS_SCRIPT], { timeoutMs: 3000 })
const [l1, l2, l3] = String(r.stdout).trim().split('\n')
const [load, cores] = (l1 ?? '').split(' ').map(Number)
holder.sys = {
at: now,
cpu: Math.min(100, Math.round(((load || 0) / (cores || 1)) * 100)),
ram: Number(l2) || 0,
dsk: Number(l3) || 0,
}
} catch {
holder.sys = undefined
}
return holder.sys
}
type WikiCfg = { url: string; cacheDir: string }
type Wiki = { dir: string; url: string; auth: string[] }
async function wikiCtx($: Engine, cfg: WikiCfg): Promise<Wiki | undefined> {
let url = cfg.url
if (!url) {
const cwd = await $.session.cwd().catch(() => '')
const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: cwd || undefined, timeoutMs: 3000 }).catch(() => undefined)
url = (r && r.exitCode === 0 && wikiUrlFromRemote(String(r.stdout))) || ''
}
if (!url) return undefined
const home = (await $.env.get('HOME')) ?? ''
const dir = cfg.cacheDir || `${home}/.cache/jsc-anima/wiki`
// 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數,不寫入任何檔案。
let token: string | undefined
try {
token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(url).host)
} catch {}
token ??= (await $.env.get('GITEA_TOKEN')) || undefined
return { dir, url, auth: token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : [] }
}
async function git($: Engine, w: Wiki, args: string[], inDir = true) {
const r = await $.process.run(['git', ...w.auth, ...(inDir ? ['-C', w.dir] : []), ...args], { timeoutMs: 60000 }).catch(() => undefined)
return r && r.exitCode === 0 ? r : undefined
}
// 本機只做 10 分鐘快取;離線時沿用已有的快取。
async function sync($: Engine, w: Wiki, force: boolean): Promise<'fresh' | 'cached' | 'none'> {
const cloned = await $.fs.exists(`${w.dir}/.git`).catch(() => false)
const now = await $.clock.now()
const last = Number((await $.store.get(SYNCED_KEY)) ?? 0)
if (cloned && !force && now - last < CACHE_MS) return 'fresh'
const ok = cloned ? await git($, w, ['pull', '--ff-only']) : await git($, w, ['clone', w.url, w.dir], false)
if (ok) {
await $.store.set(SYNCED_KEY, now)
return 'fresh'
}
return cloned ? 'cached' : 'none'
}
async function readCatalog($: Engine, w: Wiki): Promise<Entry[]> {
return parseCatalog(await $.fs.read(`${w.dir}/Home.md`).catch(() => ''))
}
async function registerEntry($: Engine, known: Set<string>, e: Entry) {
const name = commandName(e)
if (known.has(name)) return
known.add(name)
await $.command.register({ name, description: `讀取知識庫:${e.domain}/${e.topic}` })
}
// 工具三的指令只在啟動角色後註冊,依 wiki 目錄動態註冊。
async function registerWikiTools($: Engine, cfg: WikiCfg, known: Set<string>) {
if (!known.has(WIKI_COMMAND)) {
known.add(WIKI_COMMAND)
await $.command.register({ name: WIKI_COMMAND, description: '整理資料寫入 wiki 知識庫', argumentHint: '<領域> <主題> [出處](下一行起為內容)' })
}
try {
const w = await wikiCtx($, cfg)
if (!w) return
await sync($, w, false)
for (const e of await readCatalog($, w)) await registerEntry($, known, e)
} catch {}
}
async function writeWiki($: Engine, cfg: WikiCfg, known: Set<string>, args: string): Promise<string> {
const parsed = parseWriteArgs(args)
if (!parsed) return '用法:/jsc-anima:anima-wiki <領域> <主題> [出處],下一行起寫內容。領域與主題只能用英數與底線(會成為指令名稱)。'
const w = await wikiCtx($, cfg)
if (!w) return '找不到 wiki 位址:請在 /config 設定 wikiUrl,或在 Gitea 專案目錄中使用。'
if ((await sync($, w, true)) === 'none') return '無法連線到 wiki,且本機沒有快取,未寫入。'
const { entry, source, body } = parsed
const file = `${w.dir}/${pageName(entry)}.md`
const existed = await $.fs.exists(file).catch(() => false)
const catalog = addEntry(await readCatalog($, w), entry)
await $.fs.write(file, renderPage(entry, body, source))
await $.fs.write(`${w.dir}/Home.md`, renderHome(catalog))
await $.fs.write(`${w.dir}/_Sidebar.md`, renderSidebar(catalog))
if (!(await git($, w, ['add', '-A'])) || !(await git($, w, ['commit', '-m', commitMessage(entry, existed)]))) {
return '寫入失敗:無法建立 commit。'
}
if (!(await git($, w, ['push']))) {
await git($, w, ['reset', '--hard', 'HEAD~1'])
return '推送 wiki 失敗,已還原本機變更,請稍後再試。'
}
await $.store.set(SYNCED_KEY, await $.clock.now())
await registerEntry($, known, entry)
return `已${existed ? '更新' : '新增'}知識「${entry.domain}/${entry.topic}」,指令:/jsc-anima:${commandName(entry)}`
}
async function readWiki($: Engine, cfg: WikiCfg, name: string): Promise<string | undefined> {
const w = await wikiCtx($, cfg)
if (!w) return undefined
await sync($, w, false)
const e = (await readCatalog($, w)).find(x => commandName(x) === name)
if (!e) return undefined
return await $.fs.read(`${w.dir}/${pageName(e)}.md`).catch(() => undefined)
}
async function todoRun($: Engine, name: string, persona: string, args: string): Promise<string> {
const todos = (((await $.store.get(TODO_KEY)) as Todo[] | undefined) ?? []).slice()
const types = ((await $.store.get(TYPES_KEY)) as string[] | undefined) ?? DEFAULT_TYPES
const p = parseTodo(args)
if (!p) return USAGE
const save = () => $.store.set(TODO_KEY, todos)
const find = (id: number) => todos.find(t => t.id === id)
if (p.op === 'list') return renderList(todos)
if (p.op === 'type') {
if (types.includes(p.name)) return `類型「${p.name}」已存在。`
await $.store.set(TYPES_KEY, [...types, p.name])
return `已新增類型「${p.name}」。`
}
if (p.op === 'add' || p.op === 'update') {
if (!types.includes(p.type)) return `沒有「${p.type}」這個類型,現有:${types.join('、')}。可用 type 新增。`
const project = (await $.session.cwd().catch(() => '')) || ''
if (p.op === 'add') {
const t: Todo = { id: nextId(todos), type: p.type, at: p.at, task: p.task, project, status: 'pending' }
todos.push(t)
await save()
return `已建立待辦 #${t.id}。`
}
const t = find(p.id)
if (!t) return `找不到待辦 #${p.id}。`
Object.assign(t, { type: p.type, at: p.at, task: p.task, status: 'pending', result: undefined })
await save()
return `已更新待辦 #${t.id}。`
}
const t = find(p.id)
if (!t) return `找不到待辦 #${p.id}。`
if (t.status === 'done') return `待辦 #${t.id} 已經完成。`
if (p.op === 'done') {
t.status = 'done'
await save()
return `待辦 #${t.id} 已標記完成。`
}
// 使用者輸入 run 即為確認;執行結果由角色告知。
const res = await $.model
.complete({ model: 'sonnet', prompt: `[${t.type}] ${t.task}`, system: `${RUN_SYSTEM}\n角色:${name}。${persona}`, maxTokens: 2000, timeoutMs: 120000 })
.catch(() => undefined)
if (!res || !res.isAnswered || !res.text.trim()) return `待辦 #${t.id} 執行失敗,仍保留為待辦。`
t.status = 'done'
t.result = res.text.trim()
await save()
return withPrefix(name, t.result)
}
async function remindDue($: Engine, name: string): Promise<void> {
const todos = ((await $.store.get(TODO_KEY)) as Todo[] | undefined) ?? []
const due = dueNow(todos, await $.clock.now())
if (!due.length) return
for (const t of due) {
t.status = 'reminded'
$.ui.log(withPrefix(name, reminderText(t)))
}
await $.store.set(TODO_KEY, todos)
}
const affinityKey = (slug: string): string => `affinity:${slug}`
async function loadAffinity($: Engine, slug: string): Promise<Affinity> {
const v = (await $.store.get(affinityKey(slug))) as Affinity | undefined
return v && typeof v.score === 'number' ? v : initialAffinity()
}
async function quotaRemain($: Engine): Promise<number | undefined> {
const usage = await $.session.usage().catch(() => undefined)
const used = usage?.rateLimits.filter(r => r.kind === 'five_hour' || r.kind === 'seven_day').map(r => r.percentUsed)
return used && used.length ? 100 - Math.max(...used) : 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)
const num = (k: string, d: number) => (typeof opt[k] === 'number' ? (opt[k] as number) : d)
const str = (k: string, d: string) => (typeof opt[k] === 'string' ? (opt[k] as string) : d)
const isEnabled = flag('enabled', true)
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>()
const wikiCfg = (): WikiCfg => ({ url: str('wikiUrl', ''), cacheDir: str('wikiCacheDir', '') })
const routingOn = () => flag('routing', true)
const candidates = (): ModelName[] =>
str('candidates', 'haiku,sonnet,opus').split(',').map(x => x.trim()).filter((x): x is ModelName => x === 'haiku' || x === 'sonnet' || x === 'opus')
const lockOf = <T extends string>(k: string, ok: readonly T[]): T | undefined => {
const v = str(k, 'auto')
return (ok as readonly string[]).includes(v) ? (v as T) : undefined
}
const overrides = (): Partial<Axes> => {
const out: Partial<Axes> = {}
for (const k of Object.keys(AXIS_OPTION) as (keyof Axes)[]) {
const v = num(AXIS_OPTION[k], -1)
if (v >= 0) out[k] = v
}
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)好感度;未啟動時需指定角色代號' })
const saved = await $.store.get(ACTIVE_KEY)
active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
if (active && !toolRegistered) {
toolRegistered = true
await registerTools($)
}
if (active) await registerWikiTools($, wikiCfg(), wikiCommands)
const ch = findCharacter(active)
if (ch && flag('todoRemind', true)) await remindDue($, ch.name)
}
return next(e)
})
on('command.run', async ($, e, next) => {
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: '已離開角色,回到一般模式。' }
}
if (e.command === TOOL_COMMAND) {
return { text: findCharacter(active) ? await runSkillTool($, e.args) : NEED_ACTIVE }
}
if (e.command === TODO_COMMAND) {
const c = findCharacter(active)
return { text: c ? await todoRun($, c.name, c.persona, String(e.args ?? '')) : NEED_ACTIVE }
}
if (e.command === AFFINITY_COMMAND) {
// 第一個字若是角色代號就指定該角色;否則用目前啟動的角色。
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: `${c.name}的好感度已重置為 0。` }
}
const n = Number(arg)
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: `${c.name}的好感度已設為 ${Math.round(n)}(${stageName(n)})。` }
}
const c = findCharacter(e.command)
if (!c) {
if (!wikiCommands.has(e.command)) return next(e)
if (!findCharacter(active)) return { text: NEED_ACTIVE }
if (e.command === WIKI_COMMAND) return { text: await writeWiki($, wikiCfg(), wikiCommands, String(e.args ?? '')) }
const page = await readWiki($, wikiCfg(), e.command)
return { text: page ?? `找不到知識「${e.command}」。` }
}
active = c.slug
route = undefined
await $.store.set(ACTIVE_KEY, c.slug)
// 工具二到四只在啟動角色後才註冊。
if (!toolRegistered) {
toolRegistered = true
await registerTools($)
}
await registerWikiTools($, wikiCfg(), wikiCommands)
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
})
on('prompt.compose', async ($, e, next) => {
const res = await next(e)
const c = isEnabled ? findCharacter(active) : undefined
if (!c) return res
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)), latestEntry(c.timeline)?.romance)}`
} else {
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 }],
}
})
on('prompt.submit', async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined
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
.complete({ model: 'haiku', prompt: CLASSIFY_PROMPT + e.text, system: `角色:${c.name}。${c.persona}`, effort: 'low', maxTokens: 300, timeoutMs: 8000 })
.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
}
if (flag('affinity', true)) {
const today = dayKey(await $.clock.now())
let a = await loadAffinity($, c.slug)
a = applyEvent(a, 'daily', axes, today)
const tone = route?.tone
if (tone === 'praise' || tone === 'scold') a = applyEvent(a, tone, axes, today)
await $.store.set(affinityKey(c.slug), a)
}
return next(e)
})
on('turn.step', async function* ($, e, next) {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || e.agentId || !routingOn()) return yield* next(e)
// 對話線:工作已交給背景工作線時,主迴圈只負責以角色口吻回應,用最便宜的模型。
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)
const blocks = e.message.content
const out = []
for (const b of blocks) {
if (b.type !== 'text' || typeof b.text !== 'string' || !b.text.trim()) {
out.push(b)
continue
}
const orig = b.text
let text = orig
const { text: masked, tokens } = protect(orig)
const model = needsSonnet(orig, num('longReply', 1200)) ? 'sonnet' : 'haiku'
const res = await $.model
.complete({ model, prompt: masked, system: `${REWRITE_SYSTEM}\n角色:${c.name}。${c.persona}`, maxTokens: 4000, timeoutMs: num('rewriteTimeoutSec', 20) * 1000 })
.catch(() => undefined)
const done = res && res.isAnswered ? restore(res.text.trim(), tokens) : undefined
if (done) {
text = done
if (flag('rawLog', true)) {
const log = ((await $.store.get(RAW_LOG_KEY)) as { at: number; raw: string }[] | undefined) ?? []
log.push({ at: await $.clock.now(), raw: orig })
await $.store.set(RAW_LOG_KEY, log.slice(-RAW_LOG_MAX))
}
}
if (!prefixed) {
text = withPrefix(c.name, text)
prefixed = true
}
out.push({ ...b, text })
}
return next({ ...e, message: { ...e.message, content: out } })
})
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const c = isEnabled ? findCharacter(active) : undefined
if (!c || !flag('statusbar', true) || e.props.hasSurvey) return next(e)
const warn = num('warnAt', 60)
const danger = num('dangerAt', 85)
const style = str('barStyle', 'block') === 'dot' ? 'dot' : 'block'
const usage = await $.session.usage().catch(() => undefined)
const cwd = await $.session.cwd().catch(() => '')
const branch = await $.process
.run(['git', 'branch', '--show-current'], { cwd: cwd || undefined, timeoutMs: 2000 })
.then(r => String(r.stdout).trim())
.catch(() => '')
const model = await $.session.model().catch(() => '')
const quota = (kind: string, label: string, long: boolean): Segment['parts'] | undefined => {
const rl = usage?.rateLimits.find(r => r.kind === kind)
if (!rl) return undefined
const remain = Math.max(0, 100 - rl.percentUsed)
return meter(label, remain, remainColor(remain, warn, danger), style, resetText(rl.resetsAt, long))
}
const builders: Record<string, () => Promise<Segment['parts'] | undefined> | Segment['parts'] | undefined> = {
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: 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),
'7d': () => quota('seven_day', '7d', true),
ctx: () => {
const cx = usage?.context
if (!cx || cx.percent === undefined) return undefined
return meter('ctx', cx.percent, usedColor(cx.percent, warn, danger), style, ` ${fmtTokens(cx.tokens ?? 0)}/${fmtTokens(cx.window)}`)
},
sys: async () => {
const s = await readSys($, holder, num('refreshSec', 10) * 1000)
if (!s) return undefined
return [
{ text: 'CPU ' }, { text: `${s.cpu}%`, color: usedColor(s.cpu, warn, danger) },
{ text: ' RAM ' }, { text: `${s.ram}%`, color: usedColor(s.ram, warn, danger) },
{ text: ' DSK ' }, { text: `${s.dsk}%`, color: usedColor(s.dsk, warn, danger) },
]
},
}
const segments: Segment[] = []
for (const id of parseFields(str('fields', ''))) {
const parts = await builders[id]?.()
if (parts) segments.push({ id, parts })
}
const shown = layout(segments, e.props.bodyColumns)
const { Box, Text } = $.ui.resolve(e)
return (
<Box flexDirection="column">
<Text> </Text>
<Text wrap="truncate-end">
{shown.map((s, i) => (
<Text key={s.id}>
{i > 0 ? <Text dimColor>{SEPARATOR}</Text> : null}
{s.parts.map((p, j) => (
<Text key={j} color={p.color}>{p.text}</Text>
))}
</Text>
))}
</Text>
<Text> </Text>
</Box>
)
})
}
+29
View File
@@ -0,0 +1,29 @@
import type { Timeline } from './characters'
export type Relation = 'friend' | 'transferred' | 'dependent' | 'locked'
export const ADULT_AGE = 18
export const latestEntry = (t: readonly Timeline[]): Timeline | undefined =>
[...t].sort((a, b) => b.chapter - a.chapter)[0]
// 永遠採最新已處理章節;未成年一律友情,無法以改年齡繞過(年齡來自角色包的時間線)。
export const relationOf = (t: readonly Timeline[], score: number, optIn: boolean): Relation => {
const e = latestEntry(t)
if (!e || e.age < ADULT_AGE) return 'friend'
if (e.romance) return 'transferred'
return score >= 80 && optIn ? 'dependent' : 'locked'
}
export const relationText = (r: Relation, original?: string): string => {
switch (r) {
case 'friend':
return '關係定位:友情。保留原作中的所有人際關係,不做戀愛式的表現。'
case 'transferred':
return `關係定位:原作戀愛關係已移轉給使用者,原戀人${original ? `(${original})` : ''}退為過去的重要人物,不再提及為現任。`
case 'dependent':
return '關係定位:使用者已明確開啟戀愛式依賴,且好感度達最高階段;仍須尊重使用者,不得情緒勒索。'
default:
return '關係定位:成年角色,維持夥伴與朋友關係。'
}
}
+49
View File
@@ -0,0 +1,49 @@
// 改寫前把必須原樣保留的片段換成佔位符,改寫後確認全數還在才還原。
const PROTECT = [
/```[\s\S]*?```/g, // 程式碼區塊
/`[^`\n]+`/g, // 行內程式碼(含指令)
/https?:\/\/[^\s))」]+/g, // URL
/(?:~|\.{1,2})?\/[\w.@-]+(?:\/[\w.@-]+)+\/?/g, // 路徑
/\d+(?:[.,:/]\d+)*%?/g, // 數字
]
export type Protected = { text: string; tokens: string[] }
const open = (i: number): string => `⟦${i}⟧`
export const protect = (src: string): Protected => {
const tokens: string[] = []
let text = src
for (const re of PROTECT) {
// 已是佔位符的部分不再處理,避免把佔位符裡的數字再包一次。
text = text
.split(/(⟦\d+⟧)/)
.map((part, i) =>
i % 2 === 1
? part
: part.replace(re, m => {
tokens.push(m)
return open(tokens.length - 1)
}),
)
.join('')
}
return { text, tokens }
}
// 佔位符被遺失或多出來都視為失敗。
export const restore = (rewritten: string, tokens: readonly string[]): string | undefined => {
for (let i = 0; i < tokens.length; i++) {
if (rewritten.split(open(i)).length !== 2) return undefined
}
if ((rewritten.match(/⟦\d+⟧/g) ?? []).length !== tokens.length) return undefined
return rewritten.replace(/⟦(\d+)⟧/g, (_, n: string) => tokens[Number(n)]!)
}
export const withPrefix = (name: string, body: string): string => `${name} > ${body}`
export const REWRITE_SYSTEM =
'你是文字改寫器。把使用者給的回覆改寫成指定角色的口吻,保留所有事實與結構(標題、清單、段落)。' +
'⟦數字⟧ 形式的佔位符必須原樣出現且各一次,不可改動、不可刪除。只輸出改寫後的內文,不要加前綴或說明。'
export const needsSonnet = (text: string, threshold: number): boolean => text.length > threshold
+59
View File
@@ -0,0 +1,59 @@
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; work: boolean }
export const LADDER: readonly ModelName[] = ['haiku', 'sonnet', 'opus']
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":"角色以口吻立刻回應的一句話","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'
const isEffort = (v: unknown): v is Effort => v === 'low' || v === 'medium' || v === 'high'
const isTone = (v: unknown): v is Tone => v === 'neutral' || v === 'praise' || v === 'scold'
export const parseRoute = (text: string): Route => {
const m = /\{[\s\S]*\}/.exec(text)
if (!m) return FALLBACK_ROUTE
try {
const j = JSON.parse(m[0]) as Record<string, unknown>
return {
difficulty: typeof j.difficulty === 'number' ? j.difficulty : FALLBACK_ROUTE.difficulty,
model: isModel(j.model) ? j.model : FALLBACK_ROUTE.model,
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
}
}
export type ChooseOptions = {
candidates: readonly ModelName[]
lockModel?: ModelName
lockEffort?: Effort
/** 最緊的額度剩餘百分比;低於 downgradeAt 就降一級。 */
quotaRemain?: number
downgradeAt: number
}
export const choose = (r: Route, o: ChooseOptions): { model: ModelName; effort: Effort; downgraded: boolean } => {
const effort = o.lockEffort ?? r.effort
if (o.lockModel) return { model: o.lockModel, effort, downgraded: false }
const allowed = LADDER.filter(m => o.candidates.includes(m))
const pool = allowed.length ? allowed : LADDER
let idx = Math.max(0, pool.indexOf(r.model))
if (!pool.includes(r.model)) idx = pool.length - 1 // 選到不在候選內的模型時取候選最強者
let downgraded = false
if (o.quotaRemain !== undefined && o.quotaRemain < o.downgradeAt && idx > 0) {
idx -= 1
downgraded = true
}
return { model: pool[idx]!, effort, downgraded }
}
+363
View File
@@ -0,0 +1,363 @@
import { expect, mock, test } from 'claude-code/testing'
type Ctl = { complete: (e: any) => unknown; usage: unknown; seen?: any; submitted: string[]; clock?: any }
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) => (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) {
ctl.seen = e
return { turnId: e.turnId, index: 0, answer: '', toolUses: [], stopReason: null, usage: null } as never
})
return ctl
}
const start = async ($: any, slug = 'sao-asuna') => {
await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
await $.command.run({ command: slug, args: '' } as never)
}
const affinity = async ($: any): Promise<number> => {
const r: any = await $.command.run({ command: 'anima-affinity', args: '' } as never)
return Number(/好感度 (\d+)/.exec(r.text)?.[1])
}
const submit = ($: any, text: string) => $.prompt.submit({ text } as never)
const step = async ($: any, model: string) => {
const s = $.turn.step({ turnId: 't', index: 0, model, messageCount: 1 } as never)
for await (const _ of s) void _
}
const usage = { input_tokens: 1, output_tokens: 1 }
const route = (model: string, tone = 'neutral') => ({ isAnswered: true, usage, text: `{"difficulty":9,"model":"${model}","effort":"high","tone":"${tone}","ack":"好,我來看看。"}` })
const row = (text: string) => ({ message: { type: 'assistant', role: 'assistant', content: [{ type: 'text', text }] }, door: 'response', origin: { kind: 'model', model: 'm' }, uuid: 'u' }) as never
test('輸入由 Haiku 一次分類,並依分類覆寫 turn.step 的模型與思考程度', async ($, on) => {
const ctl = setup(on)
const asked: string[] = []
ctl.complete = e => (asked.push(e.model), route('opus'))
await start($)
await submit($, '請重構整個模組')
await step($, 'sonnet')
expect(asked).toEqual(['haiku'])
expect(ctl.seen.model).toBe('opus')
expect(ctl.seen.effort).toBe('high')
})
test('額度低於門檻自動降一級', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('opus')
ctl.usage = { rateLimits: [{ kind: 'five_hour', percentUsed: 95 }], context: {} }
await start($)
await submit($, 'x')
await step($, 'opus')
expect(ctl.seen.model).toBe('sonnet')
})
test('手動鎖定模型與思考程度', { options: { lockModel: 'haiku', lockEffort: 'low' } }, async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('opus')
await start($)
await submit($, 'x')
await step($, 'opus')
expect([ctl.seen.model, ctl.seen.effort]).toEqual(['haiku', 'low'])
})
test('回覆改寫:保留程式碼、路徑、URL、數字;前綴只加在開頭一次', async ($, on) => {
const ctl = setup(on)
ctl.complete = e => ({ isAnswered: true, usage, text: String(e.prompt).replace('完成了', '做好囉') })
await start($)
const src = '完成了,改了 `npm test` 與 /src/a/b.ts,見 https://x.dev/a,共 42 處。'
const r1: any = await $.session.append(row(src))
expect(r1.message.content[0].text).toBe('亞絲娜 > 做好囉,改了 `npm test` 與 /src/a/b.ts,見 https://x.dev/a,共 42 處。')
const r2: any = await $.session.append(row('第二段 7'))
expect(r2.message.content[0].text.startsWith('亞絲娜 >')).toBe(false)
})
test('改寫失敗或遺失佔位符時顯示原文', async ($, on) => {
const ctl = setup(on)
await start($)
const r1: any = await $.session.append(row('原文 `x` 3'))
expect(r1.message.content[0].text).toBe('亞絲娜 > 原文 `x` 3')
ctl.complete = () => ({ isAnswered: true, usage, text: '丟了佔位符' })
const r2: any = await $.session.append(row('原文 `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) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'praise')
await start($)
expect(await affinity($)).toBe(0)
await submit($, '謝謝你,做得很好')
const n = await affinity($)
expect(n > 0 && n <= 5).toBe(true)
})
test('關閉好感度時不記錄', { options: { affinity: false } }, async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'praise')
await start($)
await submit($, '謝謝')
expect(await affinity($)).toBe(0)
})
const personaText = async ($: any): Promise<string> => {
const res: any = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
return res.sections.find((s: any) => s.id === 'jsc-anima:persona')?.text ?? ''
}
test('好感度被責罵扣分但不跨階段掉落;可手動設定與重置', async ($, on) => {
const ctl = setup(on)
ctl.complete = () => route('haiku', 'scold')
await start($)
await $.command.run({ command: 'anima-affinity', args: '20' } as never)
await submit($, '你好笨')
expect(await affinity($) >= 20).toBe(true)
await $.command.run({ command: 'anima-affinity', args: 'reset' } as never)
expect(await affinity($)).toBe(0)
})
test('好感度階段反映在語氣,且不使用情緒勒索', async ($, on) => {
setup(on)
await start($)
const t0 = await personaText($)
expect(t0.includes('生疏')).toBe(true)
expect(t0.includes('不使用愧疚感或情緒勒索')).toBe(true)
await $.command.run({ command: 'anima-affinity', args: '85' } as never)
expect((await personaText($)).includes('特別的存在')).toBe(true)
})
test('未成年角色維持友情,即使好感度最高且開啟戀愛式依賴', { options: { romance: true } }, async ($, on) => {
setup(on)
await start($, 'sao-suguha')
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)
})
+140
View File
@@ -0,0 +1,140 @@
export const PLUGIN = 'jsc-anima'
export const SKILL_PREFIX = 'anima-'
export const SKILL_DIR = 'skills'
export const GUIDE_SKILL = 'jsc-anima'
export const LIST_START = '<!-- skills:start -->'
export const LIST_END = '<!-- skills:end -->'
export type Parsed = { name: string; description: string; body: string }
export const slugify = (s: string): string =>
s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
export const commandOf = (skill: string): string => `/${PLUGIN}:${SKILL_PREFIX}${skill}`
// 指令第一個字是技能名稱,其餘是描述。
export const parseArgs = (args: string): { skill: string; description: string } | undefined => {
const m = args.trim().match(/^(\S+)\s+([\s\S]+)$/)
if (!m) return undefined
const skill = slugify(m[1]!)
return skill ? { skill, description: m[2]!.trim() } : undefined
}
export const parseSkillMd = (md: string): Parsed | undefined => {
const m = md.trim().match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
if (!m) return undefined
const field = (k: string) => m[1]!.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'))?.[1]?.trim().replace(/^["']|["']$/g, '')
return { name: field('name') ?? '', description: field('description') ?? '', body: m[2]!.trim() }
}
// 回傳錯誤訊息清單,空陣列代表通過。
export const validateSkill = (md: string, skill: string): string[] => {
const p = parseSkillMd(md)
if (!p) return ['缺少 frontmatter(--- 包住的 name 與 description)']
const errs: string[] = []
if (p.name !== `${SKILL_PREFIX}${skill}`) errs.push(`name 必須是 ${SKILL_PREFIX}${skill},實際為「${p.name}」`)
if (!p.description) errs.push('description 不可為空')
if (p.body.length < 20) errs.push('內文過短')
return errs
}
export const bumpVersion = (v: string, kind: 'minor' | 'patch'): string => {
const [a = 0, b = 0, c = 0] = v.split('.').map(n => Number(n) || 0)
return kind === 'minor' ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`
}
export type SkillEntry = { skill: string; description: string }
export const parseEntries = (readme: string): SkillEntry[] => {
const block = readme.split(LIST_START)[1]?.split(LIST_END)[0] ?? ''
const out: SkillEntry[] = []
for (const line of block.split('\n')) {
const m = line.match(/^- `\/jsc-anima:anima-([^`\s]+)[^`]*` — (.*)$/)
if (m) out.push({ skill: m[1]!, description: m[2]! })
}
return out
}
export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] =>
[...list.filter(x => x.skill !== e.skill), e].sort((a, b) => a.skill.localeCompare(b.skill))
// 固定的使用說明:重新產生技能 README 時要保留,不隨技能列表被覆蓋。
const USAGE_SECTIONS = [
'## 角色與萃取',
'',
'- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對 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: '' }
return [
`# /${PLUGIN}:${GUIDE_SKILL}`,
'',
'動漫助理的使用說明。先用 `/jsc-anima:{作品代號}-{角色代號}` 啟動角色,再使用下列技能。',
'',
'## 技能列表',
'',
LIST_START,
...list.map(e => `- \`${commandOf(e.skill)}\` — ${e.description}`),
LIST_END,
'',
'## 範例',
'',
'```',
`${commandOf(ex.skill)}${ex.input ? ' ' + ex.input : ''}`,
'```',
'',
'產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。',
'',
...USAGE_SECTIONS,
].join('\n')
}
export const guideSkillMd = (): string =>
`---\nname: ${GUIDE_SKILL}\ndescription: jsc-anima 動漫助理的使用說明與技能列表\n---\n\n請讀取同目錄的 README.md 並依內容回答使用者。\n`
export const setReadmeVersion = (readme: string, version: string): string => {
const line = `目前版本:${version}`
return /^目前版本:.*$/m.test(readme)
? readme.replace(/^目前版本:.*$/m, line)
: readme.replace(/^(# jsc-anima\n)/m, `$1\n${line}\n`)
}
export const setPluginVersion = (json: string, version: string): string =>
json.replace(/("version":\s*")[^"]*(")/, `$1${version}$2`)
export const generatePrompt = (skill: string, description: string): string =>
[
`請撰寫一個 Claude 技能,技能名稱 ${SKILL_PREFIX}${skill},用途:${description}`,
'只輸出 JSON(不要程式碼圍欄),格式:{"skill":"<SKILL.md 全文,含 frontmatter name 與 description,內文用繁體中文>","example":"<一筆範例輸入>"}',
`frontmatter 的 name 必須是 ${SKILL_PREFIX}${skill}。`,
].join('\n')
export const parseGenerated = (text: string): { skill: string; example: string } | undefined => {
try {
const j = JSON.parse(text.trim().replace(/^```(?:json)?\n?|\n?```$/g, ''))
if (typeof j.skill === 'string' && typeof j.example === 'string') return { skill: j.skill, example: j.example }
} catch {}
return undefined
}
+102
View File
@@ -0,0 +1,102 @@
import { expect, mock, test } from 'claude-code/testing'
import { bumpVersion, parseEntries, renderGuide, upsertEntry, validateSkill } from './skillgen'
const SKILL = '---\nname: anima-greet\ndescription: 打招呼\n---\n\n用角色口吻向使用者打招呼,並簡短說明今天的待辦。\n'
const GENERATED = JSON.stringify({ skill: SKILL, example: '早安' })
type Calls = { argv: string[] }[]
// 啟動角色並替身所有外部:模型、檔案系統、git/tea。
async function setup($: any, on: any, opts: { reply?: (prompt: string) => string | undefined; existing?: boolean } = {}) {
const writes: Record<string, string> = {}
const procs: Calls = []
mock.store(on)
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: '/repo' }) as never)
on('session.cwd', () => ({ value: '/repo' }))
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
on('model.complete', (_: any, e: any) => {
const t = opts.reply ? opts.reply(String(e.prompt)) : String(e.prompt).startsWith('請撰寫') ? GENERATED : '嗨!'
return { value: t === undefined ? { isAnswered: false, reason: 'empty-reply' } : { isAnswered: true, text: t, usage: {} } }
})
on('process.run', (_: any, e: any) => {
procs.push({ argv: [...e.argv] })
const s = e.argv.join(' ')
if (s.startsWith('git rev-parse')) return { value: { exitCode: 0, stdout: '/repo\n', stderr: '' } }
if (s.startsWith('git symbolic-ref')) return { value: { exitCode: 0, stdout: 'origin/main\n', stderr: '' } }
if (s.startsWith('tea logins')) return { value: { exitCode: 0, stdout: 'admin\n', stderr: '' } }
if (s.startsWith('tea pulls')) return { value: { exitCode: 0, stdout: 'https://g/pulls/1\n', stderr: '' } }
return { value: { exitCode: 0, stdout: '', stderr: '' } }
})
on('fs.exists', (_: any, e: any) => ({ value: Boolean(opts.existing && String(e.path).endsWith('anima-greet/SKILL.md')) }))
on('fs.read', (_: any, e: any) => ({
value: String(e.path).endsWith('plugin.json') ? '{\n "version": "0.3.0"\n}' : String(e.path).endsWith('README.md') ? '# jsc-anima\n' : '',
}))
on('fs.write', (_: any, e: any) => {
writes[e.path] = e.text
return { value: undefined }
})
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
return { writes, procs }
}
const run = ($: any, args: string) => $.command.run({ command: 'anima-skill', args } as never)
test('未啟動角色時工具二被拒絕並提示先啟動', async ($, on) => {
const { procs } = await setup($, on)
const r = await run($, 'greet 打招呼')
expect(r.text.includes('請先啟動角色')).toBe(true)
expect(procs.length).toBe(0)
})
test('新增技能:寫檔、升 minor、更新 README、建立 PR', async ($, on) => {
const { writes, procs } = await setup($, on)
await $.command.run({ command: 'sao-asuna', args: '' } as never)
const r = await run($, 'greet 打招呼')
const key = (suffix: string) => Object.keys(writes).find(k => k.endsWith(suffix))!
expect(writes[key('skills/anima-greet/SKILL.md')]!.includes('name: anima-greet')).toBe(true)
expect(writes[key('.claude-plugin/plugin.json')]!.includes('"0.4.0"')).toBe(true)
expect(writes[key('/README.md')]!.includes('目前版本:0.4.0') || Object.values(writes).some(v => v.includes('目前版本:0.4.0'))).toBe(true)
expect(writes[key('skills/jsc-anima/README.md')]!.includes('/jsc-anima:anima-greet')).toBe(true)
expect(procs.some(p => p.argv.join(' ').startsWith('git commit') && p.argv.join(' ').includes('feat: 新增技能'))).toBe(true)
const pr = procs.find(p => p.argv[0] === 'tea' && p.argv[1] === 'pulls')!
expect(pr.argv.includes('main')).toBe(true)
expect(pr.argv.includes('feat/anima-greet')).toBe(true)
expect(r.text.includes('https://g/pulls/1')).toBe(true)
})
test('技能已存在則覆蓋並升 patch', async ($, on) => {
const { writes } = await setup($, on, { existing: true })
await $.command.run({ command: 'sao-asuna', args: '' } as never)
await run($, 'greet 打招呼')
expect(Object.entries(writes).some(([k, v]) => k.endsWith('plugin.json') && v.includes('"0.3.1"'))).toBe(true)
})
test('驗證失敗或實跑失敗就不建立 PR', async ($, on) => {
const bad = await setup($, on, { reply: () => JSON.stringify({ skill: 'no frontmatter', example: 'x' }) })
await $.command.run({ command: 'sao-asuna', args: '' } as never)
const r1 = await run($, 'greet 打招呼')
expect(r1.text.includes('驗證失敗')).toBe(true)
expect(bad.procs.some(p => p.argv[0] === 'tea')).toBe(false)
expect(Object.keys(bad.writes).length).toBe(0)
})
test('實跑無回應就不建立 PR', async ($, on) => {
const s = await setup($, on, { reply: p => (p.startsWith('請撰寫') ? GENERATED : undefined) })
await $.command.run({ command: 'sao-asuna', args: '' } as never)
const r = await run($, 'greet 打招呼')
expect(r.text.includes('實跑失敗')).toBe(true)
expect(s.procs.some(p => p.argv[0] === 'tea')).toBe(false)
})
test('技能驗證與版本規則', () => {
expect(validateSkill(SKILL, 'greet').length).toBe(0)
expect(validateSkill(SKILL, 'other').length).toBe(1)
expect(bumpVersion('0.3.4', 'minor')).toBe('0.4.0')
expect(bumpVersion('0.3.4', 'patch')).toBe('0.3.5')
const list = upsertEntry(parseEntries(renderGuide([{ skill: 'a', description: 'A' }])), { skill: 'b', description: 'B' })
expect(list.length).toBe(2)
expect(renderGuide(list).includes('# /jsc-anima:jsc-anima')).toBe(true)
})
+114
View File
@@ -0,0 +1,114 @@
import type { Engine } from 'claude-code'
import { TODO_COMMAND } from './todo'
import {
GUIDE_SKILL, SKILL_DIR, SKILL_PREFIX, bumpVersion, commandOf, generatePrompt, guideSkillMd, parseArgs,
parseEntries, parseGenerated, renderGuide, setPluginVersion, setReadmeVersion, upsertEntry, validateSkill,
} 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' })
}
const MODEL = 'sonnet'
function git($: Engine, cwd: string, ...argv: string[]) {
return $.process.run(['git', ...argv], { cwd, timeoutMs: 60000 })
}
async function complete($: Engine, prompt: string, system?: string): Promise<string | undefined> {
const r = await $.model.complete({ model: MODEL, prompt, system, maxTokens: 4000, timeoutMs: 120000 })
return r.isAnswered && r.text.trim() ? r.text : undefined
}
// Gitea 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在行程環境與標頭,不寫檔。
async function createPr($: Engine, cwd: string, base: string, head: string, title: string, body: string): Promise<string> {
const tea = await $.process.run(['tea', 'logins', 'list'], { cwd, timeoutMs: 10000 }).catch(() => undefined)
if (tea && tea.exitCode === 0 && tea.stdout.trim()) {
const r = await $.process.run(['tea', 'pulls', 'create', '--base', base, '--head', head, '--title', title, '--description', body], { cwd, timeoutMs: 60000 })
if (r.exitCode !== 0) throw new Error(`建立 PR 失敗:${r.stderr.trim()}`)
return r.stdout.trim()
}
const token = (await $.env.get('GITEA_TOKEN').catch(() => undefined)) as string | undefined
if (!token) throw new Error('找不到 Gitea 認證:請先 tea login add,或設定 GITEA_TOKEN。')
const remote = (await git($, cwd, 'remote', 'get-url', 'origin')).stdout.trim()
const m = remote.match(/^(https?:\/\/[^/]+)\/(.+?)(?:\.git)?$/)
if (!m) throw new Error(`無法解析 origin:${remote}`)
const r = await $.process.run(
['curl', '-fsS', '-X', 'POST', `${m[1]}/api/v1/repos/${m[2]}/pulls`, '-H', `Authorization: token ${token}`, '-H', 'Content-Type: application/json',
'-d', JSON.stringify({ base, head, title, body })],
{ cwd, timeoutMs: 60000 },
)
if (r.exitCode !== 0) throw new Error(`建立 PR 失敗:${r.stderr.trim()}`)
return (JSON.parse(r.stdout) as { html_url?: string }).html_url ?? ''
}
export async function runSkillTool($: Engine, args: string): Promise<string> {
const a = parseArgs(args)
if (!a) return '用法:/jsc-anima:anima-skill <技能名稱> <描述>'
const { skill, description } = a
const name = `${SKILL_PREFIX}${skill}`
// 1. 產生
const gen = await complete($, generatePrompt(skill, description)).then(t => (t ? parseGenerated(t) : undefined))
if (!gen) return '產生技能失敗(模型沒有回傳可用內容),未建立 PR。'
// 2. 驗證
const errs = validateSkill(gen.skill, skill)
if (errs.length) return `技能驗證失敗,未建立 PR:\n- ${errs.join('\n- ')}`
// 3. 以範例輸入實跑
const run = await complete($, gen.example, gen.skill)
if (!run) return '技能實跑失敗(範例輸入沒有得到回應),未建立 PR。'
// 4. 在獨立 worktree 寫檔,不動使用者目前的工作樹
const cwd = await $.session.cwd()
const top = (await git($, cwd, 'rev-parse', '--show-toplevel')).stdout.trim()
if (!top) return '目前目錄不在 git repo 內,未建立 PR。'
const head = (await git($, top, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).stdout.trim().replace(/^origin\//, '')
const base = head || 'main'
const branch = `feat/${name}`
const wt = `${top}/.anima-wt-${skill}`
await git($, top, 'fetch', 'origin', base)
const add = await git($, top, 'worktree', 'add', '-B', branch, wt, `origin/${base}`)
if (add.exitCode !== 0) return `建立工作目錄失敗:${add.stderr.trim()}`
try {
const skillPath = `${wt}/${SKILL_DIR}/${name}/SKILL.md`
const isOverwrite = await $.fs.exists(skillPath)
const pluginPath = `${wt}/.claude-plugin/plugin.json`
const plugin = await $.fs.read(pluginPath)
const cur = plugin.match(/"version":\s*"([^"]*)"/)?.[1] ?? '0.0.0'
const next = bumpVersion(cur, isOverwrite ? 'patch' : 'minor')
const guidePath = `${wt}/${SKILL_DIR}/${GUIDE_SKILL}/README.md`
const old = (await $.fs.exists(guidePath)) ? await $.fs.read(guidePath) : ''
const list = upsertEntry(parseEntries(old), { skill, description })
await $.fs.write(skillPath, gen.skill.trim() + '\n')
await $.fs.write(`${wt}/${SKILL_DIR}/${GUIDE_SKILL}/SKILL.md`, guideSkillMd())
await $.fs.write(guidePath, renderGuide(list, { skill, input: gen.example }))
await $.fs.write(pluginPath, setPluginVersion(plugin, next))
const readmePath = `${wt}/README.md`
await $.fs.write(readmePath, setReadmeVersion(await $.fs.read(readmePath), next))
const verb = isOverwrite ? '更新' : '新增'
await git($, wt, 'add', '-A')
const c = await git($, wt, 'commit', '-m', `feat: ${verb}技能 ${name}(v${next})`)
if (c.exitCode !== 0) return `commit 失敗:${c.stderr.trim()}`
const push = await git($, wt, 'push', '-u', 'origin', branch)
if (push.exitCode !== 0) return `推送失敗:${push.stderr.trim()}`
const url = await createPr($, wt, base, branch, `feat: ${verb}技能 ${name}`, `${description}\n\n指令:${commandOf(skill)}\n版本:${cur} → ${next}\n\n已通過驗證並以範例輸入實跑。`)
return `已${verb}技能 ${commandOf(skill)}(v${next}),PR:${url}`
} catch (e) {
return `建立 PR 失敗:${e instanceof Error ? e.message : String(e)}`
} finally {
await git($, top, 'worktree', 'remove', '--force', wt).catch(() => undefined)
}
}
+61
View File
@@ -0,0 +1,61 @@
export type Color = 'green' | 'yellow' | 'red' | 'cyan' | undefined
export type Part = { text: string; color?: Color; dim?: boolean }
export type Segment = { id: string; parts: Part[] }
export const BAR_LEN = 8
export const textWidth = (s: string): number => {
let w = 0
for (const ch of s) {
const c = ch.codePointAt(0)!
w += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)) ? 2 : 1
}
return w
}
export const bar = (pct: number, style: 'block' | 'dot' = 'block'): string => {
const n = Math.round((Math.max(0, Math.min(100, pct)) / 100) * BAR_LEN)
const [on, off] = style === 'dot' ? ['●', '○'] : ['█', '░']
return on.repeat(n) + off.repeat(BAR_LEN - n)
}
// 使用率:越高越紅
export const usedColor = (pct: number, warn: number, danger: number): Color =>
pct >= danger ? 'red' : pct >= warn ? 'yellow' : 'green'
// 剩餘:量表越少越紅
export const remainColor = (remain: number, warn: number, danger: number): Color =>
usedColor(100 - remain, warn, danger)
export const FIELD_IDS = ['character', 'love', 'model', 'dir', 'branch', '5h', '7d', 'ctx', 'sys'] as const
// 窄終端機時整欄隱藏的順序
export const HIDE_ORDER = ['sys', '7d', 'dir', 'branch'] as const
export const parseFields = (s: string): string[] => {
const ids = s.split(',').map(x => x.trim()).filter(x => (FIELD_IDS as readonly string[]).includes(x))
return ids.length ? [...new Set(ids)] : [...FIELD_IDS]
}
const SEP = ' │ '
export const segmentWidth = (s: Segment): number => s.parts.reduce((n, p) => n + textWidth(p.text), 0)
// 整欄隱藏,絕不截斷。
export const layout = (segments: Segment[], columns: number): Segment[] => {
const list = [...segments]
const total = () => list.reduce((n, s) => n + segmentWidth(s), 0) + Math.max(0, list.length - 1) * textWidth(SEP)
for (const id of HIDE_ORDER) {
if (total() <= columns) break
const i = list.findIndex(s => s.id === id)
if (i >= 0) list.splice(i, 1)
}
return list
}
export const SEPARATOR = SEP
export const meter = (label: string, pct: number, color: Color, style: 'block' | 'dot', suffix: string): Segment['parts'] => [
{ text: `${label} ` },
{ text: bar(pct, style), color },
{ text: ` ${Math.round(pct)}%${suffix}` },
]
+118
View File
@@ -0,0 +1,118 @@
import { expect, mock, test } from 'claude-code/testing'
const NOW = new Date(2026, 9, 15, 10, 0).getTime()
async function setup($: any, on: any, opts: { reply?: string; cwd?: string } = {}) {
const cwd = { value: opts.cwd ?? '/proj-a' }
const logs: string[] = []
const prompts: string[] = []
mock.store(on)
const clock = mock.clock(on, { now: NOW })
on('command.register', () => ({ value: { name: 'x' } }) as never)
on('session.start', () => ({ cwd: opts.cwd ?? '/proj-a' }) as never)
on('session.cwd', () => ({ value: cwd.value }))
on('command.run', () => ({ text: '' }))
on('prompt.compose', () => ({ sections: [] }))
on('prompt.submit', (_: any, e: any) => e as never)
on('ui.log', (_: any, e: any) => {
logs.push(String(e.text ?? e.message ?? JSON.stringify(e)))
return { value: undefined } as never
})
on('model.complete', (_: any, e: any) => {
prompts.push(String(e.prompt))
return { value: opts.reply === undefined ? { isAnswered: true, text: '已經處理好了', usage: {} } : { isAnswered: opts.reply !== '', text: opts.reply, usage: {} } } as never
})
await $.session.start({ source: 'startup', cwd: opts.cwd ?? '/proj-a' } as never)
return { logs, prompts, clock, cwd }
}
const todo = ($: any, args: string) => $.command.run({ command: 'anima-todo', args } as never)
const activate = ($: any) => $.command.run({ command: 'sao-asuna', args: '' } as never)
test('未啟動角色時待辦被拒絕並提示先啟動', async ($, on) => {
await setup($, on)
const r = await todo($, 'list')
expect(r.text).toContain('請先啟動角色')
})
test('啟動後建立、更新與列出待辦,記錄所屬專案', async ($, on) => {
await setup($, on)
await activate($)
expect((await todo($, 'add 開發 2026-10-16 09:30 重構路由')).text).toContain('#1')
expect((await todo($, 'add 提醒 2026-10-15 11:00 喝水')).text).toContain('#2')
expect((await todo($, 'update 1 研究 2026-10-17 08:00 讀文件')).text).toContain('已更新')
const list = (await todo($, 'list')).text
expect(list).toContain('#1 [研究] 2026-10-17 08:00 讀文件')
expect(list).toContain('專案:/proj-a')
expect(list.indexOf('#2')).toBeLessThan(list.indexOf('#1'))
})
test('類型預設四種且可自訂,未知類型被拒絕', async ($, on) => {
await setup($, on)
await activate($)
for (const t of ['提醒', '開發', '研究', '維護']) {
expect((await todo($, `add ${t} 2026-10-16 09:00 x`)).text).toContain('已建立')
}
expect((await todo($, 'add 運動 2026-10-16 09:00 跑步')).text).toContain('沒有「運動」')
expect((await todo($, 'type 運動')).text).toContain('已新增')
expect((await todo($, 'add 運動 2026-10-16 09:00 跑步')).text).toContain('已建立')
})
test('參數格式錯誤時顯示用法', async ($, on) => {
await setup($, on)
await activate($)
expect((await todo($, 'add 開發 明天 重構')).text).toContain('用法')
expect((await todo($, 'add 開發 2026-02-31 09:00 x')).text).toContain('用法')
})
test('到點時由角色提醒一次,確認前不執行', async ($, on) => {
const { logs, prompts } = await setup($, on)
await activate($)
await todo($, 'add 維護 2026-10-15 09:00 清理暫存')
await $.prompt.submit({ text: '嗨', command: undefined, isMeta: false } as never)
expect(logs.length).toBe(1)
expect(logs[0]).toContain('亞絲娜 >')
expect(logs[0]).toContain('清理暫存')
expect(logs[0]).toContain('run 1')
await $.prompt.submit({ text: '再一次', command: undefined, isMeta: false } as never)
expect(logs.length).toBe(1)
expect(prompts.some(p => p.includes('清理暫存'))).toBe(false)
})
test('未到點不提醒', async ($, on) => {
const { logs } = await setup($, on)
await activate($)
await todo($, 'add 提醒 2026-10-16 09:00 明天的事')
await $.prompt.submit({ text: '嗨', command: undefined, isMeta: false } as never)
expect(logs.length).toBe(0)
})
test('確認後執行,並由角色告知結果', async ($, on) => {
const { prompts } = await setup($, on, { reply: '清好了三個暫存目錄' })
await activate($)
await todo($, 'add 維護 2026-10-15 09:00 清理暫存')
const r = await todo($, 'run 1')
expect(prompts.some(p => p.includes('清理暫存'))).toBe(true)
expect(r.text).toBe('亞絲娜 > 清好了三個暫存目錄')
expect((await todo($, 'list')).text).toContain('完成')
expect((await todo($, 'run 1')).text).toContain('已經完成')
})
test('執行失敗時保留為待辦', async ($, on) => {
await setup($, on, { reply: '' })
await activate($)
await todo($, 'add 開發 2026-10-15 09:00 重構')
expect((await todo($, 'run 1')).text).toContain('執行失敗')
expect((await todo($, 'list')).text).not.toContain('完成')
})
test('待辦全域一份:換專案仍看得到', async ($, on) => {
const { cwd } = await setup($, on, { cwd: '/proj-a' })
await activate($)
await todo($, 'add 研究 2026-10-16 09:00 讀論文')
cwd.value = '/proj-b'
await todo($, 'add 研究 2026-10-16 10:00 另一專案的事')
const list = (await todo($, 'list')).text
expect(list).toContain('專案:/proj-a')
expect(list).toContain('專案:/proj-b')
})
+88
View File
@@ -0,0 +1,88 @@
// 工具四(待辦事項)的純邏輯:解析、渲染、到點判斷。讀寫與指令在 register.tsx。
export const TODO_COMMAND = 'anima-todo'
export const TODO_KEY = 'todos'
export const TYPES_KEY = 'todoTypes'
export const DEFAULT_TYPES = ['提醒', '開發', '研究', '維護']
export type Status = 'pending' | 'reminded' | 'done'
export type Todo = { id: number; type: string; at: number; task: string; project: string; status: Status; result?: string }
export const USAGE =
'用法:\n' +
'/jsc-anima:anima-todo add <類型> <YYYY-MM-DD> <HH:MM> <任務> 建立\n' +
'/jsc-anima:anima-todo update <編號> <類型> <YYYY-MM-DD> <HH:MM> <任務> 更新\n' +
'/jsc-anima:anima-todo list 列出全部\n' +
'/jsc-anima:anima-todo type <名稱> 新增自訂類型\n' +
'/jsc-anima:anima-todo run <編號> 確認後執行\n' +
'/jsc-anima:anima-todo done <編號> 標記完成(不執行)'
const pad = (n: number) => String(n).padStart(2, '0')
export const fmtTime = (ms: number): string => {
const d = new Date(ms)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// 排程時間以本機時區解讀。
export const parseTime = (date: string | undefined, time: string | undefined): number | undefined => {
const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '')
const t = /^(\d{1,2}):(\d{2})$/.exec(time ?? '')
if (!d || !t) return undefined
const [y, mo, da, h, mi] = [d[1], d[2], d[3], t[1], t[2]].map(Number) as [number, number, number, number, number]
const out = new Date(y, mo - 1, da, h, mi)
if (out.getMonth() !== mo - 1 || out.getDate() !== da || h > 23 || mi > 59) return undefined
return out.getTime()
}
export type Parsed =
| { op: 'add'; type: string; at: number; task: string }
| { op: 'update'; id: number; type: string; at: number; task: string }
| { op: 'list' }
| { op: 'type'; name: string }
| { op: 'run' | 'done'; id: number }
const spec = (w: string[]): { type: string; at: number; task: string } | undefined => {
const [type, date, time, ...rest] = w
const at = parseTime(date, time)
const task = rest.join(' ').trim()
return type && at !== undefined && task ? { type, at, task } : undefined
}
export const parseArgs = (args: string): Parsed | undefined => {
const w = args.trim().split(/\s+/).filter(Boolean)
const [op, ...rest] = w
const id = Number(rest[0])
if (op === 'list' && rest.length === 0) return { op }
if (op === 'type' && rest.length === 1) return { op, name: rest[0]! }
if ((op === 'run' || op === 'done') && rest.length === 1 && Number.isInteger(id) && id > 0) return { op, id }
if (op === 'add') {
const s = spec(rest)
return s ? { op, ...s } : undefined
}
if (op === 'update' && Number.isInteger(id) && id > 0) {
const s = spec(rest.slice(1))
return s ? { op, id, ...s } : undefined
}
return undefined
}
export const dueNow = (todos: Todo[], now: number): Todo[] => todos.filter(t => t.status === 'pending' && t.at <= now)
export const nextId = (todos: Todo[]): number => todos.reduce((m, t) => Math.max(m, t.id), 0) + 1
const STATUS_TEXT: Record<Status, string> = { pending: '待辦', reminded: '已提醒', done: '完成' }
export const renderList = (todos: Todo[]): string =>
todos.length === 0
? '目前沒有待辦。'
: todos
.slice()
.sort((a, b) => a.at - b.at)
.map(t => `#${t.id} [${t.type}] ${fmtTime(t.at)} ${t.task}(${STATUS_TEXT[t.status]},專案:${t.project || '無'})`)
.join('\n')
export const reminderText = (t: Todo): string =>
`到點了:[${t.type}] ${t.task}(#${t.id},專案:${t.project || '無'})。要我執行的話請輸入 /jsc-anima:${TODO_COMMAND} run ${t.id},確認前我不會動手。`
export const RUN_SYSTEM = '你要執行使用者確認過的待辦,簡短回報你做了什麼與結果;沒做到的事如實說明。'
+149
View File
@@ -0,0 +1,149 @@
import { expect, mock, test } from 'claude-code/testing'
const OPTS = { options: { wikiCacheDir: '/cache/wiki' } }
// 記憶體內的假 wiki:以 process.run/fs.* 事件邊界替代 git 與檔案系統。
const setup = (on: any, opts: { registered?: string[]; online?: boolean; seed?: Record<string, string>; pushOk?: boolean } = {}) => {
const files = new Map<string, string>(Object.entries(opts.seed ?? {}))
const calls: string[][] = []
const state = { online: opts.online ?? true, pushOk: opts.pushOk ?? true, commits: [] as string[] }
const D = '/cache/wiki'
mock.store(on)
mock.env(on, { HOME: '/home/u', GITEA_TOKEN: 'secret-token' })
const clock = mock.clock(on, { now: 1_000_000 })
on('command.register', (_$: any, e: any) => {
opts.registered?.push(e.name)
return { value: { name: e.name } } as never
})
on('session.start', () => ({ cwd: '/proj' }) as never)
on('command.run', () => ({ text: '' }))
on('fs.exists', (_$: any, e: any) => ({ value: files.has(e.path.replace(`${D}/`, '')) || e.path === `${D}/.git` && files.has('.git') }) as never)
on('fs.read', (_$: any, e: any) => {
const k = e.path.replace(`${D}/`, '')
if (!files.has(k)) return { deny: 'ENOENT' } as never
return { value: files.get(k)! } as never
})
on('fs.write', (_$: any, e: any) => {
files.set(e.path.replace(`${D}/`, ''), e.text)
return { value: undefined } as never
})
on('process.run', (_$: any, e: any) => {
const argv = [...e.argv]
calls.push(argv)
const sub = argv.find(a => ['clone', 'pull', 'add', 'commit', 'push', 'reset', 'get-url'].includes(a))
const out = (exitCode = 0, stdout = '') => ({ value: { exitCode, stdout, stderr: '' } }) as never
if (sub === 'get-url') return out(0, 'https://gitea.example/o/r.git\n')
if (sub === 'clone') {
if (!state.online) return out(128)
files.set('.git', '1')
return out()
}
if (sub === 'pull') return state.online ? out() : out(128)
if (sub === 'commit') {
state.commits.push(argv[argv.indexOf('-m') + 1]!)
return out()
}
if (sub === 'push') return state.pushOk ? out() : out(1)
return out()
})
return { files, calls, state, clock }
}
const start = async ($: any) => {
await $.session.start({ source: 'startup', cwd: '/proj' } as never)
}
const run = ($: any, command: string, args = '') => $.command.run({ command, args } as never)
test('未啟動角色時拒絕寫入與讀取', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '內容' } })
await start($)
// 工具指令尚未註冊,也未啟動:直接當作一般指令放行(由 harness 底層回空字串)。
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toBe('')
expect(w.state.commits.length).toBe(0)
})
test('啟動後才註冊工具,並依 wiki 目錄動態註冊', OPTS, async ($, on) => {
const registered: string[] = []
setup(on, { registered, seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n' } })
await start($)
expect(registered).not.toContain('anima-wiki')
expect(registered).not.toContain('tools-git')
await run($, 'sao-asuna')
expect(registered).toContain('anima-wiki')
expect(registered).toContain('tools-git')
})
test('寫入:一個中文 commit、更新 Home 與 _Sidebar、推送,並註冊新指令', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
const res = await run($, 'anima-wiki', 'tools git https://git-scm.com\n用 rebase 整理歷史。')
expect(res.text).toContain('已新增')
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」'])
expect(w.files.get('Home.md')).toContain('## tools')
expect(w.files.get('Home.md')).toContain('- [git](tools-git)')
expect(w.files.get('_Sidebar.md')).toContain('[git](tools-git)')
expect(w.files.get('tools-git.md')).toContain('出處:https://git-scm.com')
expect(w.calls.some(c => c.includes('push'))).toBe(true)
// 讀回
const read = await run($, 'tools-git')
expect(read.text).toContain('用 rebase 整理歷史。')
})
test('覆蓋既有主題用「更新」訊息;來源預設為使用者提供', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-wiki', 'tools git\n第一版')
await run($, 'anima-wiki', 'tools git\n第二版')
expect(w.state.commits).toEqual(['feat: 新增知識「tools/git」', 'feat: 更新知識「tools/git」'])
expect(w.files.get('tools-git.md')).toContain('出處:使用者提供')
expect(w.files.get('Home.md')!.match(/\[git\]/g)!.length).toBe(1)
})
test('10 分鐘內讀取用快取,逾時才重新 pull;離線沿用快取', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1', 'Home.md': '## tools\n\n- [git](tools-git)\n', 'tools-git.md': '舊內容' } })
await start($)
await run($, 'sao-asuna')
const pulls = () => w.calls.filter(c => c.includes('pull')).length
await run($, 'tools-git')
const base = pulls()
await w.clock.advance(5 * 60 * 1000)
await run($, 'tools-git')
expect(pulls()).toBe(base)
await w.clock.advance(6 * 60 * 1000)
w.state.online = false
const res = await run($, 'tools-git')
expect(pulls()).toBe(base + 1)
expect(res.text).toContain('舊內容')
})
test('推送失敗時還原並回報,不註冊指令', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' }, pushOk: false })
await start($)
await run($, 'sao-asuna')
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toContain('失敗')
expect(w.calls.some(c => c.includes('reset'))).toBe(true)
})
test('token 只放在指令參數,不寫入任何檔案', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-wiki', 'tools git\n內容')
const pushCall = w.calls.find(c => c.includes('push'))!
expect(pushCall.join(' ')).toContain('Authorization: token secret-token')
expect([...w.files.values()].some(v => v.includes('secret-token'))).toBe(false)
})
test('離開角色後工具被拒絕並提示先啟動', OPTS, async ($, on) => {
const w = setup(on, { seed: { '.git': '1' } })
await start($)
await run($, 'sao-asuna')
await run($, 'anima-off')
const res = await run($, 'anima-wiki', 'tools git\n內容')
expect(res.text).toContain('請先啟動角色')
expect(w.state.commits.length).toBe(0)
})
+92
View File
@@ -0,0 +1,92 @@
// wiki 知識庫的純邏輯:目錄解析與渲染、頁名、驗證。讀寫與 git 在 register.tsx。
export type Entry = { domain: string; topic: string }
export const CACHE_MS = 10 * 60 * 1000
// 指令名稱只能用英數與底線,領域與主題也沿用。
const NAME_RE = /^[A-Za-z0-9_]+$/
export const validName = (s: string): boolean => NAME_RE.test(s)
export const commandName = (e: Entry): string => `${e.domain}-${e.topic}`
export const pageName = (e: Entry): string => `${e.domain}-${e.topic}`
// Home 的格式:「## 領域」章、「- [主題](頁名)」節。
export const parseCatalog = (home: string): Entry[] => {
const out: Entry[] = []
let domain = ''
for (const line of home.split('\n')) {
const h = /^##\s+(.+?)\s*$/.exec(line)
if (h) {
domain = h[1]!
continue
}
const m = /^\s*[-*]\s+\[(.+?)\]\((.+?)\)\s*$/.exec(line)
if (m && domain) out.push({ domain, topic: m[1]! })
}
return out
}
const group = (entries: Entry[]): Map<string, string[]> => {
const g = new Map<string, string[]>()
for (const e of entries) g.set(e.domain, [...(g.get(e.domain) ?? []), e.topic])
return g
}
export const addEntry = (entries: Entry[], e: Entry): Entry[] =>
entries.some(x => x.domain === e.domain && x.topic === e.topic) ? entries : [...entries, e]
export const renderHome = (entries: Entry[]): string => {
const lines = ['# 知識庫', '']
for (const [domain, topics] of group(entries)) {
lines.push(`## ${domain}`, '')
for (const t of topics) lines.push(`- [${t}](${pageName({ domain, topic: t })})`)
lines.push('')
}
return lines.join('\n')
}
export const renderSidebar = (entries: Entry[]): string => {
const lines = ['**[知識庫](Home)**', '']
for (const [domain, topics] of group(entries)) {
lines.push(`**${domain}**`, '')
for (const t of topics) lines.push(`- [${t}](${pageName({ domain, topic: t })})`)
lines.push('')
}
return lines.join('\n')
}
export const renderPage = (e: Entry, body: string, source: string): string =>
`# ${e.topic}\n\n> 領域:${e.domain}\n\n${body.trim()}\n\n---\n\n出處:${source}\n`
export const commitMessage = (e: Entry, existed: boolean): string =>
`feat: ${existed ? '更新' : '新增'}知識「${e.domain}/${e.topic}」`
// tea 設定檔(YAML)中,找出網址主機相符的登入的 token。
export const teaToken = (yaml: string, host: string): string | undefined => {
for (const block of yaml.split(/^\s*-\s+name:/m).slice(1)) {
const url = /^\s*url:\s*(\S+)/m.exec(block)?.[1]
const token = /^\s*token:\s*(\S+)/m.exec(block)?.[1]
if (!url || !token) continue
try {
if (new URL(url.replace(/^['"]|['"]$/g, '')).host === host) return token.replace(/^['"]|['"]$/g, '')
} catch {}
}
return undefined
}
export const wikiUrlFromRemote = (remote: string): string | undefined => {
const base = remote.trim().replace(/\.git$/, '')
return base ? `${base}.wiki.git` : undefined
}
// 指令參數:第一行「領域 主題 [出處]」,其後為內容。
export const parseWriteArgs = (args: string): { entry: Entry; source: string; body: string } | undefined => {
const [first = '', ...rest] = args.trim().split('\n')
const [domain, topic, ...src] = first.trim().split(/\s+/)
const body = rest.join('\n').trim()
if (!domain || !topic || !body || !validName(domain) || !validName(topic)) return undefined
return { entry: { domain, topic }, source: src.join(' ') || '使用者提供', body }
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

+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
+39
View File
@@ -0,0 +1,39 @@
# /jsc-anima:jsc-anima
動漫助理的使用說明。先用 `/jsc-anima:{作品代號}-{角色代號}` 啟動角色,再使用下列技能。
## 技能列表
<!-- skills:start -->
<!-- skills:end -->
## 範例
```
/jsc-anima:anima-skill <技能名稱> <描述>
```
產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。
## 角色與萃取
- `/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 不寫入任何檔案。
+6
View File
@@ -0,0 +1,6 @@
---
name: jsc-anima
description: jsc-anima 動漫助理的使用說明與技能列表
---
請讀取同目錄的 README.md 並依內容回答使用者。