diff --git a/plugins/cockpit/hooks/companion/affinity.ts b/plugins/cockpit/hooks/companion/affinity.ts new file mode 100644 index 0000000..4f741af --- /dev/null +++ b/plugins/cockpit/hooks/companion/affinity.ts @@ -0,0 +1,49 @@ +import { clamp } from './mood' +import type { Affinity } from './types' + +const DAY = 86_400_000 + +export const GAIN = 0.01 +export const DAILY_CAP = 0.05 +export const LOSS = 0.03 +const IDLE_GRACE_DAYS = 7 +const IDLE_HALF_LIFE_DAYS = 30 + +// 升階門檻;降階門檻 = 升階門檻 − 0.05,形成遲滯 +export const UP = [0.3, 0.55, 0.8] +export const HYSTERESIS = 0.05 + +export const dayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10) + +export const initialAffinity = (now: number): Affinity => ({ value: 0, stage: 0, dayKey: dayKey(now), dayGain: 0, lastSeen: now }) + +export function stageFor(value: number, current: number): number { + let s = current + while (s < UP.length && value >= UP[s]!) s++ + while (s > 0 && value < UP[s - 1]! - HYSTERESIS) s-- + return s +} + +// 離開超過 7 天後,以 30 天半衰期回到 0 +export function idleDecay(a: Affinity, now: number): Affinity { + const idleDays = (now - a.lastSeen) / DAY + if (idleDays <= IDLE_GRACE_DAYS) return a + const value = a.value * Math.pow(0.5, (idleDays - IDLE_GRACE_DAYS) / IDLE_HALF_LIFE_DAYS) + return { ...a, value, stage: stageFor(value, a.stage) } +} + +export function interact(prev: Affinity, kind: 'meaningful' | 'negative', now: number): Affinity { + let a = idleDecay(prev, now) + const key = dayKey(now) + if (a.dayKey !== key) a = { ...a, dayKey: key, dayGain: 0 } + let { value, dayGain } = a + if (kind === 'meaningful') { + const gain = Math.max(0, Math.min(GAIN, DAILY_CAP - dayGain)) + value += gain + dayGain += gain + } else { + value -= LOSS + } + value = clamp(value) + return { value, dayGain, dayKey: key, lastSeen: now, stage: stageFor(value, a.stage) } +} diff --git a/plugins/cockpit/hooks/companion/band.test.ts b/plugins/cockpit/hooks/companion/band.test.ts new file mode 100644 index 0000000..6d7f89a --- /dev/null +++ b/plugins/cockpit/hooks/companion/band.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { bandLines, fit, lineWidth } from '../hud/format' +import type { HudData } from '../hud/format' +import { fitCompanion } from './band' +import type { View } from './core' + +const DATA: HudData = { + model: 'claude-opus-5-5[1m]', + cwd: '/home/dev/work/luopan', + home: '/home/dev', + git: { branch: 'issue-3', dirty: 1, ahead: 0, behind: 0 }, + usage: { fiveHour: { percent: 42 }, sevenDay: { percent: 71 }, ctxPercent: 31 }, + sys: { cpu: 23 }, + tzOffsetMin: 480, +} + +const VIEW: View = { + core: { id: 'miko', name: '月見ミコ', facts: {} as never, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 } }, + mood: { p: 0.5, a: 0, d: -0.5 }, + label: '放鬆', + kaomoji: '(´ᴗ`)', + affinity: 0.35, + stage: '熟悉', + todos: 2, +} + +const text = (v: View, cols: number, compact = false, bubble?: string) => fitCompanion(v, cols, compact, bubble).map(s => s.text).join('') + +describe('角色行', () => { + test('寬版顯示顏文字、名字、心情、PAD 條、階段、待辦、氣泡', () => { + expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · P▰▰▱ A▰▰▱ D▰▱▱ · 熟悉 · ☐2 · 💬 早安') + }) + + test('緊湊模式只剩「顏文字 名字 心情」', () => { + expect(text(VIEW, 200, true)).toBe('(´ᴗ`) 月見ミコ 放鬆') + }) + + test('寬度不足時依序捨棄氣泡、待辦、PAD、階段,不換行', () => { + const full = text(VIEW, 200, false, '早安') + let prev = full.length + for (const cols of [60, 50, 40, 34, 28, 22]) { + const t = text(VIEW, cols, false, '早安') + expect(t.includes('\n')).toBe(false) + expect(t.length <= prev).toBe(true) + prev = t.length + } + expect(text(VIEW, 40, false, '早安')).not.toContain('早安') + expect(text(VIEW, 36, false, '早安')).not.toContain('☐') + expect(text(VIEW, 30, false, '早安')).not.toContain('PAD') + expect(text(VIEW, 22, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆') + expect(lineWidth(fitCompanion(VIEW, 24))).toBeLessThanOrEqual(24) + }) + + test('沒有待辦時不顯示待辦數', () => { + expect(text({ ...VIEW, todos: 0 }, 200)).not.toContain('☐') + }) +}) + +describe('band', () => { + test('沒有角色時只有第一行,且與現況逐字相同', () => { + for (const cols of [200, 100, 60]) { + const lines = bandLines(DATA, cols, false, null) + expect(lines.length).toBe(1) + expect(lines[0]).toEqual(fit(DATA, cols, false)) + } + }) + + test('有角色時第一行不變,第二行是角色行', () => { + const lines = bandLines(DATA, 120, false, fitCompanion(VIEW, 120)) + expect(lines.length).toBe(2) + expect(lines[0]).toEqual(fit(DATA, 120, false)) + }) +}) diff --git a/plugins/cockpit/hooks/companion/band.ts b/plugins/cockpit/hooks/companion/band.ts new file mode 100644 index 0000000..07a5efa --- /dev/null +++ b/plugins/cockpit/hooks/companion/band.ts @@ -0,0 +1,33 @@ +import { bar, lineWidth } from '../hud/format' +import type { Seg } from '../hud/format' +import type { View } from './core' + +const axis = (v: number) => bar(((v + 1) / 2) * 100, 3) + +// 由完整版往下捨棄次要資訊:氣泡 → 待辦 → PAD 條 → 好感階段;緊湊版只留「顏文字 名字 心情」 +function line(v: View, level: number, compact: boolean, bubble?: string): Seg[] { + const out: Seg[] = [ + { text: `${v.kaomoji} `, color: 'claude' }, + { text: v.core.name, bold: true }, + { text: ' ' }, + { text: v.label, color: 'suggestion' }, + ] + if (compact) return out + const dot = { text: ' · ', dim: true } + if (level < 3) out.push(dot, { text: `P${axis(v.mood.p)} A${axis(v.mood.a)} D${axis(v.mood.d)}`, dim: true }) + if (level < 4) out.push(dot, { text: v.stage }) + if (level < 2 && v.todos > 0) out.push(dot, { text: `☐${v.todos}`, color: 'warning' }) + if (level < 1 && bubble) out.push(dot, { text: `💬 ${bubble}`, dim: true }) + return out +} + +export const COMPANION_MAX_LEVEL = 4 + +export function fitCompanion(v: View, columns: number, compact = false, bubble?: string): Seg[] { + if (compact) return line(v, 0, true) + for (let level = 0; level <= COMPANION_MAX_LEVEL; level++) { + const l = line(v, level, false, bubble) + if (lineWidth(l) <= columns) return l + } + return line(v, 0, true) +} diff --git a/plugins/cockpit/hooks/companion/companion.test.ts b/plugins/cockpit/hooks/companion/companion.test.ts new file mode 100644 index 0000000..274b4f3 --- /dev/null +++ b/plugins/cockpit/hooks/companion/companion.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { createCompanion } from './core' +import { fakeClock, memoryFs } from './fs' +import type { CompanionCore } from './types' + +// 測試用原創範例角色,不依賴網路或受版權保護的資料 +const MIKO: CompanionCore = { + id: 'miko', + name: '月見ミコ', + facts: { + birthday: { value: '3/3', source: 'test', kind: 'novel', confidence: 1 }, + zodiac: 'unknown', + bloodType: 'unknown', + hair: 'unknown', + face: 'unknown', + height: 'unknown', + weight: 'unknown', + measurements: 'unknown', + }, + neuroticism: 50, + baseline: { p: 0, a: 0, d: 0 }, +} +const H = 3_600_000 +const DAY = 24 * H +const ROOT = '/h/.cockpit' + +function setup(builtins = [MIKO]) { + const fs = memoryFs() + const clock = fakeClock() + return { fs, clock, c: createCompanion({ fs, clock, root: ROOT, builtins }) } +} + +describe('啟用', () => { + test('第一次啟用自動啟用預設角色,之後不再', async () => { + const { c } = setup() + expect((await c.ensureActivated()).first).toBe(true) + expect((await c.view())?.core.name).toBe('月見ミコ') + expect((await c.ensureActivated()).first).toBe(false) + }) + + test('companion off 後沒有角色,且重新啟動不會自動再啟用', async () => { + const { c } = setup() + await c.ensureActivated() + await c.command('companion off') + expect(await c.view()).toBe(null) + await c.ensureActivated() + expect(await c.view()).toBe(null) + expect(await c.command('status')).toBe('目前沒有啟用角色。') + }) + + test('切換角色保留原角色資料', async () => { + const other = { ...MIKO, id: 'ren', name: '蓮' } + const { c } = setup([MIKO, other]) + await c.ensureActivated() + await c.moodEvent('thanks') + const before = (await c.view())!.mood + expect(await c.command('switch ren')).toContain('蓮') + expect((await c.view())?.core.id).toBe('ren') + await c.command('switch miko') + expect((await c.view())!.mood).toEqual(before) + expect(await c.command('switch nobody')).toContain('找不到') + }) +}) + +describe('原子寫入', () => { + test('不留暫存檔', async () => { + const { c, fs } = setup() + await c.ensureActivated() + await c.moodEvent('thanks') + expect([...fs.files.keys()].some(k => k.endsWith('.tmp'))).toBe(false) + }) + + test('寫到一半當機時舊資料不損毀', async () => { + const { c, fs } = setup() + await c.ensureActivated() + const path = `${ROOT}/companions/miko/state.json` + const old = fs.files.get(path) + const real = fs.rename + fs.rename = async () => { + throw new Error('crash') + } + await expect(c.moodEvent('thanks')).rejects.toThrow() + fs.rename = real + expect(fs.files.get(path)).toBe(old) + }) +}) + +describe('心情', () => { + test('事件更新 PAD 並對應標籤', async () => { + const { c } = setup() + await c.ensureActivated() + await c.moodEvent('thanks') + const v = (await c.view())! + expect(v.mood.p).toBeCloseTo(0.15) + expect(v.label).toBe('興奮') + }) + + test('衰減依經過時間回到基線,半衰期 8 小時', async () => { + const { c, clock } = setup() + await c.ensureActivated() + await c.moodEvent('thanks') + clock.advance(8 * H) + expect((await c.view())!.mood.p).toBeCloseTo(0.075) + clock.advance(10 * DAY) + expect(Math.abs((await c.view())!.mood.p)).toBeLessThan(0.001) + }) + + test('高神經質衰減較慢', async () => { + const nervous = { ...MIKO, neuroticism: 100 } + const { c, clock } = setup([nervous]) + await c.ensureActivated() + await c.moodEvent('thanks') + clock.advance(8 * H) + expect((await c.view())!.mood.p).toBeGreaterThan(0.09) + }) + + test('單軸每回合變化不超過 0.3,且不超出 ±1', async () => { + const { c } = setup() + await c.ensureActivated() + for (let i = 0; i < 10; i++) await c.moodEvent('scold') + expect((await c.view())!.mood.p).toBe(-1) + const { c: c2 } = setup() + await c2.ensureActivated() + await c2.moodEvent('scold') + expect((await c2.view())!.mood.p).toBeCloseTo(-0.2) + }) + + test('八分區標籤', async () => { + const { c } = setup() + await c.ensureActivated() + await c.moodEvent('scold') + expect((await c.view())!.label).toBe('焦慮') + await c.moodEvent('lateNight') + expect((await c.view())!.label).toBeTruthy() + }) +}) + +describe('好感度', () => { + test('從 0 開始,每次 +0.01,每天上限 +0.05', async () => { + const { c } = setup() + await c.ensureActivated() + expect((await c.view())!.affinity).toBe(0) + for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful') + expect((await c.view())!.affinity).toBeCloseTo(0.05) + }) + + test('隔天可繼續累積;負面事件 −0.03', async () => { + const { c, clock } = setup() + await c.ensureActivated() + for (let i = 0; i < 6; i++) await c.affinityEvent('meaningful') + clock.advance(DAY) + await c.affinityEvent('meaningful') + expect((await c.view())!.affinity).toBeCloseTo(0.06) + await c.affinityEvent('negative') + expect((await c.view())!.affinity).toBeCloseTo(0.03) + }) + + test('離開超過 7 天後緩慢回到 0', async () => { + const { c, clock } = setup() + await c.ensureActivated() + for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful') + clock.advance(7 * DAY) + expect((await c.view())!.affinity).toBeCloseTo(0.05) + clock.advance(30 * DAY) + expect((await c.view())!.affinity).toBeCloseTo(0.025) + }) + + test('階段切換有遲滯', async () => { + const { c, clock } = setup() + await c.ensureActivated() + expect((await c.view())!.stage).toBe('陌生') + for (let d = 0; d < 6; d++) { + for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful') + clock.advance(DAY) + } + expect((await c.view())!.affinity).toBeCloseTo(0.3) + expect((await c.view())!.stage).toBe('熟悉') + await c.affinityEvent('negative') // 0.27,仍在遲滯區間 + expect((await c.view())!.stage).toBe('熟悉') + await c.affinityEvent('negative') // 0.24,低於降階門檻 0.25 + expect((await c.view())!.stage).toBe('陌生') + }) +}) + +describe('指令與設定', () => { + test('status 展開事實欄位與來源,查不到為 unknown', async () => { + const { c } = setup() + await c.ensureActivated() + const out = await c.command('status') + expect(out).toContain('月見ミコ') + expect(out).toContain('birthday:3/3(novel,信心 1,test)') + expect(out).toContain('bloodType:unknown') + }) + + test('setup 寫入共用 user.json,換角色不受影響', async () => { + const { c, fs } = setup() + await c.ensureActivated() + expect(await c.command('setup nickname=阿建 careLimit=3 quietStart=13:00 quietEnd=14:00')).toContain('設定已儲存') + const u = JSON.parse(fs.files.get(`${ROOT}/user.json`)!) + expect(u.nickname).toBe('阿建') + expect(u.careLimit).toBe(3) + expect(u.quiet).toEqual({ start: '13:00', end: '14:00' }) + expect(await c.command('setup bogus=1')).toContain('無法解析') + expect((await c.getUser()).nickname).toBe('阿建') + }) + + test('預設角色白上吹雪的事實帶來源', async () => { + const fs = memoryFs() + const c = createCompanion({ fs, clock: fakeClock(), root: ROOT }) + await c.ensureActivated() + const out = await c.command('status') + expect(out).toContain('白上吹雪') + expect(out).toContain('hololivepro.com') + expect(out).toContain('weight:unknown') + }) +}) diff --git a/plugins/cockpit/hooks/companion/core.ts b/plugins/cockpit/hooks/companion/core.ts new file mode 100644 index 0000000..6a59a87 --- /dev/null +++ b/plugins/cockpit/hooks/companion/core.ts @@ -0,0 +1,167 @@ +import { idleDecay, initialAffinity, interact } from './affinity' +import { FUBUKI } from './defaults' +import { readJson, writeJson } from './fs' +import type { Clock, Fs } from './fs' +import { applyDelta, decay, MOOD_EVENTS, moodLabel } from './mood' +import type { MoodEvent } from './mood' +import { STAGES } from './types' +import type { CompanionCore, CompanionState, Global, Pad, UserSettings } from './types' + +export const DEFAULT_USER: UserSettings = { + nickname: '', + wake: '08:00', + sleep: '23:00', + tz: '+0800', + careLimit: 5, + quiet: { start: '', end: '' }, +} + +export type View = { + core: CompanionCore + mood: Pad + label: string + kaomoji: string + affinity: number + stage: string + todos: number +} + +export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[] } + +export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: CompanionOptions) { + const globalPath = `${root}/global.json` + const userPath = `${root}/user.json` + const dir = (id: string) => `${root}/companions/${id}` + + const getGlobal = async (): Promise => (await readJson(fs, globalPath)) ?? { active: null, activatedOnce: false } + const getUser = async (): Promise => ({ ...DEFAULT_USER, ...(await readJson>(fs, userPath)) }) + const getCore = (id: string) => readJson(fs, `${dir(id)}/core.json`) + const getState = (id: string) => readJson(fs, `${dir(id)}/state.json`) + const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s) + + async function install(core: CompanionCore) { + if (await getCore(core.id)) return + const now = clock.now() + await writeJson(fs, `${dir(core.id)}/core.json`, core) + await putState(core.id, { mood: { ...core.baseline, at: now }, affinity: initialAffinity(now), todos: 0 }) + } + + // 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用 + async function ensureActivated(): Promise<{ first: boolean; name?: string }> { + const g = await getGlobal() + if (g.activatedOnce) return { first: false } + const def = builtins[0] + if (!def) return { first: false } + await install(def) + await writeJson(fs, globalPath, { active: def.id, activatedOnce: true }) + return { first: true, name: def.name } + } + + // 衰減只在讀取時計算,不寫回檔案 + async function view(): Promise { + const { active } = await getGlobal() + if (!active) return null + const core = await getCore(active) + const st = await getState(active) + if (!core || !st) return null + const now = clock.now() + const mood = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism) + const aff = idleDecay(st.affinity, now) + return { core, mood, ...moodLabel(mood), affinity: aff.value, stage: STAGES[aff.stage]!, todos: st.todos } + } + + async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) { + const { active } = await getGlobal() + if (!active) return + const core = await getCore(active) + const st = await getState(active) + if (!core || !st) return + await putState(active, fn(core, st, clock.now())) + } + + async function moodEvent(ev: MoodEvent) { + await mutate((core, st, now) => { + const cur = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism) + return { ...st, mood: { ...applyDelta(cur, MOOD_EVENTS[ev]), at: now } } + }) + } + + async function affinityEvent(kind: 'meaningful' | 'negative') { + await mutate((_, st, now) => ({ ...st, affinity: interact(st.affinity, kind, now) })) + } + + const bar = (v: number) => (v >= 0 ? '+' : '') + v.toFixed(2) + + async function status(): Promise { + const v = await view() + if (!v) return '目前沒有啟用角色。' + const lines = [`${v.kaomoji} ${v.core.name}|${v.label}`] + lines.push(`心情 PAD:P ${bar(v.mood.p)} A ${bar(v.mood.a)} D ${bar(v.mood.d)}`) + lines.push(`好感:${v.stage}(${bar(v.affinity)})`) + lines.push(`待辦:${v.todos}`) + lines.push('基本資訊:') + for (const [k, f] of Object.entries(v.core.facts)) { + lines.push(f === 'unknown' ? ` ${k}:unknown` : ` ${k}:${f.value}(${f.kind ?? '?'},信心 ${f.confidence ?? '?'},${f.source ?? '無來源'})`) + } + return lines.join('\n') + } + + async function available(): Promise { + const ids = new Set(await fs.list(`${root}/companions`)) + for (const b of builtins) ids.add(b.id) + return [...ids].sort() + } + + async function switchTo(id: string): Promise { + const builtin = builtins.find(b => b.id === id) + if (builtin) await install(builtin) + if (!(await getCore(id))) return `找不到角色「${id}」。可用:${(await available()).join('、') || '(無)'}` + const g = await getGlobal() + await writeJson(fs, globalPath, { ...g, active: id, activatedOnce: true }) + return `已切換到 ${(await getCore(id))!.name}。` + } + + async function setup(args: string[]): Promise { + const u = await getUser() + const known = ['nickname', 'wake', 'sleep', 'tz', 'careLimit', 'quietStart', 'quietEnd'] + const bad: string[] = [] + for (const a of args) { + const i = a.indexOf('=') + const k = i < 0 ? a : a.slice(0, i) + const val = i < 0 ? '' : a.slice(i + 1) + if (!known.includes(k) || i < 0) bad.push(a) + else if (k === 'careLimit') { + const n = Number(val) + if (!Number.isInteger(n) || n < 0) bad.push(a) + else u.careLimit = n + } else if (k === 'quietStart') u.quiet.start = val + else if (k === 'quietEnd') u.quiet.end = val + else (u as Record)[k] = val + } + if (bad.length) return `無法解析:${bad.join(' ')}\n用法:/cockpit setup nickname=… wake=HH:MM sleep=HH:MM tz=+0800 careLimit=N quietStart=HH:MM quietEnd=HH:MM` + if (args.length) await writeJson(fs, userPath, u) + return [ + args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):', + ` nickname=${u.nickname}`, + ` wake=${u.wake} sleep=${u.sleep} tz=${u.tz}`, + ` careLimit=${u.careLimit} quietStart=${u.quiet.start} quietEnd=${u.quiet.end}`, + ].join('\n') + } + + async function command(raw: string): Promise { + const [sub = '', ...rest] = raw.trim().split(/\s+/).filter(Boolean) + if (sub === 'status') return status() + if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/cockpit switch <角色>。可用:${(await available()).join('、')}` + if (sub === 'companion' && rest[0] === 'off') { + const g = await getGlobal() + await writeJson(fs, globalPath, { ...g, active: null, activatedOnce: true }) + return '助理已關閉,所有資料保留。' + } + if (sub === 'setup') return setup(rest) + return '用法:/cockpit status | switch <角色> | companion off | setup [key=value…]' + } + + return { ensureActivated, view, moodEvent, affinityEvent, command, getUser } +} + +export type Companion = ReturnType diff --git a/plugins/cockpit/hooks/companion/defaults.ts b/plugins/cockpit/hooks/companion/defaults.ts new file mode 100644 index 0000000..16f5810 --- /dev/null +++ b/plugins/cockpit/hooks/companion/defaults.ts @@ -0,0 +1,22 @@ +import type { CompanionCore } from './types' + +const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/' + +// 只放帶來源的事實欄位與官方公開的招牌短語;個性、記憶、口癖表在本機生成,不進 repo +export const FUBUKI: CompanionCore = { + id: 'fubuki', + name: '白上吹雪', + facts: { + birthday: { value: '10/5', source: OFFICIAL, kind: 'official', confidence: 1 }, + zodiac: { value: '天秤座', source: `${OFFICIAL}(由生日推得)`, kind: 'official', confidence: 0.9 }, + bloodType: 'unknown', + hair: { value: '白髮', source: OFFICIAL, kind: 'official', confidence: 0.8 }, + face: 'unknown', + height: { value: '155cm', source: OFFICIAL, kind: 'official', confidence: 1 }, + weight: 'unknown', + measurements: 'unknown', + }, + phrases: ['Good foxy day!', 'Hi, Friends!'], + neuroticism: 50, + baseline: { p: 0.2, a: 0.1, d: 0.1 }, +} diff --git a/plugins/cockpit/hooks/companion/fs.ts b/plugins/cockpit/hooks/companion/fs.ts new file mode 100644 index 0000000..fc277dc --- /dev/null +++ b/plugins/cockpit/hooks/companion/fs.ts @@ -0,0 +1,58 @@ +// 檔案系統與時鐘皆由外部注入;核心只依賴這兩個介面 +export interface Fs { + read(path: string): Promise + write(path: string, data: string): Promise + rename(from: string, to: string): Promise + list(dir: string): Promise +} + +export type Clock = { now(): number } + +// 先寫暫存檔再 rename,當機時不會留下寫到一半的檔案 +export async function writeAtomic(fs: Fs, path: string, data: string): Promise { + const tmp = `${path}.tmp` + await fs.write(tmp, data) + await fs.rename(tmp, path) +} + +export async function readJson(fs: Fs, path: string): Promise { + const raw = await fs.read(path) + if (raw === null) return null + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + +export const writeJson = (fs: Fs, path: string, value: unknown) => writeAtomic(fs, path, JSON.stringify(value, null, 2)) + +export function memoryFs(seed: Record = {}): Fs & { files: Map } { + const files = new Map(Object.entries(seed)) + return { + files, + async read(path) { + return files.get(path) ?? null + }, + async write(path, data) { + files.set(path, data) + }, + async rename(from, to) { + const v = files.get(from) + if (v === undefined) throw new Error(`ENOENT: ${from}`) + files.delete(from) + files.set(to, v) + }, + async list(dir) { + const prefix = dir.endsWith('/') ? dir : dir + '/' + const names = new Set() + for (const k of files.keys()) if (k.startsWith(prefix)) names.add(k.slice(prefix.length).split('/')[0]!) + return [...names].filter(n => !n.endsWith('.tmp')).sort() + }, + } +} + +export function fakeClock(start = Date.parse('2026-10-12T00:00:00Z')) { + let t = start + return { now: () => t, advance: (ms: number) => void (t += ms), set: (ms: number) => void (t = ms) } +} diff --git a/plugins/cockpit/hooks/companion/mood.ts b/plugins/cockpit/hooks/companion/mood.ts new file mode 100644 index 0000000..23c8323 --- /dev/null +++ b/plugins/cockpit/hooks/companion/mood.ts @@ -0,0 +1,60 @@ +import type { Pad } from './types' + +export const clamp = (x: number, lo = -1, hi = 1) => Math.max(lo, Math.min(hi, x)) + +const HOUR = 3_600_000 +const MOOD_HALF_LIFE_H = 8 + +// 高神經質的角色情緒消退較慢:50 為基準,0→0.5 倍,100→1.5 倍 +export const halfLife = (neuroticism: number) => MOOD_HALF_LIFE_H * HOUR * (0.5 + neuroticism / 100) + +// 讀檔時才計算:x = B + (x − B)·0.5^(Δ/τ) +export function decay(mood: Pad, baseline: Pad, elapsedMs: number, neuroticism: number): Pad { + if (elapsedMs <= 0) return { p: mood.p, a: mood.a, d: mood.d } + const k = Math.pow(0.5, elapsedMs / halfLife(neuroticism)) + return { + p: baseline.p + (mood.p - baseline.p) * k, + a: baseline.a + (mood.a - baseline.a) * k, + d: baseline.d + (mood.d - baseline.d) * k, + } +} + +// 單軸每回合變化上限 ±0.3 +export const TURN_CAP = 0.3 + +export function applyDelta(mood: Pad, delta: Pad): Pad { + const step = (x: number, dx: number) => clamp(x + clamp(dx, -TURN_CAP, TURN_CAP)) + return { p: step(mood.p, delta.p), a: step(mood.a, delta.a), d: step(mood.d, delta.d) } +} + +export const MOOD_EVENTS = { + thanks: { p: 0.15, a: 0.05, d: 0.05 }, + scold: { p: -0.2, a: 0.15, d: -0.1 }, + success: { p: 0.08, a: 0.03, d: 0.05 }, + toolFail: { p: -0.08, a: 0.1, d: -0.08 }, + return: { p: 0.1, a: 0.08, d: 0 }, + lateNight: { p: -0.05, a: -0.1, d: 0 }, + favorite: { p: 0.12, a: 0.08, d: 0.03 }, + ignored: { p: -0.05, a: -0.03, d: -0.03 }, +} as const satisfies Record +export type MoodEvent = keyof typeof MOOD_EVENTS + +// 八分區(Mehrabian 的 PAD 八分類)→ 情緒標籤與顏文字 +const OCTANTS: Record = { + '+++': { label: '興奮', kaomoji: '(≧▽≦)' }, + '++-': { label: '撒嬌', kaomoji: '(๑>ᴗ<๑)' }, + '+-+': { label: '放鬆', kaomoji: '(´ᴗ`)' }, + '+--': { label: '溫順', kaomoji: '(︶ᴗ︶)' }, + '---': { label: '無聊', kaomoji: '(-_-)' }, + '--+': { label: '不屑', kaomoji: '(¬_¬)' }, + '-++': { label: '生氣', kaomoji: '(╬`益´)' }, + '-+-': { label: '焦慮', kaomoji: '(;´Д`)' }, +} +const CALM = { label: '平靜', kaomoji: '(・ω・)' } +const CALM_RADIUS = 0.1 + +export function moodLabel(m: Pad): { label: string; kaomoji: string } { + if (Math.abs(m.p) < CALM_RADIUS && Math.abs(m.a) < CALM_RADIUS && Math.abs(m.d) < CALM_RADIUS) return CALM + const s = (x: number) => (x >= 0 ? '+' : '-') + return OCTANTS[s(m.p) + s(m.a) + s(m.d)]! +} diff --git a/plugins/cockpit/hooks/companion/node-fs.ts b/plugins/cockpit/hooks/companion/node-fs.ts new file mode 100644 index 0000000..a06a9a2 --- /dev/null +++ b/plugins/cockpit/hooks/companion/node-fs.ts @@ -0,0 +1,26 @@ +import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' + +import type { Fs } from './fs' + +export const nodeFs: Fs = { + async read(path) { + try { + return await readFile(path, 'utf8') + } catch { + return null + } + }, + async write(path, data) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, data, 'utf8') + }, + rename, + async list(dir) { + try { + return (await readdir(dir)).filter(n => !n.endsWith('.tmp')).sort() + } catch { + return [] + } + }, +} diff --git a/plugins/cockpit/hooks/companion/types.ts b/plugins/cockpit/hooks/companion/types.ts new file mode 100644 index 0000000..e2f18ae --- /dev/null +++ b/plugins/cockpit/hooks/companion/types.ts @@ -0,0 +1,46 @@ +export type Pad = { p: number; a: number; d: number } + +export type Source = 'novel' | 'official' | 'wiki' + +export type Fact = { value: string; source?: string; kind?: Source; confidence?: number } + +export const FACT_KEYS = ['birthday', 'zodiac', 'bloodType', 'hair', 'face', 'height', 'weight', 'measurements'] as const +export type FactKey = (typeof FACT_KEYS)[number] + +export type CompanionCore = { + id: string + name: string + facts: Record + phrases?: string[] + // 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值 + neuroticism: number + baseline: Pad +} + +export const STAGES = ['陌生', '熟悉', '親近', '特別'] as const +export type Stage = (typeof STAGES)[number] + +export type Affinity = { + value: number + stage: number + dayKey: string + dayGain: number + lastSeen: number +} + +export type CompanionState = { + mood: Pad & { at: number } + affinity: Affinity + todos: number +} + +export type UserSettings = { + nickname: string + wake: string + sleep: string + tz: string + careLimit: number + quiet: { start: string; end: string } +} + +export type Global = { active: string | null; activatedOnce: boolean } diff --git a/plugins/cockpit/hooks/hud/format.ts b/plugins/cockpit/hooks/hud/format.ts index 0d93cdd..7628f4c 100644 --- a/plugins/cockpit/hooks/hud/format.ts +++ b/plugins/cockpit/hooks/hud/format.ts @@ -232,3 +232,9 @@ export function parseDf(out: string): number | undefined { const m = row && /(\d+)%/.exec(row) return m ? Number(m[1]) : undefined } + +// band 的所有行:第一行恆為 HUD;有角色時才多第二行角色行 +export function bandLines(d: HudData, columns: number, compact: boolean, companion: Seg[] | null): Seg[][] { + const first = fit(d, columns, compact) + return companion ? [first, companion] : [first] +} diff --git a/plugins/cockpit/hooks/hud/index.tsx b/plugins/cockpit/hooks/hud/index.tsx index 03ac216..f5fe744 100644 --- a/plugins/cockpit/hooks/hud/index.tsx +++ b/plugins/cockpit/hooks/hud/index.tsx @@ -2,7 +2,10 @@ import { atom, read, update } from 'claude-code' import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code' import type { Mode, Sys, Usage } from '../../types' -import { cpuPercent, fit, parseCpu, parseDf, parseGit, parseMem, parseOffset } from './format' +import { fitCompanion } from '../companion/band' +import { createCompanion } from '../companion/core' +import { nodeFs } from '../companion/node-fs' +import { bandLines, cpuPercent, parseCpu, parseDf, parseGit, parseMem, parseOffset } from './format' import type { CpuSample } from './format' const usageAtom = atom({ plugin: 'cockpit', key: 'usage' } as const, {}) @@ -34,6 +37,7 @@ function toUsage(context: SessionContextUsage, rateLimits: SessionRateLimit[]): } let home: string | undefined +let companion: ReturnType | undefined let tzOffsetMin = 0 let prevCpu: CpuSample | null = null let ticks = 0 @@ -95,8 +99,8 @@ export const register: Register = on => { on('session.start', async ($, e, next) => { await $.command.register({ name: 'cockpit', - description: 'Cockpit 控制:hud 狀態列', - argumentHint: 'hud on | off | compact', + description: 'Cockpit 控制:助理(status、switch、companion off、setup)與 hud 狀態列', + argumentHint: 'status | switch <角色> | companion off | setup | hud on | off | compact', }) // 別名:/hud 等同 /cockpit hud await $.command.register({ @@ -105,6 +109,9 @@ export const register: Register = on => { argumentHint: 'on | off | compact', }) home = await $.env.get('HOME') + companion = createCompanion({ fs: nodeFs, clock: { now: Date.now }, root: `${home ?? ''}/.cockpit` }) + const first = await companion.ensureActivated() + if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /cockpit companion off 可關閉`) try { const r = await $.process.run(['date', '+%z'], { timeoutMs: 3000 }) tzOffsetMin = parseOffset(r.stdout) @@ -147,41 +154,42 @@ export const register: Register = on => { on('command.run', { command: 'cockpit' }, async ($, e, next) => { const [sub, ...rest] = e.args.trim().split(/\s+/) - if (sub !== 'hud') return { text: '用法:/cockpit hud on | off | compact' } + if (sub === 'hud') return runHud($, rest.join(' ')) - return runHud($, rest.join(' ')) + return { text: companion ? await companion.command(e.args) : '尚未就緒。' } }) on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { const mode = await read($, modeAtom) if (e.props.hasSurvey || mode === 'off') return next(e) - const line = fit( - { - model: await $.session.model(), - cwd: await $.session.cwd(), - home, - git: await read($, gitAtom), - usage: await read($, usageAtom), - sys: await read($, sysAtom), - tzOffsetMin, - effort: await read($, effortAtom), - }, - e.props.bodyColumns, - mode === 'compact', - ) + const data = { + model: await $.session.model(), + cwd: await $.session.cwd(), + home, + git: await read($, gitAtom), + usage: await read($, usageAtom), + sys: await read($, sysAtom), + tzOffsetMin, + effort: await read($, effortAtom), + } + const view = await companion?.view() + const cols = e.props.bodyColumns + const lines = bandLines(data, cols, mode === 'compact', view ? fitCompanion(view, cols, mode === 'compact') : null) const { Box, Text } = $.ui.resolve(e) // 引擎在 band 與輸入框之間已留一行空白,上方補一行讓上下間距相同 return ( - - - {line.map(s => ( - - {s.text} - - ))} - + + {lines.map(line => ( + + {line.map(s => ( + + {s.text} + + ))} + + ))} ) })