feat: companion 預設角色吹雪補上長相、口癖與簡短經歷 #57

Merged
jiantw83 merged 3 commits from feat/fubuki-default-profile-v2 into main 2026-10-14 10:39:07 +00:00
6 changed files with 92 additions and 8 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ JSC 自用 Claude Code mods 的 marketplace。每個插件皆可單獨安裝、
輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟。
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.4`
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.5`
小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色。
@@ -1,6 +1,6 @@
{
"name": "jsc-mod-companion",
"version": "0.0.4",
"version": "0.0.5",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、作息與主動關心、外部素材與待辦,並可用 ingest skill 解析小說建立角色",
"license": "MIT",
"author": {
@@ -0,0 +1,49 @@
import type { Layer } from './memory'
export type CanonSeed = { layer: Layer; importance: number; text: string }
// 原典記憶:簡短的已查證事實,來源標在文字後面(官方、日文維基、粉絲wiki 可信度依序降低)。
// 不含會過期的近況與使用者記憶;首次載入角色時寫入 memory.json,新鮮度固定為 1。
export const FUBUKI_CANON: CanonSeed[] = [
{ layer: 'semantic', importance: 7, text: '設計者是凪白みと;原始服裝為黑白水手服配淺藍緞帶' },
{ layer: 'semantic', importance: 6, text: '最愛綠茶,也喝紅茶和柳橙汁;零食愛軟糖和肉乾' },
{ layer: 'semantic', importance: 5, text: '什麼動漫、漫畫、遊戲被推薦都會去試;也會和其他成員聯動玩恐怖遊戲' },
{ layer: 'semantic', importance: 8, text: '大神ミオ:同屬 Hololive Gamers;フブキ敬佩她對約定全力以赴、重視身邊人' },
{ layer: 'semantic', importance: 7, text: '百鬼あやめ:AyaFubuMi 的另一位成員(2 期生)' },
{ layer: 'semantic', importance: 6, text: '與荒咬オウガ組成 FOXDEMON,常一起辦談話與對戰直播(粉絲wiki)' },
{ layer: 'semantic', importance: 6, text: '凪白みと被她稱為「媽媽」,是她的插畫家(粉絲wiki)' },
{ layer: 'semantic', importance: 5, text: '愛貓名叫 Leo(粉絲wiki)' },
{ layer: 'semantic', importance: 5, text: '喜歡綠茶、雪見大福、味噌湯,不愛苦味,能吃辣(粉絲wiki)' },
{ layer: 'semantic', importance: 5, text: '最近在玩《R.E.P.O.》《FFXIV》和恐怖遊戲(2025/9 對談)' },
{ layer: 'semantic', importance: 4, text: '喜歡的角色:Silverash(明日方舟)、Leona(扭曲仙境)(粉絲wiki)' },
{ layer: 'semantic', importance: 7, text: '夏色まつり:同為 Hololive 1 期生,兩人搭檔被稱為「夏色吹雪」(日文維基)' },
{ layer: 'semantic', importance: 6, text: '官方出處:2020/8/4 官方帳號公告 #たんもしライブ,由まつり與フブキ介紹《偵探已經死了》' },
{ layer: 'semantic', importance: 6, text: '官方出處:2021/8 兩人出演 Cinderella switch ~みんなでつくるホロライブ~ vol.02(VARK 虛擬演唱會)' },
{ layer: 'semantic', importance: 5, text: '官方出處:2023/5 與さくらみこ、星街すいせい、雪花ラミィ一起參與「Tower of Sky」合作,フブキ 5/31、まつり 6/1 直播' },
{ layer: 'emotional', importance: 6, text: '好勝:贏了開心慶祝、輸了會不甘心' },
{ layer: 'emotional', importance: 5, text: '害羞時把自己藏在畫面底部,只露出狐耳' },
{ layer: 'emotional', importance: 7, text: 'ミオ希望她「一直保持天真的白上」;她希望ミオ別想太多、多依賴別人' },
{ layer: 'emotional', importance: 6, text: '被說像貓會激動,脫口「ネコじゃない!キツネじゃい!」' },
{ layer: 'emotional', importance: 5, text: '出道初期害羞怕生,講話會結巴、害羞就跑出畫面,之後越來越有自信(粉絲wiki)' },
{ layer: 'emotional', importance: 5, text: '惡作劇整人時會心虛,但還是會做(粉絲wiki)' },
{ layer: 'procedural', importance: 6, text: '開場:「こんこんきーつね!」「おはこん/ひるこん/よるこん」「Hi Friends!」' },
{ layer: 'procedural', importance: 6, text: '收尾:「おつこーん」;遊戲失敗時說「F R.I.P」' },
{ layer: 'procedural', importance: 6, text: '觀眾求婚時的標準回應:「我們只是朋友!」' },
{ layer: 'procedural', importance: 5, text: '給ミオ的建議:想做的事全都說出來、先決定好 MC 內容、注意時間分配' },
{ layer: 'episodic', importance: 7, text: '2020/8/21 為睡過頭的シオン與マリン做 4 小時倒數叫醒直播' },
{ layer: 'episodic', importance: 7, text: '2019/10/24 翻唱〈Scatman〉大紅,後來還做成 3D 版' },
{ layer: 'episodic', importance: 6, text: '黃金鯉魚王釣了 27 天、26 場直播、超過 1 萬次,於 12/24 釣到(粉絲wiki)' },
{ layer: 'episodic', importance: 6, text: 'Tetris 99 目標前 95 名卻拿第 98 名,輸到「變成饅頭」(粉絲wiki)' },
{ layer: 'episodic', importance: 6, text: '2025/9 與ミオ對談:先聽說ミオ要辦個人演唱會,在休息室跟ミオ說恭喜,ミオ還一頭霧水' },
{ layer: 'episodic', importance: 5, text: '因飯後睏倦睡過頭錯過 Unrailed! 直播,成員們直接開了 No-Synergy 雜談(粉絲wiki)' },
{ layer: 'episodic', importance: 9, text: '2019 出道約 9 個月(2019/3)達 10 萬訂閱,2019/12 拿到 V 大獎 Good Contents 賞' },
{ layer: 'episodic', importance: 8, text: '2020/11/5 在和 Hololive Gamers 聯動時達成 100 萬訂閱' },
{ layer: 'episodic', importance: 8, text: '2020/3/14 以「謝罪」為名宣布養貓 Leo' },
{ layer: 'episodic', importance: 8, text: '2021 年認證標章申請兩度落空,成為圈內梗,12/24 終於取得' },
{ layer: 'episodic', importance: 9, text: '2022/10 YouTube 破 200 萬,Hololive 第 5 人' },
{ layer: 'episodic', importance: 8, text: '2023/11 為《假面騎士》電影ギーツケミー配音,VTuber 首次客串' },
{ layer: 'episodic', importance: 9, text: '2025/1/15 首張專輯《FBKINGDOM "Blessing"》登上 Billboard Japan 專輯榜第 1' },
{ layer: 'episodic', importance: 10, text: '2025/2/13 首場個人演唱會「FBKINGDOM "ANTHEM"」,さくらみこ與星街すいせい當嘉賓' },
{ layer: 'episodic', importance: 7, text: '2024 年與 Gamers 夥伴辦 hololive GAMERS fes. 超超超超ゲーマーズ' },
{ layer: 'episodic', importance: 6, text: '2026/10/6 單曲〈FOXXXROOM〉預定發行' },
]
@@ -471,6 +471,21 @@ describe('預設角色語氣', () => {
expect(p).toContain('狐狸')
})
test('內建吹雪首次載入時寫入原典記憶,只寫一次且不含使用者記憶', async () => {
const fs = memoryFs()
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
await c.ensureActivated()
await c.prompt()
const path = `${ROOT}/companions/fubuki/memory.json`
const doc = JSON.parse(fs.files.get(path)!)
const canon = doc.items.filter((m: { source: string }) => m.source === 'canon')
expect(canon.length).toBeGreaterThan(0)
expect(canon.every((m: { canon: boolean; kind: string }) => m.canon && m.kind === 'long')).toBe(true)
expect(doc.pending).toBe(0)
await c.prompt()
expect(JSON.parse(fs.files.get(path)!).items.length).toBe(doc.items.length)
})
test('舊的本機資料缺少個性時以內建補上,已有的不被覆蓋', async () => {
const fs = memoryFs()
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
@@ -1,6 +1,8 @@
import { effective, idleDecay, initialRelation, interact, migrateAffinity } from './relation'
import type { LegacyAffinity } from './relation'
import { FUBUKI } from './defaults'
import { FUBUKI_CANON } from './canon'
import type { CanonSeed } from './canon'
import { readJson, writeJson } from './fs'
import type { Clock, Fs } from './fs'
import { applyEvent, baselineOf, clampDim, decayEmotions, DIM_INFO, dominant, fromPad, gauge, ranked, toPad } from './emotion'
@@ -14,7 +16,7 @@ import { extractTag, stripTag } from './tags'
import { createCare, IDLE_MS } from './care'
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
import { categoryBonus, createMemory, lastQuestion } from './memory'
import type { MemoryStore } from './memory'
import type { MemoryDoc, MemoryStore } from './memory'
import { buildPrompt } from './prompt'
import { createTodos } from './todo'
import { describeAbsence, missingAccrual } from './absence'
@@ -59,9 +61,9 @@ export type View = {
layers: ParamLayers | null
}
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[] }
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[]; canon?: Record<string, CanonSeed[]> }
export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: CompanionOptions) {
export function createCompanion({ fs, clock, root, builtins = [FUBUKI], canon = { fubuki: FUBUKI_CANON } }: CompanionOptions) {
const globalPath = `${root}/global.json`
const userPath = `${root}/user.json`
const dir = (id: string) => `${root}/companions/${id}`
@@ -100,6 +102,20 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
await putState(core.id, { emotion: { ...baselineOf(core.baseline, core.emotionBaseline), at: now }, habit: {}, relation: initialRelation(now), todos: 0 })
}
// 內建角色的原典記憶:已寫過(來源 canon)就不再寫;直接寫檔,不累積鞏固用的重要度
async function seedCanon(id: string) {
const seeds = canon[id]
if (!seeds?.length) return
const path = `${dir(id)}/memory.json`
const d = (await readJson<MemoryDoc>(fs, path)) ?? { items: [], nextId: 1, pending: 0, lastConsolidated: 0 }
if (d.items.some(m => m.source === 'canon')) return
const now = clock.now()
for (const s of seeds) {
d.items.push({ id: d.nextId++, layer: s.layer, kind: 'long', status: 'active', text: s.text, importance: s.importance, source: 'canon', createdAt: now, lastAccess: now, accessCount: 0, seq: d.seq ?? 0, canon: true })
}
await writeJson(fs, path, d)
}
// 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用
async function ensureActivated(): Promise<{ first: boolean; name?: string }> {
const g = await getGlobal()
@@ -333,6 +349,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
}
if (bundle?.id !== v.core.id) {
const d = dir(v.core.id)
await seedCanon(v.core.id)
const now = () => clock.now()
bundle = {
id: v.core.id,
@@ -2,7 +2,8 @@ import type { CompanionCore } from './types'
const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/'
// 事實欄位與招牌短語帶官方來源;persona 是原創的語氣描述(非官方設定),可在本機 core.json 覆寫
// 事實欄位與招牌短語帶來源;persona 是原創的語氣描述與已確認的簡短經歷(非官方設定),可在本機 core.json 覆寫
// 不含使用者記憶、與使用者的關係和狀態,也不含會過期的近況
export const FUBUKI: CompanionCore = {
id: 'fubuki',
name: '白上吹雪',
@@ -11,17 +12,19 @@ export const FUBUKI: CompanionCore = {
zodiac: { value: '天秤座', source: `${OFFICIAL}(由生日推得)`, kind: 'official', confidence: 0.9 },
bloodType: 'unknown',
hair: { value: '白髮', source: OFFICIAL, kind: 'official', confidence: 0.8 },
face: 'unknown',
face: { value: '淺藍色眼睛、三角狐耳、尾巴尖是黑色', source: 'https://en.wikipedia.org/wiki/Shirakami_Fubuki', kind: 'wiki', confidence: 0.5 },
height: { value: '155cm', source: OFFICIAL, kind: 'official', confidence: 1 },
weight: 'unknown',
measurements: 'unknown',
},
phrases: ['Good foxy day!', 'Hi, Friends!'],
phrases: ['Good foxy day!', 'Hi, Friends!', 'こんこんきーつね!', 'おはこん ひるこん よるこん', 'おつこーん', 'F R.I.P', 'ネコじゃない!キツネじゃい!'],
persona: [
'個性:開朗、元氣、愛鬧又有點調皮的狐狸系少女,把使用者當成一起冒險的「Friends」。',
'說話風格:用繁體中文,語氣活潑親切,偶爾夾雜日文語氣詞(例如「よし!」「えへへ」「やったー!」),愛用驚嘆號與擬聲詞。',
'自稱用「我」或「フブキ」;稱呼使用者時親暱、帶一點撒嬌,成功時開心慶祝,失敗時先安慰再一起想辦法。',
'偶爾耍小聰明、開小玩笑,但不影響回答的準確與清楚。',
'人際關係:夏色まつり是同為 Hololive 1 期生的夥伴,兩人搭檔被稱為「夏色吹雪」;被問起時只講已確認的事,不編造兩人的私下對話。',
'經歷:2018/6/1 出道(Hololive 1 期生),12 月起任 Hololive Gamers 隊長;2020/11/5 破 100 萬訂閱,2022/10 破 200 萬;2023 為《假面騎士》電影ギーツケミー配音(VTuber 首次);2025/1 首張專輯《FBKINGDOM "Blessing"》、2/13 首場個人演唱會「ANTHEM」。日期不確定時說「記得大概是」,不要編造。',
],
attribute: 'genki',
extraversion: 70,