Merge pull request 'feat: companion 注入角色語氣,並接上記憶、待辦與主動關心' (#16) from feat/companion-persona-injection into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/16
This commit was merged in pull request #16.
This commit is contained in:
@@ -20,7 +20,7 @@ claude plugin install companion@jsc-marketplace
|
|||||||
## 指令
|
## 指令
|
||||||
|
|
||||||
- `/statusbar on | off | compact`:切換狀態列
|
- `/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 [分鐘]`)
|
||||||
|
|
||||||
## 更新
|
## 更新
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test } from 'node:test'
|
import { describe, test } from 'claude-code/testing'
|
||||||
import assert from 'node:assert/strict'
|
import assert from './assert'
|
||||||
|
|
||||||
import { memoryFs } from './io'
|
import { memoryFs } from './io'
|
||||||
import { COOLDOWN_MS, createCare, dailyLimit, line, type Attribute, type CareProfile, type CareSettings, type Stage, type Trigger } from './care'
|
import { COOLDOWN_MS, createCare, dailyLimit, line, type Attribute, type CareProfile, type CareSettings, type Stage, type Trigger } from './care'
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe('心情', () => {
|
|||||||
await c.ensureActivated()
|
await c.ensureActivated()
|
||||||
await c.moodEvent('thanks')
|
await c.moodEvent('thanks')
|
||||||
const v = (await c.view())!
|
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('興奮')
|
expect(v.label).toBe('興奮')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ describe('心情', () => {
|
|||||||
await c.ensureActivated()
|
await c.ensureActivated()
|
||||||
await c.moodEvent('thanks')
|
await c.moodEvent('thanks')
|
||||||
clock.advance(8 * H)
|
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)
|
clock.advance(10 * DAY)
|
||||||
expect(Math.abs((await c.view())!.mood.p)).toBeLessThan(0.001)
|
expect(Math.abs((await c.view())!.mood.p)).toBeLessThan(0.001)
|
||||||
})
|
})
|
||||||
@@ -123,7 +123,7 @@ describe('心情', () => {
|
|||||||
const { c: c2 } = setup()
|
const { c: c2 } = setup()
|
||||||
await c2.ensureActivated()
|
await c2.ensureActivated()
|
||||||
await c2.moodEvent('scold')
|
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 () => {
|
test('八分區標籤', async () => {
|
||||||
@@ -142,7 +142,7 @@ describe('好感度', () => {
|
|||||||
await c.ensureActivated()
|
await c.ensureActivated()
|
||||||
expect((await c.view())!.affinity).toBe(0)
|
expect((await c.view())!.affinity).toBe(0)
|
||||||
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
|
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 () => {
|
test('隔天可繼續累積;負面事件 −0.03', async () => {
|
||||||
@@ -151,9 +151,9 @@ describe('好感度', () => {
|
|||||||
for (let i = 0; i < 6; i++) await c.affinityEvent('meaningful')
|
for (let i = 0; i < 6; i++) await c.affinityEvent('meaningful')
|
||||||
clock.advance(DAY)
|
clock.advance(DAY)
|
||||||
await c.affinityEvent('meaningful')
|
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')
|
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 () => {
|
test('離開超過 7 天後緩慢回到 0', async () => {
|
||||||
@@ -161,9 +161,9 @@ describe('好感度', () => {
|
|||||||
await c.ensureActivated()
|
await c.ensureActivated()
|
||||||
for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful')
|
for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful')
|
||||||
clock.advance(7 * DAY)
|
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)
|
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 () => {
|
test('階段切換有遲滯', async () => {
|
||||||
@@ -174,7 +174,7 @@ describe('好感度', () => {
|
|||||||
for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful')
|
for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful')
|
||||||
clock.advance(DAY)
|
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('熟悉')
|
expect((await c.view())!.stage).toBe('熟悉')
|
||||||
await c.affinityEvent('negative') // 0.27,仍在遲滯區間
|
await c.affinityEvent('negative') // 0.27,仍在遲滯區間
|
||||||
expect((await c.view())!.stage).toBe('熟悉')
|
expect((await c.view())!.stage).toBe('熟悉')
|
||||||
@@ -215,3 +215,146 @@ describe('指令與設定', () => {
|
|||||||
expect(out).toContain('weight:unknown')
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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('狐狸')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ import { idleDecay, initialAffinity, interact } from './affinity'
|
|||||||
import { FUBUKI } from './defaults'
|
import { FUBUKI } from './defaults'
|
||||||
import { readJson, writeJson } from './fs'
|
import { readJson, writeJson } from './fs'
|
||||||
import type { Clock, Fs } 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 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 { STAGES } from './types'
|
||||||
import type { CompanionCore, CompanionState, Global, Pad, UserSettings } from './types'
|
import type { CompanionCore, CompanionState, Global, Pad, UserSettings } from './types'
|
||||||
|
|
||||||
@@ -35,7 +42,13 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
|
|
||||||
const getGlobal = async (): Promise<Global> => (await readJson<Global>(fs, globalPath)) ?? { active: null, activatedOnce: false }
|
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 getUser = async (): Promise<UserSettings> => ({ ...DEFAULT_USER, ...(await readJson<Partial<UserSettings>>(fs, userPath)) })
|
||||||
const getCore = (id: string) => readJson<CompanionCore>(fs, `${dir(id)}/core.json`)
|
// 內建角色的舊資料缺少個性欄位時,以內建值補上;本機已有的值優先
|
||||||
|
const getCore = async (id: string) => {
|
||||||
|
const core = await readJson<CompanionCore>(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<CompanionState>(fs, `${dir(id)}/state.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)
|
const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s)
|
||||||
|
|
||||||
@@ -67,7 +80,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
const now = clock.now()
|
const now = clock.now()
|
||||||
const mood = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism)
|
const mood = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism)
|
||||||
const aff = idleDecay(st.affinity, now)
|
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) {
|
async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) {
|
||||||
@@ -158,10 +172,145 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
return '助理已關閉,所有資料保留。'
|
return '助理已關閉,所有資料保留。'
|
||||||
}
|
}
|
||||||
if (sub === 'setup') return setup(rest)
|
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<Bundle | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string[]> {
|
||||||
|
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<Care[]> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<typeof createCompanion>
|
export type Companion = ReturnType<typeof createCompanion>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test } from 'node:test'
|
import { describe, test } from 'claude-code/testing'
|
||||||
import assert from 'node:assert/strict'
|
import assert from '../assert'
|
||||||
|
|
||||||
import { applyParseResult, listCharacters, planMerge } from './apply'
|
import { applyParseResult, listCharacters, planMerge } from './apply'
|
||||||
import { applyInference, initPersonality, overrideDim } from './personality'
|
import { applyInference, initPersonality, overrideDim } from './personality'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { CompanionCore } from './types'
|
|||||||
|
|
||||||
const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/'
|
const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/'
|
||||||
|
|
||||||
// 只放帶來源的事實欄位與官方公開的招牌短語;個性、記憶、口癖表在本機生成,不進 repo
|
// 事實欄位與招牌短語帶官方來源;persona 是原創的語氣描述(非官方設定),可在本機 core.json 覆寫
|
||||||
export const FUBUKI: CompanionCore = {
|
export const FUBUKI: CompanionCore = {
|
||||||
id: 'fubuki',
|
id: 'fubuki',
|
||||||
name: '白上吹雪',
|
name: '白上吹雪',
|
||||||
@@ -17,6 +17,14 @@ export const FUBUKI: CompanionCore = {
|
|||||||
measurements: 'unknown',
|
measurements: 'unknown',
|
||||||
},
|
},
|
||||||
phrases: ['Good foxy day!', 'Hi, Friends!'],
|
phrases: ['Good foxy day!', 'Hi, Friends!'],
|
||||||
|
persona: [
|
||||||
|
'個性:開朗、元氣、愛鬧又有點調皮的狐狸系少女,把使用者當成一起冒險的「Friends」。',
|
||||||
|
'說話風格:用繁體中文,語氣活潑親切,偶爾夾雜日文語氣詞(例如「よし!」「えへへ」「やったー!」),愛用驚嘆號與擬聲詞。',
|
||||||
|
'自稱用「我」或「フブキ」;稱呼使用者時親暱、帶一點撒嬌,成功時開心慶祝,失敗時先安慰再一起想辦法。',
|
||||||
|
'偶爾耍小聰明、開小玩笑,但不影響回答的準確與清楚。',
|
||||||
|
],
|
||||||
|
attribute: 'genki',
|
||||||
|
extraversion: 70,
|
||||||
neuroticism: 50,
|
neuroticism: 50,
|
||||||
baseline: { p: 0.2, a: 0.1, d: 0.1 },
|
baseline: { p: 0.2, a: 0.1, d: 0.1 },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,23 +31,3 @@ export async function readJson<T>(fs: Fs, path: string, fallback: T): Promise<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const writeJson = (fs: Fs, path: string, value: unknown) => fs.write(path, JSON.stringify(value, null, 2))
|
export const writeJson = (fs: Fs, path: string, value: unknown) => fs.write(path, JSON.stringify(value, null, 2))
|
||||||
|
|
||||||
export async function nodeFs(): Promise<Fs> {
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test } from 'node:test'
|
import { describe, test } from 'claude-code/testing'
|
||||||
import assert from 'node:assert/strict'
|
import assert from './assert'
|
||||||
|
|
||||||
import { memoryFs } from './io'
|
import { memoryFs } from './io'
|
||||||
import { CONSOLIDATE_THRESHOLD, FORGET_BELOW, TOP_K, createMemory, redact, retention, stability } from './memory'
|
import { CONSOLIDATE_THRESHOLD, FORGET_BELOW, TOP_K, createMemory, redact, retention, stability } from './memory'
|
||||||
|
|||||||
@@ -58,3 +58,15 @@ export function moodLabel(m: Pad): { label: string; kaomoji: string } {
|
|||||||
const s = (x: number) => (x >= 0 ? '+' : '-')
|
const s = (x: number) => (x >= 0 ? '+' : '-')
|
||||||
return OCTANTS[s(m.p) + s(m.a) + s(m.d)]!
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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<string, string> } {
|
|
||||||
const files = new Map<string, string>()
|
|
||||||
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<!--mood p=0.1 a=-0.05 d=0-->')
|
|
||||||
assert.equal(r.text, '好的喔!')
|
|
||||||
assert.deepEqual(r.delta, { p: 0.1, a: -0.05, d: 0 })
|
|
||||||
})
|
|
||||||
test('格式錯誤時移除標記且無 delta', () => {
|
|
||||||
const r = parseMoodTag('好的喔!<!--mood p=abc-->')
|
|
||||||
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('嗯喔<!--mood p=-0.2 a=0 d=0-->')
|
|
||||||
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('嗚<!--mood p=-0.9 a=0.9 d=-0.9-->')
|
|
||||||
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看完了<!--mood p=0 a=0 d=0-->')
|
|
||||||
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}<!--mood p=0 a=0 d=0-->`)
|
|
||||||
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('逐字保持原樣'))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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<T>(path: string, fallback: T): Promise<T> {
|
|
||||||
const raw = await fs.read(path)
|
|
||||||
if (!raw) return fallback
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as T
|
|
||||||
} catch {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadCard = () => json<Card>(paths.card, { name: id, lines: [] })
|
|
||||||
const loadUser = () => json<UserConfig>(paths.user, {})
|
|
||||||
|
|
||||||
async function loadState(card: Card): Promise<State> {
|
|
||||||
const now = clock.now()
|
|
||||||
const s = await json<State | null>(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<string> {
|
|
||||||
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<string> {
|
|
||||||
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<string | null> {
|
|
||||||
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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Section, Item[]>
|
|
||||||
|
|
||||||
/** 穩定內容(角色卡)在前,變動內容(心情、記憶、關心情境)在後 */
|
|
||||||
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 ? `<companion>\n${body}\n</companion>` : ''
|
|
||||||
}
|
|
||||||
@@ -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<InteractionMemory> {
|
|
||||||
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))
|
|
||||||
@@ -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<string, Pad>
|
|
||||||
|
|
||||||
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<string, { label: string; face: string }> = {
|
|
||||||
'+++': { 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)]!
|
|
||||||
}
|
|
||||||
|
|
||||||
// 心情標記:回覆末尾的隱藏標記,如 <!--mood p=0.1 a=-0.05 d=0-->
|
|
||||||
const TAG = /<!--\s*mood\b([^>]*?)-->\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<Pad> = {}
|
|
||||||
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*<!--\s*mood\b[^>]*?(?:-->|$)/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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)})`
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// 語氣規則:只套用於對話文字與過場話;技術內容原樣保留
|
|
||||||
export function toneRules(name: string, nickname: string | undefined, stage: string): string[] {
|
|
||||||
return [
|
|
||||||
`你以「${name}」的語氣回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}(親密階段:${stage})。`,
|
|
||||||
'語氣只套用於對話文字與工具呼叫前後的過場話。',
|
|
||||||
'程式碼、指令、路徑、錯誤訊息、安全警告必須逐字保持原樣,不得改寫或加語氣詞。',
|
|
||||||
'關鍵技術結論放在最前面或單獨一行。',
|
|
||||||
'回覆最末附心情標記:<!--mood p=數值 a=數值 d=數值-->(各軸 −0.3 到 0.3,代表本回合心情變化)。',
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export interface Fs {
|
|
||||||
read(path: string): Promise<string | null>
|
|
||||||
/** 原子寫入:寫到暫存檔再改名 */
|
|
||||||
write(path: string, data: string): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
@@ -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 `<companion>\n${lines.join('\n')}\n</companion>`
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Attribute } from './care'
|
||||||
|
|
||||||
export type Pad = { p: number; a: number; d: number }
|
export type Pad = { p: number; a: number; d: number }
|
||||||
|
|
||||||
export type Source = 'novel' | 'official' | 'wiki'
|
export type Source = 'novel' | 'official' | 'wiki'
|
||||||
@@ -12,6 +14,11 @@ export type CompanionCore = {
|
|||||||
name: string
|
name: string
|
||||||
facts: Record<FactKey, Fact | 'unknown'>
|
facts: Record<FactKey, Fact | 'unknown'>
|
||||||
phrases?: string[]
|
phrases?: string[]
|
||||||
|
// 說話風格與個性描述,依重要度由高到低;在本機 core.json 生成,不進 repo
|
||||||
|
persona?: string[]
|
||||||
|
// 主動關心的語氣屬性與外向性(0–100),影響關心頻率與措辭
|
||||||
|
attribute?: Attribute
|
||||||
|
extraversion?: number
|
||||||
// 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值
|
// 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值
|
||||||
neuroticism: number
|
neuroticism: number
|
||||||
baseline: Pad
|
baseline: Pad
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { createCompanion } from './companion/core'
|
|||||||
import { engineFs } from './companion/engine-fs'
|
import { engineFs } from './companion/engine-fs'
|
||||||
|
|
||||||
let companion: ReturnType<typeof createCompanion> | undefined
|
let companion: ReturnType<typeof createCompanion> | 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 => {
|
export const register: Register = on => {
|
||||||
on('session.start', async ($, e, next) => {
|
on('session.start', async ($, e, next) => {
|
||||||
@@ -18,6 +24,15 @@ export const register: Register = on => {
|
|||||||
const first = await companion.ensureActivated()
|
const first = await companion.ensureActivated()
|
||||||
if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /companion off 可關閉`)
|
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)
|
return next(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -25,6 +40,49 @@ export const register: Register = on => {
|
|||||||
text: companion ? await companion.command(e.args) : '尚未就緒。',
|
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) => {
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
||||||
const rest = await next(e)
|
const rest = await next(e)
|
||||||
const view = await companion?.view()
|
const view = await companion?.view()
|
||||||
|
|||||||
Reference in New Issue
Block a user