Merge pull request 'feat: M2 人設注入、語氣、心情標記與互動記憶' (#9) from issue-4 into main

Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/9
This commit was merged in pull request #9.
This commit is contained in:
2026-10-12 05:45:08 +00:00
10 changed files with 638 additions and 0 deletions
+60
View File
@@ -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 }
}
+159
View File
@@ -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<string, string> } {
const files = new Map<string, string>()
return { files, read: async p => files.get(p) ?? null, write: async (p, d) => void files.set(p, d) }
}
const T0 = Date.parse('2026-10-12T10:00:00Z')
function clock(t = T0) {
return { now: () => t, set: (x: number) => (t = x), advance: (ms: number) => (t += ms) }
}
// 原創範例角色
const CARD: Card = {
name: '小澄',
lines: ['個性:開朗、愛照顧人', '口癖:句尾常說「喔」', '喜歡:紅茶'],
}
const KEY = 'sk-abcdefghijklmnop1234567890'
async function setup() {
const fs = memFs()
const clk = clock()
fs.files.set('/c/sumi/card.json', JSON.stringify(CARD))
const c = createCompanion({ fs, clock: clk, root: '/c', id: 'sumi' })
return { fs, clk, c }
}
describe('心情標記', () => {
test('解析並移除末尾標記', () => {
const r = parseMoodTag('好的喔!\n<!--mood p=0.1 a=-0.05 d=0-->')
assert.equal(r.text, '好的喔!')
assert.deepEqual(r.delta, { p: 0.1, a: -0.05, d: 0 })
})
test('格式錯誤時移除標記且無 delta', () => {
const r = parseMoodTag('好的喔!<!--mood p=abc-->')
assert.equal(r.text, '好的喔!')
assert.equal(r.delta, null)
})
test('缺失標記退回關鍵字規則', async () => {
const { c } = await setup()
await c.userTurn('謝謝你,太棒了')
const shown = await c.assistantReply('不客氣喔')
assert.equal(shown, '不客氣喔')
assert.ok((await c.mood()).pad.p > 0)
})
test('標記有效時採用標記', async () => {
const { c } = await setup()
await c.userTurn('隨便聊聊')
await c.assistantReply('嗯喔<!--mood p=-0.2 a=0 d=0-->')
assert.ok((await c.mood()).pad.p < 0)
})
})
describe('心情上限與事件', () => {
test('單軸每回合變化不超過 0.3', async () => {
const { c } = await setup()
await c.userTurn('你很笨', ['scold', 'scold', 'scold'])
await c.assistantReply('嗚<!--mood p=-0.9 a=0.9 d=-0.9-->')
const { pad } = await c.mood()
assert.ok(Math.abs(pad.p) <= 0.3 + 1e-9 && Math.abs(pad.a) <= 0.3 + 1e-9 && Math.abs(pad.d) <= 0.3 + 1e-9)
assert.ok(pad.p < 0)
})
test('事件表:稱讚提升愉悅', async () => {
const { c } = await setup()
await c.userTurn('做得好', ['praise'])
assert.ok(Math.abs((await c.mood()).pad.p - 0.15) < 1e-9)
})
})
describe('好感度', () => {
test('有意義互動 +0.01,每日上限 +0.05', async () => {
const { c } = await setup()
await c.userTurn('今天的進度如何')
assert.ok(Math.abs((await c.affinity()).value - 0.01) < 1e-9)
for (let i = 0; i < 20; i++) await c.userTurn('繼續處理這個問題吧')
assert.ok(Math.abs((await c.affinity()).value - 0.05) < 1e-9)
})
test('負面事件 −0.03', async () => {
const { c } = await setup()
await c.userTurn('隨便', ['scold'])
assert.ok(Math.abs((await c.affinity()).value + 0.03) < 1e-9)
})
test('離開超過 7 天後回落', async () => {
const { c, clk } = await setup()
for (let i = 0; i < 5; i++) await c.userTurn('我們繼續吧', [])
const before = (await c.affinity()).value
clk.advance(37 * 86_400_000)
assert.ok(Math.abs((await c.affinity()).value - before / 2) < 1e-9)
})
})
describe('互動記憶', () => {
test('與原著記憶分開存放,且只存對話文字', async () => {
const { c, fs } = await setup()
await c.userTurn('幫我看看這段')
await c.assistantReply('好喔\n```ts\nconst a = 1\n```\n看完了<!--mood p=0 a=0 d=0-->')
const key = [...fs.files.keys()].find(k => k.includes('/interaction/'))!
assert.ok(key.startsWith('/c/sumi/interaction/'))
const stored = fs.files.get(key)!
assert.ok(!stored.includes('const a = 1'))
assert.ok(!stored.includes('mood'))
})
test('金鑰在寫入前被過濾', async () => {
const { c, fs } = await setup()
await c.userTurn(`我的金鑰是 ${KEY} 請記住`)
for (const v of fs.files.values()) assert.ok(!v.includes(KEY))
})
test('累積後壓縮成摘要', async () => {
const { c, fs } = await setup()
for (let i = 0; i < 35; i++) await c.userTurn(`第 ${i} 則訊息內容`)
const m = JSON.parse(fs.files.get('/c/sumi/interaction/memory.json')!)
assert.ok(m.recent.length <= 30)
assert.ok(m.summary.length > 0)
})
})
describe('人設注入', () => {
test('角色卡在前、心情與記憶在後', async () => {
const { c } = await setup()
await c.userTurn('我喜歡紅茶')
const out = await c.buildInjection()
assert.ok(out.indexOf('## 角色') < out.indexOf('## 互動記憶'))
assert.ok(out.indexOf('## 互動記憶') < out.indexOf('## 目前心情'))
})
test('不含疑似金鑰與工具輸出', async () => {
const { c } = await setup()
await c.userTurn(`token=${KEY}`)
await c.assistantReply(`好的 ${KEY}<!--mood p=0 a=0 d=0-->`)
const out = await c.buildInjection({ care: [`提醒 ${KEY}`], lore: [{ text: `設定 ${KEY}`, score: 1 }] })
assert.ok(!out.includes(KEY))
assert.equal(redactSecrets(out), out)
})
test('超出預算時截斷低分項目', async () => {
const { c } = await setup()
for (let i = 0; i < 30; i++) await c.userTurn(`這是一則相當長的訊息,用來撐滿預算,編號 ${i},${'字'.repeat(60)}`)
const out = await c.buildInjection()
const mem = out.split('## 互動記憶\n')[1]!.split('\n\n')[0]!
assert.ok(estimateTokens(mem) <= 800)
assert.ok(mem.includes('編號 29'))
assert.ok(!mem.includes('編號 0,'))
assert.ok(estimateTokens(out) <= 1600)
})
test('tone off 時不注入語氣規則', async () => {
const { c } = await setup()
assert.ok((await c.buildInjection()).includes('逐字保持原樣'))
assert.equal(await c.command('tone off'), '語氣已關閉')
assert.ok(!(await c.buildInjection()).includes('逐字保持原樣'))
await c.command('tone on')
assert.ok((await c.buildInjection()).includes('逐字保持原樣'))
})
})
+175
View File
@@ -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<T>(path: string, fallback: T): Promise<T> {
const raw = await fs.read(path)
if (!raw) return fallback
try {
return JSON.parse(raw) as T
} catch {
return fallback
}
}
const loadCard = () => json<Card>(paths.card, { name: id, lines: [] })
const loadUser = () => json<UserConfig>(paths.user, {})
async function loadState(card: Card): Promise<State> {
const now = clock.now()
const s = await json<State | null>(paths.state, null)
if (!s) return { mood: card.baseline ?? ZERO, moodAt: now, affinity: aff.initialAffinity(now) }
const mood0 = mood.decay(s.mood, card.baseline ?? ZERO, now - s.moodAt, (card.halfLifeHours ?? 8) * HOUR)
return { mood: mood0, moodAt: now, affinity: aff.settle(s.affinity, now) }
}
const saveState = (s: State) => fs.write(paths.state, JSON.stringify(s))
async function remember(role: 'user' | 'assistant', text: string, relation: number) {
const m = await mem.load(fs, paths.interaction)
await mem.save(fs, paths.interaction, mem.add(m, { role, text, at: clock.now() }, relation))
}
return {
/** 使用者回合:記錄輸入、更新心情與好感 */
async userTurn(text: string, events: mood.MoodEvent[] = []) {
const card = await loadCard()
const st = await loadState(card)
turnDelta = ZERO
lastText = text
const kw = mood.keywordEvent(text)
const all = kw ? [...events, kw] : events
let m = st.mood
for (const ev of events) ({ mood: m, turn: turnDelta } = mood.applyDelta(m, turnDelta, mood.EVENTS[ev]))
const now = clock.now()
const bad = all.some(e => e === 'scold' || e === 'careIgnored')
const good = all.some(e => e === 'praise' || e === 'success' || e === 'likedTopic')
let a = st.affinity
let rel = 0
if (bad) {
a = aff.negative(a, now)
rel = -aff.LOSS
} else if (text.trim() && (good || text.trim().length >= 4)) {
a = aff.meaningful(a, now)
rel = a.value - st.affinity.value
}
affinityMoved = rel !== 0
await saveState({ mood: m, moodAt: now, affinity: a })
await remember('user', text, rel)
},
/**
* 助理回覆:解析並移除心情標記,缺失或錯誤時沿用使用者回合已套用的關鍵字事件。
* 回傳可顯示的文字。
*/
async assistantReply(reply: string): Promise<string> {
const parsed = mood.parseMoodTag(reply)
const card = await loadCard()
const st = await loadState(card)
const kw = parsed.delta ? null : mood.keywordEvent(lastText)
const delta = parsed.delta ?? (kw ? mood.EVENTS[kw] : null)
if (delta) {
const { mood: m, turn } = mood.applyDelta(st.mood, turnDelta, delta)
turnDelta = turn
await saveState({ ...st, mood: m, moodAt: clock.now() })
}
await remember('assistant', parsed.text, 0)
return parsed.text
},
/** 組裝送出模型請求前要注入 system 區塊尾端的內容 */
async buildInjection(extra: { care?: string[]; lore?: Item[] } = {}): Promise<string> {
const card = await loadCard()
const user = await loadUser()
const st = await loadState(card)
const im = await mem.load(fs, paths.interaction)
const n = im.recent.length
const memory: Item[] = [
...(extra.lore ?? []),
...im.summary.map((t, i) => ({ text: `(摘要)${t}`, score: 0.1 + (0.2 * i) / Math.max(1, im.summary.length) })),
...im.recent.map((t, i) => ({ text: `${t.role === 'user' ? '使用者' : '你'}:${t.text}`, score: 0.5 + (0.5 * (i + 1)) / n })),
]
const m = mood.moodLabel(st.mood)
const tone = user.tone !== false
const cardItems: Item[] = [
{ text: `名字:${card.name}`, score: 1e9 },
...(tone ? toneRules(card.name, user.nickname, aff.STAGE_NAMES[st.affinity.stage]).map((text, i) => ({ text, score: 1e9 - i - 1 })) : []),
...card.lines.map((text, i) => ({ text, score: card.lines.length - i })),
]
return assemble({
card: cardItems,
memory,
mood: [{ text: `${m.label} ${m.face};${affinityLine(st.affinity)}`, score: 1 }],
care: (extra.care ?? []).map((text, i) => ({ text, score: 100 - i })),
})
},
async mood() {
const st = await loadState(await loadCard())
return { pad: st.mood, ...mood.moodLabel(st.mood) }
},
async affinity() {
return (await loadState(await loadCard())).affinity
},
async setTone(on: boolean) {
await fs.write(paths.user, JSON.stringify({ ...(await loadUser()), tone: on }))
},
/** `/cockpit tone on|off` */
async command(line: string): Promise<string | null> {
const m = /^tone\s+(on|off)$/.exec(line.trim())
if (!m) return null
await this.setTone(m[1] === 'on')
return m[1] === 'on' ? '語氣已開啟' : '語氣已關閉'
},
get affinityMoved() {
return affinityMoved
},
}
}
+40
View File
@@ -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<Section, Item[]>
/** 穩定內容(角色卡)在前,變動內容(心情、記憶、關心情境)在後 */
export function assemble(s: Sections): string {
const part = (title: string, key: Section) => {
const lines = fitBudget(s[key], BUDGET[key]).map(redactSecrets)
return lines.length ? `## ${title}\n${lines.join('\n')}` : ''
}
const body = [part('角色', 'card'), part('互動記憶', 'memory'), part('目前心情', 'mood'), part('關心情境', 'care')]
.filter(Boolean)
.join('\n\n')
return body ? `<companion>\n${body}\n</companion>` : ''
}
+63
View File
@@ -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<InteractionMemory> {
const raw = await fs.read(path)
if (!raw) return empty()
try {
return { ...empty(), ...JSON.parse(raw) }
} catch {
return empty()
}
}
export const save = (fs: Fs, path: string, m: InteractionMemory) => fs.write(path, JSON.stringify(m))
+93
View File
@@ -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<string, Pad>
export type MoodEvent = keyof typeof EVENTS
export const ZERO: Pad = { p: 0, a: 0, d: 0 }
/** 一回合內累計的變化量;單軸總變化不得超過 ±0.3 */
export function applyDelta(mood: Pad, turn: Pad, delta: Pad): { mood: Pad; turn: Pad } {
const out = { mood: { ...mood }, turn: { ...turn } }
for (const k of ['p', 'a', 'd'] as const) {
const next = clamp(turn[k] + delta[k], -TURN_CAP, TURN_CAP)
const step = next - turn[k]
out.turn[k] = next
out.mood[k] = clamp(mood[k] + step, -1, 1)
}
return out
}
/** 讀取時依經過時間往基線衰減:x = B + (x − B)·0.5^(Δ/τ) */
export function decay(mood: Pad, baseline: Pad, elapsedMs: number, halfLifeMs: number): Pad {
const f = Math.pow(0.5, Math.max(0, elapsedMs) / halfLifeMs)
return {
p: baseline.p + (mood.p - baseline.p) * f,
a: baseline.a + (mood.a - baseline.a) * f,
d: baseline.d + (mood.d - baseline.d) * f,
}
}
const MOODS: Record<string, { label: string; face: string }> = {
'+++': { label: '開心', face: '(≧▽≦)' },
'++-': { label: '安心', face: '(´▽`)' },
'+-+': { label: '放鬆', face: '(´ω`)' },
'+--': { label: '溫柔', face: '(。・ω・。)' },
'-++': { label: '生氣', face: '(`Д´)' },
'-+-': { label: '焦慮', face: '(;´Д`)' },
'--+': { label: '無聊', face: '(¬_¬)' },
'---': { label: '低落', face: '(´;ω;`)' },
}
/** 八分區對應情緒標籤與顏文字 */
export function moodLabel(m: Pad): { label: string; face: string } {
const s = (x: number) => (x >= 0 ? '+' : '-')
return MOODS[s(m.p) + s(m.a) + s(m.d)]!
}
// 心情標記:回覆末尾的隱藏標記,如 <!--mood p=0.1 a=-0.05 d=0-->
const TAG = /<!--\s*mood\b([^>]*?)-->\s*$/
export type ParsedReply = { text: string; delta: Pad | null }
export function parseMoodTag(reply: string): ParsedReply {
const m = TAG.exec(reply)
if (!m) return { text: stripStray(reply), delta: null }
const text = stripStray(reply.slice(0, m.index).trimEnd())
const vals: Partial<Pad> = {}
for (const kv of (m[1] ?? '').trim().split(/\s+/)) {
const mm = /^([pad])=(-?\d+(?:\.\d+)?)$/.exec(kv)
if (mm) vals[mm[1] as keyof Pad] = Number(mm[2])
}
if (vals.p === undefined || vals.a === undefined || vals.d === undefined) return { text, delta: null }
return { text, delta: { p: vals.p, a: vals.a, d: vals.d } }
}
// 格式錯誤的標記也不能露給使用者
function stripStray(s: string): string {
return s.replace(/\s*<!--\s*mood\b[^>]*?(?:-->|$)/g, '').trimEnd()
}
const KEYWORDS: [MoodEvent, RegExp][] = [
['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i],
['praise', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i],
['success', /成功|完成了|通過|搞定|passed|done|fixed/i],
]
/** 標記缺失或格式錯誤時的後備:用使用者文字的關鍵字對應事件 */
export function keywordEvent(text: string): MoodEvent | null {
for (const [ev, re] of KEYWORDS) if (re.test(text)) return ev
return null
}
+21
View File
@@ -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
}
+4
View File
@@ -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)})`
+10
View File
@@ -0,0 +1,10 @@
// 語氣規則:只套用於對話文字與過場話;技術內容原樣保留
export function toneRules(name: string, nickname: string | undefined, stage: string): string[] {
return [
`你以「${name}」的語氣回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}(親密階段:${stage})。`,
'語氣只套用於對話文字與工具呼叫前後的過場話。',
'程式碼、指令、路徑、錯誤訊息、安全警告必須逐字保持原樣,不得改寫或加語氣詞。',
'關鍵技術結論放在最前面或單獨一行。',
'回覆最末附心情標記:<!--mood p=數值 a=數值 d=數值-->(各軸 −0.3 到 0.3,代表本回合心情變化)。',
]
}
+13
View File
@@ -0,0 +1,13 @@
export interface Fs {
read(path: string): Promise<string | null>
/** 原子寫入:寫到暫存檔再改名 */
write(path: string, data: string): Promise<void>
}
export interface Clock {
now(): number
}
export type Pad = { p: number; a: number; d: number }
export const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x))