merge: 合併 origin/main(M0 cockpit 改名)並整合 companion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "cockpit",
|
||||
"version": "0.1.0",
|
||||
"description": "Cockpit 外掛,內含 hud 模組(輸入框上方單行 HUD):模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟",
|
||||
"license": "MIT",
|
||||
"types": "./types/index.d.ts",
|
||||
"author": {
|
||||
"name": "JSC"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<Global> => (await readJson<Global>(fs, globalPath)) ?? { active: null, activatedOnce: false }
|
||||
const getUser = async (): Promise<UserSettings> => ({ ...DEFAULT_USER, ...(await readJson<Partial<UserSettings>>(fs, userPath)) })
|
||||
const getCore = (id: string) => readJson<CompanionCore>(fs, `${dir(id)}/core.json`)
|
||||
const getState = (id: string) => readJson<CompanionState>(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<View | null> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string, unknown>)[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<string> {
|
||||
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<typeof createCompanion>
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 檔案系統與時鐘皆由外部注入;核心只依賴這兩個介面
|
||||
export interface Fs {
|
||||
read(path: string): Promise<string | null>
|
||||
write(path: string, data: string): Promise<void>
|
||||
rename(from: string, to: string): Promise<void>
|
||||
list(dir: string): Promise<string[]>
|
||||
}
|
||||
|
||||
export type Clock = { now(): number }
|
||||
|
||||
// 先寫暫存檔再 rename,當機時不會留下寫到一半的檔案
|
||||
export async function writeAtomic(fs: Fs, path: string, data: string): Promise<void> {
|
||||
const tmp = `${path}.tmp`
|
||||
await fs.write(tmp, data)
|
||||
await fs.rename(tmp, path)
|
||||
}
|
||||
|
||||
export async function readJson<T>(fs: Fs, path: string): Promise<T | null> {
|
||||
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<string, string> = {}): Fs & { files: Map<string, string> } {
|
||||
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<string>()
|
||||
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) }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { Register } from 'claude-code'
|
||||
|
||||
// companion 模組:目前為空殼,之後的階段再補內容
|
||||
export const register: Register = () => {}
|
||||
@@ -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<string, Pad>
|
||||
export type MoodEvent = keyof typeof MOOD_EVENTS
|
||||
|
||||
// 八分區(Mehrabian 的 PAD 八分類)→ 情緒標籤與顏文字
|
||||
const OCTANTS: Record<string, { label: string; kaomoji: string }> = {
|
||||
'+++': { 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)]!
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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<FactKey, Fact | 'unknown'>
|
||||
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 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "modules": ["./register.tsx"] }
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { Git, Sys, Usage, Window } from '../types'
|
||||
|
||||
export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
|
||||
|
||||
export type HudData = {
|
||||
model: string
|
||||
cwd: string
|
||||
home?: string
|
||||
git: Git | null
|
||||
usage: Usage
|
||||
sys: Sys
|
||||
tzOffsetMin: number
|
||||
effort?: string | null
|
||||
}
|
||||
|
||||
// 收合層級:0 完整 → 1 去進度條 → 2 目錄只留末段 → 3 去 context 用量 → 4 去磁碟 → 5 去重置時間 → 6 極窄
|
||||
export const MAX_LEVEL = 6
|
||||
|
||||
export const levelColor = (p: number) => (p >= 85 ? 'error' : p >= 60 ? 'warning' : 'success')
|
||||
|
||||
export function bar(p: number, cells = 5): string {
|
||||
const n = Math.max(0, Math.min(cells, Math.round((p / 100) * cells)))
|
||||
return '▰'.repeat(n) + '▱'.repeat(cells - n)
|
||||
}
|
||||
|
||||
export function prettyModel(raw: string): string {
|
||||
const s = raw.replace(/\[1m\]/i, '').trim()
|
||||
const m = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i.exec(s)
|
||||
const fam = m?.[1]
|
||||
if (!m || !fam) return s
|
||||
const name = fam.charAt(0).toUpperCase() + fam.slice(1).toLowerCase()
|
||||
return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
|
||||
}
|
||||
|
||||
// 模型等級:冷色空心 → 暖色實心,避開表示用量門檻的綠/黃/紅
|
||||
const TIERS: Record<string, { glyph: string; color?: string }> = {
|
||||
haiku: { glyph: '○', color: '#7f9bb3' },
|
||||
sonnet: { glyph: '◇', color: '#4f9ee8' },
|
||||
opus: { glyph: '◆', color: 'claude' },
|
||||
fable: { glyph: '✦', color: '#a77bf0' },
|
||||
}
|
||||
|
||||
export function modelStyle(raw: string): { glyph: string; color?: string } {
|
||||
const fam = /(haiku|sonnet|opus|fable)/i.exec(raw)?.[1]?.toLowerCase()
|
||||
return (fam && TIERS[fam]) || { glyph: '◆' }
|
||||
}
|
||||
|
||||
export function tokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1000) return `${Math.round(n / 1000)}k`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const shift = (iso: string, off: number) => new Date(Date.parse(iso) + off * 60_000)
|
||||
|
||||
export const clock = (iso: string, off: number) => {
|
||||
const d = shift(iso, off)
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
|
||||
}
|
||||
|
||||
export const day = (iso: string, off: number) => {
|
||||
const d = shift(iso, off)
|
||||
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`
|
||||
}
|
||||
|
||||
// `+0800` → 480
|
||||
export function parseOffset(z: string): number {
|
||||
const m = /^([+-])(\d{2})(\d{2})$/.exec(z.trim())
|
||||
if (!m) return 0
|
||||
const v = Number(m[2]) * 60 + Number(m[3])
|
||||
return m[1] === '-' ? -v : v
|
||||
}
|
||||
|
||||
export function shortDir(cwd: string, home: string | undefined, lastOnly: boolean): string {
|
||||
const p = home && (cwd === home || cwd.startsWith(home + '/')) ? '~' + cwd.slice(home.length) : cwd
|
||||
if (!lastOnly) return p
|
||||
const last = p.split('/').filter(Boolean).pop()
|
||||
return last && last !== p ? `…/${last}` : p
|
||||
}
|
||||
|
||||
const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/
|
||||
|
||||
export function width(s: string): number {
|
||||
let w = 0
|
||||
for (const ch of s) w += WIDE.test(ch) ? 2 : 1
|
||||
return w
|
||||
}
|
||||
|
||||
export const lineWidth = (segs: Seg[]) => segs.reduce((w, s) => w + width(s.text), 0)
|
||||
|
||||
function rate(label: string, w: Window | undefined, level: number, reset: (iso: string) => string): Seg[] {
|
||||
if (!w) return [{ text: `${label} —`, dim: true }]
|
||||
const c = levelColor(w.percent)
|
||||
const out: Seg[] = [{ text: `${label} `, dim: true }]
|
||||
if (level < 1) out.push({ text: `${bar(w.percent)} `, color: c })
|
||||
out.push({ text: `${Math.round(w.percent)}%`, color: c })
|
||||
if (level < 5 && w.resetsAt) out.push({ text: ` ↻${reset(w.resetsAt)}`, dim: true })
|
||||
return out
|
||||
}
|
||||
|
||||
function full(d: HudData, level: number): Seg[] {
|
||||
const groups: Seg[][] = []
|
||||
const ms = modelStyle(d.model)
|
||||
const model: Seg[] = [{ text: `${ms.glyph} ${prettyModel(d.model)}`, color: ms.color, bold: true }]
|
||||
if (d.effort && level < 5) model.push({ text: ` ·${d.effort}`, dim: true })
|
||||
groups.push(model)
|
||||
groups.push([{ text: shortDir(d.cwd, d.home, level >= 2), color: 'suggestion' }])
|
||||
if (d.git) {
|
||||
const g: Seg[] = [{ text: '⎇ ', dim: true }, { text: d.git.branch, bold: true }]
|
||||
if (d.git.dirty) g.push({ text: ` ±${d.git.dirty}`, color: 'warning' })
|
||||
if (d.git.ahead) g.push({ text: ` ↑${d.git.ahead}`, dim: true })
|
||||
if (d.git.behind) g.push({ text: ` ↓${d.git.behind}`, dim: true })
|
||||
groups.push(g)
|
||||
}
|
||||
groups.push(rate('5h', d.usage.fiveHour, level, iso => clock(iso, d.tzOffsetMin)))
|
||||
groups.push(rate('7d', d.usage.sevenDay, level, iso => day(iso, d.tzOffsetMin)))
|
||||
|
||||
const u = d.usage
|
||||
if (u.ctxPercent === undefined) {
|
||||
groups.push([{ text: 'ctx —', dim: true }])
|
||||
} else {
|
||||
const c: Seg[] = [{ text: 'ctx ', dim: true }, { text: `${u.ctxPercent}%`, color: levelColor(u.ctxPercent) }]
|
||||
if (level < 3 && u.ctxTokens !== undefined && u.ctxWindow)
|
||||
c.push({ text: ` ${tokens(u.ctxTokens)}/${tokens(u.ctxWindow)}`, dim: true })
|
||||
groups.push(c)
|
||||
}
|
||||
|
||||
const s = d.sys
|
||||
const sys: Seg[] = []
|
||||
const add = (label: string, p: number | undefined) => {
|
||||
if (p === undefined) return
|
||||
if (sys.length) sys.push({ text: ' ' })
|
||||
sys.push({ text: `${label} `, dim: true }, { text: `${Math.round(p)}%`, color: levelColor(p) })
|
||||
}
|
||||
add('CPU', s.cpu)
|
||||
add('MEM', s.memUsed !== undefined && s.memTotal ? (s.memUsed / s.memTotal) * 100 : undefined)
|
||||
if (level < 4) add('DSK', s.disk)
|
||||
if (sys.length) groups.push(sys)
|
||||
|
||||
const line: Seg[] = []
|
||||
groups.forEach((g, i) => {
|
||||
if (i) line.push({ text: ' │ ', dim: true })
|
||||
line.push(...g)
|
||||
})
|
||||
return line
|
||||
}
|
||||
|
||||
function ultra(d: HudData): Seg[] {
|
||||
const u = d.usage
|
||||
const ms = modelStyle(d.model)
|
||||
const out: Seg[] = [{ text: `${ms.glyph} ${prettyModel(d.model).split(' ')[0]}`, color: ms.color, bold: true }]
|
||||
if (d.git) out.push({ text: ' ⎇', dim: true }, { text: d.git.branch, bold: true })
|
||||
const num = (label: string, p: number | undefined) => {
|
||||
out.push({ text: ` ${label}`, dim: true })
|
||||
out.push(p === undefined ? { text: '—', dim: true } : { text: String(Math.round(p)), color: levelColor(p) })
|
||||
}
|
||||
num('5h', u.fiveHour?.percent)
|
||||
num('7d', u.sevenDay?.percent)
|
||||
num('ctx', u.ctxPercent)
|
||||
if (d.sys.cpu !== undefined) num('cpu', d.sys.cpu)
|
||||
return out
|
||||
}
|
||||
|
||||
export function build(d: HudData, level: number): Seg[] {
|
||||
return level >= MAX_LEVEL ? ultra(d) : full(d, level)
|
||||
}
|
||||
|
||||
// 由完整版往下收合,回傳第一個放得進 columns 的版本
|
||||
export function fit(d: HudData, columns: number, compact = false): Seg[] {
|
||||
if (compact) return ultra(d)
|
||||
for (let level = 0; level < MAX_LEVEL; level++) {
|
||||
const line = full(d, level)
|
||||
if (lineWidth(line) <= columns) return line
|
||||
}
|
||||
return ultra(d)
|
||||
}
|
||||
|
||||
// git status --porcelain=v2 -b
|
||||
export function parseGit(out: string): Git | null {
|
||||
let head = ''
|
||||
let oid = ''
|
||||
let ahead = 0
|
||||
let behind = 0
|
||||
let dirty = 0
|
||||
for (const line of out.split('\n')) {
|
||||
if (!line) continue
|
||||
if (line.startsWith('# branch.head ')) head = line.slice(14)
|
||||
else if (line.startsWith('# branch.oid ')) oid = line.slice(13)
|
||||
else if (line.startsWith('# branch.ab ')) {
|
||||
const m = /\+(\d+) -(\d+)/.exec(line)
|
||||
if (m) [ahead, behind] = [Number(m[1]), Number(m[2])]
|
||||
} else if (!line.startsWith('#')) dirty++
|
||||
}
|
||||
if (!head) return null
|
||||
const branch = head === '(detached)' ? oid.slice(0, 7) || 'detached' : head
|
||||
return { branch, dirty, ahead, behind }
|
||||
}
|
||||
|
||||
export type CpuSample = { idle: number; total: number }
|
||||
|
||||
export function parseCpu(stat: string): CpuSample | null {
|
||||
const line = stat.split('\n').find(l => l.startsWith('cpu '))
|
||||
if (!line) return null
|
||||
const v = line.trim().split(/\s+/).slice(1).map(Number)
|
||||
const idle = (v[3] ?? 0) + (v[4] ?? 0)
|
||||
const total = v.reduce((a, b) => a + b, 0)
|
||||
return { idle, total }
|
||||
}
|
||||
|
||||
export function cpuPercent(prev: CpuSample, cur: CpuSample): number | undefined {
|
||||
const dt = cur.total - prev.total
|
||||
if (dt <= 0) return undefined
|
||||
return Math.max(0, Math.min(100, (1 - (cur.idle - prev.idle) / dt) * 100))
|
||||
}
|
||||
|
||||
export function parseMem(meminfo: string): { used: number; total: number } | null {
|
||||
const kb = (k: string) => {
|
||||
const m = new RegExp(`^${k}:\\s+(\\d+)`, 'm').exec(meminfo)
|
||||
return m ? Number(m[1]) * 1024 : undefined
|
||||
}
|
||||
const total = kb('MemTotal')
|
||||
const avail = kb('MemAvailable')
|
||||
if (!total || avail === undefined) return null
|
||||
return { used: total - avail, total }
|
||||
}
|
||||
|
||||
// df -P <path> 的第二行第五欄
|
||||
export function parseDf(out: string): number | undefined {
|
||||
const row = out.trim().split('\n')[1]
|
||||
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]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, test } from 'claude-code/testing'
|
||||
|
||||
import type { HudData } from './format'
|
||||
import { bar, day, clock, fit, lineWidth, parseCpu, cpuPercent, parseDf, parseGit, parseMem, parseOffset, prettyModel, shortDir, tokens } from './format'
|
||||
|
||||
const RESET_5H = '2026-10-12T07:40:00Z'
|
||||
const RESET_7D = '2026-10-16T01:00:00Z'
|
||||
|
||||
const DATA: HudData = {
|
||||
model: 'claude-opus-5-5[1m]',
|
||||
cwd: '/home/dev/work/luopan',
|
||||
home: '/home/dev',
|
||||
git: { branch: 'issue-278', dirty: 3, ahead: 1, behind: 0 },
|
||||
usage: {
|
||||
fiveHour: { percent: 42, resetsAt: RESET_5H },
|
||||
sevenDay: { percent: 71, resetsAt: RESET_7D },
|
||||
ctxTokens: 312_000,
|
||||
ctxWindow: 1_000_000,
|
||||
ctxPercent: 31,
|
||||
},
|
||||
sys: { cpu: 23, memUsed: 63, memTotal: 100, disk: 78 },
|
||||
tzOffsetMin: 480,
|
||||
effort: 'high',
|
||||
}
|
||||
|
||||
const text = (d: HudData, columns: number, compact = false) => fit(d, columns, compact).map(s => s.text).join('')
|
||||
|
||||
describe('format', () => {
|
||||
test('wide line shows every field on one line', async () => {
|
||||
const line = text(DATA, 200)
|
||||
expect(line).toBe(
|
||||
'◆ Opus 5.5 ·high │ ~/work/luopan │ ⎇ issue-278 ±3 ↑1 │ 5h ▰▰▱▱▱ 42% ↻15:40 │ 7d ▰▰▰▰▱ 71% ↻10/16 │ ctx 31% 312k/1.0M │ CPU 23% MEM 63% DSK 78%',
|
||||
)
|
||||
expect(line.includes('\n')).toBe(false)
|
||||
})
|
||||
|
||||
test('narrow widths collapse and always fit', async () => {
|
||||
for (const cols of [140, 120, 100, 90, 80, 60]) {
|
||||
const segs = fit(DATA, cols)
|
||||
expect(lineWidth(segs) <= cols || cols < 50).toBe(true)
|
||||
}
|
||||
expect(text(DATA, 40)).toBe('◆ Opus ⎇issue-278 5h42 7d71 ctx31 cpu23')
|
||||
expect(text(DATA, 200, true)).toBe('◆ Opus ⎇issue-278 5h42 7d71 ctx31 cpu23')
|
||||
})
|
||||
|
||||
test('missing figures show a dash', async () => {
|
||||
const line = text({ ...DATA, git: null, usage: {}, sys: {}, effort: null }, 200)
|
||||
expect(line).toBe('◆ Opus 5.5 │ ~/work/luopan │ 5h — │ 7d — │ ctx —')
|
||||
})
|
||||
|
||||
test('helpers', async () => {
|
||||
expect(bar(0)).toBe('▱▱▱▱▱')
|
||||
expect(bar(100)).toBe('▰▰▰▰▰')
|
||||
expect(prettyModel('claude-sonnet-5-5')).toBe('Sonnet 5.5')
|
||||
expect(prettyModel('Opus 5.5')).toBe('Opus 5.5')
|
||||
expect(tokens(950)).toBe('950')
|
||||
expect(tokens(200_000)).toBe('200k')
|
||||
expect(clock(RESET_5H, 480)).toBe('15:40')
|
||||
expect(day(RESET_7D, 480)).toBe('10/16')
|
||||
expect(day('2026-10-15T20:00:00Z', 480)).toBe('10/16')
|
||||
expect(parseOffset('+0800\n')).toBe(480)
|
||||
expect(parseOffset('-0530')).toBe(-330)
|
||||
expect(shortDir('/srv/x/y', '/home/dev', true)).toBe('…/y')
|
||||
expect(shortDir('/home/dev', '/home/dev', false)).toBe('~')
|
||||
})
|
||||
|
||||
test('model tiers', async () => {
|
||||
const head = (model: string) => fit({ ...DATA, model }, 200)[0]
|
||||
expect(head('claude-haiku-5-5')).toEqual({ text: '○ Haiku 5.5', color: '#7f9bb3', bold: true })
|
||||
expect(head('claude-sonnet-5-5')).toEqual({ text: '◇ Sonnet 5.5', color: '#4f9ee8', bold: true })
|
||||
expect(head('claude-opus-5-5')).toEqual({ text: '◆ Opus 5.5', color: 'claude', bold: true })
|
||||
expect(head('claude-fable-5-1')).toEqual({ text: '✦ Fable 5.1', color: '#a77bf0', bold: true })
|
||||
expect(head('some-other-model')).toEqual({ text: '◆ some-other-model', color: undefined, bold: true })
|
||||
expect(text({ ...DATA, model: 'claude-fable-5-1' }, 40)).toBe('✦ Fable ⎇issue-278 5h42 7d71 ctx31 cpu23')
|
||||
})
|
||||
|
||||
test('parsers', async () => {
|
||||
expect(parseGit('# branch.oid abc1234ff\n# branch.head main\n# branch.ab +2 -1\n1 .M N... a\n? new.txt\n')).toEqual({
|
||||
branch: 'main',
|
||||
dirty: 2,
|
||||
ahead: 2,
|
||||
behind: 1,
|
||||
})
|
||||
expect(parseGit('# branch.oid abc1234ff\n# branch.head (detached)\n')?.branch).toBe('abc1234')
|
||||
const a = parseCpu('cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1')
|
||||
const b = parseCpu('cpu 150 0 150 750 150 0 0 0 0 0\n')
|
||||
expect(a && b && cpuPercent(a, b)).toBe(50)
|
||||
expect(parseMem('MemTotal: 1000 kB\nMemFree: 1 kB\nMemAvailable: 250 kB\n')).toEqual({ used: 768000, total: 1024000 })
|
||||
expect(parseDf('Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sdc 100 78 22 78% /\n')).toBe(78)
|
||||
})
|
||||
})
|
||||
|
||||
describe('band', () => {
|
||||
test('draws one line above the prompt and hides with /hud off', async ($, on) => {
|
||||
on('session.model', () => ({ value: 'claude-opus-5-5' }))
|
||||
on('session.cwd', () => ({ value: '/home/dev/work/luopan' }))
|
||||
const props = {
|
||||
hasSurvey: false,
|
||||
isWorking: false,
|
||||
maxRows: 10,
|
||||
bodyColumns: 200,
|
||||
scroll: { offset: 0, bodyRows: 10 },
|
||||
view: {},
|
||||
}
|
||||
for (const surface of ['terminal', 'desktop'] as const) {
|
||||
const ui = await $.ui.mount({ plugin: 'hud', surface, component: 'AbovePrompt', props })
|
||||
expect(await ui.find({ type: 'Text', text: /◆ Opus 5\.5/ })).toBeDefined()
|
||||
expect(await ui.find({ type: 'Text', text: /5h —/ })).toBeDefined()
|
||||
await ui.unmount()
|
||||
}
|
||||
const ran = await $.command.run({ command: 'hud', args: 'off' } as Parameters<typeof $.command.run>[0])
|
||||
expect(JSON.stringify(ran)).toContain('隱藏')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { atom, read, update } from 'claude-code'
|
||||
import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
|
||||
|
||||
import type { Mode, Sys, Usage } from '../../types'
|
||||
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, {})
|
||||
const gitAtom = atom({ plugin: 'cockpit', key: 'git' } as const, null)
|
||||
const sysAtom = atom({ plugin: 'cockpit', key: 'sys' } as const, {})
|
||||
const modeAtom = atom({ plugin: 'cockpit', key: 'mode' } as const, 'auto')
|
||||
const alertedAtom = atom({ plugin: 'cockpit', key: 'alerted' } as const, [])
|
||||
const effortAtom = atom({ plugin: 'cockpit', key: 'effort' } as const, null)
|
||||
|
||||
const SYS_MS = 3000
|
||||
const GIT_MS = 15_000
|
||||
const DISK_EVERY = 10 // 每 10 次系統取樣(30 秒)讀一次磁碟
|
||||
const ALERT_AT = 90
|
||||
const GIT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
|
||||
const LABEL: Record<string, string> = { five_hour: '5 小時', seven_day: '7 天' }
|
||||
|
||||
function toUsage(context: SessionContextUsage, rateLimits: SessionRateLimit[]): Usage {
|
||||
const win = (kind: string) => {
|
||||
const r = rateLimits.find(x => x.kind === kind)
|
||||
return r && { percent: r.percentUsed, resetsAt: r.resetsAt }
|
||||
}
|
||||
return {
|
||||
fiveHour: win('five_hour'),
|
||||
sevenDay: win('seven_day'),
|
||||
ctxTokens: context.tokens,
|
||||
ctxWindow: context.window,
|
||||
ctxPercent: context.percent,
|
||||
}
|
||||
}
|
||||
|
||||
let home: string | undefined
|
||||
let companion: ReturnType<typeof createCompanion> | undefined
|
||||
let tzOffsetMin = 0
|
||||
let prevCpu: CpuSample | null = null
|
||||
let ticks = 0
|
||||
|
||||
async function refreshGit($: EngineInterface) {
|
||||
try {
|
||||
const r = await $.process.run(['git', 'status', '--porcelain=v2', '-b'], { timeoutMs: 5000 })
|
||||
const git = r.exitCode === 0 ? parseGit(r.stdout) : null
|
||||
await update($, gitAtom, () => git)
|
||||
} catch {
|
||||
await update($, gitAtom, () => null)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSys($: EngineInterface) {
|
||||
const next: Sys = { ...(await read($, sysAtom)) }
|
||||
try {
|
||||
const cur = parseCpu(await $.fs.read('/proc/stat'))
|
||||
if (cur) {
|
||||
if (prevCpu) next.cpu = cpuPercent(prevCpu, cur) ?? next.cpu
|
||||
prevCpu = cur
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const mem = parseMem(await $.fs.read('/proc/meminfo'))
|
||||
if (mem) [next.memUsed, next.memTotal] = [mem.used, mem.total]
|
||||
} catch {}
|
||||
if (ticks++ % DISK_EVERY === 0) {
|
||||
try {
|
||||
const r = await $.process.run(['df', '-P', await $.session.cwd()], { timeoutMs: 5000 })
|
||||
if (r.exitCode === 0) next.disk = parseDf(r.stdout)
|
||||
} catch {}
|
||||
}
|
||||
await update($, sysAtom, () => next)
|
||||
}
|
||||
|
||||
// 每個額度視窗(以重置時間區分)只提醒一次
|
||||
async function alert($: EngineInterface, rateLimits: SessionRateLimit[]) {
|
||||
const done = await read($, alertedAtom)
|
||||
for (const r of rateLimits) {
|
||||
const id = `${r.kind}@${r.resetsAt ?? ''}`
|
||||
if (r.percentUsed < ALERT_AT || done.includes(id)) continue
|
||||
await update($, alertedAtom, list => [...list, id].slice(-20))
|
||||
const label = LABEL[r.kind] ?? r.kind
|
||||
$.ui.toast(`${label}額度已用 ${Math.round(r.percentUsed)}%`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runHud($: EngineInterface, args: string) {
|
||||
const arg = args.trim()
|
||||
const mode: Mode = arg === 'off' ? 'off' : arg === 'compact' ? 'compact' : 'auto'
|
||||
await update($, modeAtom, () => mode)
|
||||
const said: Record<Mode, string> = { auto: '狀態列已開啟。', off: '狀態列已隱藏。', compact: '狀態列改用極窄版。' }
|
||||
|
||||
return { text: said[mode] }
|
||||
}
|
||||
|
||||
export const register: Register = on => {
|
||||
on('session.start', async ($, e, next) => {
|
||||
await $.command.register({
|
||||
name: 'cockpit',
|
||||
description: 'Cockpit 控制:助理(status、switch、companion off、setup)與 hud 狀態列',
|
||||
argumentHint: 'status | switch <角色> | companion off | setup | hud on | off | compact',
|
||||
})
|
||||
// 別名:/hud <arg> 等同 /cockpit hud <arg>
|
||||
await $.command.register({
|
||||
name: 'hud',
|
||||
description: '切換輸入框上方的狀態列(/cockpit hud 的別名)',
|
||||
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)
|
||||
} catch {}
|
||||
const u = await $.session.usage()
|
||||
await update($, usageAtom, () => toUsage(u.context, u.rateLimits))
|
||||
void refreshGit($)
|
||||
void refreshSys($)
|
||||
$.clock.every(SYS_MS, () => void refreshSys($))
|
||||
$.clock.every(GIT_MS, () => void refreshGit($))
|
||||
|
||||
return next(e)
|
||||
})
|
||||
|
||||
on('session.measure', async ($, e, next) => {
|
||||
await update($, usageAtom, () => toUsage(e.context, e.rateLimits))
|
||||
if (e.changed.includes('rateLimits')) await alert($, e.rateLimits)
|
||||
|
||||
return next(e)
|
||||
})
|
||||
|
||||
// 思考等級只在送出模型請求時看得到;只記主對話的,不記 subagent
|
||||
on('turn.step', async function* ($, e, next) {
|
||||
if (!e.agentId) {
|
||||
const effort = e.effort === undefined ? null : String(e.effort)
|
||||
if (effort !== (await read($, effortAtom))) await update($, effortAtom, () => effort)
|
||||
}
|
||||
|
||||
return yield* next(e)
|
||||
})
|
||||
|
||||
on('tool.call', async ($, e, next) => {
|
||||
const ran = await next(e)
|
||||
if (GIT_TOOLS.has(e.tool)) void refreshGit($)
|
||||
|
||||
return ran
|
||||
})
|
||||
|
||||
on('command.run', { command: 'hud' }, ($, e) => runHud($, e.args))
|
||||
|
||||
on('command.run', { command: 'cockpit' }, async ($, e, next) => {
|
||||
const [sub, ...rest] = e.args.trim().split(/\s+/)
|
||||
if (sub === 'hud') 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 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 (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{lines.map(line => (
|
||||
<Text wrap="truncate">
|
||||
{line.map(s => (
|
||||
<Text color={s.color} dimColor={s.dim} bold={s.bold}>
|
||||
{s.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Register } from 'claude-code'
|
||||
|
||||
import { register as companion } from './companion'
|
||||
import { register as hud } from './hud'
|
||||
|
||||
export const register: Register = on => {
|
||||
hud(on)
|
||||
companion(on)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.claude-plugin/types/tsconfig.json"
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
export type Window = { percent: number; resetsAt?: string }
|
||||
|
||||
export type Usage = {
|
||||
fiveHour?: Window
|
||||
sevenDay?: Window
|
||||
ctxTokens?: number
|
||||
ctxWindow?: number
|
||||
ctxPercent?: number
|
||||
}
|
||||
|
||||
export type Git = { branch: string; dirty: number; ahead: number; behind: number }
|
||||
|
||||
export type Sys = { cpu?: number; memUsed?: number; memTotal?: number; disk?: number }
|
||||
|
||||
export type Mode = 'auto' | 'compact' | 'off'
|
||||
|
||||
declare module 'claude-code' {
|
||||
interface PluginState {
|
||||
cockpit: {
|
||||
usage: Usage
|
||||
git: Git | null
|
||||
sys: Sys
|
||||
mode: Mode
|
||||
alerted: string[]
|
||||
effort: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user