diff --git a/plugins/hud/hooks/companion/care.test.ts b/plugins/hud/hooks/companion/care.test.ts new file mode 100644 index 0000000..a153a43 --- /dev/null +++ b/plugins/hud/hooks/companion/care.test.ts @@ -0,0 +1,241 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' + +import { memoryFs } from './io' +import { COOLDOWN_MS, createCare, dailyLimit, 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; settings?: Partial; 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, 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('/cockpit 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() + 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('交報告')) + }) +}) diff --git a/plugins/hud/hooks/companion/care.ts b/plugins/hud/hooks/companion/care.ts new file mode 100644 index 0000000..3c61f6e --- /dev/null +++ b/plugins/hud/hooks/companion/care.ts @@ -0,0 +1,415 @@ +import type { Clock, Fs } from './io' +import { readJson, writeJson } from './io' + +export type Trigger = 'session_start' | 'idle_return' | 'long_work' | 'late_night' | 'tool_failures' | 'quota_warning' | 'todo_due' +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 +} + +export interface CareSettings { + /** 使用者設定的每日上限 */ + dailyCap: number + tzOffsetMin: number + /** 靜音時段(本地小時),可跨午夜 */ + quietHours?: { start: number; end: number } +} + +export interface CareState { + lastFired: Partial> + day: string + count: number + quietUntil: number + workStart: number + lastActivity: number + failStreak: number + /** 待送出的開場關心(會話開始或閒置回來),由 tick 在可打擾時才發出 */ + lead?: Trigger +} + +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 = { + 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, +} + +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 = { stranger: 2, familiar: 4, close: 6, special: 8 } + +/** 好感階段與外向性決定每日關心上限,再受使用者設定封頂 */ +export function dailyLimit(p: CareProfile, s: CareSettings): number { + const factor = 0.5 + p.extraversion / 100 + 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>> = { + 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}」到時間了,需要的話我陪你。`, + }, + }, +} + +/** 台詞查詢(供測試與內容檢查) */ +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 = { + session_start: 'toast', + idle_return: 'toast', + long_work: 'inject', + late_night: 'inject', + tool_failures: 'inject', + quota_warning: 'toast', + todo_due: 'toast', +} + +const initial = (): CareState => ({ lastFired: {}, day: '', count: 0, quietUntil: 0, workStart: 0, lastActivity: 0, failStreak: 0 }) + +export interface CareEngine { + /** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */ + onUserTurn(): Promise + /** 工具結果:連續失敗計數 */ + onToolResult(ok: boolean): Promise + /** 定時/事件檢查:回傳要立即顯示的 toast 或氣泡 */ + tick(signals?: Signals): Promise + /** /cockpit quiet [分鐘],0 取消靜音 */ + quiet(minutes: number): Promise + state(): Promise +} + +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>(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 last = s.lastFired[trigger] + if (last !== undefined && now - last < COOLDOWN_MS[trigger]) return null + if (s.count >= dailyLimit(d.profile(), d.settings())) return null + const p = d.profile() + 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) { + const s = await load() + s.failStreak = ok ? 0 : s.failStreak + 1 + 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) + } + 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, + } +} diff --git a/plugins/hud/hooks/companion/io.ts b/plugins/hud/hooks/companion/io.ts new file mode 100644 index 0000000..41ed3bc --- /dev/null +++ b/plugins/hud/hooks/companion/io.ts @@ -0,0 +1,53 @@ +// 檔案系統與時鐘由外部注入,核心不直接碰真實環境 +export interface Fs { + read(path: string): Promise + /** 原子寫入:先寫暫存檔再改名 */ + write(path: string, data: string): Promise +} + +export type Clock = () => number + +export function memoryFs(): Fs & { files: Map } { + const files = new Map() + return { + files, + async read(p) { + return files.get(p) ?? null + }, + async write(p, d) { + files.set(p, d) + }, + } +} + +export async function readJson(fs: Fs, path: string, fallback: T): Promise { + 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)) + +export async function nodeFs(): Promise { + const { readFile, writeFile, rename, mkdir } = await import('node:fs/promises') + const { dirname } = await import('node:path') + return { + async read(p) { + try { + return await readFile(p, 'utf8') + } catch { + return null + } + }, + async write(p, d) { + await mkdir(dirname(p), { recursive: true }) + const tmp = `${p}.${process.pid}.tmp` + await writeFile(tmp, d) + await rename(tmp, p) + }, + } +} diff --git a/plugins/hud/hooks/companion/memory.test.ts b/plugins/hud/hooks/companion/memory.test.ts new file mode 100644 index 0000000..e252ce8 --- /dev/null +++ b/plugins/hud/hooks/companion/memory.test.ts @@ -0,0 +1,143 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' + +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, '喜歡貓') + }) +}) diff --git a/plugins/hud/hooks/companion/memory.ts b/plugins/hud/hooks/companion/memory.ts new file mode 100644 index 0000000..3613a71 --- /dev/null +++ b/plugins/hud/hooks/companion/memory.ts @@ -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) => + 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() + 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 + /** 檢索前 6 筆,並更新取用紀錄 */ + recall(query: string, mood?: Pad): Promise + /** 累積達門檻、會話結束或手動 reflect 時鞏固 */ + consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; forgotten: number; reflection?: Memory }> + all(): Promise + pending(): Promise +} + +export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore { + const load = () => readJson(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 + }, + } +} diff --git a/plugins/hud/hooks/companion/todo.ts b/plugins/hud/hooks/companion/todo.ts new file mode 100644 index 0000000..9c79a1c --- /dev/null +++ b/plugins/hud/hooks/companion/todo.ts @@ -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 + complete(id: number): Promise + list(opts?: { all?: boolean }): Promise + /** 角色行顯示的待辦數(未完成) */ + open(): Promise + /** 取出到期且尚未提醒的項目,並標記為已提醒 */ + due(): Promise +} + +export function createTodos(fs: Fs, clock: Clock, path: string): TodoList { + const load = () => readJson(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 + }, + } +}