From 83fd180872e0a42b8c570b43b380577b96b45ee0 Mon Sep 17 00:00:00 2001 From: jiantw83 Date: Mon, 12 Oct 2026 13:27:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20companion=20=E6=A0=B8=E5=BF=83=E7=9A=84?= =?UTF-8?q?=E4=BA=BA=E8=A8=AD=E6=B3=A8=E5=85=A5=E3=80=81=E8=AA=9E=E6=B0=A3?= =?UTF-8?q?=E3=80=81=E5=BF=83=E6=83=85=E6=A8=99=E8=A8=98=E8=88=87=E4=BA=92?= =?UTF-8?q?=E5=8B=95=E8=A8=98=E6=86=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- plugins/cockpit/companion/affinity.ts | 60 +++++++ plugins/cockpit/companion/companion.test.ts | 159 ++++++++++++++++++ plugins/cockpit/companion/core.ts | 175 ++++++++++++++++++++ plugins/cockpit/companion/inject.ts | 40 +++++ plugins/cockpit/companion/interaction.ts | 63 +++++++ plugins/cockpit/companion/mood.ts | 93 +++++++++++ plugins/cockpit/companion/secrets.ts | 21 +++ plugins/cockpit/companion/status.ts | 4 + plugins/cockpit/companion/tone.ts | 10 ++ plugins/cockpit/companion/types.ts | 13 ++ 10 files changed, 638 insertions(+) create mode 100644 plugins/cockpit/companion/affinity.ts create mode 100644 plugins/cockpit/companion/companion.test.ts create mode 100644 plugins/cockpit/companion/core.ts create mode 100644 plugins/cockpit/companion/inject.ts create mode 100644 plugins/cockpit/companion/interaction.ts create mode 100644 plugins/cockpit/companion/mood.ts create mode 100644 plugins/cockpit/companion/secrets.ts create mode 100644 plugins/cockpit/companion/status.ts create mode 100644 plugins/cockpit/companion/tone.ts create mode 100644 plugins/cockpit/companion/types.ts diff --git a/plugins/cockpit/companion/affinity.ts b/plugins/cockpit/companion/affinity.ts new file mode 100644 index 0000000..9af404a --- /dev/null +++ b/plugins/cockpit/companion/affinity.ts @@ -0,0 +1,60 @@ +import { clamp } from './types.ts' + +export const GAIN = 0.01 +export const DAILY_CAP = 0.05 +export const LOSS = 0.03 +export const IDLE_DAYS = 7 +export const HALF_LIFE_DAYS = 30 +const DAY = 86_400_000 + +export type Stage = 0 | 1 | 2 | 3 +export const STAGE_NAMES = ['陌生', '熟悉', '親近', '特別'] as const + +// 升階門檻;降階門檻 = 升階 − 0.05(遲滯) +const UP = [0.1, 0.3, 0.6] +const HYST = 0.05 + +export type Affinity = { + value: number + stage: Stage + /** 今日已增加量與其日期 */ + gainDay: string + gainToday: number + lastSeen: number +} + +export const initialAffinity = (now: number): Affinity => ({ value: 0, stage: 0, gainDay: dayKey(now), gainToday: 0, lastSeen: now }) + +export const dayKey = (t: number) => new Date(t).toISOString().slice(0, 10) + +export function stageFor(value: number, prev: Stage): Stage { + let s = prev + while (s < 3 && value >= UP[s]!) s++ + while (s > 0 && value < UP[s - 1]! - HYST) s-- + return s as Stage +} + +/** 讀取時計算:離開超過 7 天後以 30 天半衰期回到 0 */ +export function settle(a: Affinity, now: number): Affinity { + const idleDays = (now - a.lastSeen) / DAY + if (idleDays <= IDLE_DAYS) return a + const f = Math.pow(0.5, (idleDays - IDLE_DAYS) / HALF_LIFE_DAYS) + const value = a.value * f + return { ...a, value, stage: stageFor(value, a.stage), lastSeen: now } +} + +export function meaningful(a: Affinity, now: number): Affinity { + const s = settle(a, now) + const today = dayKey(now) + const gainToday = s.gainDay === today ? s.gainToday : 0 + const gain = clamp(Math.min(GAIN, DAILY_CAP - gainToday), 0, GAIN) + const value = clamp(s.value + gain, -1, 1) + return { value, stage: stageFor(value, s.stage), gainDay: today, gainToday: gainToday + gain, lastSeen: now } +} + +export function negative(a: Affinity, now: number): Affinity { + const s = settle(a, now) + const value = clamp(s.value - LOSS, -1, 1) + const today = dayKey(now) + return { ...s, value, stage: stageFor(value, s.stage), gainDay: today, gainToday: s.gainDay === today ? s.gainToday : 0, lastSeen: now } +} diff --git a/plugins/cockpit/companion/companion.test.ts b/plugins/cockpit/companion/companion.test.ts new file mode 100644 index 0000000..e637578 --- /dev/null +++ b/plugins/cockpit/companion/companion.test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { createCompanion } from './core.ts' +import type { Card } from './core.ts' +import { estimateTokens } from './inject.ts' +import { parseMoodTag } from './mood.ts' +import { redactSecrets } from './secrets.ts' +import type { Fs } from './types.ts' + +// 記憶體檔案系統與假時鐘 +function memFs(): Fs & { files: Map } { + const files = new Map() + return { files, read: async p => files.get(p) ?? null, write: async (p, d) => void files.set(p, d) } +} +const T0 = Date.parse('2026-10-12T10:00:00Z') +function clock(t = T0) { + return { now: () => t, set: (x: number) => (t = x), advance: (ms: number) => (t += ms) } +} + +// 原創範例角色 +const CARD: Card = { + name: '小澄', + lines: ['個性:開朗、愛照顧人', '口癖:句尾常說「喔」', '喜歡:紅茶'], +} +const KEY = 'sk-abcdefghijklmnop1234567890' + +async function setup() { + const fs = memFs() + const clk = clock() + fs.files.set('/c/sumi/card.json', JSON.stringify(CARD)) + const c = createCompanion({ fs, clock: clk, root: '/c', id: 'sumi' }) + return { fs, clk, c } +} + +describe('心情標記', () => { + test('解析並移除末尾標記', () => { + const r = parseMoodTag('好的喔!\n') + assert.equal(r.text, '好的喔!') + assert.deepEqual(r.delta, { p: 0.1, a: -0.05, d: 0 }) + }) + test('格式錯誤時移除標記且無 delta', () => { + const r = parseMoodTag('好的喔!') + assert.equal(r.text, '好的喔!') + assert.equal(r.delta, null) + }) + test('缺失標記退回關鍵字規則', async () => { + const { c } = await setup() + await c.userTurn('謝謝你,太棒了') + const shown = await c.assistantReply('不客氣喔') + assert.equal(shown, '不客氣喔') + assert.ok((await c.mood()).pad.p > 0) + }) + test('標記有效時採用標記', async () => { + const { c } = await setup() + await c.userTurn('隨便聊聊') + await c.assistantReply('嗯喔') + assert.ok((await c.mood()).pad.p < 0) + }) +}) + +describe('心情上限與事件', () => { + test('單軸每回合變化不超過 0.3', async () => { + const { c } = await setup() + await c.userTurn('你很笨', ['scold', 'scold', 'scold']) + await c.assistantReply('嗚') + const { pad } = await c.mood() + assert.ok(Math.abs(pad.p) <= 0.3 + 1e-9 && Math.abs(pad.a) <= 0.3 + 1e-9 && Math.abs(pad.d) <= 0.3 + 1e-9) + assert.ok(pad.p < 0) + }) + test('事件表:稱讚提升愉悅', async () => { + const { c } = await setup() + await c.userTurn('做得好', ['praise']) + assert.ok(Math.abs((await c.mood()).pad.p - 0.15) < 1e-9) + }) +}) + +describe('好感度', () => { + test('有意義互動 +0.01,每日上限 +0.05', async () => { + const { c } = await setup() + await c.userTurn('今天的進度如何') + assert.ok(Math.abs((await c.affinity()).value - 0.01) < 1e-9) + for (let i = 0; i < 20; i++) await c.userTurn('繼續處理這個問題吧') + assert.ok(Math.abs((await c.affinity()).value - 0.05) < 1e-9) + }) + test('負面事件 −0.03', async () => { + const { c } = await setup() + await c.userTurn('隨便', ['scold']) + assert.ok(Math.abs((await c.affinity()).value + 0.03) < 1e-9) + }) + test('離開超過 7 天後回落', async () => { + const { c, clk } = await setup() + for (let i = 0; i < 5; i++) await c.userTurn('我們繼續吧', []) + const before = (await c.affinity()).value + clk.advance(37 * 86_400_000) + assert.ok(Math.abs((await c.affinity()).value - before / 2) < 1e-9) + }) +}) + +describe('互動記憶', () => { + test('與原著記憶分開存放,且只存對話文字', async () => { + const { c, fs } = await setup() + await c.userTurn('幫我看看這段') + await c.assistantReply('好喔\n```ts\nconst a = 1\n```\n看完了') + const key = [...fs.files.keys()].find(k => k.includes('/interaction/'))! + assert.ok(key.startsWith('/c/sumi/interaction/')) + const stored = fs.files.get(key)! + assert.ok(!stored.includes('const a = 1')) + assert.ok(!stored.includes('mood')) + }) + test('金鑰在寫入前被過濾', async () => { + const { c, fs } = await setup() + await c.userTurn(`我的金鑰是 ${KEY} 請記住`) + for (const v of fs.files.values()) assert.ok(!v.includes(KEY)) + }) + test('累積後壓縮成摘要', async () => { + const { c, fs } = await setup() + for (let i = 0; i < 35; i++) await c.userTurn(`第 ${i} 則訊息內容`) + const m = JSON.parse(fs.files.get('/c/sumi/interaction/memory.json')!) + assert.ok(m.recent.length <= 30) + assert.ok(m.summary.length > 0) + }) +}) + +describe('人設注入', () => { + test('角色卡在前、心情與記憶在後', async () => { + const { c } = await setup() + await c.userTurn('我喜歡紅茶') + const out = await c.buildInjection() + assert.ok(out.indexOf('## 角色') < out.indexOf('## 互動記憶')) + assert.ok(out.indexOf('## 互動記憶') < out.indexOf('## 目前心情')) + }) + test('不含疑似金鑰與工具輸出', async () => { + const { c } = await setup() + await c.userTurn(`token=${KEY}`) + await c.assistantReply(`好的 ${KEY}`) + const out = await c.buildInjection({ care: [`提醒 ${KEY}`], lore: [{ text: `設定 ${KEY}`, score: 1 }] }) + assert.ok(!out.includes(KEY)) + assert.equal(redactSecrets(out), out) + }) + test('超出預算時截斷低分項目', async () => { + const { c } = await setup() + for (let i = 0; i < 30; i++) await c.userTurn(`這是一則相當長的訊息,用來撐滿預算,編號 ${i},${'字'.repeat(60)}`) + const out = await c.buildInjection() + const mem = out.split('## 互動記憶\n')[1]!.split('\n\n')[0]! + assert.ok(estimateTokens(mem) <= 800) + assert.ok(mem.includes('編號 29')) + assert.ok(!mem.includes('編號 0,')) + assert.ok(estimateTokens(out) <= 1600) + }) + test('tone off 時不注入語氣規則', async () => { + const { c } = await setup() + assert.ok((await c.buildInjection()).includes('逐字保持原樣')) + assert.equal(await c.command('tone off'), '語氣已關閉') + assert.ok(!(await c.buildInjection()).includes('逐字保持原樣')) + await c.command('tone on') + assert.ok((await c.buildInjection()).includes('逐字保持原樣')) + }) +}) diff --git a/plugins/cockpit/companion/core.ts b/plugins/cockpit/companion/core.ts new file mode 100644 index 0000000..8beef7e --- /dev/null +++ b/plugins/cockpit/companion/core.ts @@ -0,0 +1,175 @@ +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 + }, + } +} diff --git a/plugins/cockpit/companion/inject.ts b/plugins/cockpit/companion/inject.ts new file mode 100644 index 0000000..f79c035 --- /dev/null +++ b/plugins/cockpit/companion/inject.ts @@ -0,0 +1,40 @@ +import { redactSecrets } from './secrets.ts' + +export const BUDGET = { card: 400, mood: 100, memory: 800, care: 200 } as const +export type Section = keyof typeof BUDGET + +/** 粗估 token:CJK 一字約一 token,其餘約四字元一 token */ +export function estimateTokens(text: string): number { + let cjk = 0 + for (const ch of text) if (/[⺀-鿿＀-￯ -〿]/.test(ch)) cjk++ + return cjk + Math.ceil((text.length - cjk) / 4) +} + +export type Item = { text: string; score: number } + +/** 在預算內保留高分項目,維持原順序;超出時先丟低分 */ +export function fitBudget(items: Item[], budget: number): string[] { + const kept = new Set(items.map((_, i) => i)) + const order = items.map((it, i) => ({ i, score: it.score })).sort((a, b) => a.score - b.score || b.i - a.i) + let total = items.reduce((n, it) => n + estimateTokens(it.text), 0) + for (const { i } of order) { + if (total <= budget) break + total -= estimateTokens(items[i]!.text) + kept.delete(i) + } + return items.filter((_, i) => kept.has(i)).map(it => it.text) +} + +export type Sections = Record + +/** 穩定內容(角色卡)在前,變動內容(心情、記憶、關心情境)在後 */ +export function assemble(s: Sections): string { + const part = (title: string, key: Section) => { + const lines = fitBudget(s[key], BUDGET[key]).map(redactSecrets) + return lines.length ? `## ${title}\n${lines.join('\n')}` : '' + } + const body = [part('角色', 'card'), part('互動記憶', 'memory'), part('目前心情', 'mood'), part('關心情境', 'care')] + .filter(Boolean) + .join('\n\n') + return body ? `\n${body}\n` : '' +} diff --git a/plugins/cockpit/companion/interaction.ts b/plugins/cockpit/companion/interaction.ts new file mode 100644 index 0000000..cc97bc9 --- /dev/null +++ b/plugins/cockpit/companion/interaction.ts @@ -0,0 +1,63 @@ +import { redactSecrets } from './secrets.ts' +import type { Fs } from './types.ts' + +export type Turn = { role: 'user' | 'assistant'; text: string; at: number } +export type InteractionMemory = { + recent: Turn[] + /** 壓縮後的摘要行與累計關係變化 */ + summary: string[] + relationDelta: number +} + +export const RECENT_MAX = 30 +export const COMPRESS_COUNT = 20 +const SUMMARY_MAX = 40 +const LINE_MAX = 60 + +export const empty = (): InteractionMemory => ({ recent: [], summary: [], relationDelta: 0 }) + +// 互動記憶不存大段程式碼;只留對話文字 +export function dialogueOnly(text: string): string { + return text + .replace(/```[\s\S]*?(?:```|$)/g, ' ') + .replace(/`[^`\n]{40,}`/g, ' ') + .replace(/[ \t]+/g, ' ') + .trim() +} + +export function clean(text: string): string { + return redactSecrets(dialogueOnly(text)) +} + +export function add(m: InteractionMemory, turn: Turn, relationDelta: number): InteractionMemory { + const text = clean(turn.text) + if (!text) return { ...m, relationDelta: m.relationDelta + relationDelta } + let next: InteractionMemory = { + ...m, + recent: [...m.recent, { ...turn, text }], + relationDelta: m.relationDelta + relationDelta, + } + if (next.recent.length > RECENT_MAX) next = compress(next) + return next +} + +/** 把最舊的逐字紀錄壓縮成摘要行 */ +export function compress(m: InteractionMemory): InteractionMemory { + const old = m.recent.slice(0, COMPRESS_COUNT) + const lines = old + .filter(t => t.role === 'user') + .map(t => (t.text.length > LINE_MAX ? t.text.slice(0, LINE_MAX) + '…' : t.text)) + return { ...m, recent: m.recent.slice(COMPRESS_COUNT), summary: [...m.summary, ...lines].slice(-SUMMARY_MAX) } +} + +export async function load(fs: Fs, path: string): Promise { + const raw = await fs.read(path) + if (!raw) return empty() + try { + return { ...empty(), ...JSON.parse(raw) } + } catch { + return empty() + } +} + +export const save = (fs: Fs, path: string, m: InteractionMemory) => fs.write(path, JSON.stringify(m)) diff --git a/plugins/cockpit/companion/mood.ts b/plugins/cockpit/companion/mood.ts new file mode 100644 index 0000000..01387b3 --- /dev/null +++ b/plugins/cockpit/companion/mood.ts @@ -0,0 +1,93 @@ +import { clamp } from './types.ts' +import type { Pad } from './types.ts' + +export const TURN_CAP = 0.3 + +export const EVENTS = { + praise: { p: 0.15, a: 0.05, d: 0.05 }, + scold: { p: -0.2, a: 0.15, d: -0.1 }, + success: { p: 0.08, a: 0.03, d: 0.05 }, + toolFailures: { p: -0.08, a: 0.1, d: -0.08 }, + returnAfterIdle: { p: 0.1, a: 0.08, d: 0 }, + lateNight: { p: -0.05, a: -0.1, d: 0 }, + likedTopic: { p: 0.12, a: 0.08, d: 0.03 }, + careIgnored: { p: -0.05, a: -0.03, d: -0.03 }, +} as const satisfies Record + +export type MoodEvent = keyof typeof EVENTS + +export const ZERO: Pad = { p: 0, a: 0, d: 0 } + +/** 一回合內累計的變化量;單軸總變化不得超過 ±0.3 */ +export function applyDelta(mood: Pad, turn: Pad, delta: Pad): { mood: Pad; turn: Pad } { + const out = { mood: { ...mood }, turn: { ...turn } } + for (const k of ['p', 'a', 'd'] as const) { + const next = clamp(turn[k] + delta[k], -TURN_CAP, TURN_CAP) + const step = next - turn[k] + out.turn[k] = next + out.mood[k] = clamp(mood[k] + step, -1, 1) + } + return out +} + +/** 讀取時依經過時間往基線衰減:x = B + (x − B)·0.5^(Δ/τ) */ +export function decay(mood: Pad, baseline: Pad, elapsedMs: number, halfLifeMs: number): Pad { + const f = Math.pow(0.5, Math.max(0, elapsedMs) / halfLifeMs) + return { + p: baseline.p + (mood.p - baseline.p) * f, + a: baseline.a + (mood.a - baseline.a) * f, + d: baseline.d + (mood.d - baseline.d) * f, + } +} + +const MOODS: Record = { + '+++': { label: '開心', face: '(≧▽≦)' }, + '++-': { label: '安心', face: '(´▽`)' }, + '+-+': { label: '放鬆', face: '(´ω`)' }, + '+--': { label: '溫柔', face: '(。・ω・。)' }, + '-++': { label: '生氣', face: '(`Д´)' }, + '-+-': { label: '焦慮', face: '(;´Д`)' }, + '--+': { label: '無聊', face: '(¬_¬)' }, + '---': { label: '低落', face: '(´;ω;`)' }, +} + +/** 八分區對應情緒標籤與顏文字 */ +export function moodLabel(m: Pad): { label: string; face: string } { + const s = (x: number) => (x >= 0 ? '+' : '-') + return MOODS[s(m.p) + s(m.a) + s(m.d)]! +} + +// 心情標記:回覆末尾的隱藏標記,如 +const TAG = /\s*$/ + +export type ParsedReply = { text: string; delta: Pad | null } + +export function parseMoodTag(reply: string): ParsedReply { + const m = TAG.exec(reply) + if (!m) return { text: stripStray(reply), delta: null } + const text = stripStray(reply.slice(0, m.index).trimEnd()) + const vals: Partial = {} + for (const kv of (m[1] ?? '').trim().split(/\s+/)) { + const mm = /^([pad])=(-?\d+(?:\.\d+)?)$/.exec(kv) + if (mm) vals[mm[1] as keyof Pad] = Number(mm[2]) + } + if (vals.p === undefined || vals.a === undefined || vals.d === undefined) return { text, delta: null } + return { text, delta: { p: vals.p, a: vals.a, d: vals.d } } +} + +// 格式錯誤的標記也不能露給使用者 +function stripStray(s: string): string { + return s.replace(/\s*|$)/g, '').trimEnd() +} + +const KEYWORDS: [MoodEvent, RegExp][] = [ + ['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i], + ['praise', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i], + ['success', /成功|完成了|通過|搞定|passed|done|fixed/i], +] + +/** 標記缺失或格式錯誤時的後備:用使用者文字的關鍵字對應事件 */ +export function keywordEvent(text: string): MoodEvent | null { + for (const [ev, re] of KEYWORDS) if (re.test(text)) return ev + return null +} diff --git a/plugins/cockpit/companion/secrets.ts b/plugins/cockpit/companion/secrets.ts new file mode 100644 index 0000000..26d2301 --- /dev/null +++ b/plugins/cockpit/companion/secrets.ts @@ -0,0 +1,21 @@ +// 疑似金鑰:寫入互動記憶與注入內容前一律過濾 +const PATTERNS: RegExp[] = [ + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, + /\bsk-[A-Za-z0-9_-]{16,}/g, + /\b(?:ghp|gho|ghu|ghs|ghr|glpat|xox[baprs])[-_][A-Za-z0-9_-]{10,}/g, + /\bAKIA[0-9A-Z]{16}\b/g, + /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, + /\b(?:api[_-]?key|secret|token|passwd|password|pwd)\s*[:=]\s*\S{6,}/gi, + /\b[A-Za-z0-9+/_-]{32,}={0,2}(?![A-Za-z0-9])/g, +] + +export const REDACTED = '[已遮蔽]' + +export function redactSecrets(text: string): string { + return PATTERNS.reduce((s, re) => s.replace(re, REDACTED), text) +} + +export function hasSecret(text: string): boolean { + return redactSecrets(text) !== text +} diff --git a/plugins/cockpit/companion/status.ts b/plugins/cockpit/companion/status.ts new file mode 100644 index 0000000..9e7be03 --- /dev/null +++ b/plugins/cockpit/companion/status.ts @@ -0,0 +1,4 @@ +import { STAGE_NAMES } from './affinity.ts' +import type { Affinity } from './affinity.ts' + +export const affinityLine = (a: Affinity) => `好感:${STAGE_NAMES[a.stage]}(${a.value.toFixed(2)})` diff --git a/plugins/cockpit/companion/tone.ts b/plugins/cockpit/companion/tone.ts new file mode 100644 index 0000000..45ce9c6 --- /dev/null +++ b/plugins/cockpit/companion/tone.ts @@ -0,0 +1,10 @@ +// 語氣規則:只套用於對話文字與過場話;技術內容原樣保留 +export function toneRules(name: string, nickname: string | undefined, stage: string): string[] { + return [ + `你以「${name}」的語氣回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}(親密階段:${stage})。`, + '語氣只套用於對話文字與工具呼叫前後的過場話。', + '程式碼、指令、路徑、錯誤訊息、安全警告必須逐字保持原樣,不得改寫或加語氣詞。', + '關鍵技術結論放在最前面或單獨一行。', + '回覆最末附心情標記:(各軸 −0.3 到 0.3,代表本回合心情變化)。', + ] +} diff --git a/plugins/cockpit/companion/types.ts b/plugins/cockpit/companion/types.ts new file mode 100644 index 0000000..3c530c7 --- /dev/null +++ b/plugins/cockpit/companion/types.ts @@ -0,0 +1,13 @@ +export interface Fs { + read(path: string): Promise + /** 原子寫入:寫到暫存檔再改名 */ + write(path: string, data: string): Promise +} + +export interface Clock { + now(): number +} + +export type Pad = { p: number; a: number; d: number } + +export const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x)) -- 2.53.0