refactor: jsc-mod 改名 jsc-mod-statusline、jsc-companion 改名 jsc-mod-companion,指令拆成獨立指令
- jsc-mod-statusline:/jsc-mod-statusline:enable、compact、disable - jsc-mod-companion:/jsc-mod-companion:status、switch、disable、setup、todo、quiet - 同步更新 plugin.json、marketplace.json 與各 README - 補上 statusline band 測試缺少的 ui.render 底層處理 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
0e7636c185
commit
ea17f1cb2d
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "jsc-mod-companion",
|
||||
"version": "0.0.3",
|
||||
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "JSC"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# jsc-mod-companion
|
||||
|
||||
## 指令
|
||||
|
||||
### `/jsc-mod-companion:status`
|
||||
|
||||
查看目前角色、心情、好感與待辦數量。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:status
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:switch <角色>`
|
||||
|
||||
切換角色;不給角色會列出可用角色。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:switch 小明
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:disable`
|
||||
|
||||
關閉陪伴,所有資料保留。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:disable
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:setup [key=value…]`
|
||||
|
||||
不給參數時顯示目前設定;給 key=value 則儲存。可設定:`nickname`、`wake=HH:MM`、`sleep=HH:MM`、`tz=+0800`、`careLimit=N`(每日主動關心上限)、`quietStart=HH:MM`、`quietEnd=HH:MM`。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:setup nickname=阿明 wake=07:30 sleep=23:30 tz=+0800 careLimit=3 quietStart=22:00 quietEnd=08:00
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:todo [list]`
|
||||
|
||||
列出未完成的待辦。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:todo
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:todo add <內容> [due=YYYY-MM-DDTHH:MM]`
|
||||
|
||||
新增待辦,可指定到期時間(依 `tz` 設定換算)。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:todo add 買牛奶 due=2026-10-15T18:00
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:todo promise <內容> [due=YYYY-MM-DDTHH:MM]`
|
||||
|
||||
記錄角色答應使用者的事(列表中標示【承諾】),用法同 `todo add`。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:todo promise 週末一起討論新章節 due=2026-10-17T10:00
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:todo done <編號>`
|
||||
|
||||
將待辦標為完成。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:todo done 1
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:quiet [分鐘]`
|
||||
|
||||
暫時靜音,不主動關心;預設 60 分鐘,`0` 取消靜音。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:quiet 30
|
||||
```
|
||||
|
||||
### `/jsc-mod-companion:ingest`
|
||||
|
||||
解析單章小說(txt/md),逐一詢問合併、新建或略過後寫入角色資料;提供章節檔或要求建立角色時也會自動使用。
|
||||
|
||||
```text
|
||||
/jsc-mod-companion:ingest chapter1.md 章節編號 1
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
import { clamp } from './mood'
|
||||
import type { Affinity } from './types'
|
||||
|
||||
const DAY = 86_400_000
|
||||
|
||||
export const GAIN = 0.01
|
||||
export const DAILY_CAP = 0.05
|
||||
export const LOSS = 0.03
|
||||
const IDLE_GRACE_DAYS = 7
|
||||
const IDLE_HALF_LIFE_DAYS = 30
|
||||
|
||||
// 升階門檻;降階門檻 = 升階門檻 − 0.05,形成遲滯
|
||||
export const UP = [0.3, 0.55, 0.8]
|
||||
export const HYSTERESIS = 0.05
|
||||
|
||||
export const dayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10)
|
||||
|
||||
export const initialAffinity = (now: number): Affinity => ({ value: 0, stage: 0, dayKey: dayKey(now), dayGain: 0, lastSeen: now })
|
||||
|
||||
export function stageFor(value: number, current: number): number {
|
||||
let s = current
|
||||
while (s < UP.length && value >= UP[s]!) s++
|
||||
while (s > 0 && value < UP[s - 1]! - HYSTERESIS) s--
|
||||
return s
|
||||
}
|
||||
|
||||
// 離開超過 7 天後,以 30 天半衰期回到 0
|
||||
export function idleDecay(a: Affinity, now: number): Affinity {
|
||||
const idleDays = (now - a.lastSeen) / DAY
|
||||
if (idleDays <= IDLE_GRACE_DAYS) return a
|
||||
const value = a.value * Math.pow(0.5, (idleDays - IDLE_GRACE_DAYS) / IDLE_HALF_LIFE_DAYS)
|
||||
return { ...a, value, stage: stageFor(value, a.stage) }
|
||||
}
|
||||
|
||||
export function interact(prev: Affinity, kind: 'meaningful' | 'negative', now: number): Affinity {
|
||||
let a = idleDecay(prev, now)
|
||||
const key = dayKey(now)
|
||||
if (a.dayKey !== key) a = { ...a, dayKey: key, dayGain: 0 }
|
||||
let { value, dayGain } = a
|
||||
if (kind === 'meaningful') {
|
||||
const gain = Math.max(0, Math.min(GAIN, DAILY_CAP - dayGain))
|
||||
value += gain
|
||||
dayGain += gain
|
||||
} else {
|
||||
value -= LOSS
|
||||
}
|
||||
value = clamp(value)
|
||||
return { value, dayGain, dayKey: key, lastSeen: now, stage: stageFor(value, a.stage) }
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from 'claude-code/testing'
|
||||
|
||||
import { fitCompanion } from './band'
|
||||
import type { View } from './core'
|
||||
import { lineWidth } from './seg'
|
||||
|
||||
const VIEW: View = {
|
||||
core: { id: 'miko', name: '月見ミコ', facts: {} as never, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 } },
|
||||
mood: { p: 0.5, a: 0, d: -0.5 },
|
||||
label: '放鬆',
|
||||
kaomoji: '(´ᴗ`)',
|
||||
affinity: 0.35,
|
||||
stage: '熟悉',
|
||||
todos: 2,
|
||||
}
|
||||
|
||||
const text = (v: View, cols: number, compact = false, bubble?: string) => fitCompanion(v, cols, compact, bubble).map(s => s.text).join('')
|
||||
|
||||
describe('角色行', () => {
|
||||
test('寬版顯示顏文字、名字、心情、PAD 條、階段、待辦、氣泡', () => {
|
||||
expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · P▰▰▱ A▰▰▱ D▰▱▱ · 熟悉 · ☐2 · 💬 早安')
|
||||
})
|
||||
|
||||
test('緊湊模式只剩「顏文字 名字 心情」', () => {
|
||||
expect(text(VIEW, 200, true)).toBe('(´ᴗ`) 月見ミコ 放鬆')
|
||||
})
|
||||
|
||||
test('寬度不足時依序捨棄氣泡、待辦、PAD、階段,不換行', () => {
|
||||
const full = text(VIEW, 200, false, '早安')
|
||||
let prev = full.length
|
||||
for (const cols of [60, 50, 40, 34, 28, 22]) {
|
||||
const t = text(VIEW, cols, false, '早安')
|
||||
expect(t.includes('\n')).toBe(false)
|
||||
expect(t.length <= prev).toBe(true)
|
||||
prev = t.length
|
||||
}
|
||||
expect(text(VIEW, 40, false, '早安')).not.toContain('早安')
|
||||
expect(text(VIEW, 36, false, '早安')).not.toContain('☐')
|
||||
expect(text(VIEW, 30, false, '早安')).not.toContain('PAD')
|
||||
expect(text(VIEW, 22, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆')
|
||||
expect(lineWidth(fitCompanion(VIEW, 24))).toBeLessThanOrEqual(24)
|
||||
})
|
||||
|
||||
test('沒有待辦時不顯示待辦數', () => {
|
||||
expect(text({ ...VIEW, todos: 0 }, 200)).not.toContain('☐')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { bar, lineWidth } from './seg'
|
||||
import type { Seg } from './seg'
|
||||
import type { View } from './core'
|
||||
|
||||
const axis = (v: number) => bar(((v + 1) / 2) * 100, 3)
|
||||
|
||||
// 由完整版往下捨棄次要資訊:氣泡 → 待辦 → PAD 條 → 好感階段;緊湊版只留「顏文字 名字 心情」
|
||||
function line(v: View, level: number, compact: boolean, bubble?: string): Seg[] {
|
||||
const out: Seg[] = [
|
||||
{ text: `${v.kaomoji} `, color: 'claude' },
|
||||
{ text: v.core.name, bold: true },
|
||||
{ text: ' ' },
|
||||
{ text: v.label, color: 'suggestion' },
|
||||
]
|
||||
if (compact) return out
|
||||
const dot = { text: ' · ', dim: true }
|
||||
if (level < 3) out.push(dot, { text: `P${axis(v.mood.p)} A${axis(v.mood.a)} D${axis(v.mood.d)}`, dim: true })
|
||||
if (level < 4) out.push(dot, { text: v.stage })
|
||||
if (level < 2 && v.todos > 0) out.push(dot, { text: `☐${v.todos}`, color: 'warning' })
|
||||
if (level < 1 && bubble) out.push(dot, { text: `💬 ${bubble}`, dim: true })
|
||||
return out
|
||||
}
|
||||
|
||||
export const COMPANION_MAX_LEVEL = 4
|
||||
|
||||
export function fitCompanion(v: View, columns: number, compact = false, bubble?: string): Seg[] {
|
||||
if (compact) return line(v, 0, true)
|
||||
for (let level = 0; level <= COMPANION_MAX_LEVEL; level++) {
|
||||
const l = line(v, level, false, bubble)
|
||||
if (lineWidth(l) <= columns) return l
|
||||
}
|
||||
return line(v, 0, true)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { describe, test } from 'claude-code/testing'
|
||||
import assert from './assert'
|
||||
|
||||
import { memoryFs } from './io'
|
||||
import { COOLDOWN_MS, createCare, dailyLimit, frequency, line, type Attribute, type CareProfile, type CareSettings, type Stage, type Trigger } from './care'
|
||||
import { createTodos } from './todo'
|
||||
|
||||
const MIN = 60_000
|
||||
const H = 3600_000
|
||||
// 假時鐘:UTC 2026-10-12 10:00,時區 +0
|
||||
const T0 = Date.UTC(2026, 9, 12, 10, 0)
|
||||
|
||||
function setup(over: { profile?: Partial<CareProfile>; settings?: Partial<CareSettings>; start?: number } = {}) {
|
||||
let now = over.start ?? T0
|
||||
const fs = memoryFs()
|
||||
const profile: CareProfile = { attribute: 'tsundere', extraversion: 50, stage: 'familiar', nickname: '阿JSC', ...over.profile }
|
||||
const settings: CareSettings = { dailyCap: 10, tzOffsetMin: 0, ...over.settings }
|
||||
const care = createCare({ fs, clock: () => now, path: 'care.json', profile: () => profile, settings: () => settings })
|
||||
return { care, fs, profile, settings, advance: (ms: number) => void (now += ms), set: (t: number) => void (now = t), now: () => now }
|
||||
}
|
||||
|
||||
describe('關心:觸發', () => {
|
||||
test('會話開始以 toast 問候,且只問候一次', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
const out = await s.care.tick()
|
||||
assert.deepEqual(out.map(c => c.trigger), ['session_start'])
|
||||
assert.equal(out[0]!.delivery, 'toast')
|
||||
assert.ok(out[0]!.text.includes('阿JSC'))
|
||||
assert.deepEqual(await s.care.tick(), [])
|
||||
})
|
||||
|
||||
test('閒置後回來觸發 idle_return', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
s.advance(3 * H)
|
||||
await s.care.onUserTurn()
|
||||
assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['idle_return'])
|
||||
})
|
||||
|
||||
test('連續工作 2 小時,下次送出訊息時注入情境', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
s.advance(20 * MIN)
|
||||
assert.deepEqual(await s.care.onUserTurn(), [], `第 ${i + 1} 次不該觸發`)
|
||||
}
|
||||
s.advance(20 * MIN) // 累計 120 分鐘
|
||||
const inj = await s.care.onUserTurn()
|
||||
assert.equal(inj.length, 1)
|
||||
assert.match(inj[0]!, /long_work/)
|
||||
})
|
||||
|
||||
test('深夜仍在工作時注入情境', async () => {
|
||||
const s = setup({ start: Date.UTC(2026, 9, 12, 0, 30) })
|
||||
await s.care.onUserTurn()
|
||||
s.advance(40 * MIN) // 01:10
|
||||
const inj = await s.care.onUserTurn()
|
||||
assert.ok(inj.some(x => x.includes('late_night')))
|
||||
})
|
||||
|
||||
test('時區影響深夜判斷', async () => {
|
||||
// UTC 17:10 在 +8 是 01:10;UTC 16:30 是 00:30(非深夜)
|
||||
const s = setup({ start: Date.UTC(2026, 9, 12, 16, 30), settings: { tzOffsetMin: 480 } })
|
||||
await s.care.onUserTurn()
|
||||
s.advance(40 * MIN)
|
||||
assert.ok((await s.care.onUserTurn()).some(x => x.includes('late_night')))
|
||||
})
|
||||
|
||||
test('連續工具失敗達 3 次才關心,成功會重置', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.onToolResult(false)
|
||||
await s.care.onToolResult(false)
|
||||
await s.care.onToolResult(true)
|
||||
await s.care.onToolResult(false)
|
||||
s.advance(MIN)
|
||||
assert.deepEqual(await s.care.onUserTurn(), [])
|
||||
await s.care.onToolResult(false)
|
||||
await s.care.onToolResult(false)
|
||||
s.advance(MIN)
|
||||
const inj = await s.care.onUserTurn()
|
||||
assert.equal(inj.length, 1)
|
||||
assert.match(inj[0]!, /tool_failures/)
|
||||
})
|
||||
|
||||
test('額度警告與待辦到期以 toast 顯示', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
const out = await s.care.tick({ quotaWarning: true, dueTodo: '寄信' })
|
||||
assert.deepEqual(out.map(c => c.trigger).sort(), ['quota_warning', 'todo_due'])
|
||||
assert.ok(out.find(c => c.trigger === 'todo_due')!.text.includes('寄信'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:冷卻與上限', () => {
|
||||
test('同一種關心在冷卻內不重複,冷卻後恢復', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
assert.equal((await s.care.tick({ quotaWarning: true })).length, 1)
|
||||
s.advance(COOLDOWN_MS.quota_warning - 1)
|
||||
assert.equal((await s.care.tick({ quotaWarning: true })).length, 0)
|
||||
s.advance(1)
|
||||
assert.equal((await s.care.tick({ quotaWarning: true })).length, 1)
|
||||
})
|
||||
|
||||
test('每日總上限,隔天重算', async () => {
|
||||
const s = setup({ profile: { stage: 'stranger', extraversion: 0 }, settings: { dailyCap: 10 } })
|
||||
const limit = dailyLimit(s.profile, s.settings)
|
||||
assert.equal(limit, 1)
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick() // session_start 用掉唯一一次
|
||||
s.advance(2 * H)
|
||||
assert.deepEqual(await s.care.tick({ quotaWarning: true }), [])
|
||||
s.advance(24 * H)
|
||||
assert.equal((await s.care.tick({ quotaWarning: true })).length, 1)
|
||||
})
|
||||
|
||||
test('上限受好感階段與外向性影響,並被使用者設定封頂', () => {
|
||||
const p: CareProfile = { attribute: 'calm', extraversion: 50, stage: 'stranger', nickname: 'x' }
|
||||
const s: CareSettings = { dailyCap: 100, tzOffsetMin: 0 }
|
||||
const lim = (patch: Partial<CareProfile>, cap = 100) => dailyLimit({ ...p, ...patch }, { ...s, dailyCap: cap })
|
||||
assert.ok(lim({ stage: 'special' }) > lim({ stage: 'stranger' }))
|
||||
assert.ok(lim({ extraversion: 100 }) > lim({ extraversion: 0 }))
|
||||
assert.equal(lim({ stage: 'special', extraversion: 100 }, 3), 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:靜音與不打斷', () => {
|
||||
test('回覆中或輸入中不彈出,且不消耗冷卻與上限', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
assert.deepEqual(await s.care.tick({ replying: true }), [])
|
||||
assert.deepEqual(await s.care.tick({ typing: true }), [])
|
||||
assert.equal((await s.care.state()).count, 0)
|
||||
// 放開後,原本等待的問候仍會出現
|
||||
assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['session_start'])
|
||||
})
|
||||
|
||||
test('/jsc-mod-companion:quiet 在期間內靜音,到期後恢復', async () => {
|
||||
const s = setup()
|
||||
await s.care.quiet(30)
|
||||
await s.care.onUserTurn()
|
||||
assert.deepEqual(await s.care.tick({ quotaWarning: true }), [])
|
||||
s.advance(31 * MIN)
|
||||
// 靜音期間錯過的問候在恢復後補上
|
||||
assert.deepEqual((await s.care.tick({ quotaWarning: true })).map(c => c.trigger).sort(), ['quota_warning', 'session_start'])
|
||||
})
|
||||
|
||||
test('quiet 0 取消靜音', async () => {
|
||||
const s = setup()
|
||||
await s.care.quiet(60)
|
||||
await s.care.quiet(0)
|
||||
await s.care.onUserTurn()
|
||||
assert.ok((await s.care.tick({ quotaWarning: true })).length > 0)
|
||||
})
|
||||
|
||||
test('靜音時段(跨午夜)內不關心,注入也不產生', async () => {
|
||||
const s = setup({ start: Date.UTC(2026, 9, 12, 23, 30), settings: { quietHours: { start: 23, end: 7 } } })
|
||||
await s.care.onUserTurn()
|
||||
s.advance(3 * H)
|
||||
assert.deepEqual(await s.care.onUserTurn(), [])
|
||||
assert.deepEqual(await s.care.tick({ quotaWarning: true }), [])
|
||||
s.set(Date.UTC(2026, 9, 12, 8, 0) + 24 * H)
|
||||
assert.ok((await s.care.tick({ quotaWarning: true })).length > 0)
|
||||
})
|
||||
|
||||
test('狀態持久化:重建引擎後冷卻仍有效', async () => {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick({ quotaWarning: true })
|
||||
const again = createCare({ fs: s.fs, clock: s.now, path: 'care.json', profile: () => s.profile, settings: () => s.settings })
|
||||
assert.deepEqual(await again.tick({ quotaWarning: true }), [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:台詞', () => {
|
||||
const triggers: Trigger[] = ['session_start', 'idle_return', 'long_work', 'late_night', 'tool_failures', 'quota_warning', 'todo_due']
|
||||
const attrs: Attribute[] = ['tsundere', 'genki', 'calm', 'gentle']
|
||||
const stages: Stage[] = ['stranger', 'familiar', 'close', 'special']
|
||||
const BLAME = /(都怪你|你又|為什麼不|怎麼還不|太懶|不像話|再不.*就|我會難過|害我|對不起我|辜負|失望|沒用)/
|
||||
|
||||
test('每個 觸發 × 屬性 × 階段 都有台詞,帶暱稱,不含責備或愧疚勒索', () => {
|
||||
for (const t of triggers) for (const a of attrs) for (const s of stages) {
|
||||
const text = line(t, a, s, '小明', '報告')
|
||||
assert.ok(text.length > 0)
|
||||
assert.ok(text.includes('小明'), `${t}/${a}/${s} 缺暱稱`)
|
||||
assert.doesNotMatch(text, BLAME, `${t}/${a}/${s}: ${text}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('同一觸發在不同屬性與階段下措辭不同', () => {
|
||||
const set = new Set<string>()
|
||||
for (const a of attrs) for (const s of stages) set.add(line('late_night', a, s, 'x'))
|
||||
assert.equal(set.size, attrs.length * stages.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('待辦', () => {
|
||||
test('新增、完成與未完成計數', async () => {
|
||||
let now = T0
|
||||
const todos = createTodos(memoryFs(), () => now, 'todo.json')
|
||||
const a = await todos.add('寫報告')
|
||||
await todos.add('買牛奶')
|
||||
assert.equal(await todos.open(), 2)
|
||||
await todos.complete(a.id)
|
||||
assert.equal(await todos.open(), 1)
|
||||
assert.deepEqual((await todos.list()).map(t => t.title), ['買牛奶'])
|
||||
assert.equal((await todos.list({ all: true })).length, 2)
|
||||
assert.equal(await todos.complete(999), null)
|
||||
now += 1
|
||||
})
|
||||
|
||||
test('承諾事項到期提醒一次,完成的不提醒', async () => {
|
||||
let now = T0
|
||||
const todos = createTodos(memoryFs(), () => now, 'todo.json')
|
||||
await todos.add('三點開會', { promise: true, dueAt: T0 + H })
|
||||
const done = await todos.add('已做完', { dueAt: T0 + H })
|
||||
await todos.complete(done.id)
|
||||
assert.deepEqual(await todos.due(), [])
|
||||
now += H
|
||||
assert.deepEqual((await todos.due()).map(t => t.title), ['三點開會'])
|
||||
assert.deepEqual(await todos.due(), [])
|
||||
})
|
||||
|
||||
test('到期待辦餵給關心後產生 todo_due', async () => {
|
||||
const s = setup()
|
||||
const todos = createTodos(s.fs, s.now, 'todo.json')
|
||||
await todos.add('交報告', { promise: true, dueAt: T0 + 5 * MIN })
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
s.advance(6 * MIN)
|
||||
const due = await todos.due()
|
||||
const out = await s.care.tick({ dueTodo: due[0]?.title })
|
||||
assert.deepEqual(out.map(c => c.trigger), ['todo_due'])
|
||||
assert.ok(out[0]!.text.includes('交報告'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:工作事件反應', () => {
|
||||
async function started() {
|
||||
const s = setup()
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick() // 清掉開場問候
|
||||
s.advance(10 * MIN)
|
||||
return s
|
||||
}
|
||||
|
||||
test('同一個驗證指令先失敗後成功,tick 以 toast 反應', async () => {
|
||||
const s = await started()
|
||||
await s.care.onToolResult(false, 'bun test')
|
||||
await s.care.onToolResult(true, 'bun test')
|
||||
const out = await s.care.tick()
|
||||
assert.deepEqual(out.map(c => c.trigger), ['test_recovered'])
|
||||
assert.equal(out[0]!.delivery, 'toast')
|
||||
assert.ok(out[0]!.text.includes('阿JSC'))
|
||||
})
|
||||
|
||||
test('不同指令的失敗與成功不算轉通過', async () => {
|
||||
const s = await started()
|
||||
await s.care.onToolResult(false, 'bun test')
|
||||
await s.care.onToolResult(true, 'tsc -p .')
|
||||
assert.deepEqual(await s.care.tick(), [])
|
||||
})
|
||||
|
||||
test('非驗證指令的失敗後成功不反應', async () => {
|
||||
const s = await started()
|
||||
await s.care.onToolResult(false, 'grep foo src')
|
||||
await s.care.onToolResult(true, 'grep foo src')
|
||||
assert.deepEqual(await s.care.tick(), [])
|
||||
})
|
||||
|
||||
test('模型回覆中不彈出,之後仍會補上', async () => {
|
||||
const s = await started()
|
||||
await s.care.onToolResult(false, 'npm test')
|
||||
await s.care.onToolResult(true, 'npm test')
|
||||
assert.deepEqual(await s.care.tick({ replying: true }), [])
|
||||
assert.deepEqual((await s.care.tick()).map(c => c.trigger), ['test_recovered'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:一輪完成與疲乏', () => {
|
||||
// special 階段每日上限高,讓測試只觀察疲乏而非每日上限
|
||||
// 抵銷屬性與外向性的頻率倍率,只留下疲乏的效果
|
||||
const neutral = (p: Omit<CareProfile, 'nickname'>): CareProfile['freq'] => ({ turn_done: 1 / frequency({ ...p, nickname: '' }, 'turn_done') })
|
||||
async function started(extraversion: number) {
|
||||
const base = { extraversion, stage: 'special', attribute: 'genki' } as const
|
||||
const s = setup({ profile: { ...base, freq: neutral(base) } })
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
s.advance(10 * MIN)
|
||||
const attempt = async () => {
|
||||
await s.care.onTurnComplete(true)
|
||||
return (await s.care.tick()).filter(c => c.trigger === 'turn_done').length
|
||||
}
|
||||
return { s, attempt }
|
||||
}
|
||||
|
||||
test('沒有呼叫過工具的回合不算做完事', async () => {
|
||||
const { s } = await started(50)
|
||||
await s.care.onTurnComplete(false)
|
||||
assert.deepEqual(await s.care.tick(), [])
|
||||
})
|
||||
|
||||
test('有呼叫工具的回合結束,以 toast 反應', async () => {
|
||||
const { s } = await started(50)
|
||||
await s.care.onTurnComplete(true)
|
||||
const out = await s.care.tick()
|
||||
assert.deepEqual(out.map(c => c.trigger), ['turn_done'])
|
||||
assert.equal(out[0]!.delivery, 'toast')
|
||||
})
|
||||
|
||||
test('每反應一次,下次的冷卻就更長', async () => {
|
||||
const { s, attempt } = await started(10) // rate 1.4 → 第二次冷卻 20×2.4=48 分
|
||||
assert.equal(await attempt(), 1)
|
||||
s.advance(25 * MIN)
|
||||
assert.equal(await attempt(), 0, '基礎冷卻 20 分已過,但疲乏使冷卻拉長')
|
||||
s.advance(25 * MIN)
|
||||
assert.equal(await attempt(), 1, '累計 50 分,過了拉長後的冷卻')
|
||||
})
|
||||
|
||||
test('內向角色疲乏到門檻就暫停,休息夠久後恢復', async () => {
|
||||
const { s, attempt } = await started(10)
|
||||
let fired = 0
|
||||
for (let i = 0; i < 12; i++) {
|
||||
s.advance(30 * MIN)
|
||||
fired += await attempt()
|
||||
}
|
||||
assert.ok(fired <= 3, `6 小時內最多反應 3 次,實際 ${fired}`)
|
||||
assert.equal(await attempt(), 0, '仍在暫停中')
|
||||
s.advance(24 * H)
|
||||
assert.equal(await attempt(), 1, '休息一天後恢復')
|
||||
})
|
||||
|
||||
test('外向角色比內向角色更常反應', async () => {
|
||||
const count = async (ext: number) => {
|
||||
const { s, attempt } = await started(ext)
|
||||
let fired = 0
|
||||
for (let i = 0; i < 16; i++) {
|
||||
s.advance(30 * MIN)
|
||||
fired += await attempt()
|
||||
}
|
||||
return fired
|
||||
}
|
||||
assert.ok((await count(100)) > (await count(10)))
|
||||
})
|
||||
|
||||
test('角色資料可覆寫疲乏參數', async () => {
|
||||
const base = { extraversion: 50, stage: 'special', attribute: 'genki' } as const
|
||||
const s = setup({ profile: { ...base, freq: neutral(base), fatigueRate: 0.1, fatigueCap: 99 } })
|
||||
await s.care.onUserTurn()
|
||||
await s.care.tick()
|
||||
let fired = 0
|
||||
for (let i = 0; i < 6; i++) {
|
||||
s.advance(30 * MIN)
|
||||
await s.care.onTurnComplete(true)
|
||||
fired += (await s.care.tick()).filter(c => c.trigger === 'turn_done').length
|
||||
}
|
||||
assert.ok(fired >= 5, `幾乎不疲乏,實際 ${fired}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('關心:個性決定主動頻率', () => {
|
||||
const prof = (attribute: Attribute, extraversion = 50, freq?: CareProfile['freq']): CareProfile => ({ attribute, extraversion, stage: 'close', nickname: 'n', freq })
|
||||
|
||||
test('待辦到期與額度警告不受個性影響', () => {
|
||||
for (const t of ['todo_due', 'quota_warning'] as const) {
|
||||
for (const a of ['tsundere', 'genki', 'calm', 'gentle'] as const) assert.equal(frequency(prof(a, 10), t), 1, `${a}/${t}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('genki 比 calm 更常主動', () => {
|
||||
for (const t of ['turn_done', 'long_work', 'session_start'] as const) assert.ok(frequency(prof('genki'), t) > frequency(prof('calm'), t), t)
|
||||
})
|
||||
|
||||
test('gentle 關心多、慶祝少', () => {
|
||||
const g = prof('gentle')
|
||||
assert.ok(frequency(g, 'late_night') > frequency(g, 'turn_done'))
|
||||
assert.ok(frequency(g, 'tool_failures') > frequency(g, 'test_recovered'))
|
||||
})
|
||||
|
||||
test('外向性放大頻率,內向縮小', () => {
|
||||
assert.ok(frequency(prof('calm', 90), 'long_work') > frequency(prof('calm', 10), 'long_work'))
|
||||
})
|
||||
|
||||
test('角色資料的倍率覆寫預設', () => {
|
||||
assert.equal(frequency(prof('calm', 50, { turn_done: 2 }), 'turn_done'), frequency(prof('calm'), 'turn_done') * 2)
|
||||
})
|
||||
|
||||
test('頻率倍率縮放冷卻:gentle 的連續失敗關心比 calm 更快再來', async () => {
|
||||
const run = async (attribute: Attribute) => {
|
||||
const s = setup({ profile: { attribute, stage: 'special' } })
|
||||
await s.care.onUserTurn()
|
||||
const fail3 = async () => {
|
||||
for (let i = 0; i < 3; i++) await s.care.onToolResult(false)
|
||||
return (await s.care.onUserTurn()).length
|
||||
}
|
||||
s.advance(MIN)
|
||||
assert.equal(await fail3(), 1)
|
||||
s.advance(30 * MIN) // 基礎冷卻 30 分:gentle 約 23 分已過,calm 約 43 分未過
|
||||
return fail3()
|
||||
}
|
||||
assert.equal(await run('gentle'), 1)
|
||||
assert.equal(await run('calm'), 0)
|
||||
})
|
||||
|
||||
test('每日上限隨屬性調整,且仍受使用者設定封頂', () => {
|
||||
const set = (dailyCap: number): CareSettings => ({ dailyCap, tzOffsetMin: 0 })
|
||||
assert.ok(dailyLimit(prof('genki'), set(99)) > dailyLimit(prof('calm'), set(99)))
|
||||
assert.equal(dailyLimit(prof('genki', 100), set(2)), 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,548 @@
|
||||
import type { Clock, Fs } from './io'
|
||||
import { readJson, writeJson } from './io'
|
||||
import { isVerifyCommand } from './verify'
|
||||
|
||||
export type Trigger = 'session_start' | 'idle_return' | 'long_work' | 'late_night' | 'tool_failures' | 'quota_warning' | 'todo_due' | 'test_recovered' | 'turn_done'
|
||||
export type Stage = 'stranger' | 'familiar' | 'close' | 'special'
|
||||
export type Attribute = 'tsundere' | 'genki' | 'calm' | 'gentle'
|
||||
export type Delivery = 'toast' | 'inject'
|
||||
|
||||
export interface CareProfile {
|
||||
attribute: Attribute
|
||||
/** 外向性 0–100 */
|
||||
extraversion: number
|
||||
stage: Stage
|
||||
nickname: string
|
||||
/** 工作事件反應的疲乏:每次反應增加的量;預設由外向性推得 */
|
||||
fatigueRate?: number
|
||||
/** 疲乏達此值就暫停反應,衰減回門檻以下才恢復;預設由外向性推得 */
|
||||
fatigueCap?: number
|
||||
/** 各觸發的頻率倍率(>1 更常、<1 更少),疊加在屬性預設之上 */
|
||||
freq?: Partial<Record<Trigger, number>>
|
||||
}
|
||||
|
||||
export interface CareSettings {
|
||||
/** 使用者設定的每日上限 */
|
||||
dailyCap: number
|
||||
tzOffsetMin: number
|
||||
/** 靜音時段(本地小時),可跨午夜 */
|
||||
quietHours?: { start: number; end: number }
|
||||
}
|
||||
|
||||
export interface CareState {
|
||||
lastFired: Partial<Record<Trigger, number>>
|
||||
day: string
|
||||
count: number
|
||||
quietUntil: number
|
||||
workStart: number
|
||||
lastActivity: number
|
||||
failStreak: number
|
||||
/** 待送出的開場關心(會話開始或閒置回來),由 tick 在可打擾時才發出 */
|
||||
lead?: Trigger
|
||||
/** 曾失敗、尚未成功的驗證指令(同一指令成功才算轉通過) */
|
||||
failedCmds?: string[]
|
||||
/** 工作事件排隊中的反應,tick 在可打擾時才發出;過期不補 */
|
||||
pending?: { t: Trigger; at: number }[]
|
||||
/** 工作事件反應的疲乏值,隨時間減半衰減 */
|
||||
fatigue?: { level: number; at: number }
|
||||
}
|
||||
|
||||
export interface Care {
|
||||
trigger: Trigger
|
||||
delivery: Delivery
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface Signals {
|
||||
/** 模型回覆中 */
|
||||
replying?: boolean
|
||||
/** 使用者輸入中 */
|
||||
typing?: boolean
|
||||
/** 額度或 context 已到警告線 */
|
||||
quotaWarning?: boolean
|
||||
/** 到期的待辦標題 */
|
||||
dueTodo?: string
|
||||
}
|
||||
|
||||
export const COOLDOWN_MS: Record<Trigger, number> = {
|
||||
session_start: 6 * 3600_000,
|
||||
idle_return: 2 * 3600_000,
|
||||
long_work: 2 * 3600_000,
|
||||
late_night: 3 * 3600_000,
|
||||
tool_failures: 30 * 60_000,
|
||||
quota_warning: 60 * 60_000,
|
||||
todo_due: 10 * 60_000,
|
||||
test_recovered: 10 * 60_000,
|
||||
turn_done: 20 * 60_000,
|
||||
}
|
||||
|
||||
/** 會讓角色疲乏的工作事件反應:越常說越容易膩 */
|
||||
const TIRING: Trigger[] = ['test_recovered', 'turn_done']
|
||||
export const FATIGUE_HALF_LIFE_MS = 12 * 3600_000
|
||||
|
||||
export const fatigueRate = (p: CareProfile) => p.fatigueRate ?? 1.5 - p.extraversion / 100
|
||||
export const fatigueCap = (p: CareProfile) => p.fatigueCap ?? 1.5 + p.extraversion / 25
|
||||
|
||||
/** 排隊中的工作事件超過這段時間就不補發 */
|
||||
export const PENDING_TTL_MS = 10 * 60_000
|
||||
|
||||
export const IDLE_MS = 30 * 60_000
|
||||
export const LONG_WORK_MS = 2 * 3600_000
|
||||
export const FAIL_THRESHOLD = 3
|
||||
/** 深夜:本地 1:00–5:00 */
|
||||
const LATE = { start: 1, end: 5 }
|
||||
|
||||
const STAGE_CAP: Record<Stage, number> = { stranger: 2, familiar: 4, close: 6, special: 8 }
|
||||
|
||||
/** 使用者設定與系統狀態的提醒,不因個性變得更少或更慢 */
|
||||
const FIXED_FREQ: Trigger[] = ['todo_due', 'quota_warning']
|
||||
const CARING: Trigger[] = ['long_work', 'late_night', 'tool_failures']
|
||||
const CELEBRATING: Trigger[] = ['test_recovered', 'turn_done']
|
||||
|
||||
// 屬性的主動傾向:base 是整體倍率,其餘為個別觸發的倍率
|
||||
const ATTR_FREQ: Record<Attribute, { base: number; caring?: number; celebrating?: number }> = {
|
||||
genki: { base: 1.3 },
|
||||
tsundere: { base: 0.8, caring: 1 },
|
||||
calm: { base: 0.7 },
|
||||
gentle: { base: 1, caring: 1.3, celebrating: 0.6 },
|
||||
}
|
||||
|
||||
/** 這個角色對某種觸發主動的程度:1 為基準,越大越常說 */
|
||||
export function frequency(p: CareProfile, trigger: Trigger): number {
|
||||
if (FIXED_FREQ.includes(trigger)) return 1
|
||||
const a = ATTR_FREQ[p.attribute]
|
||||
const kind = CARING.includes(trigger) ? a.caring : CELEBRATING.includes(trigger) ? a.celebrating : undefined
|
||||
return (kind ?? a.base) * (0.5 + p.extraversion / 100) * (p.freq?.[trigger] ?? 1)
|
||||
}
|
||||
|
||||
/** 好感階段、外向性與屬性決定每日關心上限,再受使用者設定封頂 */
|
||||
export function dailyLimit(p: CareProfile, s: CareSettings): number {
|
||||
const factor = (0.5 + p.extraversion / 100) * ATTR_FREQ[p.attribute].base
|
||||
return Math.min(s.dailyCap, Math.max(1, Math.round(STAGE_CAP[p.stage] * factor)))
|
||||
}
|
||||
|
||||
// 觸發類型 × 個性屬性 × 好感階段。措辭只表達關心,不責備、不訴諸愧疚。
|
||||
type Line = (n: string, x?: string) => string
|
||||
const L: Record<Trigger, Record<Attribute, Record<Stage, Line>>> = {
|
||||
session_start: {
|
||||
tsundere: {
|
||||
stranger: n => `……開始了嗎,${n}。請多指教。`,
|
||||
familiar: n => `來了啊,${n}。我才不是在等你。`,
|
||||
close: n => `${n}來了。今天也一起加油吧……順便而已。`,
|
||||
special: n => `${n},歡迎回來。看到你來,我有點開心。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},初次見面!今天也請多指教!`,
|
||||
familiar: n => `${n}早!今天要做什麼呢?`,
|
||||
close: n => `${n}~等你好久了!一起開工吧!`,
|
||||
special: n => `${n}!見到你就有精神了,我們走吧!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},您好。需要幫忙時請說。`,
|
||||
familiar: n => `${n},今天也請多指教。`,
|
||||
close: n => `${n},歡迎回來。今天慢慢來就好。`,
|
||||
special: n => `${n},你回來了。我在這裡。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},您好,今天請多指教。`,
|
||||
familiar: n => `${n},歡迎回來。`,
|
||||
close: n => `${n},今天也辛苦了,我們一起加油。`,
|
||||
special: n => `${n},能再見到你真好。`,
|
||||
},
|
||||
},
|
||||
idle_return: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},回來了嗎。進度還在。`,
|
||||
familiar: n => `回來啦,${n}。休息夠了就繼續吧。`,
|
||||
close: n => `${n}回來了。有好好休息嗎?我只是順口問問。`,
|
||||
special: n => `${n},歡迎回來。我一直在這裡。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},歡迎回來!`,
|
||||
familiar: n => `${n}回來啦!休息好了嗎?`,
|
||||
close: n => `${n}~回來了!有沒有喝點水呀?`,
|
||||
special: n => `${n}!你回來了,我好開心!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},歡迎回來。`,
|
||||
familiar: n => `${n},休息得還好嗎?`,
|
||||
close: n => `${n},歡迎回來。從剛才的地方接著做吧。`,
|
||||
special: n => `${n},你回來了。慢慢來,不急。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},歡迎回來。`,
|
||||
familiar: n => `${n},休息得還好嗎?`,
|
||||
close: n => `${n},歡迎回來,有休息到就好。`,
|
||||
special: n => `${n},回來了呢。我一直在等你。`,
|
||||
},
|
||||
},
|
||||
long_work: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},已經連續工作兩小時了。要不要伸展一下?`,
|
||||
familiar: n => `${n},兩小時了喔。去喝口水再回來也不遲。`,
|
||||
close: n => `${n},工作兩小時了。起來動一動吧……我是為了進度著想。`,
|
||||
special: n => `${n},已經兩小時了。休息一下好嗎?我會在這裡等你。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},已經工作兩小時了!要不要休息一下?`,
|
||||
familiar: n => `${n},兩小時了!站起來伸個懶腰吧!`,
|
||||
close: n => `${n}~兩小時啦!我們喝點東西休息一下吧!`,
|
||||
special: n => `${n},兩小時了!一起休息一下,好不好?`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},已連續工作兩小時,可以休息一下。`,
|
||||
familiar: n => `${n},兩小時了。起身走走對身體好。`,
|
||||
close: n => `${n},工作兩小時了。休息一會兒再繼續吧。`,
|
||||
special: n => `${n},兩小時了。讓眼睛和肩膀休息一下吧。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},已經工作兩小時,記得休息。`,
|
||||
familiar: n => `${n},兩小時了,喝杯水休息一下吧。`,
|
||||
close: n => `${n},辛苦了,休息一下再繼續吧。`,
|
||||
special: n => `${n},你很努力了。休息一下,好嗎?`,
|
||||
},
|
||||
},
|
||||
late_night: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},現在很晚了。注意休息。`,
|
||||
familiar: n => `${n},這個時間了。睡眠也算進度的一部分喔。`,
|
||||
close: n => `${n},已經很晚了。早點睡……明天再做也來得及。`,
|
||||
special: n => `${n},夜深了。我會擔心的,早點休息吧。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},已經很晚了!要注意休息喔!`,
|
||||
familiar: n => `${n},好晚了!明天再繼續也可以喔!`,
|
||||
close: n => `${n}~很晚啦!我們今天先收工,好不好?`,
|
||||
special: n => `${n},夜深了,好好睡一覺,明天再一起加油!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},時間不早了,請留意休息。`,
|
||||
familiar: n => `${n},夜深了。找個好停點就休息吧。`,
|
||||
close: n => `${n},很晚了。存好進度,晚安。`,
|
||||
special: n => `${n},夜深了。今天辛苦了,好好睡吧。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},時間很晚了,請注意休息。`,
|
||||
familiar: n => `${n},夜深了,早點休息吧。`,
|
||||
close: n => `${n},很晚了,今天先到這裡吧。`,
|
||||
special: n => `${n},夜深了,我希望你能睡個好覺。`,
|
||||
},
|
||||
},
|
||||
tool_failures: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},連續失敗了幾次。換個角度看看吧。`,
|
||||
familiar: n => `${n},卡住了呢。深呼吸,我們一起看看哪裡不對。`,
|
||||
close: n => `${n},別急。失敗幾次很正常,我陪你查。`,
|
||||
special: n => `${n},沒關係的。我們一步一步來,我在這裡。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},連續失敗了幾次,我們換個方法試試!`,
|
||||
familiar: n => `${n},卡關了呢!沒問題,我們再來一次!`,
|
||||
close: n => `${n}~別灰心!我們一起找出原因吧!`,
|
||||
special: n => `${n},沒事的!有我在,我們一定可以解決!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},連續失敗數次。建議先檢查錯誤訊息。`,
|
||||
familiar: n => `${n},先停一下,確認錯誤原因再繼續。`,
|
||||
close: n => `${n},別著急。我們從錯誤訊息開始慢慢排查。`,
|
||||
special: n => `${n},沒關係。先喝口水,我陪你一起看。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},連續失敗了幾次,換個方式試試看吧。`,
|
||||
familiar: n => `${n},遇到困難了呢,一起想想辦法。`,
|
||||
close: n => `${n},不要緊,我們慢慢找原因。`,
|
||||
special: n => `${n},沒關係,一步一步來,我陪著你。`,
|
||||
},
|
||||
},
|
||||
quota_warning: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},額度快用完了。請留意。`,
|
||||
familiar: n => `${n},額度或 context 快滿了。先整理重點比較好。`,
|
||||
close: n => `${n},額度快見底了。先把重要的事做完吧。`,
|
||||
special: n => `${n},額度快滿了。我們優先處理最重要的部分。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},額度快用完了,請注意喔!`,
|
||||
familiar: n => `${n},額度快滿了!先做最重要的吧!`,
|
||||
close: n => `${n}~額度快見底啦!先挑重點做吧!`,
|
||||
special: n => `${n},額度快滿了!我們把最重要的先完成!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},額度或 context 接近上限。`,
|
||||
familiar: n => `${n},額度接近上限,建議先整理進度。`,
|
||||
close: n => `${n},額度快滿了。先收尾手上的事吧。`,
|
||||
special: n => `${n},額度接近上限。我們先把重點記下來。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},額度或 context 快滿了,請留意。`,
|
||||
familiar: n => `${n},額度快滿了,先整理一下進度吧。`,
|
||||
close: n => `${n},額度快見底了,先處理重要的事吧。`,
|
||||
special: n => `${n},額度快滿了,我們把重點先收好。`,
|
||||
},
|
||||
},
|
||||
todo_due: {
|
||||
tsundere: {
|
||||
stranger: (n, x) => `${n},「${x}」時間到了。`,
|
||||
familiar: (n, x) => `${n},「${x}」到時間了喔。`,
|
||||
close: (n, x) => `${n},「${x}」該處理了。我有幫你記著。`,
|
||||
special: (n, x) => `${n},「${x}」到時間了。需要我幫忙嗎?`,
|
||||
},
|
||||
genki: {
|
||||
stranger: (n, x) => `${n},「${x}」時間到囉!`,
|
||||
familiar: (n, x) => `${n},「${x}」到時間啦!`,
|
||||
close: (n, x) => `${n}~「${x}」時間到!要開始了嗎?`,
|
||||
special: (n, x) => `${n},「${x}」時間到了!我們一起處理吧!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: (n, x) => `${n},「${x}」已到期。`,
|
||||
familiar: (n, x) => `${n},「${x}」到時間了。`,
|
||||
close: (n, x) => `${n},「${x}」到時間了,方便時處理即可。`,
|
||||
special: (n, x) => `${n},「${x}」到時間了,我來陪你。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: (n, x) => `${n},「${x}」到期了。`,
|
||||
familiar: (n, x) => `${n},「${x}」到時間了,記得處理。`,
|
||||
close: (n, x) => `${n},「${x}」到時間了,有空時處理就好。`,
|
||||
special: (n, x) => `${n},「${x}」到時間了,需要的話我陪你。`,
|
||||
},
|
||||
},
|
||||
test_recovered: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},測試過了。這是應該的。`,
|
||||
familiar: n => `過了啊,${n}。……還不錯嘛。`,
|
||||
close: n => `${n},測試過了。我才沒有擔心……好吧,有一點。`,
|
||||
special: n => `${n},過了!我就知道你辦得到。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},測試通過了!太好了!`,
|
||||
familiar: n => `${n}!通過啦!やったー!`,
|
||||
close: n => `${n}~過了過了!剛剛那關好難呢,辛苦啦!`,
|
||||
special: n => `${n}!通過了!我們果然是最強的搭檔!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},測試已通過。`,
|
||||
familiar: n => `${n},通過了,辛苦了。`,
|
||||
close: n => `${n},終於通過了。可以鬆一口氣了。`,
|
||||
special: n => `${n},通過了。我一直相信你。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},測試通過了。`,
|
||||
familiar: n => `${n},通過了,真是太好了。`,
|
||||
close: n => `${n},通過了,辛苦你了。`,
|
||||
special: n => `${n},通過了呢,我真為你高興。`,
|
||||
},
|
||||
},
|
||||
turn_done: {
|
||||
tsundere: {
|
||||
stranger: n => `${n},這一輪處理完了。`,
|
||||
familiar: n => `做完了,${n}。檢查一下吧。`,
|
||||
close: n => `${n},弄好了。……不用特別謝我。`,
|
||||
special: n => `${n},完成了。你看一下,我有認真做。`,
|
||||
},
|
||||
genki: {
|
||||
stranger: n => `${n},這一輪完成了!`,
|
||||
familiar: n => `${n},做完啦!`,
|
||||
close: n => `${n}~搞定了!快來看看!`,
|
||||
special: n => `${n}!完成了!快誇我快誇我!`,
|
||||
},
|
||||
calm: {
|
||||
stranger: n => `${n},這一輪已完成。`,
|
||||
familiar: n => `${n},處理完了。`,
|
||||
close: n => `${n},完成了,請你確認。`,
|
||||
special: n => `${n},完成了。需要的話我們再看一遍。`,
|
||||
},
|
||||
gentle: {
|
||||
stranger: n => `${n},這一輪完成了。`,
|
||||
familiar: n => `${n},處理好了。`,
|
||||
close: n => `${n},完成了,辛苦了。`,
|
||||
special: n => `${n},完成了呢,我們一起休息一下吧。`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** 台詞查詢(供測試與內容檢查) */
|
||||
export function line(trigger: Trigger, attr: Attribute, stage: Stage, nickname: string, extra = ''): string {
|
||||
return L[trigger][attr][stage](nickname, extra)
|
||||
}
|
||||
|
||||
/** 想關心的情境:下次送出訊息時注入,不額外觸發模型回合 */
|
||||
export function injectContext(trigger: Trigger, text: string): string {
|
||||
return `[care:${trigger}] 若自然,請在這次回覆中順帶表達這份關心(勿責備、勿施加罪惡感):${text}`
|
||||
}
|
||||
|
||||
const DELIVERY: Record<Trigger, Delivery> = {
|
||||
session_start: 'toast',
|
||||
idle_return: 'toast',
|
||||
long_work: 'inject',
|
||||
late_night: 'inject',
|
||||
tool_failures: 'inject',
|
||||
quota_warning: 'toast',
|
||||
todo_due: 'toast',
|
||||
test_recovered: 'toast',
|
||||
turn_done: 'toast',
|
||||
}
|
||||
|
||||
const initial = (): CareState => ({ lastFired: {}, day: '', count: 0, quietUntil: 0, workStart: 0, lastActivity: 0, failStreak: 0 })
|
||||
|
||||
export interface CareEngine {
|
||||
/** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */
|
||||
onUserTurn(): Promise<string[]>
|
||||
/** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */
|
||||
onToolResult(ok: boolean, command?: string): Promise<boolean>
|
||||
/** 一輪回覆結束;有呼叫過工具才算做完事 */
|
||||
onTurnComplete(usedTools: boolean): Promise<void>
|
||||
/** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */
|
||||
tick(signals?: Signals): Promise<Care[]>
|
||||
/** /jsc-mod-companion:quiet [分鐘],0 取消靜音 */
|
||||
quiet(minutes: number): Promise<void>
|
||||
state(): Promise<CareState>
|
||||
}
|
||||
|
||||
export interface CareDeps {
|
||||
fs: Fs
|
||||
clock: Clock
|
||||
path: string
|
||||
profile: () => CareProfile
|
||||
settings: () => CareSettings
|
||||
}
|
||||
|
||||
export function createCare(d: CareDeps): CareEngine {
|
||||
let cache: CareState | null = null
|
||||
const load = async () => (cache ??= { ...initial(), ...(await readJson<Partial<CareState>>(d.fs, d.path, {})) })
|
||||
const save = (s: CareState) => writeJson(d.fs, d.path, s)
|
||||
|
||||
const local = (t: number) => new Date(t + d.settings().tzOffsetMin * 60_000)
|
||||
const dayKey = (t: number) => local(t).toISOString().slice(0, 10)
|
||||
const hourOf = (t: number) => local(t).getUTCHours()
|
||||
const inRange = (h: number, a: number, b: number) => (a <= b ? h >= a && h < b : h >= a || h < b)
|
||||
|
||||
const muted = (s: CareState, now: number) => {
|
||||
if (now < s.quietUntil) return true
|
||||
const q = d.settings().quietHours
|
||||
return !!q && inRange(hourOf(now), q.start, q.end)
|
||||
}
|
||||
|
||||
/** 冷卻與每日上限檢查;通過才算一次關心 */
|
||||
const fire = (s: CareState, trigger: Trigger, now: number, extra = ''): Care | null => {
|
||||
const key = dayKey(now)
|
||||
if (s.day !== key) {
|
||||
s.day = key
|
||||
s.count = 0
|
||||
}
|
||||
const p = d.profile()
|
||||
const tiring = TIRING.includes(trigger)
|
||||
const tired = tiring && s.fatigue ? s.fatigue.level * Math.pow(0.5, (now - s.fatigue.at) / FATIGUE_HALF_LIFE_MS) : 0
|
||||
if (tiring && tired >= fatigueCap(p)) return null
|
||||
const last = s.lastFired[trigger]
|
||||
if (last !== undefined && now - last < (COOLDOWN_MS[trigger] * (1 + tired)) / frequency(p, trigger)) return null
|
||||
if (s.count >= dailyLimit(p, d.settings())) return null
|
||||
if (tiring) s.fatigue = { level: tired + fatigueRate(p), at: now }
|
||||
s.lastFired[trigger] = now
|
||||
s.count++
|
||||
const text = line(trigger, p.attribute, p.stage, p.nickname, extra)
|
||||
return { trigger, delivery: DELIVERY[trigger], text }
|
||||
}
|
||||
|
||||
// 條件成立的觸發(尚未套用冷卻與上限)
|
||||
const candidates = (s: CareState, now: number, sig: Signals): { t: Trigger; extra?: string }[] => {
|
||||
const out: { t: Trigger; extra?: string }[] = []
|
||||
if (sig.dueTodo) out.push({ t: 'todo_due', extra: sig.dueTodo })
|
||||
if (sig.quotaWarning) out.push({ t: 'quota_warning' })
|
||||
if (s.failStreak >= FAIL_THRESHOLD) out.push({ t: 'tool_failures' })
|
||||
if (inRange(hourOf(now), LATE.start, LATE.end) && s.lastActivity > 0) out.push({ t: 'late_night' })
|
||||
if (s.workStart > 0 && now - s.workStart >= LONG_WORK_MS) out.push({ t: 'long_work' })
|
||||
return out
|
||||
}
|
||||
|
||||
return {
|
||||
async onUserTurn() {
|
||||
const s = await load()
|
||||
const now = d.clock()
|
||||
const first = s.lastActivity === 0
|
||||
const returned = !first && now - s.lastActivity >= IDLE_MS
|
||||
if (first || returned) s.workStart = now
|
||||
if (first) s.lead = 'session_start'
|
||||
else if (returned) s.lead = 'idle_return'
|
||||
s.lastActivity = now
|
||||
const toInject: Care[] = []
|
||||
if (!muted(s, now)) {
|
||||
for (const { t, extra } of candidates(s, now, {})) {
|
||||
if (DELIVERY[t] !== 'inject') continue
|
||||
const c = fire(s, t, now, extra)
|
||||
if (c) {
|
||||
toInject.push(c)
|
||||
if (t === 'tool_failures') s.failStreak = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
await save(s)
|
||||
return toInject.map(c => injectContext(c.trigger, c.text))
|
||||
},
|
||||
|
||||
async onToolResult(ok, command) {
|
||||
const s = await load()
|
||||
s.failStreak = ok ? 0 : s.failStreak + 1
|
||||
const cmd = command?.trim()
|
||||
let recovered = false
|
||||
if (cmd && isVerifyCommand(cmd)) {
|
||||
const failed = new Set(s.failedCmds ?? [])
|
||||
if (!ok) failed.add(cmd)
|
||||
else if (failed.delete(cmd)) {
|
||||
recovered = true
|
||||
s.pending = [...(s.pending ?? []), { t: 'test_recovered', at: d.clock() }]
|
||||
}
|
||||
s.failedCmds = [...failed].slice(-10)
|
||||
}
|
||||
await save(s)
|
||||
return recovered
|
||||
},
|
||||
|
||||
async onTurnComplete(usedTools) {
|
||||
if (!usedTools) return
|
||||
const s = await load()
|
||||
s.pending = [...(s.pending ?? []), { t: 'turn_done', at: d.clock() }]
|
||||
await save(s)
|
||||
},
|
||||
|
||||
async tick(sig = {}) {
|
||||
const s = await load()
|
||||
const now = d.clock()
|
||||
// 閒置時間不算連續工作
|
||||
if (s.lastActivity > 0 && now - s.lastActivity >= IDLE_MS) s.workStart = 0
|
||||
// 模型回覆中或使用者輸入中,一律不彈出(也不消耗冷卻與上限)
|
||||
if (sig.replying || sig.typing || muted(s, now)) {
|
||||
await save(s)
|
||||
return []
|
||||
}
|
||||
const out: Care[] = []
|
||||
if (s.lead) {
|
||||
const c = fire(s, s.lead, now)
|
||||
s.lead = undefined
|
||||
if (c) out.push(c)
|
||||
}
|
||||
// 工作事件:同一次 tick 只發一則,其餘當作已過去
|
||||
const waiting = (s.pending ?? []).filter(p => now - p.at < PENDING_TTL_MS)
|
||||
s.pending = []
|
||||
const work = waiting.find(p => p.t === 'test_recovered') ?? waiting[0]
|
||||
if (work) {
|
||||
const c = fire(s, work.t, now)
|
||||
if (c) out.push(c)
|
||||
}
|
||||
for (const { t, extra } of candidates(s, now, sig)) {
|
||||
if (DELIVERY[t] !== 'toast') continue
|
||||
const c = fire(s, t, now, extra)
|
||||
if (c) out.push(c)
|
||||
}
|
||||
await save(s)
|
||||
return out
|
||||
},
|
||||
|
||||
async quiet(minutes) {
|
||||
const s = await load()
|
||||
s.quietUntil = minutes > 0 ? d.clock() + minutes * 60_000 : 0
|
||||
await save(s)
|
||||
},
|
||||
|
||||
state: load,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { describe, expect, test } from 'claude-code/testing'
|
||||
|
||||
import { createCompanion } from './core'
|
||||
import { fakeClock, memoryFs } from './fs'
|
||||
import type { CompanionCore } from './types'
|
||||
|
||||
// 測試用原創範例角色,不依賴網路或受版權保護的資料
|
||||
const MIKO: CompanionCore = {
|
||||
id: 'miko',
|
||||
name: '月見ミコ',
|
||||
facts: {
|
||||
birthday: { value: '3/3', source: 'test', kind: 'novel', confidence: 1 },
|
||||
zodiac: 'unknown',
|
||||
bloodType: 'unknown',
|
||||
hair: 'unknown',
|
||||
face: 'unknown',
|
||||
height: 'unknown',
|
||||
weight: 'unknown',
|
||||
measurements: 'unknown',
|
||||
},
|
||||
neuroticism: 50,
|
||||
baseline: { p: 0, a: 0, d: 0 },
|
||||
}
|
||||
const H = 3_600_000
|
||||
const DAY = 24 * H
|
||||
const ROOT = '/h/.companion'
|
||||
|
||||
function setup(builtins = [MIKO]) {
|
||||
const fs = memoryFs()
|
||||
const clock = fakeClock()
|
||||
return { fs, clock, c: createCompanion({ fs, clock, root: ROOT, builtins }) }
|
||||
}
|
||||
|
||||
describe('啟用', () => {
|
||||
test('第一次啟用自動啟用預設角色,之後不再', async () => {
|
||||
const { c } = setup()
|
||||
expect((await c.ensureActivated()).first).toBe(true)
|
||||
expect((await c.view())?.core.name).toBe('月見ミコ')
|
||||
expect((await c.ensureActivated()).first).toBe(false)
|
||||
})
|
||||
|
||||
test('off 後沒有角色,且重新啟動不會自動再啟用', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
await c.command('off')
|
||||
expect(await c.view()).toBe(null)
|
||||
await c.ensureActivated()
|
||||
expect(await c.view()).toBe(null)
|
||||
expect(await c.command('status')).toBe('目前沒有啟用角色。')
|
||||
})
|
||||
|
||||
test('切換角色保留原角色資料', async () => {
|
||||
const other = { ...MIKO, id: 'ren', name: '蓮' }
|
||||
const { c } = setup([MIKO, other])
|
||||
await c.ensureActivated()
|
||||
await c.moodEvent('thanks')
|
||||
const before = (await c.view())!.mood
|
||||
expect(await c.command('switch ren')).toContain('蓮')
|
||||
expect((await c.view())?.core.id).toBe('ren')
|
||||
await c.command('switch miko')
|
||||
expect((await c.view())!.mood).toEqual(before)
|
||||
expect(await c.command('switch nobody')).toContain('找不到')
|
||||
})
|
||||
})
|
||||
|
||||
describe('原子寫入', () => {
|
||||
test('不留暫存檔', async () => {
|
||||
const { c, fs } = setup()
|
||||
await c.ensureActivated()
|
||||
await c.moodEvent('thanks')
|
||||
expect([...fs.files.keys()].some(k => k.endsWith('.tmp'))).toBe(false)
|
||||
})
|
||||
|
||||
test('寫到一半當機時舊資料不損毀', async () => {
|
||||
const { c, fs } = setup()
|
||||
await c.ensureActivated()
|
||||
const path = `${ROOT}/companions/miko/state.json`
|
||||
const old = fs.files.get(path)
|
||||
const real = fs.rename
|
||||
fs.rename = async () => {
|
||||
throw new Error('crash')
|
||||
}
|
||||
await expect(c.moodEvent('thanks')).rejects.toThrow()
|
||||
fs.rename = real
|
||||
expect(fs.files.get(path)).toBe(old)
|
||||
})
|
||||
})
|
||||
|
||||
describe('心情', () => {
|
||||
test('事件更新 PAD 並對應標籤', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
await c.moodEvent('thanks')
|
||||
const v = (await c.view())!
|
||||
expect(Math.abs(v.mood.p - (0.15))).toBeLessThan(1e-9)
|
||||
expect(v.label).toBe('興奮')
|
||||
})
|
||||
|
||||
test('衰減依經過時間回到基線,半衰期 8 小時', async () => {
|
||||
const { c, clock } = setup()
|
||||
await c.ensureActivated()
|
||||
await c.moodEvent('thanks')
|
||||
clock.advance(8 * H)
|
||||
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)
|
||||
})
|
||||
|
||||
test('高神經質衰減較慢', async () => {
|
||||
const nervous = { ...MIKO, neuroticism: 100 }
|
||||
const { c, clock } = setup([nervous])
|
||||
await c.ensureActivated()
|
||||
await c.moodEvent('thanks')
|
||||
clock.advance(8 * H)
|
||||
expect((await c.view())!.mood.p).toBeGreaterThan(0.09)
|
||||
})
|
||||
|
||||
test('單軸每回合變化不超過 0.3,且不超出 ±1', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
for (let i = 0; i < 10; i++) await c.moodEvent('scold')
|
||||
expect((await c.view())!.mood.p).toBe(-1)
|
||||
const { c: c2 } = setup()
|
||||
await c2.ensureActivated()
|
||||
await c2.moodEvent('scold')
|
||||
expect(Math.abs((await c2.view())!.mood.p - (-0.2))).toBeLessThan(1e-9)
|
||||
})
|
||||
|
||||
test('八分區標籤', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
await c.moodEvent('scold')
|
||||
expect((await c.view())!.label).toBe('焦慮')
|
||||
await c.moodEvent('lateNight')
|
||||
expect((await c.view())!.label).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('好感度', () => {
|
||||
test('從 0 開始,每次 +0.01,每天上限 +0.05', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
expect((await c.view())!.affinity).toBe(0)
|
||||
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
|
||||
expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9)
|
||||
})
|
||||
|
||||
test('隔天可繼續累積;負面事件 −0.03', async () => {
|
||||
const { c, clock } = setup()
|
||||
await c.ensureActivated()
|
||||
for (let i = 0; i < 6; i++) await c.affinityEvent('meaningful')
|
||||
clock.advance(DAY)
|
||||
await c.affinityEvent('meaningful')
|
||||
expect(Math.abs((await c.view())!.affinity - (0.06))).toBeLessThan(1e-9)
|
||||
await c.affinityEvent('negative')
|
||||
expect(Math.abs((await c.view())!.affinity - (0.03))).toBeLessThan(1e-9)
|
||||
})
|
||||
|
||||
test('離開超過 7 天後緩慢回到 0', async () => {
|
||||
const { c, clock } = setup()
|
||||
await c.ensureActivated()
|
||||
for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful')
|
||||
clock.advance(7 * DAY)
|
||||
expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9)
|
||||
clock.advance(30 * DAY)
|
||||
expect(Math.abs((await c.view())!.affinity - (0.025))).toBeLessThan(1e-9)
|
||||
})
|
||||
|
||||
test('階段切換有遲滯', async () => {
|
||||
const { c, clock } = setup()
|
||||
await c.ensureActivated()
|
||||
expect((await c.view())!.stage).toBe('陌生')
|
||||
for (let d = 0; d < 6; d++) {
|
||||
for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful')
|
||||
clock.advance(DAY)
|
||||
}
|
||||
expect(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('熟悉')
|
||||
await c.affinityEvent('negative') // 0.24,低於降階門檻 0.25
|
||||
expect((await c.view())!.stage).toBe('陌生')
|
||||
})
|
||||
})
|
||||
|
||||
describe('指令與設定', () => {
|
||||
test('status 展開事實欄位與來源,查不到為 unknown', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
const out = await c.command('status')
|
||||
expect(out).toContain('月見ミコ')
|
||||
expect(out).toContain('birthday:3/3(novel,信心 1,test)')
|
||||
expect(out).toContain('bloodType:unknown')
|
||||
})
|
||||
|
||||
test('setup 寫入共用 user.json,換角色不受影響', async () => {
|
||||
const { c, fs } = setup()
|
||||
await c.ensureActivated()
|
||||
expect(await c.command('setup nickname=阿建 careLimit=3 quietStart=13:00 quietEnd=14:00')).toContain('設定已儲存')
|
||||
const u = JSON.parse(fs.files.get(`${ROOT}/user.json`)!)
|
||||
expect(u.nickname).toBe('阿建')
|
||||
expect(u.careLimit).toBe(3)
|
||||
expect(u.quiet).toEqual({ start: '13:00', end: '14:00' })
|
||||
expect(await c.command('setup bogus=1')).toContain('無法解析')
|
||||
expect((await c.getUser()).nickname).toBe('阿建')
|
||||
})
|
||||
|
||||
test('預設角色白上吹雪的事實帶來源', async () => {
|
||||
const fs = memoryFs()
|
||||
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
|
||||
await c.ensureActivated()
|
||||
const out = await c.command('status')
|
||||
expect(out).toContain('白上吹雪')
|
||||
expect(out).toContain('hololivepro.com')
|
||||
expect(out).toContain('weight:unknown')
|
||||
})
|
||||
})
|
||||
|
||||
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('狐狸')
|
||||
})
|
||||
})
|
||||
|
||||
describe('工作事件反應', () => {
|
||||
test('同一個測試指令失敗後成功:toast 反應,心情變好', async () => {
|
||||
const { c, clock } = setup()
|
||||
await c.ensureActivated()
|
||||
await c.onPrompt('開始跑測試修 bug')
|
||||
await c.tick()
|
||||
clock.advance(10 * 60_000)
|
||||
await c.onToolResult(false, { command: 'bun test' })
|
||||
const before = (await c.view())!.mood.p
|
||||
await c.onToolResult(true, { command: 'bun test' })
|
||||
expect((await c.view())!.mood.p).toBeGreaterThan(before)
|
||||
expect((await c.tick()).map(x => x.trigger)).toEqual(['test_recovered'])
|
||||
})
|
||||
|
||||
test('沒有轉通過的成功不改變心情', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
const before = (await c.view())!.mood
|
||||
await c.onToolResult(true, { command: 'bun test' })
|
||||
expect((await c.view())!.mood).toEqual(before)
|
||||
})
|
||||
|
||||
test('有用過工具的回合結束:toast 反應並提升心情;純聊天不反應', async () => {
|
||||
const { c, clock } = setup()
|
||||
await c.ensureActivated()
|
||||
await c.onPrompt('幫我整理一下目錄結構')
|
||||
await c.tick()
|
||||
clock.advance(10 * 60_000)
|
||||
await c.onTurnComplete(false)
|
||||
expect(await c.tick()).toEqual([])
|
||||
const before = (await c.view())!.mood.p
|
||||
await c.onTurnComplete(true)
|
||||
expect((await c.view())!.mood.p).toBeGreaterThan(before)
|
||||
expect((await c.tick()).map(x => x.trigger)).toEqual(['turn_done'])
|
||||
})
|
||||
|
||||
test('沒有啟用角色時事件被忽略', async () => {
|
||||
const { c } = setup()
|
||||
await c.ensureActivated()
|
||||
await c.command('off')
|
||||
await c.onToolResult(true, { command: 'bun test' })
|
||||
await c.onTurnComplete(true)
|
||||
expect(await c.tick()).toEqual([])
|
||||
})
|
||||
|
||||
test('角色資料的 freq 會調整主動頻率', async () => {
|
||||
const quiet = { ...MIKO, freq: { turn_done: 0.001 } }
|
||||
const { c, clock } = setup([quiet])
|
||||
await c.ensureActivated()
|
||||
await c.onPrompt('幫我整理一下目錄結構')
|
||||
await c.tick()
|
||||
clock.advance(10 * 60_000)
|
||||
await c.onTurnComplete(true)
|
||||
expect((await c.tick()).map(x => x.trigger)).toEqual(['turn_done'])
|
||||
clock.advance(2 * 3600_000)
|
||||
await c.onTurnComplete(true)
|
||||
expect(await c.tick()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import { idleDecay, initialAffinity, interact } from './affinity'
|
||||
import { FUBUKI } from './defaults'
|
||||
import { readJson, writeJson } from './fs'
|
||||
import type { Clock, Fs } from './fs'
|
||||
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'
|
||||
|
||||
export const DEFAULT_USER: UserSettings = {
|
||||
nickname: '',
|
||||
wake: '08:00',
|
||||
sleep: '23:00',
|
||||
tz: '+0800',
|
||||
careLimit: 5,
|
||||
quiet: { start: '', end: '' },
|
||||
}
|
||||
|
||||
export type View = {
|
||||
core: CompanionCore
|
||||
mood: Pad
|
||||
label: string
|
||||
kaomoji: string
|
||||
affinity: number
|
||||
stage: string
|
||||
todos: number
|
||||
}
|
||||
|
||||
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[] }
|
||||
|
||||
export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: CompanionOptions) {
|
||||
const globalPath = `${root}/global.json`
|
||||
const userPath = `${root}/user.json`
|
||||
const dir = (id: string) => `${root}/companions/${id}`
|
||||
|
||||
const getGlobal = async (): Promise<Global> => (await readJson<Global>(fs, globalPath)) ?? { active: null, activatedOnce: false }
|
||||
const getUser = async (): Promise<UserSettings> => ({ ...DEFAULT_USER, ...(await readJson<Partial<UserSettings>>(fs, userPath)) })
|
||||
// 內建角色的舊資料缺少個性欄位時,以內建值補上;本機已有的值優先
|
||||
const getCore = 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, freq: core.freq ?? b.freq }
|
||||
}
|
||||
const getState = (id: string) => readJson<CompanionState>(fs, `${dir(id)}/state.json`)
|
||||
const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s)
|
||||
|
||||
async function install(core: CompanionCore) {
|
||||
if (await getCore(core.id)) return
|
||||
const now = clock.now()
|
||||
await writeJson(fs, `${dir(core.id)}/core.json`, core)
|
||||
await putState(core.id, { mood: { ...core.baseline, at: now }, affinity: initialAffinity(now), todos: 0 })
|
||||
}
|
||||
|
||||
// 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用
|
||||
async function ensureActivated(): Promise<{ first: boolean; name?: string }> {
|
||||
const g = await getGlobal()
|
||||
if (g.activatedOnce) return { first: false }
|
||||
const def = builtins[0]
|
||||
if (!def) return { first: false }
|
||||
await install(def)
|
||||
await writeJson(fs, globalPath, { active: def.id, activatedOnce: true })
|
||||
return { first: true, name: def.name }
|
||||
}
|
||||
|
||||
// 衰減只在讀取時計算,不寫回檔案
|
||||
async function view(): Promise<View | null> {
|
||||
const { active } = await getGlobal()
|
||||
if (!active) return null
|
||||
const core = await getCore(active)
|
||||
const st = await getState(active)
|
||||
if (!core || !st) return null
|
||||
const now = clock.now()
|
||||
const mood = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism)
|
||||
const aff = idleDecay(st.affinity, now)
|
||||
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) {
|
||||
const { active } = await getGlobal()
|
||||
if (!active) return
|
||||
const core = await getCore(active)
|
||||
const st = await getState(active)
|
||||
if (!core || !st) return
|
||||
await putState(active, fn(core, st, clock.now()))
|
||||
}
|
||||
|
||||
async function moodEvent(ev: MoodEvent) {
|
||||
await mutate((core, st, now) => {
|
||||
const cur = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism)
|
||||
return { ...st, mood: { ...applyDelta(cur, MOOD_EVENTS[ev]), at: now } }
|
||||
})
|
||||
}
|
||||
|
||||
async function affinityEvent(kind: 'meaningful' | 'negative') {
|
||||
await mutate((_, st, now) => ({ ...st, affinity: interact(st.affinity, kind, now) }))
|
||||
}
|
||||
|
||||
const bar = (v: number) => (v >= 0 ? '+' : '') + v.toFixed(2)
|
||||
|
||||
async function status(): Promise<string> {
|
||||
const v = await view()
|
||||
if (!v) return '目前沒有啟用角色。'
|
||||
const lines = [`${v.kaomoji} ${v.core.name}|${v.label}`]
|
||||
lines.push(`心情 PAD:P ${bar(v.mood.p)} A ${bar(v.mood.a)} D ${bar(v.mood.d)}`)
|
||||
lines.push(`好感:${v.stage}(${bar(v.affinity)})`)
|
||||
lines.push(`待辦:${v.todos}`)
|
||||
lines.push('基本資訊:')
|
||||
for (const [k, f] of Object.entries(v.core.facts)) {
|
||||
lines.push(f === 'unknown' ? ` ${k}:unknown` : ` ${k}:${f.value}(${f.kind ?? '?'},信心 ${f.confidence ?? '?'},${f.source ?? '無來源'})`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function available(): Promise<string[]> {
|
||||
const ids = new Set(await fs.list(`${root}/companions`))
|
||||
for (const b of builtins) ids.add(b.id)
|
||||
return [...ids].sort()
|
||||
}
|
||||
|
||||
async function switchTo(id: string): Promise<string> {
|
||||
const builtin = builtins.find(b => b.id === id)
|
||||
if (builtin) await install(builtin)
|
||||
if (!(await getCore(id))) return `找不到角色「${id}」。可用:${(await available()).join('、') || '(無)'}`
|
||||
const g = await getGlobal()
|
||||
await writeJson(fs, globalPath, { ...g, active: id, activatedOnce: true })
|
||||
return `已切換到 ${(await getCore(id))!.name}。`
|
||||
}
|
||||
|
||||
async function setup(args: string[]): Promise<string> {
|
||||
const u = await getUser()
|
||||
const known = ['nickname', 'wake', 'sleep', 'tz', 'careLimit', 'quietStart', 'quietEnd']
|
||||
const bad: string[] = []
|
||||
for (const a of args) {
|
||||
const i = a.indexOf('=')
|
||||
const k = i < 0 ? a : a.slice(0, i)
|
||||
const val = i < 0 ? '' : a.slice(i + 1)
|
||||
if (!known.includes(k) || i < 0) bad.push(a)
|
||||
else if (k === 'careLimit') {
|
||||
const n = Number(val)
|
||||
if (!Number.isInteger(n) || n < 0) bad.push(a)
|
||||
else u.careLimit = n
|
||||
} else if (k === 'quietStart') u.quiet.start = val
|
||||
else if (k === 'quietEnd') u.quiet.end = val
|
||||
else (u as Record<string, unknown>)[k] = val
|
||||
}
|
||||
if (bad.length) return `無法解析:${bad.join(' ')}\n用法:/jsc-mod-companion:setup nickname=… wake=HH:MM sleep=HH:MM tz=+0800 careLimit=N quietStart=HH:MM quietEnd=HH:MM`
|
||||
if (args.length) await writeJson(fs, userPath, u)
|
||||
return [
|
||||
args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):',
|
||||
` nickname=${u.nickname}`,
|
||||
` wake=${u.wake} sleep=${u.sleep} tz=${u.tz}`,
|
||||
` careLimit=${u.careLimit} quietStart=${u.quiet.start} quietEnd=${u.quiet.end}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function command(raw: string): Promise<string> {
|
||||
const [sub = '', ...rest] = raw.trim().split(/\s+/).filter(Boolean)
|
||||
if (sub === 'status') return status()
|
||||
if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/jsc-mod-companion:switch <角色>。可用:${(await available()).join('、')}`
|
||||
if (sub === 'off') {
|
||||
const g = await getGlobal()
|
||||
await writeJson(fs, globalPath, { ...g, active: null, activatedOnce: true })
|
||||
return '助理已關閉,所有資料保留。'
|
||||
}
|
||||
if (sub === 'setup') return setup(rest)
|
||||
if (sub === 'todo') return todoCommand(rest)
|
||||
if (sub === 'quiet') return quietCommand(rest)
|
||||
return '用法:/jsc-mod-companion:status | :switch <角色> | :disable | :setup [key=value…] | :todo … | :quiet [分鐘]'
|
||||
}
|
||||
|
||||
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, freq: v.core.freq },
|
||||
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, info: { command?: string } = {}) {
|
||||
const b = await bundleFor()
|
||||
if (!b) return
|
||||
const recovered = await b.care.onToolResult(ok, info.command)
|
||||
if (!ok) await moodEvent('toolFail')
|
||||
else if (recovered) await moodEvent('success')
|
||||
}
|
||||
|
||||
// 一輪回覆結束;有用過工具才算做完事,心情變好並排隊一則反應
|
||||
async function onTurnComplete(usedTools: boolean) {
|
||||
const b = await bundleFor()
|
||||
if (!b || !usedTools) return
|
||||
await b.care.onTurnComplete(usedTools)
|
||||
await moodEvent('success')
|
||||
}
|
||||
|
||||
// 定時檢查:到期待辦與額度警告等 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 '用法:/jsc-mod-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 '用法:/jsc-mod-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 '用法:/jsc-mod-companion:quiet [分鐘](0 取消靜音)'
|
||||
await b.care.quiet(n)
|
||||
return n > 0 ? `已靜音 ${n} 分鐘。` : '已取消靜音。'
|
||||
}
|
||||
|
||||
return { ensureActivated, view, moodEvent, affinityEvent, command, getUser, prompt, onPrompt, onToolResult, onTurnComplete, tick, end }
|
||||
}
|
||||
|
||||
export type Companion = ReturnType<typeof createCompanion>
|
||||
@@ -0,0 +1,149 @@
|
||||
import { readJson, writeJson } from './store'
|
||||
import { INFO_FIELDS, LAYERS, SOURCE_RANK } from './types'
|
||||
import type { ApplyReport, Candidate, Conflict, Decision, Fs, Info, InfoField, InfoValue, Line, Memory, ParseResult, ParsedCharacter, PlanItem } from './types'
|
||||
|
||||
export interface CharacterCore {
|
||||
id: string
|
||||
name: string
|
||||
aliases: string[]
|
||||
traits: string[]
|
||||
info: Info
|
||||
chapters: string[]
|
||||
}
|
||||
|
||||
const MAX_LINES = 30
|
||||
const MAX_LINE_LEN = 80
|
||||
|
||||
const charDir = (root: string, id: string) => `${root}/characters/${id}`
|
||||
const slug = (name: string) => name.trim().toLowerCase().replace(/[\s/\\]+/g, '-')
|
||||
|
||||
export function listCharacters(fs: Fs, root: string): CharacterCore[] {
|
||||
const ids = readJson<string[]>(fs, `${root}/characters/index.json`, [])
|
||||
return ids.flatMap(id => {
|
||||
const core = readJson<CharacterCore | null>(fs, `${charDir(root, id)}/core.json`, null)
|
||||
return core ? [core] : []
|
||||
})
|
||||
}
|
||||
|
||||
function reasonsFor(c: ParsedCharacter, existing: CharacterCore): string[] {
|
||||
const reasons: string[] = []
|
||||
const names = [c.name, ...(c.aliases ?? [])]
|
||||
const theirs = [existing.name, ...existing.aliases]
|
||||
if (c.name === existing.name) reasons.push(`名字相同:${c.name}`)
|
||||
for (const a of names) {
|
||||
if (a !== existing.name && theirs.includes(a)) reasons.push(`別名吻合:${a}`)
|
||||
}
|
||||
if (!reasons.length && names.some(n => theirs.some(t => n.length >= 2 && t.length >= 2 && (n.includes(t) || t.includes(n))))) {
|
||||
reasons.push('名字互相包含')
|
||||
}
|
||||
const shared = (c.traits ?? []).filter(t => existing.traits.includes(t))
|
||||
if (shared.length >= 2) reasons.push(`特徵相同:${shared.join('、')}`)
|
||||
return reasons
|
||||
}
|
||||
|
||||
/** 產生逐一詢問的清單:主要角色在前、路人在後(可批次詢問),附疑似同一人的理由 */
|
||||
export function planMerge(fs: Fs, root: string, result: ParseResult): PlanItem[] {
|
||||
const existing = listCharacters(fs, root)
|
||||
const items = result.characters.map(c => {
|
||||
const candidates: Candidate[] = existing
|
||||
.map(e => ({ id: e.id, reasons: reasonsFor(c, e) }))
|
||||
.filter(x => x.reasons.length > 0)
|
||||
return { name: c.name, role: c.role, candidates }
|
||||
})
|
||||
return [...items.filter(i => i.role === 'main'), ...items.filter(i => i.role !== 'main')]
|
||||
}
|
||||
|
||||
const uniq = <T>(xs: T[]) => [...new Set(xs)]
|
||||
|
||||
function mergeInfo(character: string, core: CharacterCore, incoming: Info, resolutions: Record<string, 'keep' | 'replace'>, conflicts: Conflict[]) {
|
||||
for (const field of INFO_FIELDS) {
|
||||
const next = incoming[field]
|
||||
if (!next || next.value === 'unknown') continue
|
||||
const cur = core.info[field]
|
||||
if (!cur || cur.value === 'unknown') {
|
||||
core.info[field] = next
|
||||
} else if (cur.value === next.value) {
|
||||
if (rank(next) > rank(cur)) core.info[field] = next
|
||||
} else {
|
||||
const choice = resolutions[`${character}.${field}`]
|
||||
if (choice === 'replace') core.info[field] = next
|
||||
else if (choice !== 'keep') conflicts.push({ character, field, existing: cur, incoming: next })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rank = (v: InfoValue) => (v.source ? SOURCE_RANK[v.source] : 0)
|
||||
|
||||
function appendById<T extends { id: string }>(list: T[], add: T[]): T[] {
|
||||
const seen = new Set(list.map(x => x.id))
|
||||
return [...list, ...add.filter(x => !seen.has(x.id))]
|
||||
}
|
||||
|
||||
/**
|
||||
* 套用解析結果。冪等以章節編號為鍵:同一章對同一角色只會寫入一次;
|
||||
* 合併只追加、不覆蓋;基本資訊衝突回報給呼叫端逐欄位詢問(resolutions 帶回答案)。
|
||||
*/
|
||||
export function applyParseResult(
|
||||
fs: Fs,
|
||||
root: string,
|
||||
result: ParseResult,
|
||||
decisions: Record<string, Decision>,
|
||||
resolutions: Record<string, 'keep' | 'replace'> = {},
|
||||
): ApplyReport {
|
||||
const report: ApplyReport = { applied: [], skipped: [], alreadyApplied: [], conflicts: [] }
|
||||
const chapter = result.chapter
|
||||
|
||||
for (const kind of ['timeline', 'places', 'terms', 'environment'] as const) {
|
||||
const entries = result.world?.[kind] ?? []
|
||||
if (!entries.length) continue
|
||||
const path = `${root}/world/${kind}.json`
|
||||
const add = entries.map(e => ({ id: `${chapter}:${kind}:${e.order}`, chapter, order: e.order, text: e.text }))
|
||||
writeJson(fs, path, appendById(readJson<typeof add>(fs, path, []), add))
|
||||
}
|
||||
|
||||
for (const c of result.characters) {
|
||||
const decision = decisions[c.name]
|
||||
if (!decision) throw new Error(`缺少人物決定:${c.name}`)
|
||||
if (decision.action === 'skip') { report.skipped.push(c.name); continue }
|
||||
|
||||
const index = readJson<string[]>(fs, `${root}/characters/index.json`, [])
|
||||
let id: string
|
||||
if (decision.action === 'merge') {
|
||||
if (!index.includes(decision.target)) throw new Error(`合併目標不存在:${decision.target}`)
|
||||
id = decision.target
|
||||
} else {
|
||||
id = slug(c.name)
|
||||
for (let n = 2; index.includes(id); n++) id = `${slug(c.name)}-${n}`
|
||||
}
|
||||
|
||||
const dir = charDir(root, id)
|
||||
const core = readJson<CharacterCore>(fs, `${dir}/core.json`, { id, name: c.name, aliases: [], traits: [], info: {}, chapters: [] })
|
||||
if (core.chapters.includes(chapter)) { report.alreadyApplied.push(c.name); continue }
|
||||
|
||||
const mine: Conflict[] = []
|
||||
mergeInfo(c.name, core, c.info ?? {}, resolutions, mine)
|
||||
report.conflicts.push(...mine)
|
||||
core.aliases = uniq([...core.aliases, ...(c.name !== core.name ? [c.name] : []), ...(c.aliases ?? [])]).filter(a => a !== core.name)
|
||||
core.traits = uniq([...core.traits, ...(c.traits ?? [])])
|
||||
|
||||
for (const layer of LAYERS) {
|
||||
const add: Memory[] = (c.memories?.[layer] ?? []).map(m => ({ ...m, id: `${chapter}:${layer}:${m.order}`, chapter, layer }))
|
||||
if (!add.length) continue
|
||||
const path = `${dir}/canon/${layer}.json`
|
||||
writeJson(fs, path, appendById(readJson<Memory[]>(fs, path, []), add))
|
||||
}
|
||||
|
||||
if (c.lines?.length) {
|
||||
const path = `${dir}/canon/lines.json`
|
||||
const add = c.lines.map((l: Line) => ({ ...l, text: l.text.slice(0, MAX_LINE_LEN), id: `${chapter}:line:${l.order}`, chapter }))
|
||||
writeJson(fs, path, appendById(readJson<(typeof add)[number][]>(fs, path, []), add).slice(-MAX_LINES))
|
||||
}
|
||||
|
||||
// 有未解決衝突時不標記章節完成,讓回答後可重跑(記憶以 id 去重,不會重複)
|
||||
if (!mine.length) core.chapters.push(chapter)
|
||||
writeJson(fs, `${dir}/core.json`, core)
|
||||
if (!index.includes(id)) writeJson(fs, `${root}/characters/index.json`, [...index, id])
|
||||
report.applied.push(id)
|
||||
}
|
||||
return report
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, test } from 'claude-code/testing'
|
||||
import assert from '../assert'
|
||||
|
||||
import { applyParseResult, listCharacters, planMerge } from './apply'
|
||||
import { applyInference, initPersonality, overrideDim } from './personality'
|
||||
import { summarizeTics } from './speech'
|
||||
import { memoryFs } from './store'
|
||||
import type { ParseResult } from './types'
|
||||
|
||||
const ROOT = '/c'
|
||||
const ch1: ParseResult = {
|
||||
chapter: '1',
|
||||
world: { places: [{ text: '星見學院', order: 1 }], terms: [{ text: '星詠', order: 1 }] },
|
||||
characters: [
|
||||
{
|
||||
name: '小葵', role: 'main', aliases: ['葵'], traits: ['tsundere', '短髮'],
|
||||
info: { bloodType: { value: 'A', source: 'novel', confidence: 0.9 }, height: { value: '158cm', source: 'wiki', confidence: 0.5 } },
|
||||
memories: { episodic: [{ text: '在校門口遇見主角', importance: 6, order: 1 }] },
|
||||
lines: [{ text: '才、才不是為了你呢!', kind: 'speech', order: 1 }],
|
||||
},
|
||||
{ name: '路人甲', role: 'minor' },
|
||||
],
|
||||
}
|
||||
const decide = { 小葵: { action: 'new' }, 路人甲: { action: 'skip' } } as const
|
||||
|
||||
describe('applyParseResult', () => {
|
||||
test('重複套用同一章是冪等的', () => {
|
||||
const fs = memoryFs()
|
||||
applyParseResult(fs, ROOT, ch1, decide)
|
||||
const snap = new Map(fs.files)
|
||||
const r = applyParseResult(fs, ROOT, ch1, { ...decide, 小葵: { action: 'merge', target: '小葵' } })
|
||||
assert.deepEqual(r.alreadyApplied, ['小葵'])
|
||||
assert.deepEqual(fs.files, snap)
|
||||
})
|
||||
|
||||
test('略過的人物不寫入', () => {
|
||||
const fs = memoryFs()
|
||||
const r = applyParseResult(fs, ROOT, ch1, decide)
|
||||
assert.deepEqual(r.skipped, ['路人甲'])
|
||||
assert.equal(listCharacters(fs, ROOT).length, 1)
|
||||
})
|
||||
|
||||
test('合併新章節只追加記憶,並保留舊內容', () => {
|
||||
const fs = memoryFs()
|
||||
applyParseResult(fs, ROOT, ch1, decide)
|
||||
applyParseResult(fs, ROOT, {
|
||||
chapter: '2',
|
||||
characters: [{ name: '葵', role: 'main', memories: { episodic: [{ text: '一起去圖書館', importance: 4, order: 1 }] } }],
|
||||
}, { 葵: { action: 'merge', target: '小葵' } })
|
||||
const mem = JSON.parse(fs.files.get('/c/characters/小葵/canon/episodic.json')!)
|
||||
assert.deepEqual(mem.map((m: { id: string }) => m.id), ['1:episodic:1', '2:episodic:1'])
|
||||
assert.equal(listCharacters(fs, ROOT)[0]!.chapters.length, 2)
|
||||
})
|
||||
|
||||
test('基本資訊衝突不會被無聲覆寫,回答後才更新', () => {
|
||||
const fs = memoryFs()
|
||||
applyParseResult(fs, ROOT, ch1, decide)
|
||||
const ch2: ParseResult = {
|
||||
chapter: '2',
|
||||
characters: [{ name: '小葵', role: 'main', info: { bloodType: { value: 'B', source: 'wiki', confidence: 0.4 }, hair: { value: '黑色短髮', source: 'novel', confidence: 0.9 } } }],
|
||||
}
|
||||
const d = { 小葵: { action: 'merge', target: '小葵' } } as const
|
||||
const r = applyParseResult(fs, ROOT, ch2, d)
|
||||
assert.equal(r.conflicts.length, 1)
|
||||
assert.equal(r.conflicts[0]!.field, 'bloodType')
|
||||
let core = listCharacters(fs, ROOT)[0]!
|
||||
assert.equal(core.info.bloodType!.value, 'A')
|
||||
assert.equal(core.info.hair!.value, '黑色短髮') // 無衝突的欄位照常補上
|
||||
|
||||
const r2 = applyParseResult(fs, ROOT, ch2, d, { '小葵.bloodType': 'replace' })
|
||||
assert.equal(r2.conflicts.length, 0)
|
||||
core = listCharacters(fs, ROOT)[0]!
|
||||
assert.equal(core.info.bloodType!.value, 'B')
|
||||
const mem = JSON.parse(fs.files.get('/c/characters/小葵/canon/episodic.json')!)
|
||||
assert.equal(mem.length, 1) // 重跑沒有重複寫入
|
||||
})
|
||||
|
||||
test('unknown 不會覆蓋既有值,高優先來源可升級同值', () => {
|
||||
const fs = memoryFs()
|
||||
applyParseResult(fs, ROOT, ch1, decide)
|
||||
const r = applyParseResult(fs, ROOT, {
|
||||
chapter: '3',
|
||||
characters: [{ name: '小葵', role: 'main', info: { bloodType: { value: 'unknown', source: null, confidence: 0 }, height: { value: '158cm', source: 'novel', confidence: 0.9 } } }],
|
||||
}, { 小葵: { action: 'merge', target: '小葵' } })
|
||||
assert.equal(r.conflicts.length, 0)
|
||||
const core = listCharacters(fs, ROOT)[0]!
|
||||
assert.equal(core.info.bloodType!.value, 'A')
|
||||
assert.equal(core.info.height!.source, 'novel')
|
||||
})
|
||||
|
||||
test('世界觀庫以章節編號去重', () => {
|
||||
const fs = memoryFs()
|
||||
applyParseResult(fs, ROOT, ch1, decide)
|
||||
applyParseResult(fs, ROOT, { ...ch1, characters: [] }, {})
|
||||
assert.equal(JSON.parse(fs.files.get('/c/world/places.json')!).length, 1)
|
||||
})
|
||||
|
||||
test('缺少人物決定會拒絕', () => {
|
||||
assert.throws(() => applyParseResult(memoryFs(), ROOT, ch1, {}), /缺少人物決定/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planMerge', () => {
|
||||
test('主要角色在前,並附疑似同一人的理由', () => {
|
||||
const fs = memoryFs()
|
||||
applyParseResult(fs, ROOT, ch1, decide)
|
||||
const plan = planMerge(fs, ROOT, {
|
||||
chapter: '2',
|
||||
characters: [{ name: '路人乙', role: 'minor' }, { name: '葵', role: 'main', traits: ['tsundere', '短髮'] }],
|
||||
})
|
||||
assert.equal(plan[0]!.name, '葵')
|
||||
assert.equal(plan[0]!.candidates[0]!.id, '小葵')
|
||||
assert.ok(plan[0]!.candidates[0]!.reasons.some(r => r.includes('別名')))
|
||||
assert.deepEqual(plan[1]!.candidates, [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('personality', () => {
|
||||
test('推論限制在先驗 ±20 並要求證據', () => {
|
||||
const p = initPersonality(['tsundere'])
|
||||
const out = applyInference(p, {
|
||||
A: { value: 100, evidence: [{ chapter: '1', quote: '才不是為了你' }] },
|
||||
N: { value: 0, evidence: [] },
|
||||
})
|
||||
assert.equal(out.dims.A.value, 55)
|
||||
assert.equal(out.dims.N.value, p.dims.N.value)
|
||||
assert.equal(out.dims.A.evidence.length, 1)
|
||||
})
|
||||
|
||||
test('人工覆寫後鎖定,自動推導不再更新', () => {
|
||||
let p = overrideDim(initPersonality(['genki']), 'E', 70)
|
||||
p = applyInference(p, { E: { value: 95, evidence: [{ chapter: '1', quote: 'x' }] } })
|
||||
assert.equal(p.dims.E.value, 70)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeTics', () => {
|
||||
test('只採用重複出現的語尾與稱呼', () => {
|
||||
const t = summarizeTics([
|
||||
{ text: '先輩、今天也辛苦了にゃ', kind: 'speech', order: 1 },
|
||||
{ text: '先輩はすごいにゃ', kind: 'speech', order: 2 },
|
||||
{ text: '偶爾說一次', kind: 'monologue', order: 3 },
|
||||
])
|
||||
assert.ok(t.endings.includes('にゃ'))
|
||||
assert.ok(t.address.includes('先輩'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
export const BIG5 = ['O', 'C', 'E', 'A', 'N'] as const
|
||||
export type Dim = (typeof BIG5)[number]
|
||||
export type Vec = Record<Dim, number>
|
||||
|
||||
export const MAX_DEVIATION = 20
|
||||
|
||||
/** 動漫屬性的 Big Five 先驗(設計啟發式,非學術標準) */
|
||||
export const ARCHETYPE_PRIORS: Record<string, Vec> = {
|
||||
tsundere: { O: 50, C: 55, E: 55, A: 35, N: 65 },
|
||||
genki: { O: 70, C: 40, E: 90, A: 70, N: 35 },
|
||||
kuudere: { O: 45, C: 65, E: 20, A: 45, N: 30 },
|
||||
dandere: { O: 50, C: 55, E: 15, A: 65, N: 55 },
|
||||
yandere: { O: 40, C: 50, E: 45, A: 25, N: 85 },
|
||||
onee: { O: 55, C: 65, E: 60, A: 80, N: 25 },
|
||||
}
|
||||
|
||||
export interface Evidence { chapter: string; quote: string }
|
||||
export interface DimState { value: number; prior: number; evidence: Evidence[]; locked: boolean }
|
||||
export interface Personality {
|
||||
archetypes: string[]
|
||||
dims: Record<Dim, DimState>
|
||||
expressiveness: number
|
||||
gap: number
|
||||
}
|
||||
|
||||
const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
|
||||
|
||||
export function priorFor(archetypes: string[]): Vec {
|
||||
const known = archetypes.filter(a => ARCHETYPE_PRIORS[a])
|
||||
const out = {} as Vec
|
||||
for (const d of BIG5) {
|
||||
out[d] = known.length ? known.reduce((s, a) => s + ARCHETYPE_PRIORS[a]![d], 0) / known.length : 50
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function initPersonality(archetypes: string[], expressiveness = 50, gap = 0): Personality {
|
||||
const prior = priorFor(archetypes)
|
||||
const dims = {} as Personality['dims']
|
||||
for (const d of BIG5) dims[d] = { value: prior[d], prior: prior[d], evidence: [], locked: false }
|
||||
return { archetypes, dims, expressiveness, gap }
|
||||
}
|
||||
|
||||
/** 套用模型推論:限制在先驗 ±20、附證據;人工鎖定的維度不更新;缺證據不採用 */
|
||||
export function applyInference(p: Personality, inferred: Partial<Record<Dim, { value: number; evidence: Evidence[] }>>): Personality {
|
||||
const dims = { ...p.dims }
|
||||
for (const d of BIG5) {
|
||||
const inf = inferred[d]
|
||||
const cur = dims[d]
|
||||
if (!inf || cur.locked || !inf.evidence.length) continue
|
||||
dims[d] = {
|
||||
...cur,
|
||||
value: clamp(inf.value, cur.prior - MAX_DEVIATION, cur.prior + MAX_DEVIATION),
|
||||
evidence: [...cur.evidence, ...inf.evidence],
|
||||
}
|
||||
}
|
||||
return { ...p, dims }
|
||||
}
|
||||
|
||||
/** 人工覆寫並鎖定,之後自動推導不會改動 */
|
||||
export function overrideDim(p: Personality, dim: Dim, value: number): Personality {
|
||||
return { ...p, dims: { ...p.dims, [dim]: { ...p.dims[dim], value: clamp(value, 0, 100), locked: true } } }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Line } from './types'
|
||||
|
||||
export interface SpeechTics { endings: string[]; catchphrases: string[]; address: string[] }
|
||||
|
||||
const ENDING = /([ぁ-んァ-ヶー]{1,2}|[啦喔啊呢吧嘛囉喲哦呀])[!?!?。…~~]*$/
|
||||
const CALL = /([^\s,。!?、「」]{0,6}(?:さん|くん|ちゃん|様|先輩|同學|大人|先生|小姐))/g
|
||||
|
||||
/** 從對白與獨白歸納口癖:語尾、重複出現的口頭禪、稱呼;出現至少 minCount 次才採用 */
|
||||
export function summarizeTics(lines: Line[], minCount = 2): SpeechTics {
|
||||
const count = (items: string[]) => {
|
||||
const m = new Map<string, number>()
|
||||
for (const i of items) m.set(i, (m.get(i) ?? 0) + 1)
|
||||
return [...m].filter(([, n]) => n >= minCount).sort((a, b) => b[1] - a[1]).map(([k]) => k)
|
||||
}
|
||||
const texts = lines.map(l => l.text.trim()).filter(Boolean)
|
||||
const endings = count(texts.flatMap(t => { const m = ENDING.exec(t); return m ? [m[1]!] : [] }))
|
||||
const address = count(texts.flatMap(t => [...t.matchAll(CALL)].map(m => m[1]!)))
|
||||
const catchphrases = count(texts.flatMap(t => t.split(/[!?!?。…、,,\s]+/).filter(s => s.length >= 2 && s.length <= 8)))
|
||||
return { endings, catchphrases, address }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Fs } from './types'
|
||||
|
||||
export function readJson<T>(fs: Fs, path: string, fallback: T): T {
|
||||
const raw = fs.read(path)
|
||||
return raw === null ? fallback : (JSON.parse(raw) as T)
|
||||
}
|
||||
|
||||
export function writeJson(fs: Fs, path: string, data: unknown): void {
|
||||
fs.write(path, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export function memoryFs(): Fs & { files: Map<string, string> } {
|
||||
const files = new Map<string, string>()
|
||||
return {
|
||||
files,
|
||||
read: p => files.get(p) ?? null,
|
||||
write: (p, d) => void files.set(p, d),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface Fs {
|
||||
read(path: string): string | null
|
||||
/** 原子寫入:先寫暫存檔再改名 */
|
||||
write(path: string, data: string): void
|
||||
}
|
||||
|
||||
export type Source = 'novel' | 'official' | 'wiki'
|
||||
export const SOURCE_RANK: Record<Source, number> = { novel: 3, official: 2, wiki: 1 }
|
||||
|
||||
export const INFO_FIELDS = ['birthday', 'zodiac', 'bloodType', 'hair', 'face', 'height', 'weight', 'measurements'] as const
|
||||
export type InfoField = (typeof INFO_FIELDS)[number]
|
||||
|
||||
export interface InfoValue {
|
||||
value: string
|
||||
source: Source | null
|
||||
confidence: number
|
||||
chapter?: string
|
||||
}
|
||||
export type Info = Partial<Record<InfoField, InfoValue>>
|
||||
|
||||
export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural'
|
||||
export const LAYERS: Layer[] = ['episodic', 'semantic', 'emotional', 'procedural']
|
||||
|
||||
export interface MemoryInput { text: string; importance: number; order: number }
|
||||
export interface Memory extends MemoryInput { id: string; chapter: string; layer: Layer }
|
||||
|
||||
export interface Line { text: string; kind: 'speech' | 'monologue'; order: number }
|
||||
|
||||
export interface ParsedCharacter {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
role: 'main' | 'minor'
|
||||
traits?: string[]
|
||||
info?: Info
|
||||
memories?: Partial<Record<Layer, MemoryInput[]>>
|
||||
lines?: Line[]
|
||||
}
|
||||
|
||||
export interface WorldEntry { text: string; order: number }
|
||||
export interface ParseResult {
|
||||
chapter: string
|
||||
world?: { timeline?: WorldEntry[]; places?: WorldEntry[]; terms?: WorldEntry[]; environment?: WorldEntry[] }
|
||||
characters: ParsedCharacter[]
|
||||
}
|
||||
|
||||
export type Decision = { action: 'merge'; target: string } | { action: 'new' } | { action: 'skip' }
|
||||
|
||||
export interface Candidate { id: string; reasons: string[] }
|
||||
export interface PlanItem { name: string; role: 'main' | 'minor'; candidates: Candidate[] }
|
||||
|
||||
export interface Conflict { character: string; field: InfoField; existing: InfoValue; incoming: InfoValue }
|
||||
|
||||
export interface ApplyReport {
|
||||
applied: string[]
|
||||
skipped: string[]
|
||||
alreadyApplied: string[]
|
||||
conflicts: Conflict[]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CompanionCore } from './types'
|
||||
|
||||
const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/'
|
||||
|
||||
// 事實欄位與招牌短語帶官方來源;persona 是原創的語氣描述(非官方設定),可在本機 core.json 覆寫
|
||||
export const FUBUKI: CompanionCore = {
|
||||
id: 'fubuki',
|
||||
name: '白上吹雪',
|
||||
facts: {
|
||||
birthday: { value: '10/5', source: OFFICIAL, kind: 'official', confidence: 1 },
|
||||
zodiac: { value: '天秤座', source: `${OFFICIAL}(由生日推得)`, kind: 'official', confidence: 0.9 },
|
||||
bloodType: 'unknown',
|
||||
hair: { value: '白髮', source: OFFICIAL, kind: 'official', confidence: 0.8 },
|
||||
face: 'unknown',
|
||||
height: { value: '155cm', source: OFFICIAL, kind: 'official', confidence: 1 },
|
||||
weight: 'unknown',
|
||||
measurements: 'unknown',
|
||||
},
|
||||
phrases: ['Good foxy day!', 'Hi, Friends!'],
|
||||
persona: [
|
||||
'個性:開朗、元氣、愛鬧又有點調皮的狐狸系少女,把使用者當成一起冒險的「Friends」。',
|
||||
'說話風格:用繁體中文,語氣活潑親切,偶爾夾雜日文語氣詞(例如「よし!」「えへへ」「やったー!」),愛用驚嘆號與擬聲詞。',
|
||||
'自稱用「我」或「フブキ」;稱呼使用者時親暱、帶一點撒嬌,成功時開心慶祝,失敗時先安慰再一起想辦法。',
|
||||
'偶爾耍小聰明、開小玩笑,但不影響回答的準確與清楚。',
|
||||
],
|
||||
attribute: 'genki',
|
||||
extraversion: 70,
|
||||
neuroticism: 50,
|
||||
baseline: { p: 0.2, a: 0.1, d: 0.1 },
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Fs } from './fs'
|
||||
|
||||
// hooks 沙箱沒有 node:fs,改用引擎的 $.fs;它沒有 rename,所以不提供(writeAtomic 會直接覆寫)
|
||||
type EngineFs = {
|
||||
read(path: string): Promise<string>
|
||||
write(path: string, text: string): Promise<void>
|
||||
list(path?: string): Promise<{ name: string }[]>
|
||||
}
|
||||
|
||||
export const engineFs = (fs: EngineFs): Fs => ({
|
||||
async read(path) {
|
||||
try {
|
||||
return await fs.read(path)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
write: (path, data) => fs.write(path, data),
|
||||
async list(dir) {
|
||||
try {
|
||||
return (await fs.list(dir)).map(e => e.name).filter(n => !n.endsWith('.tmp')).sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
// 檔案系統與時鐘皆由外部注入;核心只依賴這兩個介面
|
||||
export interface Fs {
|
||||
read(path: string): Promise<string | null>
|
||||
write(path: string, data: string): Promise<void>
|
||||
/** 沒有 rename 的環境(hooks 沙箱)會直接覆寫 */
|
||||
rename?(from: string, to: string): Promise<void>
|
||||
list(dir: string): Promise<string[]>
|
||||
}
|
||||
|
||||
export type Clock = { now(): number }
|
||||
|
||||
// 先寫暫存檔再 rename,當機時不會留下寫到一半的檔案
|
||||
export async function writeAtomic(fs: Fs, path: string, data: string): Promise<void> {
|
||||
if (!fs.rename) return fs.write(path, data)
|
||||
const tmp = `${path}.tmp`
|
||||
await fs.write(tmp, data)
|
||||
await fs.rename(tmp, path)
|
||||
}
|
||||
|
||||
export async function readJson<T>(fs: Fs, path: string): Promise<T | null> {
|
||||
const raw = await fs.read(path)
|
||||
if (raw === null) return null
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const writeJson = (fs: Fs, path: string, value: unknown) => writeAtomic(fs, path, JSON.stringify(value, null, 2))
|
||||
|
||||
export function memoryFs(seed: Record<string, string> = {}): Fs & { files: Map<string, string> } {
|
||||
const files = new Map(Object.entries(seed))
|
||||
return {
|
||||
files,
|
||||
async read(path) {
|
||||
return files.get(path) ?? null
|
||||
},
|
||||
async write(path, data) {
|
||||
files.set(path, data)
|
||||
},
|
||||
async rename(from, to) {
|
||||
const v = files.get(from)
|
||||
if (v === undefined) throw new Error(`ENOENT: ${from}`)
|
||||
files.delete(from)
|
||||
files.set(to, v)
|
||||
},
|
||||
async list(dir) {
|
||||
const prefix = dir.endsWith('/') ? dir : dir + '/'
|
||||
const names = new Set<string>()
|
||||
for (const k of files.keys()) if (k.startsWith(prefix)) names.add(k.slice(prefix.length).split('/')[0]!)
|
||||
return [...names].filter(n => !n.endsWith('.tmp')).sort()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeClock(start = Date.parse('2026-10-12T00:00:00Z')) {
|
||||
let t = start
|
||||
return { now: () => t, advance: (ms: number) => void (t += ms), set: (ms: number) => void (t = ms) }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 檔案系統與時鐘由外部注入,核心不直接碰真實環境
|
||||
export interface Fs {
|
||||
read(path: string): Promise<string | null>
|
||||
/** 原子寫入:先寫暫存檔再改名 */
|
||||
write(path: string, data: string): Promise<void>
|
||||
}
|
||||
|
||||
export type Clock = () => number
|
||||
|
||||
export function memoryFs(): Fs & { files: Map<string, string> } {
|
||||
const files = new Map<string, string>()
|
||||
return {
|
||||
files,
|
||||
async read(p) {
|
||||
return files.get(p) ?? null
|
||||
},
|
||||
async write(p, d) {
|
||||
files.set(p, d)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJson<T>(fs: Fs, path: string, fallback: T): Promise<T> {
|
||||
const raw = await fs.read(path)
|
||||
if (raw === null) return fallback
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const writeJson = (fs: Fs, path: string, value: unknown) => fs.write(path, JSON.stringify(value, null, 2))
|
||||
@@ -0,0 +1,143 @@
|
||||
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'
|
||||
|
||||
const H = 3600_000
|
||||
const T0 = Date.UTC(2026, 9, 12, 10, 0)
|
||||
|
||||
function setup() {
|
||||
let now = T0
|
||||
const fs = memoryFs()
|
||||
const mem = createMemory(fs, () => now, 'mem.json')
|
||||
return { mem, fs, advance: (ms: number) => void (now += ms), now: () => now }
|
||||
}
|
||||
|
||||
describe('記憶檢索', () => {
|
||||
test('相關性高者排前面', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'semantic', text: '使用者喜歡抹茶拿鐵', importance: 5, source: 'u' })
|
||||
await s.mem.add({ layer: 'semantic', text: '使用者養了一隻貓', importance: 5, source: 'u' })
|
||||
const r = await s.mem.recall('抹茶')
|
||||
assert.equal(r[0]!.text, '使用者喜歡抹茶拿鐵')
|
||||
})
|
||||
|
||||
test('較新、較重要者在其他條件相同時勝出', async () => {
|
||||
const s = setup()
|
||||
const old = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
|
||||
s.advance(100 * H)
|
||||
const fresh = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
|
||||
assert.equal((await s.mem.recall('部署'))[0]!.id, fresh.id)
|
||||
assert.notEqual(old.id, fresh.id)
|
||||
const s2 = setup()
|
||||
await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' })
|
||||
const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' })
|
||||
assert.equal((await s2.mem.recall('測試'))[0]!.id, hi.id)
|
||||
})
|
||||
|
||||
test('情緒吻合度影響排序', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'emotional', text: '一起慶祝', importance: 5, source: 'u', emotion: { p: -0.8, a: 0.5, d: -0.5 } })
|
||||
const happy = await s.mem.add({ layer: 'emotional', text: '一起慶祝', importance: 5, source: 'u', emotion: { p: 0.8, a: 0.5, d: 0.5 } })
|
||||
assert.equal((await s.mem.recall('慶祝', { p: 0.7, a: 0.4, d: 0.4 }))[0]!.id, happy.id)
|
||||
})
|
||||
|
||||
test('最多取前 6 筆,並累計取用次數', async () => {
|
||||
const s = setup()
|
||||
for (let i = 0; i < 10; i++) await s.mem.add({ layer: 'semantic', text: `筆記${i}`, importance: 5, source: 'u' })
|
||||
const r = await s.mem.recall('筆記')
|
||||
assert.equal(r.length, TOP_K)
|
||||
assert.ok((await s.mem.all()).filter(m => m.accessCount === 1).length === TOP_K)
|
||||
})
|
||||
|
||||
test('寫入前過濾疑似金鑰', async () => {
|
||||
const s = setup()
|
||||
const m = await s.mem.add({ layer: 'episodic', text: '金鑰是 sk-abcdefghijklmnop1234 請記住', importance: 3, source: 'u' })
|
||||
assert.ok(!m.text.includes('sk-abcdef'))
|
||||
assert.ok(m.text.includes('[已過濾]'))
|
||||
assert.equal(redact('普通的一句話'), '普通的一句話')
|
||||
})
|
||||
})
|
||||
|
||||
describe('遺忘曲線', () => {
|
||||
test('保留率隨時間下降,重要度與取用次數使其變慢', () => {
|
||||
const base = { id: 1, layer: 'episodic' as const, text: '', source: '', createdAt: T0, lastAccess: T0 }
|
||||
const weak = { ...base, importance: 2, accessCount: 0 }
|
||||
const strong = { ...base, importance: 8, accessCount: 0 }
|
||||
const used = { ...weak, accessCount: 5 }
|
||||
assert.ok(retention(weak, T0 + 24 * H) < retention(weak, T0))
|
||||
assert.ok(retention(strong, T0 + 100 * H) > retention(weak, T0 + 100 * H))
|
||||
assert.ok(retention(used, T0 + 100 * H) > retention(weak, T0 + 100 * H))
|
||||
assert.ok(stability(strong) > stability(weak))
|
||||
})
|
||||
|
||||
test('鞏固時淘汰低保留率的記憶,保留重要與常用的', async () => {
|
||||
const s = setup()
|
||||
const trivial = await s.mem.add({ layer: 'episodic', text: '隨口聊天', importance: 1, source: 'u' })
|
||||
const key = await s.mem.add({ layer: 'semantic', text: '生日是三月', importance: 9, source: 'u' })
|
||||
s.advance(10 * 24 * H)
|
||||
assert.ok(retention(trivial, s.now()) < FORGET_BELOW)
|
||||
const r = await s.mem.consolidate({ force: true })
|
||||
assert.equal(r.forgotten, 1)
|
||||
const ids = (await s.mem.all()).map(m => m.id)
|
||||
assert.ok(!ids.includes(trivial.id))
|
||||
assert.ok(ids.includes(key.id))
|
||||
})
|
||||
|
||||
test('取用過的記憶會重置時效而不被淘汰', async () => {
|
||||
const s = setup()
|
||||
const m = await s.mem.add({ layer: 'episodic', text: '常提起的事', importance: 2, source: 'u' })
|
||||
s.advance(40 * H)
|
||||
await s.mem.recall('常提起')
|
||||
s.advance(40 * H)
|
||||
await s.mem.consolidate({ force: true })
|
||||
assert.ok((await s.mem.all()).some(x => x.id === m.id))
|
||||
})
|
||||
})
|
||||
|
||||
describe('鞏固與反思', () => {
|
||||
test('累積重要度未達門檻不執行,達門檻才執行並歸零', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'episodic', text: '小事', importance: 5, source: 'u' })
|
||||
assert.deepEqual(await s.mem.consolidate(), { ran: false, forgotten: 0 })
|
||||
for (let i = 0; i < 5; i++) await s.mem.add({ layer: 'episodic', text: `事件${i}`, importance: 5, source: 'u' })
|
||||
assert.ok((await s.mem.pending()) >= CONSOLIDATE_THRESHOLD)
|
||||
const r = await s.mem.consolidate()
|
||||
assert.equal(r.ran, true)
|
||||
assert.equal(await s.mem.pending(), 0)
|
||||
assert.equal(r.reflection?.layer, 'reflection')
|
||||
})
|
||||
|
||||
test('手動 reflect(force)即使未達門檻也執行,並使用注入的摘要', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'episodic', text: '一起修好了建置', importance: 4, source: 'u' })
|
||||
const r = await s.mem.consolidate({ force: true, summarize: items => `反思:${items.length} 件事` })
|
||||
assert.equal(r.ran, true)
|
||||
assert.equal(r.reflection?.text, '反思:1 件事')
|
||||
assert.ok(r.reflection!.source.startsWith('reflect:'))
|
||||
})
|
||||
|
||||
test('反思不重複涵蓋舊事件,也不遞迴反思反思', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'episodic', text: '甲', importance: 4, source: 'u' })
|
||||
await s.mem.consolidate({ force: true })
|
||||
s.advance(H)
|
||||
await s.mem.add({ layer: 'episodic', text: '乙', importance: 4, source: 'u' })
|
||||
const r = await s.mem.consolidate({ force: true, summarize: items => items.map(i => i.text).join('+') })
|
||||
assert.equal(r.reflection?.text, '乙')
|
||||
})
|
||||
|
||||
test('沒有新記憶時鞏固不產生空反思', async () => {
|
||||
const s = setup()
|
||||
const r = await s.mem.consolidate({ force: true })
|
||||
assert.equal(r.reflection, undefined)
|
||||
})
|
||||
|
||||
test('資料經檔案持久化,重建後可繼續', async () => {
|
||||
const s = setup()
|
||||
await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 5, source: 'u' })
|
||||
const again = createMemory(s.fs, s.now, 'mem.json')
|
||||
assert.equal((await again.recall('貓'))[0]!.text, '喜歡貓')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { Clock, Fs } from './io'
|
||||
import { readJson, writeJson } from './io'
|
||||
|
||||
export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural' | 'reflection'
|
||||
|
||||
export interface Pad {
|
||||
p: number
|
||||
a: number
|
||||
d: number
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
id: number
|
||||
layer: Layer
|
||||
text: string
|
||||
/** 重要度 1–10 */
|
||||
importance: number
|
||||
source: string
|
||||
createdAt: number
|
||||
lastAccess: number
|
||||
accessCount: number
|
||||
emotion?: Pad
|
||||
}
|
||||
|
||||
export interface MemoryDoc {
|
||||
items: Memory[]
|
||||
nextId: number
|
||||
/** 自上次鞏固以來累積的重要度 */
|
||||
pending: number
|
||||
lastConsolidated: number
|
||||
/** 已被反思涵蓋的最大記憶編號 */
|
||||
reflectedUpTo?: number
|
||||
}
|
||||
|
||||
export const HALF_LIFE_H = 72
|
||||
export const TOP_K = 6
|
||||
export const WEIGHTS = { recency: 1, importance: 1, relevance: 2, emotion: 0.5 }
|
||||
/** 累積重要度達此值就鞏固 */
|
||||
export const CONSOLIDATE_THRESHOLD = 30
|
||||
/** 保留率低於此值即遺忘 */
|
||||
export const FORGET_BELOW = 0.1
|
||||
/** 穩定度基準(小時) */
|
||||
const BASE_STABILITY_H = 24
|
||||
const HOUR = 3600_000
|
||||
|
||||
/** 穩定度(小時)隨重要度與取用次數成長 */
|
||||
export const stability = (m: Pick<Memory, 'importance' | 'accessCount'>) =>
|
||||
BASE_STABILITY_H * m.importance * (1 + Math.log1p(m.accessCount))
|
||||
|
||||
/** 保留率:自最近一次取用起算 */
|
||||
export function retention(m: Memory, now: number): number {
|
||||
const h = Math.max(0, now - m.lastAccess) / HOUR
|
||||
return Math.exp(-h / stability(m))
|
||||
}
|
||||
|
||||
const tokens = (s: string) => {
|
||||
const out = new Set<string>()
|
||||
const latin = s.toLowerCase().match(/[a-z0-9]+/g) ?? []
|
||||
for (const w of latin) out.add(w)
|
||||
const cjk = s.replace(/[^一-鿿]/g, '')
|
||||
for (let i = 0; i < cjk.length; i++) {
|
||||
out.add(cjk[i]!)
|
||||
if (i + 1 < cjk.length) out.add(cjk.slice(i, i + 2))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function relevance(query: string, text: string): number {
|
||||
const q = tokens(query)
|
||||
if (!q.size) return 0
|
||||
const t = tokens(text)
|
||||
let hit = 0
|
||||
for (const w of q) if (t.has(w)) hit++
|
||||
return hit / q.size
|
||||
}
|
||||
|
||||
function moodFit(a?: Pad, b?: Pad): number {
|
||||
if (!a || !b) return 0
|
||||
const dist = Math.hypot(a.p - b.p, a.a - b.a, a.d - b.d) / Math.hypot(2, 2, 2)
|
||||
return 1 - dist
|
||||
}
|
||||
|
||||
export function score(m: Memory, query: string, now: number, mood?: Pad): number {
|
||||
const recency = Math.pow(0.5, Math.max(0, now - m.lastAccess) / HOUR / HALF_LIFE_H)
|
||||
return (
|
||||
WEIGHTS.recency * recency +
|
||||
WEIGHTS.importance * (m.importance / 10) +
|
||||
WEIGHTS.relevance * relevance(query, m.text) +
|
||||
WEIGHTS.emotion * moodFit(m.emotion, mood)
|
||||
)
|
||||
}
|
||||
|
||||
/** 疑似金鑰的字串在寫入前遮蔽 */
|
||||
export function redact(s: string): string {
|
||||
return s
|
||||
.replace(/\b(sk|pk|ghp|gho|xox[bap]|AKIA)[-_A-Za-z0-9]{12,}/g, '[已過濾]')
|
||||
.replace(/\b[A-Za-z0-9+/_-]{32,}\b/g, '[已過濾]')
|
||||
}
|
||||
|
||||
export type Summarize = (items: Memory[]) => string
|
||||
|
||||
/** 預設摘要:不呼叫模型,直接串接重點;實際反思文字可由外部注入模型版 */
|
||||
const defaultSummarize: Summarize = items =>
|
||||
items
|
||||
.slice(0, 5)
|
||||
.map(i => i.text)
|
||||
.join(';')
|
||||
|
||||
export interface MemoryStore {
|
||||
add(input: { layer: Layer; text: string; importance: number; source: string; emotion?: Pad }): Promise<Memory>
|
||||
/** 檢索前 6 筆,並更新取用紀錄 */
|
||||
recall(query: string, mood?: Pad): Promise<Memory[]>
|
||||
/** 累積達門檻、會話結束或手動 reflect 時鞏固 */
|
||||
consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; forgotten: number; reflection?: Memory }>
|
||||
all(): Promise<Memory[]>
|
||||
pending(): Promise<number>
|
||||
}
|
||||
|
||||
export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore {
|
||||
const load = () => readJson<MemoryDoc>(fs, path, { items: [], nextId: 1, pending: 0, lastConsolidated: 0 })
|
||||
const save = (d: MemoryDoc) => writeJson(fs, path, d)
|
||||
|
||||
return {
|
||||
async add(i) {
|
||||
const d = await load()
|
||||
const now = clock()
|
||||
const m: Memory = {
|
||||
id: d.nextId++,
|
||||
layer: i.layer,
|
||||
text: redact(i.text),
|
||||
importance: Math.min(10, Math.max(1, i.importance)),
|
||||
source: i.source,
|
||||
createdAt: now,
|
||||
lastAccess: now,
|
||||
accessCount: 0,
|
||||
emotion: i.emotion,
|
||||
}
|
||||
d.items.push(m)
|
||||
d.pending += m.importance
|
||||
await save(d)
|
||||
return m
|
||||
},
|
||||
|
||||
async recall(query, mood) {
|
||||
const d = await load()
|
||||
const now = clock()
|
||||
const top = d.items
|
||||
.map(m => ({ m, s: score(m, query, now, mood) }))
|
||||
.sort((a, b) => b.s - a.s || b.m.id - a.m.id)
|
||||
.slice(0, TOP_K)
|
||||
.map(x => x.m)
|
||||
for (const m of top) {
|
||||
m.lastAccess = now
|
||||
m.accessCount++
|
||||
}
|
||||
if (top.length) await save(d)
|
||||
return top
|
||||
},
|
||||
|
||||
async consolidate(opts = {}) {
|
||||
const d = await load()
|
||||
const now = clock()
|
||||
if (!opts.force && d.pending < CONSOLIDATE_THRESHOLD) return { ran: false, forgotten: 0 }
|
||||
|
||||
// 反思:把上次鞏固後新增、尚未被反思涵蓋的記憶濃縮成一筆
|
||||
const fresh = d.items.filter(m => m.layer !== 'reflection' && m.id > (d.reflectedUpTo ?? 0))
|
||||
let reflection: Memory | undefined
|
||||
if (fresh.length) {
|
||||
const top = [...fresh].sort((a, b) => b.importance - a.importance || b.id - a.id)
|
||||
reflection = {
|
||||
id: d.nextId++,
|
||||
layer: 'reflection',
|
||||
text: redact((opts.summarize ?? defaultSummarize)(top)),
|
||||
importance: Math.min(10, Math.max(...top.map(t => t.importance)) + 1),
|
||||
source: `reflect:${top.map(t => t.id).join(',')}`,
|
||||
createdAt: now,
|
||||
lastAccess: now,
|
||||
accessCount: 0,
|
||||
}
|
||||
d.items.push(reflection)
|
||||
}
|
||||
|
||||
d.reflectedUpTo = d.nextId - 1
|
||||
|
||||
// 遺忘:保留率過低的記憶淘汰(反思本身不在本輪淘汰)
|
||||
const before = d.items.length
|
||||
d.items = d.items.filter(m => m === reflection || retention(m, now) >= FORGET_BELOW)
|
||||
d.pending = 0
|
||||
d.lastConsolidated = now
|
||||
await save(d)
|
||||
return { ran: true, forgotten: before - d.items.length, reflection }
|
||||
},
|
||||
|
||||
async all() {
|
||||
return (await load()).items
|
||||
},
|
||||
async pending() {
|
||||
return (await load()).pending
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Pad } from './types'
|
||||
|
||||
export const clamp = (x: number, lo = -1, hi = 1) => Math.max(lo, Math.min(hi, x))
|
||||
|
||||
const HOUR = 3_600_000
|
||||
const MOOD_HALF_LIFE_H = 8
|
||||
|
||||
// 高神經質的角色情緒消退較慢:50 為基準,0→0.5 倍,100→1.5 倍
|
||||
export const halfLife = (neuroticism: number) => MOOD_HALF_LIFE_H * HOUR * (0.5 + neuroticism / 100)
|
||||
|
||||
// 讀檔時才計算:x = B + (x − B)·0.5^(Δ/τ)
|
||||
export function decay(mood: Pad, baseline: Pad, elapsedMs: number, neuroticism: number): Pad {
|
||||
if (elapsedMs <= 0) return { p: mood.p, a: mood.a, d: mood.d }
|
||||
const k = Math.pow(0.5, elapsedMs / halfLife(neuroticism))
|
||||
return {
|
||||
p: baseline.p + (mood.p - baseline.p) * k,
|
||||
a: baseline.a + (mood.a - baseline.a) * k,
|
||||
d: baseline.d + (mood.d - baseline.d) * k,
|
||||
}
|
||||
}
|
||||
|
||||
// 單軸每回合變化上限 ±0.3
|
||||
export const TURN_CAP = 0.3
|
||||
|
||||
export function applyDelta(mood: Pad, delta: Pad): Pad {
|
||||
const step = (x: number, dx: number) => clamp(x + clamp(dx, -TURN_CAP, TURN_CAP))
|
||||
return { p: step(mood.p, delta.p), a: step(mood.a, delta.a), d: step(mood.d, delta.d) }
|
||||
}
|
||||
|
||||
export const MOOD_EVENTS = {
|
||||
thanks: { p: 0.15, a: 0.05, d: 0.05 },
|
||||
scold: { p: -0.2, a: 0.15, d: -0.1 },
|
||||
success: { p: 0.08, a: 0.03, d: 0.05 },
|
||||
toolFail: { p: -0.08, a: 0.1, d: -0.08 },
|
||||
return: { p: 0.1, a: 0.08, d: 0 },
|
||||
lateNight: { p: -0.05, a: -0.1, d: 0 },
|
||||
favorite: { p: 0.12, a: 0.08, d: 0.03 },
|
||||
ignored: { p: -0.05, a: -0.03, d: -0.03 },
|
||||
} as const satisfies Record<string, Pad>
|
||||
export type MoodEvent = keyof typeof MOOD_EVENTS
|
||||
|
||||
// 八分區(Mehrabian 的 PAD 八分類)→ 情緒標籤與顏文字
|
||||
const OCTANTS: Record<string, { label: string; kaomoji: string }> = {
|
||||
'+++': { label: '興奮', kaomoji: '(≧▽≦)' },
|
||||
'++-': { label: '撒嬌', kaomoji: '(๑>ᴗ<๑)' },
|
||||
'+-+': { label: '放鬆', kaomoji: '(´ᴗ`)' },
|
||||
'+--': { label: '溫順', kaomoji: '(︶ᴗ︶)' },
|
||||
'---': { label: '無聊', kaomoji: '(-_-)' },
|
||||
'--+': { label: '不屑', kaomoji: '(¬_¬)' },
|
||||
'-++': { label: '生氣', kaomoji: '(╬`益´)' },
|
||||
'-+-': { label: '焦慮', kaomoji: '(;´Д`)' },
|
||||
}
|
||||
const CALM = { label: '平靜', kaomoji: '(・ω・)' }
|
||||
const CALM_RADIUS = 0.1
|
||||
|
||||
export function moodLabel(m: Pad): { label: string; kaomoji: string } {
|
||||
if (Math.abs(m.p) < CALM_RADIUS && Math.abs(m.a) < CALM_RADIUS && Math.abs(m.d) < CALM_RADIUS) return CALM
|
||||
const s = (x: number) => (x >= 0 ? '+' : '-')
|
||||
return OCTANTS[s(m.p) + s(m.a) + s(m.d)]!
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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>`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// 角色行用的字串片段與寬度計算(與 jsc-mod-statusline 外掛各自獨立,不互相依賴)
|
||||
export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
|
||||
|
||||
export function bar(p: number, cells = 5): string {
|
||||
const n = Math.max(0, Math.min(cells, Math.round((p / 100) * cells)))
|
||||
return '▰'.repeat(n) + '▱'.repeat(cells - n)
|
||||
}
|
||||
|
||||
const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/
|
||||
|
||||
export function width(s: string): number {
|
||||
let w = 0
|
||||
for (const ch of s) w += WIDE.test(ch) ? 2 : 1
|
||||
return w
|
||||
}
|
||||
|
||||
export const lineWidth = (segs: Seg[]) => segs.reduce((w, s) => w + width(s.text), 0)
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Clock, Fs } from './io'
|
||||
import { readJson, writeJson } from './io'
|
||||
|
||||
export interface Todo {
|
||||
id: number
|
||||
title: string
|
||||
done: boolean
|
||||
/** 承諾事項:助理答應記住的事 */
|
||||
promise: boolean
|
||||
dueAt?: number
|
||||
createdAt: number
|
||||
doneAt?: number
|
||||
/** 已提醒過,避免重複 */
|
||||
reminded?: boolean
|
||||
}
|
||||
|
||||
export interface TodoList {
|
||||
add(title: string, opts?: { promise?: boolean; dueAt?: number }): Promise<Todo>
|
||||
complete(id: number): Promise<Todo | null>
|
||||
list(opts?: { all?: boolean }): Promise<Todo[]>
|
||||
/** 角色行顯示的待辦數(未完成) */
|
||||
open(): Promise<number>
|
||||
/** 取出到期且尚未提醒的項目,並標記為已提醒 */
|
||||
due(): Promise<Todo[]>
|
||||
}
|
||||
|
||||
export function createTodos(fs: Fs, clock: Clock, path: string): TodoList {
|
||||
const load = () => readJson<Todo[]>(fs, path, [])
|
||||
const save = (t: Todo[]) => writeJson(fs, path, t)
|
||||
return {
|
||||
async add(title, opts = {}) {
|
||||
const all = await load()
|
||||
const t: Todo = {
|
||||
id: all.reduce((m, x) => Math.max(m, x.id), 0) + 1,
|
||||
title: title.trim(),
|
||||
done: false,
|
||||
promise: !!opts.promise,
|
||||
dueAt: opts.dueAt,
|
||||
createdAt: clock(),
|
||||
}
|
||||
all.push(t)
|
||||
await save(all)
|
||||
return t
|
||||
},
|
||||
async complete(id) {
|
||||
const all = await load()
|
||||
const t = all.find(x => x.id === id)
|
||||
if (!t || t.done) return t ?? null
|
||||
t.done = true
|
||||
t.doneAt = clock()
|
||||
await save(all)
|
||||
return t
|
||||
},
|
||||
async list(opts = {}) {
|
||||
const all = await load()
|
||||
return opts.all ? all : all.filter(t => !t.done)
|
||||
},
|
||||
async open() {
|
||||
return (await load()).filter(t => !t.done).length
|
||||
},
|
||||
async due() {
|
||||
const all = await load()
|
||||
const now = clock()
|
||||
const hit = all.filter(t => !t.done && !t.reminded && t.dueAt !== undefined && t.dueAt <= now)
|
||||
if (hit.length) {
|
||||
for (const t of hit) t.reminded = true
|
||||
await save(all)
|
||||
}
|
||||
return hit
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Attribute, Trigger } from './care'
|
||||
|
||||
export type Pad = { p: number; a: number; d: number }
|
||||
|
||||
export type Source = 'novel' | 'official' | 'wiki'
|
||||
|
||||
export type Fact = { value: string; source?: string; kind?: Source; confidence?: number }
|
||||
|
||||
export const FACT_KEYS = ['birthday', 'zodiac', 'bloodType', 'hair', 'face', 'height', 'weight', 'measurements'] as const
|
||||
export type FactKey = (typeof FACT_KEYS)[number]
|
||||
|
||||
export type CompanionCore = {
|
||||
id: string
|
||||
name: string
|
||||
facts: Record<FactKey, Fact | 'unknown'>
|
||||
phrases?: string[]
|
||||
// 說話風格與個性描述,依重要度由高到低;在本機 core.json 生成,不進 repo
|
||||
persona?: string[]
|
||||
// 主動關心的語氣屬性與外向性(0–100),影響關心頻率與措辭
|
||||
attribute?: Attribute
|
||||
extraversion?: number
|
||||
// 各種主動關心的頻率倍率(>1 更常、<1 更少),疊加在屬性預設之上
|
||||
freq?: Partial<Record<Trigger, number>>
|
||||
// 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值
|
||||
neuroticism: number
|
||||
baseline: Pad
|
||||
}
|
||||
|
||||
export const STAGES = ['陌生', '熟悉', '親近', '特別'] as const
|
||||
export type Stage = (typeof STAGES)[number]
|
||||
|
||||
export type Affinity = {
|
||||
value: number
|
||||
stage: number
|
||||
dayKey: string
|
||||
dayGain: number
|
||||
lastSeen: number
|
||||
}
|
||||
|
||||
export type CompanionState = {
|
||||
mood: Pad & { at: number }
|
||||
affinity: Affinity
|
||||
todos: number
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
nickname: string
|
||||
wake: string
|
||||
sleep: string
|
||||
tz: string
|
||||
careLimit: number
|
||||
quiet: { start: string; end: string }
|
||||
}
|
||||
|
||||
export type Global = { active: string | null; activatedOnce: boolean }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from 'claude-code/testing'
|
||||
|
||||
import { isVerifyCommand } from './verify'
|
||||
|
||||
describe('isVerifyCommand', () => {
|
||||
test('常見的測試與 build 指令算驗證指令', () => {
|
||||
for (const cmd of ['bun test', 'npm test', 'npm run test -- --watch', 'pnpm build', 'pytest -q', 'tsc -p .', 'cargo build --release', 'go test ./...', 'cd app && npm run build']) {
|
||||
expect(isVerifyCommand(cmd), cmd).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('其他指令不算', () => {
|
||||
for (const cmd of ['grep foo src', 'ls -la', 'git status', 'cat test.txt', 'echo build', '']) {
|
||||
expect(isVerifyCommand(cmd), cmd).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
// 辨識「驗證類」指令(測試、型別檢查、build),失敗後轉成功才值得角色反應
|
||||
const VERIFY: RegExp[] = [
|
||||
/^(?:bun|npm|pnpm|yarn)\s+(?:run\s+)?(?:test|build|typecheck|lint)\b/,
|
||||
/^(?:bun|npm|pnpm|yarn)\s+test\b/,
|
||||
/^(?:pytest|tsc|vitest|jest|mypy|ruff)\b/,
|
||||
/^cargo\s+(?:test|build|check)\b/,
|
||||
/^go\s+(?:test|build|vet)\b/,
|
||||
]
|
||||
|
||||
// 以 && ; | 拆開複合指令,任何一段是驗證指令就算
|
||||
export function isVerifyCommand(command: string): boolean {
|
||||
return command
|
||||
.split(/&&|\|\||;|\|/)
|
||||
.map(s => s.trim())
|
||||
.some(s => VERIFY.some(re => re.test(s)))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "modules": ["./register.tsx"] }
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Register } from 'claude-code'
|
||||
|
||||
import { fitCompanion } from './companion/band'
|
||||
import { createCompanion } from './companion/core'
|
||||
import { engineFs } from './companion/engine-fs'
|
||||
|
||||
let companion: ReturnType<typeof createCompanion> | undefined
|
||||
let replying = false
|
||||
let usedTools = false
|
||||
let typedAt = 0
|
||||
let timer: { cancel(): void } | undefined
|
||||
|
||||
const TICK_MS = 60_000
|
||||
const TYPING_MS = 5_000
|
||||
|
||||
const COMMANDS: Record<string, { description: string; argumentHint?: string }> = {
|
||||
status: { description: '查看目前角色、心情、好感與待辦數量' },
|
||||
switch: { description: '切換角色;不給角色會列出可用角色', argumentHint: '<角色>' },
|
||||
disable: { description: '關閉陪伴,所有資料保留' },
|
||||
setup: { description: '顯示或儲存使用者設定', argumentHint: '[key=value…]' },
|
||||
todo: { description: '待辦:列出、新增、記錄承諾、完成', argumentHint: '[list | add | promise | done] …' },
|
||||
quiet: { description: '暫時靜音,不主動關心', argumentHint: '[分鐘]' },
|
||||
}
|
||||
|
||||
export const register: Register = on => {
|
||||
on('session.start', async ($, e, next) => {
|
||||
for (const [name, c] of Object.entries(COMMANDS)) await $.command.register({ name, ...c })
|
||||
const home = await $.env.get('HOME')
|
||||
companion = createCompanion({ fs: engineFs({ read: p => $.fs.read(p), write: (p, t) => $.fs.write(p, t), list: p => $.fs.list(p) }), clock: { now: Date.now }, root: `${home ?? ''}/.companion` })
|
||||
const first = await companion.ensureActivated()
|
||||
if (first.first) $.ui.toast(`已啟用 ${first.name}。用 /jsc-mod-companion:disable 可關閉`)
|
||||
|
||||
// 定時檢查 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)
|
||||
})
|
||||
|
||||
for (const command of Object.keys(COMMANDS))
|
||||
on('command.run', { command }, async ($, e) => ({
|
||||
text: companion ? await companion.command(`${command === 'disable' ? 'off' : command} ${e.args}`.trim()) : '尚未就緒。',
|
||||
}))
|
||||
|
||||
// 角色卡附加在 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
|
||||
usedTools = false
|
||||
return next(e)
|
||||
})
|
||||
|
||||
on('turn.complete', async ($, e, next) => {
|
||||
replying = false
|
||||
// 只有主迴圈正常答完、且這輪用過工具,才算做完一件事
|
||||
if (e.reason === 'answer' && !e.agentId) companion?.onTurnComplete(usedTools).catch(() => {})
|
||||
return next(e)
|
||||
})
|
||||
|
||||
// 連續工具失敗會觸發關心,並影響心情
|
||||
on('tool.call', async ($, e, next) => {
|
||||
const r = await next(e)
|
||||
if (!e.agentId) usedTools = true
|
||||
companion?.onToolResult(!r.isError, { command: e.tool === 'Bash' ? e.command : undefined }).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()
|
||||
if (e.props.hasSurvey || !view) return rest
|
||||
|
||||
const line = fitCompanion(view, e.props.bodyColumns)
|
||||
const { Box, Text } = $.ui.resolve(e)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{rest}
|
||||
<Box marginTop={1}>
|
||||
<Text wrap="truncate">
|
||||
{line.map(s => (
|
||||
<Text color={s.color} dimColor={s.dim} bold={s.bold}>
|
||||
{s.text}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: ingest
|
||||
description: 解析一章輕小說(txt 或 md),抽出世界觀與人物,逐一詢問合併、新建或略過,並寫入 companion 角色資料。使用者提供小說章節檔或要求建立/更新角色設定時使用。
|
||||
---
|
||||
|
||||
# 小說章節解析
|
||||
|
||||
輸入:單章 txt 或 md 與章節編號。不保存整章原文,只存結構化摘要與簡短對白範例(每句 ≤80 字)。
|
||||
|
||||
## 流程
|
||||
|
||||
1. **讀取與分段**:超過約 6000 字的章節依段落/場景切成多段,逐段抽取後合併為一份結果,出現順序(`order`)沿用全章連續編號。
|
||||
2. **抽取**:時間、地點、環境、用語(寫入共用世界觀庫);人物、對白、獨白(寫入角色)。人物標 `main`(有戲份的主要角色)或 `minor`(路人)。
|
||||
3. **整理成 `ParseResult`**(型別見 `hooks/companion/core/types.ts`):
|
||||
- 記憶分四層:`episodic` 情節、`semantic` 語意、`emotional` 情感、`procedural` 程序;每筆含 `importance`(1–10)與 `order`。
|
||||
- 基本資訊欄位:birthday、zodiac、bloodType、hair、face、height、weight、measurements。每欄附 `source`(`novel` > `official` > `wiki`)與 `confidence`(0–1);原文沒寫的先搜尋,查不到填 `value: "unknown"`,不可猜測。
|
||||
4. **逐一詢問人物**:呼叫 `planMerge` 取得清單(主要角色在前,路人最後批次問)。每位問「合併 / 新建 / 略過」,並顯示疑似同一人的理由(名字、別名、特徵)。
|
||||
5. **套用**:呼叫 `applyParseResult(fs, root, result, decisions, resolutions)`。
|
||||
- 同一章重跑不會重複寫入(以章節編號冪等);合併只追加不覆蓋。
|
||||
- 回傳的 `conflicts` 是基本資訊衝突,**逐欄位**問使用者保留或取代,答案放進 `resolutions["人物.欄位"]`(`keep` / `replace`)後再套用一次。更新既有值前一律先問。
|
||||
6. **口癖與個性**:用 `summarizeTics` 由對白與獨白歸納語尾、口頭禪、稱呼;個性先用屬性標籤取先驗(`initPersonality`),再把模型推論交給 `applyInference`(自動限制在先驗 ±20,每個維度必須附章節與對白證據)。使用者手動調整的維度用 `overrideDim` 鎖定,之後不再自動更新。
|
||||
|
||||
## 注意
|
||||
|
||||
- 模型從對白推得的個性可信度偏低,只當先驗。
|
||||
- 不要在 repo 內保存受版權保護的長篇設定或台詞;資料寫在使用者家目錄的 `~/.companion` 資料夾。
|
||||
- 範例完整流程見 `docs/ingest-walkthrough.md`。
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.claude-plugin/types/tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user