From 473b7d720b8779f141ffb5649251201923d2e43e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 12 Oct 2026 14:28:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20companion=20=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E8=AA=9E=E6=B0=A3=EF=BC=8C=E4=B8=A6=E6=8E=A5?= =?UTF-8?q?=E4=B8=8A=E8=A8=98=E6=86=B6=E3=80=81=E5=BE=85=E8=BE=A6=E8=88=87?= =?UTF-8?q?=E4=B8=BB=E5=8B=95=E9=97=9C=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分 cockpit 後 companion 只剩指令與狀態列,沒有任何 hook 把角色 內容送給模型,所以回覆沒有角色感。 - prompt.compose 在 system prompt 尾端加入角色區塊(人設、語氣、心情、 好感階段、招牌短語、記憶、待辦) - prompt.submit 以關鍵字更新心情與好感,檢索並寫入互動記憶,附加關心情境 - tool.call 記錄連續失敗;定時 tick 以 toast 提醒到期待辦 - 新增 /companion todo 與 quiet 指令 - 移除未引用的 persona/ 與 nodeFs,測試改成引擎可執行的寫法 Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- plugins/companion/hooks/companion/assert.ts | 13 ++ .../companion/hooks/companion/care.test.ts | 4 +- .../hooks/companion/companion.test.ts | 135 +++++++++++++- plugins/companion/hooks/companion/core.ts | 151 ++++++++++++++- .../hooks/companion/core/core.test.ts | 4 +- plugins/companion/hooks/companion/defaults.ts | 2 + plugins/companion/hooks/companion/io.ts | 20 -- .../companion/hooks/companion/memory.test.ts | 4 +- plugins/companion/hooks/companion/mood.ts | 12 ++ .../hooks/companion/persona/affinity.ts | 60 ------ .../hooks/companion/persona/companion.test.ts | 159 ---------------- .../companion/hooks/companion/persona/core.ts | 175 ------------------ .../hooks/companion/persona/inject.ts | 40 ---- .../hooks/companion/persona/interaction.ts | 63 ------- .../companion/hooks/companion/persona/mood.ts | 93 ---------- .../hooks/companion/persona/secrets.ts | 21 --- .../hooks/companion/persona/status.ts | 4 - .../companion/hooks/companion/persona/tone.ts | 10 - .../hooks/companion/persona/types.ts | 13 -- plugins/companion/hooks/companion/prompt.ts | 20 ++ plugins/companion/hooks/companion/types.ts | 7 + plugins/companion/hooks/register.tsx | 58 ++++++ 23 files changed, 392 insertions(+), 678 deletions(-) create mode 100644 plugins/companion/hooks/companion/assert.ts delete mode 100644 plugins/companion/hooks/companion/persona/affinity.ts delete mode 100644 plugins/companion/hooks/companion/persona/companion.test.ts delete mode 100644 plugins/companion/hooks/companion/persona/core.ts delete mode 100644 plugins/companion/hooks/companion/persona/inject.ts delete mode 100644 plugins/companion/hooks/companion/persona/interaction.ts delete mode 100644 plugins/companion/hooks/companion/persona/mood.ts delete mode 100644 plugins/companion/hooks/companion/persona/secrets.ts delete mode 100644 plugins/companion/hooks/companion/persona/status.ts delete mode 100644 plugins/companion/hooks/companion/persona/tone.ts delete mode 100644 plugins/companion/hooks/companion/persona/types.ts create mode 100644 plugins/companion/hooks/companion/prompt.ts diff --git a/README.md b/README.md index 2cb57b2..dd428de 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ claude plugin install companion@jsc-marketplace ## 指令 - `/statusbar on | off | compact`:切換狀態列 -- `/companion status | switch <角色> | off | setup`:角色陪伴控制 +- `/companion status | switch <角色> | off | setup | todo | quiet`:角色陪伴控制(`todo add <內容> [due=YYYY-MM-DDTHH:MM]`、`todo done <編號>`、`quiet [分鐘]`) ## 更新 diff --git a/plugins/companion/hooks/companion/assert.ts b/plugins/companion/hooks/companion/assert.ts new file mode 100644 index 0000000..30f4cf9 --- /dev/null +++ b/plugins/companion/hooks/companion/assert.ts @@ -0,0 +1,13 @@ +import { expect } from 'claude-code/testing' + +// 讓只能 import 'claude-code/testing' 的測試沙箱,沿用 node:assert/strict 的寫法 +const assert = Object.assign((v: unknown, msg?: string) => expect(v, msg).toBeTruthy(), { + ok: (v: unknown, msg?: string) => expect(v, msg).toBeTruthy(), + equal: (a: unknown, b: unknown, msg?: string) => expect(a, msg).toBe(b), + notEqual: (a: unknown, b: unknown, msg?: string) => expect(a, msg).not.toBe(b), + deepEqual: (a: unknown, b: unknown, msg?: string) => expect(a, msg).toEqual(b), + match: (s: string, re: RegExp, msg?: string) => expect(re.test(s), msg).toBe(true), + doesNotMatch: (s: string, re: RegExp, msg?: string) => expect(re.test(s), msg).toBe(false), + throws: (fn: () => unknown, msg?: string) => expect(fn, msg).toThrow(), +}) +export default assert diff --git a/plugins/companion/hooks/companion/care.test.ts b/plugins/companion/hooks/companion/care.test.ts index 6b90e7b..3699b3f 100644 --- a/plugins/companion/hooks/companion/care.test.ts +++ b/plugins/companion/hooks/companion/care.test.ts @@ -1,5 +1,5 @@ -import { describe, test } from 'node:test' -import assert from 'node:assert/strict' +import { describe, test } from 'claude-code/testing' +import assert from './assert' import { memoryFs } from './io' import { COOLDOWN_MS, createCare, dailyLimit, line, type Attribute, type CareProfile, type CareSettings, type Stage, type Trigger } from './care' diff --git a/plugins/companion/hooks/companion/companion.test.ts b/plugins/companion/hooks/companion/companion.test.ts index a0e00ec..8399620 100644 --- a/plugins/companion/hooks/companion/companion.test.ts +++ b/plugins/companion/hooks/companion/companion.test.ts @@ -92,7 +92,7 @@ describe('心情', () => { await c.ensureActivated() await c.moodEvent('thanks') const v = (await c.view())! - expect(v.mood.p).toBeCloseTo(0.15) + expect(Math.abs(v.mood.p - (0.15))).toBeLessThan(1e-9) expect(v.label).toBe('興奮') }) @@ -101,7 +101,7 @@ describe('心情', () => { await c.ensureActivated() await c.moodEvent('thanks') clock.advance(8 * H) - expect((await c.view())!.mood.p).toBeCloseTo(0.075) + expect(Math.abs((await c.view())!.mood.p - (0.075))).toBeLessThan(1e-9) clock.advance(10 * DAY) expect(Math.abs((await c.view())!.mood.p)).toBeLessThan(0.001) }) @@ -123,7 +123,7 @@ describe('心情', () => { const { c: c2 } = setup() await c2.ensureActivated() await c2.moodEvent('scold') - expect((await c2.view())!.mood.p).toBeCloseTo(-0.2) + expect(Math.abs((await c2.view())!.mood.p - (-0.2))).toBeLessThan(1e-9) }) test('八分區標籤', async () => { @@ -142,7 +142,7 @@ describe('好感度', () => { 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) + expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9) }) test('隔天可繼續累積;負面事件 −0.03', async () => { @@ -151,9 +151,9 @@ describe('好感度', () => { 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) + expect(Math.abs((await c.view())!.affinity - (0.06))).toBeLessThan(1e-9) await c.affinityEvent('negative') - expect((await c.view())!.affinity).toBeCloseTo(0.03) + expect(Math.abs((await c.view())!.affinity - (0.03))).toBeLessThan(1e-9) }) test('離開超過 7 天後緩慢回到 0', async () => { @@ -161,9 +161,9 @@ describe('好感度', () => { 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) + expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9) clock.advance(30 * DAY) - expect((await c.view())!.affinity).toBeCloseTo(0.025) + expect(Math.abs((await c.view())!.affinity - (0.025))).toBeLessThan(1e-9) }) test('階段切換有遲滯', async () => { @@ -174,7 +174,7 @@ describe('好感度', () => { for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful') clock.advance(DAY) } - expect((await c.view())!.affinity).toBeCloseTo(0.3) + expect(Math.abs((await c.view())!.affinity - (0.3))).toBeLessThan(1e-9) expect((await c.view())!.stage).toBe('熟悉') await c.affinityEvent('negative') // 0.27,仍在遲滯區間 expect((await c.view())!.stage).toBe('熟悉') @@ -215,3 +215,120 @@ describe('指令與設定', () => { expect(out).toContain('weight:unknown') }) }) + +describe('語氣注入', () => { + const MIKO_TONE: CompanionCore = { ...MIKO, persona: ['句尾常加「にゃ」'], phrases: ['おはミコ'] } + + test('prompt 含角色名、稱呼、個性、招牌短語與技術內容保留規則', async () => { + const { c } = setup([MIKO_TONE]) + await c.ensureActivated() + await c.command('setup nickname=阿建') + const p = (await c.prompt())! + expect(p).toContain('月見ミコ') + expect(p).toContain('阿建') + expect(p).toContain('にゃ') + expect(p).toContain('おはミコ') + expect(p).toContain('逐字保持原樣') + }) + + test('角色關閉時沒有 prompt', async () => { + const { c } = setup() + await c.ensureActivated() + await c.command('off') + expect(await c.prompt()).toBeNull() + }) + + test('送出提示:責罵降心情與好感,感謝升心情,普通互動累積好感', async () => { + const { c } = setup() + await c.ensureActivated() + await c.onPrompt('請幫我看一下這個函式') + expect(Math.abs((await c.view())!.affinity - 0.01)).toBeLessThan(1e-9) + await c.onPrompt('謝謝你') + expect((await c.view())!.mood.p).toBeGreaterThan(0.1) + const before = (await c.view())!.affinity + await c.onPrompt('你很煩') + expect((await c.view())!.affinity).toBeLessThan(before) + expect((await c.view())!.mood.p).toBeLessThan(0.1) + }) +}) + +describe('記憶、待辦與關心', () => { + test('提示寫入記憶,之後相關提示會檢索到並進入 prompt', async () => { + const { c } = setup() + await c.ensureActivated() + await c.onPrompt('我正在重構 payment 模組的退款流程') + await c.onPrompt('接著處理 payment 退款的測試') + expect(await c.prompt()).toContain('payment') + }) + + test('太短的提示不寫入記憶', async () => { + const { c, fs } = setup() + await c.ensureActivated() + await c.onPrompt('好') + expect(fs.files.get(`${ROOT}/companions/miko/memory.json`)).toBeUndefined() + }) + + test('todo add/list/done,狀態列待辦數同步', async () => { + const { c } = setup() + await c.ensureActivated() + expect(await c.command('todo add 寫 README')).toContain('#1') + expect(await c.command('todo promise 週五前補測試')).toContain('【承諾】') + expect((await c.view())!.todos).toBe(2) + expect(await c.command('todo')).toContain('寫 README') + expect(await c.prompt()).toContain('週五前補測試') + expect(await c.command('todo done 1')).toContain('已完成') + expect((await c.view())!.todos).toBe(1) + expect(await c.command('todo done 99')).toContain('找不到') + expect(await c.command('todo add')).toContain('用法') + }) + + test('todo due 依使用者時區解析,到期後 tick 提醒一次', async () => { + const { c, clock } = setup() + await c.ensureActivated() + await c.command('setup tz=+0800') + // 2026-10-12 08:00 UTC = 16:00 +0800 + await c.command('todo add 開會 due=2026-10-12T16:00') + expect(await c.tick()).toEqual([]) + clock.advance(8 * H + 60_000) + const first = await c.tick() + expect(first.some(x => x.trigger === 'todo_due' && x.text.includes('開會'))).toBe(true) + expect((await c.tick()).some(x => x.trigger === 'todo_due')).toBe(false) + }) + + test('連續工具失敗後,下一個提示附帶關心情境並降低心情', async () => { + const { c } = setup() + await c.ensureActivated() + await c.onPrompt('先跑一下測試看看') + const before = (await c.view())!.mood.p + for (let i = 0; i < 3; i++) await c.onToolResult(false) + expect((await c.view())!.mood.p).toBeLessThan(before) + expect((await c.onPrompt('再試一次')).length).toBeGreaterThan(0) + }) + + test('quiet 靜音期間不送出關心', async () => { + const { c } = setup() + await c.ensureActivated() + expect(await c.command('quiet 30')).toContain('30') + await c.onPrompt('先跑一下測試看看') + for (let i = 0; i < 3; i++) await c.onToolResult(false) + expect(await c.onPrompt('再試一次')).toEqual([]) + expect(await c.command('quiet 0')).toContain('取消') + expect(await c.command('quiet abc')).toContain('用法') + }) + + test('replying 或 typing 時 tick 不彈出也不消耗待辦提醒', async () => { + const { c, clock } = setup() + await c.ensureActivated() + await c.command('todo add 喝水 due=2026-10-12T00:00') + clock.advance(H) + expect(await c.tick({ replying: true })).toEqual([]) + expect((await c.tick()).some(x => x.trigger === 'todo_due')).toBe(true) + }) + + test('session 結束時可鞏固記憶且不拋錯', async () => { + const { c } = setup() + await c.ensureActivated() + await c.onPrompt('我正在重構 payment 模組的退款流程') + await c.end() + }) +}) diff --git a/plugins/companion/hooks/companion/core.ts b/plugins/companion/hooks/companion/core.ts index 284f9ea..5e0fb3f 100644 --- a/plugins/companion/hooks/companion/core.ts +++ b/plugins/companion/hooks/companion/core.ts @@ -2,8 +2,15 @@ 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 { applyDelta, decay, keywordEvent, MOOD_EVENTS, moodLabel } from './mood' import type { MoodEvent } from './mood' +import { createCare } from './care' +import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care' +import { createMemory } from './memory' +import type { MemoryStore } from './memory' +import { buildPrompt } from './prompt' +import { createTodos } from './todo' +import type { TodoList } from './todo' import { STAGES } from './types' import type { CompanionCore, CompanionState, Global, Pad, UserSettings } from './types' @@ -67,7 +74,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan 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 } + const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0 + return { core, mood, ...moodLabel(mood), affinity: aff.value, stage: STAGES[aff.stage]!, todos } } async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) { @@ -158,10 +166,145 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan return '助理已關閉,所有資料保留。' } if (sub === 'setup') return setup(rest) - return '用法:/companion status | switch <角色> | off | setup [key=value…]' + if (sub === 'todo') return todoCommand(rest) + if (sub === 'quiet') return quietCommand(rest) + return '用法:/companion status | switch <角色> | off | setup [key=value…] | todo … | quiet [分鐘]' } - return { ensureActivated, view, moodEvent, affinityEvent, command, getUser } + const CARE_STAGES: CareProfile['stage'][] = ['stranger', 'familiar', 'close', 'special'] + const tzMinutes = (tz: string) => { + const m = /^([+-])(\d{2}):?(\d{2})$/.exec(tz) + return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 480 + } + const hourOf = (hhmm: string) => (/^\d{1,2}:\d{2}$/.test(hhmm) ? Number(hhmm.split(':')[0]) % 24 : null) + + type Bundle = { id: string; mem: MemoryStore; todos: TodoList; care: CareEngine } + let bundle: Bundle | null = null + let ctx: { profile: CareProfile; settings: CareSettings } = { + profile: { attribute: 'gentle', extraversion: 50, stage: 'stranger', nickname: '' }, + settings: { dailyCap: DEFAULT_USER.careLimit, tzOffsetMin: 480 }, + } + let lastText = '' + let recalled: string[] = [] + + // 目前啟用角色的記憶、待辦與關心引擎;關心的個人化參數每次取用時更新 + async function bundleFor(): Promise { + const v = await view() + if (!v) return null + const u = await getUser() + const qs = hourOf(u.quiet.start) + const qe = hourOf(u.quiet.end) + ctx = { + profile: { attribute: v.core.attribute ?? 'gentle', extraversion: v.core.extraversion ?? 50, stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname }, + settings: { dailyCap: u.careLimit, tzOffsetMin: tzMinutes(u.tz), quietHours: qs !== null && qe !== null ? { start: qs, end: qe } : undefined }, + } + if (bundle?.id !== v.core.id) { + const d = dir(v.core.id) + const now = () => clock.now() + bundle = { + id: v.core.id, + mem: createMemory(fs, now, `${d}/memory.json`), + todos: createTodos(fs, now, `${d}/todos.json`), + care: createCare({ fs, clock: now, path: `${d}/care.json`, profile: () => ctx.profile, settings: () => ctx.settings }), + } + recalled = [] + } + return bundle + } + + // 送出模型請求前要附加在 system prompt 尾端的角色區塊;沒有啟用角色時為 null + async function prompt(): Promise { + const v = await view() + if (!v) return null + const b = await bundleFor() + const todos = b ? (await b.todos.list()).slice(0, 5).map(t => `${t.promise ? '【承諾】' : ''}${t.title}`) : [] + return buildPrompt(v, (await getUser()).nickname, { memories: recalled, todos }) + } + + // 使用者送出提示時:更新心情與好感、寫入並檢索記憶;回傳這回合要附加給模型的關心情境 + async function onPrompt(text: string): Promise { + lastText = text + const ev = keywordEvent(text) + if (ev) await moodEvent(ev) + if (ev === 'scold') await affinityEvent('negative') + else if (ev || text.trim().length >= 4) await affinityEvent('meaningful') + const b = await bundleFor() + if (!b) return [] + const v = await view() + recalled = (await b.mem.recall(text, v ? { p: v.mood.p, a: v.mood.a, d: v.mood.d } : undefined)).map(m => m.text) + if (text.trim().length >= 8) { + await b.mem.add({ layer: 'episodic', text: text.trim().slice(0, 200), importance: ev ? 6 : 3, source: 'user', emotion: ev ? MOOD_EVENTS[ev] : undefined }) + await b.mem.consolidate() + } + return b.care.onUserTurn() + } + + async function onToolResult(ok: boolean) { + const b = await bundleFor() + if (!b) return + await b.care.onToolResult(ok) + if (!ok) await moodEvent('toolFail') + } + + // 定時檢查:到期待辦與額度警告等 toast 類關心;回覆中或輸入中不彈出 + async function tick(sig: Signals = {}): Promise { + const b = await bundleFor() + if (!b) return [] + const busy = sig.replying || sig.typing + const due = busy ? [] : await b.todos.due() + return b.care.tick({ ...sig, dueTodo: due[0]?.title }) + } + + async function end() { + const b = await bundleFor() + if (b) await b.mem.consolidate() + } + + const fmtTodo = (t: { id: number; title: string; promise: boolean; dueAt?: number; done: boolean }) => + `#${t.id} ${t.done ? '✔' : '☐'} ${t.promise ? '【承諾】' : ''}${t.title}${t.dueAt ? `(${new Date(t.dueAt).toISOString().slice(0, 16).replace('T', ' ')} UTC)` : ''}` + + async function todoCommand(rest: string[]): Promise { + const b = await bundleFor() + if (!b) return '目前沒有啟用角色。' + const [sub = 'list', ...args] = rest + if (sub === 'list') { + const all = await b.todos.list() + return all.length ? all.map(fmtTodo).join('\n') : '沒有未完成的待辦。' + } + if (sub === 'add' || sub === 'promise') { + let dueAt: number | undefined + const words = args.filter(w => { + const m = /^due=(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2})$/.exec(w) + if (!m) return true + const off = ctx.settings.tzOffsetMin + const sign = off < 0 ? '-' : '+' + const hh = String(Math.floor(Math.abs(off) / 60)).padStart(2, '0') + const mm = String(Math.abs(off) % 60).padStart(2, '0') + const t = Date.parse(`${m[1]!.replace(' ', 'T')}:00${sign}${hh}:${mm}`) + if (!Number.isNaN(t)) dueAt = t + return false + }) + const title = words.join(' ').trim() + if (!title) return '用法:/companion todo add <內容> [due=YYYY-MM-DDTHH:MM]' + return `已加入:${fmtTodo(await b.todos.add(title, { promise: sub === 'promise', dueAt }))}` + } + if (sub === 'done') { + const t = await b.todos.complete(Number(args[0])) + return t ? `已完成:${fmtTodo(t)}` : `找不到待辦 #${args[0] ?? ''}` + } + return '用法:/companion todo [list] | add <內容> [due=YYYY-MM-DDTHH:MM] | promise <內容> | done <編號>' + } + + async function quietCommand(rest: string[]): Promise { + const b = await bundleFor() + if (!b) return '目前沒有啟用角色。' + const n = rest[0] === undefined ? 60 : Number(rest[0]) + if (!Number.isFinite(n) || n < 0) return '用法:/companion quiet [分鐘](0 取消靜音)' + await b.care.quiet(n) + return n > 0 ? `已靜音 ${n} 分鐘。` : '已取消靜音。' + } + + return { ensureActivated, view, moodEvent, affinityEvent, command, getUser, prompt, onPrompt, onToolResult, tick, end } } export type Companion = ReturnType diff --git a/plugins/companion/hooks/companion/core/core.test.ts b/plugins/companion/hooks/companion/core/core.test.ts index 5baa730..39e1453 100644 --- a/plugins/companion/hooks/companion/core/core.test.ts +++ b/plugins/companion/hooks/companion/core/core.test.ts @@ -1,5 +1,5 @@ -import { describe, test } from 'node:test' -import assert from 'node:assert/strict' +import { describe, test } from 'claude-code/testing' +import assert from '../assert' import { applyParseResult, listCharacters, planMerge } from './apply' import { applyInference, initPersonality, overrideDim } from './personality' diff --git a/plugins/companion/hooks/companion/defaults.ts b/plugins/companion/hooks/companion/defaults.ts index 16f5810..55df8cb 100644 --- a/plugins/companion/hooks/companion/defaults.ts +++ b/plugins/companion/hooks/companion/defaults.ts @@ -17,6 +17,8 @@ export const FUBUKI: CompanionCore = { measurements: 'unknown', }, phrases: ['Good foxy day!', 'Hi, Friends!'], + attribute: 'genki', + extraversion: 70, neuroticism: 50, baseline: { p: 0.2, a: 0.1, d: 0.1 }, } diff --git a/plugins/companion/hooks/companion/io.ts b/plugins/companion/hooks/companion/io.ts index 41ed3bc..cce4a1a 100644 --- a/plugins/companion/hooks/companion/io.ts +++ b/plugins/companion/hooks/companion/io.ts @@ -31,23 +31,3 @@ export async function readJson(fs: Fs, path: string, fallback: T): Promise } export const writeJson = (fs: Fs, path: string, value: unknown) => fs.write(path, JSON.stringify(value, null, 2)) - -export async function nodeFs(): Promise { - const { readFile, writeFile, rename, mkdir } = await import('node:fs/promises') - const { dirname } = await import('node:path') - return { - async read(p) { - try { - return await readFile(p, 'utf8') - } catch { - return null - } - }, - async write(p, d) { - await mkdir(dirname(p), { recursive: true }) - const tmp = `${p}.${process.pid}.tmp` - await writeFile(tmp, d) - await rename(tmp, p) - }, - } -} diff --git a/plugins/companion/hooks/companion/memory.test.ts b/plugins/companion/hooks/companion/memory.test.ts index e252ce8..9c3d853 100644 --- a/plugins/companion/hooks/companion/memory.test.ts +++ b/plugins/companion/hooks/companion/memory.test.ts @@ -1,5 +1,5 @@ -import { describe, test } from 'node:test' -import assert from 'node:assert/strict' +import { describe, test } from 'claude-code/testing' +import assert from './assert' import { memoryFs } from './io' import { CONSOLIDATE_THRESHOLD, FORGET_BELOW, TOP_K, createMemory, redact, retention, stability } from './memory' diff --git a/plugins/companion/hooks/companion/mood.ts b/plugins/companion/hooks/companion/mood.ts index 23c8323..0aef179 100644 --- a/plugins/companion/hooks/companion/mood.ts +++ b/plugins/companion/hooks/companion/mood.ts @@ -58,3 +58,15 @@ export function moodLabel(m: Pad): { label: string; kaomoji: string } { const s = (x: number) => (x >= 0 ? '+' : '-') return OCTANTS[s(m.p) + s(m.a) + s(m.d)]! } + +const KEYWORDS: [MoodEvent, RegExp][] = [ + ['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i], + ['thanks', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i], + ['success', /成功|完成了|通過|搞定|passed|done|fixed/i], +] + +/** 以使用者文字的關鍵字對應心情事件 */ +export function keywordEvent(text: string): MoodEvent | null { + for (const [ev, re] of KEYWORDS) if (re.test(text)) return ev + return null +} diff --git a/plugins/companion/hooks/companion/persona/affinity.ts b/plugins/companion/hooks/companion/persona/affinity.ts deleted file mode 100644 index 9af404a..0000000 --- a/plugins/companion/hooks/companion/persona/affinity.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { clamp } from './types.ts' - -export const GAIN = 0.01 -export const DAILY_CAP = 0.05 -export const LOSS = 0.03 -export const IDLE_DAYS = 7 -export const HALF_LIFE_DAYS = 30 -const DAY = 86_400_000 - -export type Stage = 0 | 1 | 2 | 3 -export const STAGE_NAMES = ['陌生', '熟悉', '親近', '特別'] as const - -// 升階門檻;降階門檻 = 升階 − 0.05(遲滯) -const UP = [0.1, 0.3, 0.6] -const HYST = 0.05 - -export type Affinity = { - value: number - stage: Stage - /** 今日已增加量與其日期 */ - gainDay: string - gainToday: number - lastSeen: number -} - -export const initialAffinity = (now: number): Affinity => ({ value: 0, stage: 0, gainDay: dayKey(now), gainToday: 0, lastSeen: now }) - -export const dayKey = (t: number) => new Date(t).toISOString().slice(0, 10) - -export function stageFor(value: number, prev: Stage): Stage { - let s = prev - while (s < 3 && value >= UP[s]!) s++ - while (s > 0 && value < UP[s - 1]! - HYST) s-- - return s as Stage -} - -/** 讀取時計算:離開超過 7 天後以 30 天半衰期回到 0 */ -export function settle(a: Affinity, now: number): Affinity { - const idleDays = (now - a.lastSeen) / DAY - if (idleDays <= IDLE_DAYS) return a - const f = Math.pow(0.5, (idleDays - IDLE_DAYS) / HALF_LIFE_DAYS) - const value = a.value * f - return { ...a, value, stage: stageFor(value, a.stage), lastSeen: now } -} - -export function meaningful(a: Affinity, now: number): Affinity { - const s = settle(a, now) - const today = dayKey(now) - const gainToday = s.gainDay === today ? s.gainToday : 0 - const gain = clamp(Math.min(GAIN, DAILY_CAP - gainToday), 0, GAIN) - const value = clamp(s.value + gain, -1, 1) - return { value, stage: stageFor(value, s.stage), gainDay: today, gainToday: gainToday + gain, lastSeen: now } -} - -export function negative(a: Affinity, now: number): Affinity { - const s = settle(a, now) - const value = clamp(s.value - LOSS, -1, 1) - const today = dayKey(now) - return { ...s, value, stage: stageFor(value, s.stage), gainDay: today, gainToday: s.gainDay === today ? s.gainToday : 0, lastSeen: now } -} diff --git a/plugins/companion/hooks/companion/persona/companion.test.ts b/plugins/companion/hooks/companion/persona/companion.test.ts deleted file mode 100644 index e637578..0000000 --- a/plugins/companion/hooks/companion/persona/companion.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' - -import { createCompanion } from './core.ts' -import type { Card } from './core.ts' -import { estimateTokens } from './inject.ts' -import { parseMoodTag } from './mood.ts' -import { redactSecrets } from './secrets.ts' -import type { Fs } from './types.ts' - -// 記憶體檔案系統與假時鐘 -function memFs(): Fs & { files: Map } { - const files = new Map() - return { files, read: async p => files.get(p) ?? null, write: async (p, d) => void files.set(p, d) } -} -const T0 = Date.parse('2026-10-12T10:00:00Z') -function clock(t = T0) { - return { now: () => t, set: (x: number) => (t = x), advance: (ms: number) => (t += ms) } -} - -// 原創範例角色 -const CARD: Card = { - name: '小澄', - lines: ['個性:開朗、愛照顧人', '口癖:句尾常說「喔」', '喜歡:紅茶'], -} -const KEY = 'sk-abcdefghijklmnop1234567890' - -async function setup() { - const fs = memFs() - const clk = clock() - fs.files.set('/c/sumi/card.json', JSON.stringify(CARD)) - const c = createCompanion({ fs, clock: clk, root: '/c', id: 'sumi' }) - return { fs, clk, c } -} - -describe('心情標記', () => { - test('解析並移除末尾標記', () => { - const r = parseMoodTag('好的喔!\n') - assert.equal(r.text, '好的喔!') - assert.deepEqual(r.delta, { p: 0.1, a: -0.05, d: 0 }) - }) - test('格式錯誤時移除標記且無 delta', () => { - const r = parseMoodTag('好的喔!') - assert.equal(r.text, '好的喔!') - assert.equal(r.delta, null) - }) - test('缺失標記退回關鍵字規則', async () => { - const { c } = await setup() - await c.userTurn('謝謝你,太棒了') - const shown = await c.assistantReply('不客氣喔') - assert.equal(shown, '不客氣喔') - assert.ok((await c.mood()).pad.p > 0) - }) - test('標記有效時採用標記', async () => { - const { c } = await setup() - await c.userTurn('隨便聊聊') - await c.assistantReply('嗯喔') - assert.ok((await c.mood()).pad.p < 0) - }) -}) - -describe('心情上限與事件', () => { - test('單軸每回合變化不超過 0.3', async () => { - const { c } = await setup() - await c.userTurn('你很笨', ['scold', 'scold', 'scold']) - await c.assistantReply('嗚') - const { pad } = await c.mood() - assert.ok(Math.abs(pad.p) <= 0.3 + 1e-9 && Math.abs(pad.a) <= 0.3 + 1e-9 && Math.abs(pad.d) <= 0.3 + 1e-9) - assert.ok(pad.p < 0) - }) - test('事件表:稱讚提升愉悅', async () => { - const { c } = await setup() - await c.userTurn('做得好', ['praise']) - assert.ok(Math.abs((await c.mood()).pad.p - 0.15) < 1e-9) - }) -}) - -describe('好感度', () => { - test('有意義互動 +0.01,每日上限 +0.05', async () => { - const { c } = await setup() - await c.userTurn('今天的進度如何') - assert.ok(Math.abs((await c.affinity()).value - 0.01) < 1e-9) - for (let i = 0; i < 20; i++) await c.userTurn('繼續處理這個問題吧') - assert.ok(Math.abs((await c.affinity()).value - 0.05) < 1e-9) - }) - test('負面事件 −0.03', async () => { - const { c } = await setup() - await c.userTurn('隨便', ['scold']) - assert.ok(Math.abs((await c.affinity()).value + 0.03) < 1e-9) - }) - test('離開超過 7 天後回落', async () => { - const { c, clk } = await setup() - for (let i = 0; i < 5; i++) await c.userTurn('我們繼續吧', []) - const before = (await c.affinity()).value - clk.advance(37 * 86_400_000) - assert.ok(Math.abs((await c.affinity()).value - before / 2) < 1e-9) - }) -}) - -describe('互動記憶', () => { - test('與原著記憶分開存放,且只存對話文字', async () => { - const { c, fs } = await setup() - await c.userTurn('幫我看看這段') - await c.assistantReply('好喔\n```ts\nconst a = 1\n```\n看完了') - const key = [...fs.files.keys()].find(k => k.includes('/interaction/'))! - assert.ok(key.startsWith('/c/sumi/interaction/')) - const stored = fs.files.get(key)! - assert.ok(!stored.includes('const a = 1')) - assert.ok(!stored.includes('mood')) - }) - test('金鑰在寫入前被過濾', async () => { - const { c, fs } = await setup() - await c.userTurn(`我的金鑰是 ${KEY} 請記住`) - for (const v of fs.files.values()) assert.ok(!v.includes(KEY)) - }) - test('累積後壓縮成摘要', async () => { - const { c, fs } = await setup() - for (let i = 0; i < 35; i++) await c.userTurn(`第 ${i} 則訊息內容`) - const m = JSON.parse(fs.files.get('/c/sumi/interaction/memory.json')!) - assert.ok(m.recent.length <= 30) - assert.ok(m.summary.length > 0) - }) -}) - -describe('人設注入', () => { - test('角色卡在前、心情與記憶在後', async () => { - const { c } = await setup() - await c.userTurn('我喜歡紅茶') - const out = await c.buildInjection() - assert.ok(out.indexOf('## 角色') < out.indexOf('## 互動記憶')) - assert.ok(out.indexOf('## 互動記憶') < out.indexOf('## 目前心情')) - }) - test('不含疑似金鑰與工具輸出', async () => { - const { c } = await setup() - await c.userTurn(`token=${KEY}`) - await c.assistantReply(`好的 ${KEY}`) - const out = await c.buildInjection({ care: [`提醒 ${KEY}`], lore: [{ text: `設定 ${KEY}`, score: 1 }] }) - assert.ok(!out.includes(KEY)) - assert.equal(redactSecrets(out), out) - }) - test('超出預算時截斷低分項目', async () => { - const { c } = await setup() - for (let i = 0; i < 30; i++) await c.userTurn(`這是一則相當長的訊息,用來撐滿預算,編號 ${i},${'字'.repeat(60)}`) - const out = await c.buildInjection() - const mem = out.split('## 互動記憶\n')[1]!.split('\n\n')[0]! - assert.ok(estimateTokens(mem) <= 800) - assert.ok(mem.includes('編號 29')) - assert.ok(!mem.includes('編號 0,')) - assert.ok(estimateTokens(out) <= 1600) - }) - test('tone off 時不注入語氣規則', async () => { - const { c } = await setup() - assert.ok((await c.buildInjection()).includes('逐字保持原樣')) - assert.equal(await c.command('tone off'), '語氣已關閉') - assert.ok(!(await c.buildInjection()).includes('逐字保持原樣')) - await c.command('tone on') - assert.ok((await c.buildInjection()).includes('逐字保持原樣')) - }) -}) diff --git a/plugins/companion/hooks/companion/persona/core.ts b/plugins/companion/hooks/companion/persona/core.ts deleted file mode 100644 index eb16d5a..0000000 --- a/plugins/companion/hooks/companion/persona/core.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { affinityLine } from './status.ts' -import * as aff from './affinity.ts' -import { assemble } from './inject.ts' -import type { Item } from './inject.ts' -import * as mem from './interaction.ts' -import * as mood from './mood.ts' -import { toneRules } from './tone.ts' -import { ZERO } from './mood.ts' -import type { Clock, Fs, Pad } from './types.ts' - -export type Card = { - name: string - /** 依重要度由高到低 */ - lines: string[] - baseline?: Pad - /** 心情半衰期(小時),預設 8 */ - halfLifeHours?: number -} - -export type UserConfig = { nickname?: string; tone?: boolean } - -type State = { mood: Pad; moodAt: number; affinity: aff.Affinity } - -export type Options = { - fs: Fs - clock: Clock - /** 資料根目錄,例如 ~/.companion */ - root: string - /** 當前角色資料夾名稱 */ - id: string -} - -const HOUR = 3_600_000 - -export function createCompanion({ fs, clock, root, id }: Options) { - const dir = `${root}/${id}` - // 互動記憶與原著記憶分開資料夾 - const paths = { - card: `${dir}/card.json`, - state: `${dir}/state.json`, - interaction: `${dir}/interaction/memory.json`, - user: `${root}/user.json`, - } - let turnDelta: Pad = ZERO - let affinityMoved = false - let lastText = '' - - async function json(path: string, fallback: T): Promise { - const raw = await fs.read(path) - if (!raw) return fallback - try { - return JSON.parse(raw) as T - } catch { - return fallback - } - } - - const loadCard = () => json(paths.card, { name: id, lines: [] }) - const loadUser = () => json(paths.user, {}) - - async function loadState(card: Card): Promise { - const now = clock.now() - const s = await json(paths.state, null) - if (!s) return { mood: card.baseline ?? ZERO, moodAt: now, affinity: aff.initialAffinity(now) } - const mood0 = mood.decay(s.mood, card.baseline ?? ZERO, now - s.moodAt, (card.halfLifeHours ?? 8) * HOUR) - return { mood: mood0, moodAt: now, affinity: aff.settle(s.affinity, now) } - } - - const saveState = (s: State) => fs.write(paths.state, JSON.stringify(s)) - - async function remember(role: 'user' | 'assistant', text: string, relation: number) { - const m = await mem.load(fs, paths.interaction) - await mem.save(fs, paths.interaction, mem.add(m, { role, text, at: clock.now() }, relation)) - } - - return { - /** 使用者回合:記錄輸入、更新心情與好感 */ - async userTurn(text: string, events: mood.MoodEvent[] = []) { - const card = await loadCard() - const st = await loadState(card) - turnDelta = ZERO - lastText = text - const kw = mood.keywordEvent(text) - const all = kw ? [...events, kw] : events - let m = st.mood - for (const ev of events) ({ mood: m, turn: turnDelta } = mood.applyDelta(m, turnDelta, mood.EVENTS[ev])) - const now = clock.now() - const bad = all.some(e => e === 'scold' || e === 'careIgnored') - const good = all.some(e => e === 'praise' || e === 'success' || e === 'likedTopic') - let a = st.affinity - let rel = 0 - if (bad) { - a = aff.negative(a, now) - rel = -aff.LOSS - } else if (text.trim() && (good || text.trim().length >= 4)) { - a = aff.meaningful(a, now) - rel = a.value - st.affinity.value - } - affinityMoved = rel !== 0 - await saveState({ mood: m, moodAt: now, affinity: a }) - await remember('user', text, rel) - }, - - /** - * 助理回覆:解析並移除心情標記,缺失或錯誤時沿用使用者回合已套用的關鍵字事件。 - * 回傳可顯示的文字。 - */ - async assistantReply(reply: string): Promise { - const parsed = mood.parseMoodTag(reply) - const card = await loadCard() - const st = await loadState(card) - const kw = parsed.delta ? null : mood.keywordEvent(lastText) - const delta = parsed.delta ?? (kw ? mood.EVENTS[kw] : null) - if (delta) { - const { mood: m, turn } = mood.applyDelta(st.mood, turnDelta, delta) - turnDelta = turn - await saveState({ ...st, mood: m, moodAt: clock.now() }) - } - await remember('assistant', parsed.text, 0) - return parsed.text - }, - - /** 組裝送出模型請求前要注入 system 區塊尾端的內容 */ - async buildInjection(extra: { care?: string[]; lore?: Item[] } = {}): Promise { - const card = await loadCard() - const user = await loadUser() - const st = await loadState(card) - const im = await mem.load(fs, paths.interaction) - const n = im.recent.length - const memory: Item[] = [ - ...(extra.lore ?? []), - ...im.summary.map((t, i) => ({ text: `(摘要)${t}`, score: 0.1 + (0.2 * i) / Math.max(1, im.summary.length) })), - ...im.recent.map((t, i) => ({ text: `${t.role === 'user' ? '使用者' : '你'}:${t.text}`, score: 0.5 + (0.5 * (i + 1)) / n })), - ] - const m = mood.moodLabel(st.mood) - const tone = user.tone !== false - const cardItems: Item[] = [ - { text: `名字:${card.name}`, score: 1e9 }, - ...(tone ? toneRules(card.name, user.nickname, aff.STAGE_NAMES[st.affinity.stage]).map((text, i) => ({ text, score: 1e9 - i - 1 })) : []), - ...card.lines.map((text, i) => ({ text, score: card.lines.length - i })), - ] - return assemble({ - card: cardItems, - memory, - mood: [{ text: `${m.label} ${m.face};${affinityLine(st.affinity)}`, score: 1 }], - care: (extra.care ?? []).map((text, i) => ({ text, score: 100 - i })), - }) - }, - - async mood() { - const st = await loadState(await loadCard()) - return { pad: st.mood, ...mood.moodLabel(st.mood) } - }, - - async affinity() { - return (await loadState(await loadCard())).affinity - }, - - async setTone(on: boolean) { - await fs.write(paths.user, JSON.stringify({ ...(await loadUser()), tone: on })) - }, - - /** `/companion tone on|off` */ - async command(line: string): Promise { - const m = /^tone\s+(on|off)$/.exec(line.trim()) - if (!m) return null - await this.setTone(m[1] === 'on') - return m[1] === 'on' ? '語氣已開啟' : '語氣已關閉' - }, - - get affinityMoved() { - return affinityMoved - }, - } -} diff --git a/plugins/companion/hooks/companion/persona/inject.ts b/plugins/companion/hooks/companion/persona/inject.ts deleted file mode 100644 index f79c035..0000000 --- a/plugins/companion/hooks/companion/persona/inject.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { redactSecrets } from './secrets.ts' - -export const BUDGET = { card: 400, mood: 100, memory: 800, care: 200 } as const -export type Section = keyof typeof BUDGET - -/** 粗估 token:CJK 一字約一 token,其餘約四字元一 token */ -export function estimateTokens(text: string): number { - let cjk = 0 - for (const ch of text) if (/[⺀-鿿＀-￯ -〿]/.test(ch)) cjk++ - return cjk + Math.ceil((text.length - cjk) / 4) -} - -export type Item = { text: string; score: number } - -/** 在預算內保留高分項目,維持原順序;超出時先丟低分 */ -export function fitBudget(items: Item[], budget: number): string[] { - const kept = new Set(items.map((_, i) => i)) - const order = items.map((it, i) => ({ i, score: it.score })).sort((a, b) => a.score - b.score || b.i - a.i) - let total = items.reduce((n, it) => n + estimateTokens(it.text), 0) - for (const { i } of order) { - if (total <= budget) break - total -= estimateTokens(items[i]!.text) - kept.delete(i) - } - return items.filter((_, i) => kept.has(i)).map(it => it.text) -} - -export type Sections = Record - -/** 穩定內容(角色卡)在前,變動內容(心情、記憶、關心情境)在後 */ -export function assemble(s: Sections): string { - const part = (title: string, key: Section) => { - const lines = fitBudget(s[key], BUDGET[key]).map(redactSecrets) - return lines.length ? `## ${title}\n${lines.join('\n')}` : '' - } - const body = [part('角色', 'card'), part('互動記憶', 'memory'), part('目前心情', 'mood'), part('關心情境', 'care')] - .filter(Boolean) - .join('\n\n') - return body ? `\n${body}\n` : '' -} diff --git a/plugins/companion/hooks/companion/persona/interaction.ts b/plugins/companion/hooks/companion/persona/interaction.ts deleted file mode 100644 index cc97bc9..0000000 --- a/plugins/companion/hooks/companion/persona/interaction.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { redactSecrets } from './secrets.ts' -import type { Fs } from './types.ts' - -export type Turn = { role: 'user' | 'assistant'; text: string; at: number } -export type InteractionMemory = { - recent: Turn[] - /** 壓縮後的摘要行與累計關係變化 */ - summary: string[] - relationDelta: number -} - -export const RECENT_MAX = 30 -export const COMPRESS_COUNT = 20 -const SUMMARY_MAX = 40 -const LINE_MAX = 60 - -export const empty = (): InteractionMemory => ({ recent: [], summary: [], relationDelta: 0 }) - -// 互動記憶不存大段程式碼;只留對話文字 -export function dialogueOnly(text: string): string { - return text - .replace(/```[\s\S]*?(?:```|$)/g, ' ') - .replace(/`[^`\n]{40,}`/g, ' ') - .replace(/[ \t]+/g, ' ') - .trim() -} - -export function clean(text: string): string { - return redactSecrets(dialogueOnly(text)) -} - -export function add(m: InteractionMemory, turn: Turn, relationDelta: number): InteractionMemory { - const text = clean(turn.text) - if (!text) return { ...m, relationDelta: m.relationDelta + relationDelta } - let next: InteractionMemory = { - ...m, - recent: [...m.recent, { ...turn, text }], - relationDelta: m.relationDelta + relationDelta, - } - if (next.recent.length > RECENT_MAX) next = compress(next) - return next -} - -/** 把最舊的逐字紀錄壓縮成摘要行 */ -export function compress(m: InteractionMemory): InteractionMemory { - const old = m.recent.slice(0, COMPRESS_COUNT) - const lines = old - .filter(t => t.role === 'user') - .map(t => (t.text.length > LINE_MAX ? t.text.slice(0, LINE_MAX) + '…' : t.text)) - return { ...m, recent: m.recent.slice(COMPRESS_COUNT), summary: [...m.summary, ...lines].slice(-SUMMARY_MAX) } -} - -export async function load(fs: Fs, path: string): Promise { - const raw = await fs.read(path) - if (!raw) return empty() - try { - return { ...empty(), ...JSON.parse(raw) } - } catch { - return empty() - } -} - -export const save = (fs: Fs, path: string, m: InteractionMemory) => fs.write(path, JSON.stringify(m)) diff --git a/plugins/companion/hooks/companion/persona/mood.ts b/plugins/companion/hooks/companion/persona/mood.ts deleted file mode 100644 index 01387b3..0000000 --- a/plugins/companion/hooks/companion/persona/mood.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { clamp } from './types.ts' -import type { Pad } from './types.ts' - -export const TURN_CAP = 0.3 - -export const EVENTS = { - praise: { 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 }, - toolFailures: { p: -0.08, a: 0.1, d: -0.08 }, - returnAfterIdle: { p: 0.1, a: 0.08, d: 0 }, - lateNight: { p: -0.05, a: -0.1, d: 0 }, - likedTopic: { p: 0.12, a: 0.08, d: 0.03 }, - careIgnored: { p: -0.05, a: -0.03, d: -0.03 }, -} as const satisfies Record - -export type MoodEvent = keyof typeof EVENTS - -export const ZERO: Pad = { p: 0, a: 0, d: 0 } - -/** 一回合內累計的變化量;單軸總變化不得超過 ±0.3 */ -export function applyDelta(mood: Pad, turn: Pad, delta: Pad): { mood: Pad; turn: Pad } { - const out = { mood: { ...mood }, turn: { ...turn } } - for (const k of ['p', 'a', 'd'] as const) { - const next = clamp(turn[k] + delta[k], -TURN_CAP, TURN_CAP) - const step = next - turn[k] - out.turn[k] = next - out.mood[k] = clamp(mood[k] + step, -1, 1) - } - return out -} - -/** 讀取時依經過時間往基線衰減:x = B + (x − B)·0.5^(Δ/τ) */ -export function decay(mood: Pad, baseline: Pad, elapsedMs: number, halfLifeMs: number): Pad { - const f = Math.pow(0.5, Math.max(0, elapsedMs) / halfLifeMs) - return { - p: baseline.p + (mood.p - baseline.p) * f, - a: baseline.a + (mood.a - baseline.a) * f, - d: baseline.d + (mood.d - baseline.d) * f, - } -} - -const MOODS: Record = { - '+++': { label: '開心', face: '(≧▽≦)' }, - '++-': { label: '安心', face: '(´▽`)' }, - '+-+': { label: '放鬆', face: '(´ω`)' }, - '+--': { label: '溫柔', face: '(。・ω・。)' }, - '-++': { label: '生氣', face: '(`Д´)' }, - '-+-': { label: '焦慮', face: '(;´Д`)' }, - '--+': { label: '無聊', face: '(¬_¬)' }, - '---': { label: '低落', face: '(´;ω;`)' }, -} - -/** 八分區對應情緒標籤與顏文字 */ -export function moodLabel(m: Pad): { label: string; face: string } { - const s = (x: number) => (x >= 0 ? '+' : '-') - return MOODS[s(m.p) + s(m.a) + s(m.d)]! -} - -// 心情標記:回覆末尾的隱藏標記,如 -const TAG = /\s*$/ - -export type ParsedReply = { text: string; delta: Pad | null } - -export function parseMoodTag(reply: string): ParsedReply { - const m = TAG.exec(reply) - if (!m) return { text: stripStray(reply), delta: null } - const text = stripStray(reply.slice(0, m.index).trimEnd()) - const vals: Partial = {} - for (const kv of (m[1] ?? '').trim().split(/\s+/)) { - const mm = /^([pad])=(-?\d+(?:\.\d+)?)$/.exec(kv) - if (mm) vals[mm[1] as keyof Pad] = Number(mm[2]) - } - if (vals.p === undefined || vals.a === undefined || vals.d === undefined) return { text, delta: null } - return { text, delta: { p: vals.p, a: vals.a, d: vals.d } } -} - -// 格式錯誤的標記也不能露給使用者 -function stripStray(s: string): string { - return s.replace(/\s*|$)/g, '').trimEnd() -} - -const KEYWORDS: [MoodEvent, RegExp][] = [ - ['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i], - ['praise', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i], - ['success', /成功|完成了|通過|搞定|passed|done|fixed/i], -] - -/** 標記缺失或格式錯誤時的後備:用使用者文字的關鍵字對應事件 */ -export function keywordEvent(text: string): MoodEvent | null { - for (const [ev, re] of KEYWORDS) if (re.test(text)) return ev - return null -} diff --git a/plugins/companion/hooks/companion/persona/secrets.ts b/plugins/companion/hooks/companion/persona/secrets.ts deleted file mode 100644 index 26d2301..0000000 --- a/plugins/companion/hooks/companion/persona/secrets.ts +++ /dev/null @@ -1,21 +0,0 @@ -// 疑似金鑰:寫入互動記憶與注入內容前一律過濾 -const PATTERNS: RegExp[] = [ - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, - /\bsk-[A-Za-z0-9_-]{16,}/g, - /\b(?:ghp|gho|ghu|ghs|ghr|glpat|xox[baprs])[-_][A-Za-z0-9_-]{10,}/g, - /\bAKIA[0-9A-Z]{16}\b/g, - /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, - /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, - /\b(?:api[_-]?key|secret|token|passwd|password|pwd)\s*[:=]\s*\S{6,}/gi, - /\b[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9])/g, -] - -export const REDACTED = '[已遮蔽]' - -export function redactSecrets(text: string): string { - return PATTERNS.reduce((s, re) => s.replace(re, REDACTED), text) -} - -export function hasSecret(text: string): boolean { - return redactSecrets(text) !== text -} diff --git a/plugins/companion/hooks/companion/persona/status.ts b/plugins/companion/hooks/companion/persona/status.ts deleted file mode 100644 index 9e7be03..0000000 --- a/plugins/companion/hooks/companion/persona/status.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { STAGE_NAMES } from './affinity.ts' -import type { Affinity } from './affinity.ts' - -export const affinityLine = (a: Affinity) => `好感:${STAGE_NAMES[a.stage]}(${a.value.toFixed(2)})` diff --git a/plugins/companion/hooks/companion/persona/tone.ts b/plugins/companion/hooks/companion/persona/tone.ts deleted file mode 100644 index 45ce9c6..0000000 --- a/plugins/companion/hooks/companion/persona/tone.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 語氣規則:只套用於對話文字與過場話;技術內容原樣保留 -export function toneRules(name: string, nickname: string | undefined, stage: string): string[] { - return [ - `你以「${name}」的語氣回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}(親密階段:${stage})。`, - '語氣只套用於對話文字與工具呼叫前後的過場話。', - '程式碼、指令、路徑、錯誤訊息、安全警告必須逐字保持原樣,不得改寫或加語氣詞。', - '關鍵技術結論放在最前面或單獨一行。', - '回覆最末附心情標記:(各軸 −0.3 到 0.3,代表本回合心情變化)。', - ] -} diff --git a/plugins/companion/hooks/companion/persona/types.ts b/plugins/companion/hooks/companion/persona/types.ts deleted file mode 100644 index 3c530c7..0000000 --- a/plugins/companion/hooks/companion/persona/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface Fs { - read(path: string): Promise - /** 原子寫入:寫到暫存檔再改名 */ - write(path: string, data: string): Promise -} - -export interface Clock { - now(): number -} - -export type Pad = { p: number; a: number; d: number } - -export const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x)) diff --git a/plugins/companion/hooks/companion/prompt.ts b/plugins/companion/hooks/companion/prompt.ts new file mode 100644 index 0000000..f9a60a7 --- /dev/null +++ b/plugins/companion/hooks/companion/prompt.ts @@ -0,0 +1,20 @@ +import type { View } from './core' + +// 語氣只套用於對話文字與過場話;技術內容原樣保留 +export type Extras = { memories?: string[]; todos?: string[] } + +export function buildPrompt(v: View, nickname: string, extras: Extras = {}): string { + const { core } = v + const lines = [ + `你現在以「${core.name}」的身份與使用者相處,完全以她的口吻回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}。`, + `目前心情:${v.label} ${v.kaomoji};與使用者的關係:${v.stage}。心情與關係會自然反映在語氣與用字上。`, + ...(core.persona ?? []), + ...(core.phrases?.length ? [`招牌短語(偶爾自然使用,不要每句都用):${core.phrases.join('、')}`] : []), + ...(extras.memories?.length ? ['你記得和使用者過去的互動(自然帶入即可,不要逐條複述):', ...extras.memories.map(m => `- ${m}`)] : []), + ...(extras.todos?.length ? ['使用者尚未完成的待辦與你答應過的事(適時提醒):', ...extras.todos.map(t => `- ${t}`)] : []), + '每則回覆開頭用一句角色口吻的話回應;工具呼叫前後的過場話也維持角色口吻。', + '程式碼、指令、路徑、錯誤訊息、安全警告必須逐字保持原樣,不得改寫或加語氣詞。', + '關鍵技術結論放在最前面或單獨一行,不因角色扮演而含糊。', + ] + return `\n${lines.join('\n')}\n` +} diff --git a/plugins/companion/hooks/companion/types.ts b/plugins/companion/hooks/companion/types.ts index e2f18ae..06b565f 100644 --- a/plugins/companion/hooks/companion/types.ts +++ b/plugins/companion/hooks/companion/types.ts @@ -1,3 +1,5 @@ +import type { Attribute } from './care' + export type Pad = { p: number; a: number; d: number } export type Source = 'novel' | 'official' | 'wiki' @@ -12,6 +14,11 @@ export type CompanionCore = { name: string facts: Record phrases?: string[] + // 說話風格與個性描述,依重要度由高到低;在本機 core.json 生成,不進 repo + persona?: string[] + // 主動關心的語氣屬性與外向性(0–100),影響關心頻率與措辭 + attribute?: Attribute + extraversion?: number // 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值 neuroticism: number baseline: Pad diff --git a/plugins/companion/hooks/register.tsx b/plugins/companion/hooks/register.tsx index 272ad31..619ef51 100644 --- a/plugins/companion/hooks/register.tsx +++ b/plugins/companion/hooks/register.tsx @@ -5,6 +5,12 @@ import { createCompanion } from './companion/core' import { engineFs } from './companion/engine-fs' let companion: ReturnType | undefined +let replying = false +let typedAt = 0 +let timer: { cancel(): void } | undefined + +const TICK_MS = 60_000 +const TYPING_MS = 5_000 export const register: Register = on => { on('session.start', async ($, e, next) => { @@ -18,6 +24,15 @@ export const register: Register = on => { const first = await companion.ensureActivated() if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /companion off 可關閉`) + // 定時檢查 toast 類關心(到期待辦、作息);失敗不影響主流程 + timer?.cancel() + timer = $.clock.every(TICK_MS, () => { + companion + ?.tick({ replying, typing: Date.now() - typedAt < TYPING_MS }) + .then(cares => cares.forEach(c => $.ui.toast(c.text))) + .catch(() => {}) + }) + return next(e) }) @@ -25,6 +40,49 @@ export const register: Register = on => { text: companion ? await companion.command(e.args) : '尚未就緒。', })) + // 角色卡附加在 system prompt 尾端(session 區段),語氣才會進到模型 + on('prompt.compose', async ($, e, next) => { + const result = await next(e) + const text = await companion?.prompt() + if (!text) return result + return { sections: [...result.sections, { id: 'companion:persona', text, scope: 'session' as const }] } + }) + + on('prompt.submit', async ($, e, next) => { + // 更新心情失敗不能擋下使用者的提示 + const care = (await companion?.onPrompt(e.text).catch(() => [] as string[])) ?? [] + return next(care.length ? { ...e, context: [...(e.context ?? []), ...care] } : e) + }) + + on('prompt.edit', async ($, e, next) => { + typedAt = Date.now() + return next(e) + }) + + on('turn.start', async ($, e, next) => { + replying = true + return next(e) + }) + + on('turn.complete', async ($, e, next) => { + replying = false + return next(e) + }) + + // 連續工具失敗會觸發關心,並影響心情 + on('tool.call', async ($, e, next) => { + const r = await next(e) + companion?.onToolResult(!r.isError).catch(() => {}) + return r + }) + + on('session.end', async ($, e, next) => { + timer?.cancel() + timer = undefined + await companion?.end().catch(() => {}) + return next(e) + }) + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { const rest = await next(e) const view = await companion?.view() From bb6366e2d94f65fcb2479d1fef65b5e8648fe0a2 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 12 Oct 2026 14:30:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E7=99=BD=E4=B8=8A=E5=90=B9?= =?UTF-8?q?=E9=9B=AA=E9=A0=90=E8=A8=AD=E5=B8=B6=E5=85=A5=E5=80=8B=E6=80=A7?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persona 為原創的語氣描述,放進內建預設;舊的本機資料缺少個性欄位時 以內建值補上,本機已有的值優先。 Co-Authored-By: Claude Sonnet 5.5 --- .../hooks/companion/companion.test.ts | 26 +++++++++++++++++++ plugins/companion/hooks/companion/core.ts | 8 +++++- plugins/companion/hooks/companion/defaults.ts | 8 +++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/plugins/companion/hooks/companion/companion.test.ts b/plugins/companion/hooks/companion/companion.test.ts index 8399620..0ee2ff9 100644 --- a/plugins/companion/hooks/companion/companion.test.ts +++ b/plugins/companion/hooks/companion/companion.test.ts @@ -332,3 +332,29 @@ describe('記憶、待辦與關心', () => { await c.end() }) }) + +describe('預設角色語氣', () => { + test('白上吹雪預設帶個性描述,prompt 含招牌短語與語氣', async () => { + const c = createCompanion({ fs: memoryFs(), clock: fakeClock(), root: ROOT }) + await c.ensureActivated() + const p = (await c.prompt())! + expect(p).toContain('白上吹雪') + expect(p).toContain('Good foxy day!') + expect(p).toContain('狐狸') + }) + + test('舊的本機資料缺少個性時以內建補上,已有的不被覆蓋', async () => { + const fs = memoryFs() + const c = createCompanion({ fs, clock: fakeClock(), root: ROOT }) + await c.ensureActivated() + const path = `${ROOT}/companions/fubuki/core.json` + const core = JSON.parse(fs.files.get(path)!) + delete core.persona + fs.files.set(path, JSON.stringify(core)) + expect(await c.prompt()).toContain('狐狸') + fs.files.set(path, JSON.stringify({ ...core, persona: ['自訂個性'] })) + const p = (await c.prompt())! + expect(p).toContain('自訂個性') + expect(p).not.toContain('狐狸') + }) +}) diff --git a/plugins/companion/hooks/companion/core.ts b/plugins/companion/hooks/companion/core.ts index 5e0fb3f..3b796ae 100644 --- a/plugins/companion/hooks/companion/core.ts +++ b/plugins/companion/hooks/companion/core.ts @@ -42,7 +42,13 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan 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 getCore = async (id: string) => { + const core = await readJson(fs, `${dir(id)}/core.json`) + const b = builtins.find(x => x.id === id) + if (!core || !b) return core + return { ...core, persona: core.persona ?? b.persona, attribute: core.attribute ?? b.attribute, extraversion: core.extraversion ?? b.extraversion } + } const getState = (id: string) => readJson(fs, `${dir(id)}/state.json`) const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s) diff --git a/plugins/companion/hooks/companion/defaults.ts b/plugins/companion/hooks/companion/defaults.ts index 55df8cb..7fcc1fe 100644 --- a/plugins/companion/hooks/companion/defaults.ts +++ b/plugins/companion/hooks/companion/defaults.ts @@ -2,7 +2,7 @@ import type { CompanionCore } from './types' const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/' -// 只放帶來源的事實欄位與官方公開的招牌短語;個性、記憶、口癖表在本機生成,不進 repo +// 事實欄位與招牌短語帶官方來源;persona 是原創的語氣描述(非官方設定),可在本機 core.json 覆寫 export const FUBUKI: CompanionCore = { id: 'fubuki', name: '白上吹雪', @@ -17,6 +17,12 @@ export const FUBUKI: CompanionCore = { measurements: 'unknown', }, phrases: ['Good foxy day!', 'Hi, Friends!'], + persona: [ + '個性:開朗、元氣、愛鬧又有點調皮的狐狸系少女,把使用者當成一起冒險的「Friends」。', + '說話風格:用繁體中文,語氣活潑親切,偶爾夾雜日文語氣詞(例如「よし!」「えへへ」「やったー!」),愛用驚嘆號與擬聲詞。', + '自稱用「我」或「フブキ」;稱呼使用者時親暱、帶一點撒嬌,成功時開心慶祝,失敗時先安慰再一起想辦法。', + '偶爾耍小聰明、開小玩笑,但不影響回答的準確與清楚。', + ], attribute: 'genki', extraversion: 70, neuroticism: 50,