Compare commits

..
2 Commits
Author SHA1 Message Date
jiantw83 6338b57df7 Merge pull request 'refactor: 拆分 cockpit 為可個別安裝的 statusbar 與 companion' (#12) from refactor/split-statusbar-companion into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/12
2026-10-12 05:57:49 +00:00
rootandClaude Sonnet 5.5 ba5b50ad1d refactor: 拆分 cockpit 為可個別安裝的 statusbar 與 companion
- statusbar:輸入框上方狀態列,指令 /statusbar
- companion:小說角色陪伴,指令 /companion,自行渲染角色行
- companion 資料目錄改為 ~/.companion
- 整併 M4 檔案至 companion;M2 實作移至 hooks/companion/persona
- README 移除本機資料夾安裝,補上更新與移除指令

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 13:57:24 +08:00
50 changed files with 162 additions and 133 deletions
+8 -3
View File
@@ -6,9 +6,14 @@
}, },
"plugins": [ "plugins": [
{ {
"name": "cockpit", "name": "statusbar",
"source": "./plugins/cockpit", "source": "./plugins/statusbar",
"description": "Cockpit 外掛,內含 hud 模組(輸入框上方單行 HUD):模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟" "description": "輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟"
},
{
"name": "companion",
"source": "./plugins/companion",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色"
} }
] ]
} }
+26 -13
View File
@@ -1,31 +1,44 @@
# jsc-marketplace # jsc-marketplace
JSC 自用 Claude Code mods。 JSC 自用 Claude Code mods。每個 mod 皆可單獨安裝、更新與移除。
| Mod | 說明 | | Mod | 說明 |
| --- | --- | | --- | --- |
| [cockpit](plugins/cockpit) | Cockpit 外掛,內含 hud 模組(輸入框上方單行 HUD):模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟 | | [statusbar](plugins/statusbar) | 輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟 |
| [companion](plugins/companion) | 小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色 |
## 安裝 ## 安裝
本機資料夾: 先加入 marketplace,再依需要安裝個別 mod:
```bash
claude plugin marketplace add /root/jsc-marketplace
claude plugin install cockpit@jsc-marketplace
```
或透過 Gitea 遠端:
```bash ```bash
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace.git claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace.git
claude plugin install cockpit@jsc-marketplace claude plugin install statusbar@jsc-marketplace
claude plugin install companion@jsc-marketplace
``` ```
## 指令 ## 指令
- `/cockpit hud on | off | compact`:切換狀態列 - `/statusbar on | off | compact`:切換狀態列
- `/hud on | off | compact`:上述指令的別名 - `/companion status | switch <角色> | off | setup`:角色陪伴控制
## 更新
```bash
claude plugin marketplace update jsc-marketplace
claude plugin update statusbar@jsc-marketplace
claude plugin update companion@jsc-marketplace
```
更新後需重新啟動 Claude Code 才會生效。
## 移除
```bash
claude plugin uninstall statusbar@jsc-marketplace
claude plugin uninstall companion@jsc-marketplace
claude plugin marketplace remove jsc-marketplace
```
## 授權 ## 授權
@@ -1,10 +0,0 @@
{
"name": "cockpit",
"version": "0.1.0",
"description": "Cockpit 外掛,內含 hud 模組(輸入框上方單行 HUD):模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟",
"license": "MIT",
"types": "./types/index.d.ts",
"author": {
"name": "JSC"
}
}
@@ -1,4 +0,0 @@
import type { Register } from 'claude-code'
// companion 模組:目前為空殼,之後的階段再補內容
export const register: Register = () => {}
-9
View File
@@ -1,9 +0,0 @@
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,9 @@
{
"name": "companion",
"version": "0.1.0",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
"license": "MIT",
"author": {
"name": "JSC"
}
}
@@ -1,19 +1,8 @@
import { describe, expect, test } from 'claude-code/testing' 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 { fitCompanion } from './band'
import type { View } from './core' import type { View } from './core'
import { lineWidth } from './seg'
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 = { const VIEW: View = {
core: { id: 'miko', name: '月見ミコ', facts: {} as never, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 } }, core: { id: 'miko', name: '月見ミコ', facts: {} as never, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 } },
@@ -56,19 +45,3 @@ describe('角色行', () => {
expect(text({ ...VIEW, todos: 0 }, 200)).not.toContain('☐') 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))
})
})
@@ -1,5 +1,5 @@
import { bar, lineWidth } from '../hud/format' import { bar, lineWidth } from './seg'
import type { Seg } from '../hud/format' import type { Seg } from './seg'
import type { View } from './core' import type { View } from './core'
const axis = (v: number) => bar(((v + 1) / 2) * 100, 3) const axis = (v: number) => bar(((v + 1) / 2) * 100, 3)
@@ -140,7 +140,7 @@ describe('關心:靜音與不打斷', () => {
assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['session_start']) assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['session_start'])
}) })
test('/cockpit quiet 在期間內靜音,到期後恢復', async () => { test('/companion quiet 在期間內靜音,到期後恢復', async () => {
const s = setup() const s = setup()
await s.care.quiet(30) await s.care.quiet(30)
await s.care.onUserTurn() await s.care.onUserTurn()
@@ -291,7 +291,7 @@ export interface CareEngine {
onToolResult(ok: boolean): Promise<void> onToolResult(ok: boolean): Promise<void>
/** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */ /** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */
tick(signals?: Signals): Promise<Care[]> tick(signals?: Signals): Promise<Care[]>
/** /cockpit quiet [分鐘],0 取消靜音 */ /** /companion quiet [分鐘],0 取消靜音 */
quiet(minutes: number): Promise<void> quiet(minutes: number): Promise<void>
state(): Promise<CareState> state(): Promise<CareState>
} }
@@ -23,7 +23,7 @@ const MIKO: CompanionCore = {
} }
const H = 3_600_000 const H = 3_600_000
const DAY = 24 * H const DAY = 24 * H
const ROOT = '/h/.cockpit' const ROOT = '/h/.companion'
function setup(builtins = [MIKO]) { function setup(builtins = [MIKO]) {
const fs = memoryFs() const fs = memoryFs()
@@ -39,10 +39,10 @@ describe('啟用', () => {
expect((await c.ensureActivated()).first).toBe(false) expect((await c.ensureActivated()).first).toBe(false)
}) })
test('companion off 後沒有角色,且重新啟動不會自動再啟用', async () => { test('off 後沒有角色,且重新啟動不會自動再啟用', async () => {
const { c } = setup() const { c } = setup()
await c.ensureActivated() await c.ensureActivated()
await c.command('companion off') await c.command('off')
expect(await c.view()).toBe(null) expect(await c.view()).toBe(null)
await c.ensureActivated() await c.ensureActivated()
expect(await c.view()).toBe(null) expect(await c.view()).toBe(null)
@@ -138,7 +138,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
else if (k === 'quietEnd') u.quiet.end = val else if (k === 'quietEnd') u.quiet.end = val
else (u as Record<string, unknown>)[k] = 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 (bad.length) return `無法解析:${bad.join(' ')}\n用法:/companion 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) if (args.length) await writeJson(fs, userPath, u)
return [ return [
args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):', args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):',
@@ -151,14 +151,14 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
async function command(raw: string): Promise<string> { async function command(raw: string): Promise<string> {
const [sub = '', ...rest] = raw.trim().split(/\s+/).filter(Boolean) const [sub = '', ...rest] = raw.trim().split(/\s+/).filter(Boolean)
if (sub === 'status') return status() if (sub === 'status') return status()
if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/cockpit switch <角色>。可用:${(await available()).join('、')}` if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/companion switch <角色>。可用:${(await available()).join('、')}`
if (sub === 'companion' && rest[0] === 'off') { if (sub === 'off') {
const g = await getGlobal() const g = await getGlobal()
await writeJson(fs, globalPath, { ...g, active: null, activatedOnce: true }) await writeJson(fs, globalPath, { ...g, active: null, activatedOnce: true })
return '助理已關閉,所有資料保留。' return '助理已關閉,所有資料保留。'
} }
if (sub === 'setup') return setup(rest) if (sub === 'setup') return setup(rest)
return '用法:/cockpit status | switch <角色> | companion off | setup [key=value…]' return '用法:/companion status | switch <角色> | off | setup [key=value…]'
} }
return { ensureActivated, view, moodEvent, affinityEvent, command, getUser } return { ensureActivated, view, moodEvent, affinityEvent, command, getUser }
@@ -24,7 +24,7 @@ type State = { mood: Pad; moodAt: number; affinity: aff.Affinity }
export type Options = { export type Options = {
fs: Fs fs: Fs
clock: Clock clock: Clock
/** 資料根目錄,例如 ~/cockpit */ /** 資料根目錄,例如 ~/.companion */
root: string root: string
/** 當前角色資料夾名稱 */ /** 當前角色資料夾名稱 */
id: string id: string
@@ -160,7 +160,7 @@ export function createCompanion({ fs, clock, root, id }: Options) {
await fs.write(paths.user, JSON.stringify({ ...(await loadUser()), tone: on })) await fs.write(paths.user, JSON.stringify({ ...(await loadUser()), tone: on }))
}, },
/** `/cockpit tone on|off` */ /** `/companion tone on|off` */
async command(line: string): Promise<string | null> { async command(line: string): Promise<string | null> {
const m = /^tone\s+(on|off)$/.exec(line.trim()) const m = /^tone\s+(on|off)$/.exec(line.trim())
if (!m) return null if (!m) return null
+17
View File
@@ -0,0 +1,17 @@
// 角色行用的字串片段與寬度計算(與 statusbar 外掛各自獨立,不互相依賴)
export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
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)
}
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)
+49
View File
@@ -0,0 +1,49 @@
import type { Register } from 'claude-code'
import { fitCompanion } from './companion/band'
import { createCompanion } from './companion/core'
import { nodeFs } from './companion/node-fs'
let companion: ReturnType<typeof createCompanion> | undefined
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'companion',
description: '角色陪伴控制:狀態、切換角色、關閉、使用者設定',
argumentHint: 'status | switch <角色> | off | setup',
})
const home = await $.env.get('HOME')
companion = createCompanion({ fs: nodeFs, clock: { now: Date.now }, root: `${home ?? ''}/.companion` })
const first = await companion.ensureActivated()
if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /companion off 可關閉`)
return next(e)
})
on('command.run', { command: 'companion' }, async ($, e) => ({
text: companion ? await companion.command(e.args) : '尚未就緒。',
}))
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const rest = await next(e)
const view = await companion?.view()
if (e.props.hasSurvey || !view) return rest
const line = fitCompanion(view, e.props.bodyColumns)
const { Box, Text } = $.ui.resolve(e)
return (
<Box flexDirection="column">
{rest}
<Text wrap="truncate">
{line.map(s => (
<Text color={s.color} dimColor={s.dim} bold={s.bold}>
{s.text}
</Text>
))}
</Text>
</Box>
)
})
}
@@ -1,6 +1,6 @@
--- ---
name: ingest name: ingest
description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,逐一詢問合併、新建或略過,並寫入 cockpit 角色資料。使用者提供小說章節檔或要求建立/更新角色設定時使用。 description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,逐一詢問合併、新建或略過,並寫入 companion 角色資料。使用者提供小說章節檔或要求建立/更新角色設定時使用。
--- ---
# 小說章節解析 # 小說章節解析
@@ -23,5 +23,5 @@ description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,
## 注意 ## 注意
- 模型從對白推得的個性可信度偏低,只當先驗。 - 模型從對白推得的個性可信度偏低,只當先驗。
- 不要在 repo 內保存受版權保護的長篇設定或台詞;資料寫在使用者家目錄的 cockpit 資料夾。 - 不要在 repo 內保存受版權保護的長篇設定或台詞;資料寫在使用者家目錄的 `~/.companion` 資料夾。
- 範例完整流程見 `docs/ingest-walkthrough.md`。 - 範例完整流程見 `docs/ingest-walkthrough.md`。
@@ -0,0 +1,10 @@
{
"name": "statusbar",
"version": "0.1.0",
"description": "輸入框上方的單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟",
"license": "MIT",
"types": "./types/index.d.ts",
"author": {
"name": "JSC"
}
}
@@ -232,9 +232,3 @@ export function parseDf(out: string): number | undefined {
const m = row && /(\d+)%/.exec(row) const m = row && /(\d+)%/.exec(row)
return m ? Number(m[1]) : undefined 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]
}
+1
View File
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
@@ -1,19 +1,16 @@
import { atom, read, update } from 'claude-code' import { atom, read, update } from 'claude-code'
import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code' import type { EngineInterface, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
import type { Mode, Sys, Usage } from '../../types' import type { Mode, Sys, Usage } from '../types'
import { fitCompanion } from '../companion/band' import { fit, cpuPercent, parseCpu, parseDf, parseGit, parseMem, parseOffset } from './format'
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' import type { CpuSample } from './format'
const usageAtom = atom({ plugin: 'cockpit', key: 'usage' } as const, {}) const usageAtom = atom({ plugin: 'statusbar', key: 'usage' } as const, {})
const gitAtom = atom({ plugin: 'cockpit', key: 'git' } as const, null) const gitAtom = atom({ plugin: 'statusbar', key: 'git' } as const, null)
const sysAtom = atom({ plugin: 'cockpit', key: 'sys' } as const, {}) const sysAtom = atom({ plugin: 'statusbar', key: 'sys' } as const, {})
const modeAtom = atom({ plugin: 'cockpit', key: 'mode' } as const, 'auto') const modeAtom = atom({ plugin: 'statusbar', key: 'mode' } as const, 'auto')
const alertedAtom = atom({ plugin: 'cockpit', key: 'alerted' } as const, []) const alertedAtom = atom({ plugin: 'statusbar', key: 'alerted' } as const, [])
const effortAtom = atom({ plugin: 'cockpit', key: 'effort' } as const, null) const effortAtom = atom({ plugin: 'statusbar', key: 'effort' } as const, null)
const SYS_MS = 3000 const SYS_MS = 3000
const GIT_MS = 15_000 const GIT_MS = 15_000
@@ -37,7 +34,6 @@ function toUsage(context: SessionContextUsage, rateLimits: SessionRateLimit[]):
} }
let home: string | undefined let home: string | undefined
let companion: ReturnType<typeof createCompanion> | undefined
let tzOffsetMin = 0 let tzOffsetMin = 0
let prevCpu: CpuSample | null = null let prevCpu: CpuSample | null = null
let ticks = 0 let ticks = 0
@@ -98,20 +94,11 @@ async function runHud($: EngineInterface, args: string) {
export const register: Register = on => { export const register: Register = on => {
on('session.start', async ($, e, next) => { on('session.start', async ($, e, next) => {
await $.command.register({ await $.command.register({
name: 'cockpit', name: 'statusbar',
description: 'Cockpit 控制:助理(status、switch、companion off、setup)與 hud 狀態列', description: '切換輸入框上方的狀態列',
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', argumentHint: 'on | off | compact',
}) })
home = await $.env.get('HOME') 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 { try {
const r = await $.process.run(['date', '+%z'], { timeoutMs: 3000 }) const r = await $.process.run(['date', '+%z'], { timeoutMs: 3000 })
tzOffsetMin = parseOffset(r.stdout) tzOffsetMin = parseOffset(r.stdout)
@@ -150,18 +137,12 @@ export const register: Register = on => {
return ran return ran
}) })
on('command.run', { command: 'hud' }, ($, e) => runHud($, e.args)) on('command.run', { command: 'statusbar' }, ($, 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) => { on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
const mode = await read($, modeAtom) const mode = await read($, modeAtom)
if (e.props.hasSurvey || mode === 'off') return next(e) const rest = await next(e)
if (e.props.hasSurvey || mode === 'off') return rest
const data = { const data = {
model: await $.session.model(), model: await $.session.model(),
@@ -173,15 +154,12 @@ export const register: Register = on => {
tzOffsetMin, tzOffsetMin,
effort: await read($, effortAtom), effort: await read($, effortAtom),
} }
const view = await companion?.view() const line = fit(data, e.props.bodyColumns, mode === 'compact')
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) const { Box, Text } = $.ui.resolve(e)
// 引擎在 band 與輸入框之間已留一行空白,上方補一行讓上下間距相同 // 引擎在 band 與輸入框之間已留一行空白,上方補一行讓上下間距相同
return ( return (
<Box marginTop={1} flexDirection="column"> <Box marginTop={1} flexDirection="column">
{lines.map(line => (
<Text wrap="truncate"> <Text wrap="truncate">
{line.map(s => ( {line.map(s => (
<Text color={s.color} dimColor={s.dim} bold={s.bold}> <Text color={s.color} dimColor={s.dim} bold={s.bold}>
@@ -189,7 +167,7 @@ export const register: Register = on => {
</Text> </Text>
))} ))}
</Text> </Text>
))} {rest}
</Box> </Box>
) )
}) })
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.claude-plugin/types/tsconfig.json"
}
@@ -16,7 +16,7 @@ export type Mode = 'auto' | 'compact' | 'off'
declare module 'claude-code' { declare module 'claude-code' {
interface PluginState { interface PluginState {
cockpit: { statusbar: {
usage: Usage usage: Usage
git: Git | null git: Git | null
sys: Sys sys: Sys