import { affinityLine } from './status.ts' import * as aff from './affinity.ts' import { assemble } from './inject.ts' import type { Item } from './inject.ts' import * as mem from './interaction.ts' import * as mood from './mood.ts' import { toneRules } from './tone.ts' import { ZERO } from './mood.ts' import type { Clock, Fs, Pad } from './types.ts' export type Card = { name: string /** 依重要度由高到低 */ lines: string[] baseline?: Pad /** 心情半衰期(小時),預設 8 */ halfLifeHours?: number } export type UserConfig = { nickname?: string; tone?: boolean } type State = { mood: Pad; moodAt: number; affinity: aff.Affinity } export type Options = { fs: Fs clock: Clock /** 資料根目錄,例如 ~/cockpit */ root: string /** 當前角色資料夾名稱 */ id: string } const HOUR = 3_600_000 export function createCompanion({ fs, clock, root, id }: Options) { const dir = `${root}/${id}` // 互動記憶與原著記憶分開資料夾 const paths = { card: `${dir}/card.json`, state: `${dir}/state.json`, interaction: `${dir}/interaction/memory.json`, user: `${root}/user.json`, } let turnDelta: Pad = ZERO let affinityMoved = false let lastText = '' async function json(path: string, fallback: T): Promise { const raw = await fs.read(path) if (!raw) return fallback try { return JSON.parse(raw) as T } catch { return fallback } } const loadCard = () => json(paths.card, { name: id, lines: [] }) const loadUser = () => json(paths.user, {}) async function loadState(card: Card): Promise { const now = clock.now() const s = await json(paths.state, null) if (!s) return { mood: card.baseline ?? ZERO, moodAt: now, affinity: aff.initialAffinity(now) } const mood0 = mood.decay(s.mood, card.baseline ?? ZERO, now - s.moodAt, (card.halfLifeHours ?? 8) * HOUR) return { mood: mood0, moodAt: now, affinity: aff.settle(s.affinity, now) } } const saveState = (s: State) => fs.write(paths.state, JSON.stringify(s)) async function remember(role: 'user' | 'assistant', text: string, relation: number) { const m = await mem.load(fs, paths.interaction) await mem.save(fs, paths.interaction, mem.add(m, { role, text, at: clock.now() }, relation)) } return { /** 使用者回合:記錄輸入、更新心情與好感 */ async userTurn(text: string, events: mood.MoodEvent[] = []) { const card = await loadCard() const st = await loadState(card) turnDelta = ZERO lastText = text const kw = mood.keywordEvent(text) const all = kw ? [...events, kw] : events let m = st.mood for (const ev of events) ({ mood: m, turn: turnDelta } = mood.applyDelta(m, turnDelta, mood.EVENTS[ev])) const now = clock.now() const bad = all.some(e => e === 'scold' || e === 'careIgnored') const good = all.some(e => e === 'praise' || e === 'success' || e === 'likedTopic') let a = st.affinity let rel = 0 if (bad) { a = aff.negative(a, now) rel = -aff.LOSS } else if (text.trim() && (good || text.trim().length >= 4)) { a = aff.meaningful(a, now) rel = a.value - st.affinity.value } affinityMoved = rel !== 0 await saveState({ mood: m, moodAt: now, affinity: a }) await remember('user', text, rel) }, /** * 助理回覆:解析並移除心情標記,缺失或錯誤時沿用使用者回合已套用的關鍵字事件。 * 回傳可顯示的文字。 */ async assistantReply(reply: string): Promise { const parsed = mood.parseMoodTag(reply) const card = await loadCard() const st = await loadState(card) const kw = parsed.delta ? null : mood.keywordEvent(lastText) const delta = parsed.delta ?? (kw ? mood.EVENTS[kw] : null) if (delta) { const { mood: m, turn } = mood.applyDelta(st.mood, turnDelta, delta) turnDelta = turn await saveState({ ...st, mood: m, moodAt: clock.now() }) } await remember('assistant', parsed.text, 0) return parsed.text }, /** 組裝送出模型請求前要注入 system 區塊尾端的內容 */ async buildInjection(extra: { care?: string[]; lore?: Item[] } = {}): Promise { const card = await loadCard() const user = await loadUser() const st = await loadState(card) const im = await mem.load(fs, paths.interaction) const n = im.recent.length const memory: Item[] = [ ...(extra.lore ?? []), ...im.summary.map((t, i) => ({ text: `(摘要)${t}`, score: 0.1 + (0.2 * i) / Math.max(1, im.summary.length) })), ...im.recent.map((t, i) => ({ text: `${t.role === 'user' ? '使用者' : '你'}:${t.text}`, score: 0.5 + (0.5 * (i + 1)) / n })), ] const m = mood.moodLabel(st.mood) const tone = user.tone !== false const cardItems: Item[] = [ { text: `名字:${card.name}`, score: 1e9 }, ...(tone ? toneRules(card.name, user.nickname, aff.STAGE_NAMES[st.affinity.stage]).map((text, i) => ({ text, score: 1e9 - i - 1 })) : []), ...card.lines.map((text, i) => ({ text, score: card.lines.length - i })), ] return assemble({ card: cardItems, memory, mood: [{ text: `${m.label} ${m.face};${affinityLine(st.affinity)}`, score: 1 }], care: (extra.care ?? []).map((text, i) => ({ text, score: 100 - i })), }) }, async mood() { const st = await loadState(await loadCard()) return { pad: st.mood, ...mood.moodLabel(st.mood) } }, async affinity() { return (await loadState(await loadCard())).affinity }, async setTone(on: boolean) { await fs.write(paths.user, JSON.stringify({ ...(await loadUser()), tone: on })) }, /** `/cockpit tone on|off` */ async command(line: string): Promise { const m = /^tone\s+(on|off)$/.exec(line.trim()) if (!m) return null await this.setTone(m[1] === 'on') return m[1] === 'on' ? '語氣已開啟' : '語氣已關閉' }, get affinityMoved() { return affinityMoved }, } }