diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 2ea292e..9837644 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -3613,8 +3613,21 @@ export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) { // 所以讀進來的每一欄都是外部輸入 → 一律過 `injectSafeLine`。 // --------------------------------------------------------------------------- // -export const VOICE_KINDS = { sample: "samples.md", reaction: "reactions.md" }; -export const VOICE_LABELS = { sample: "語氣樣本", reaction: "情緒反應" }; +export const VOICE_KINDS = { sample: "samples.md", reaction: "reactions.md", idiolect: "idiolect.md" }; +export const VOICE_LABELS = { sample: "語氣樣本", reaction: "情緒反應", idiolect: "語域" }; + +/** + * 語域的四格。 + * + * 語氣樣本是**減法之外的加法**:`SPEECH_BLACKLIST` 刪掉 AI 腔之後,剩下的是「乾淨的 + * 中文」,不是「這個人的中文」。自稱、句尾、口癖才是把名字遮掉還認得出是誰的東西。 + * + * 「不說」單獨一格而不是塞進禁忌:禁忌是**話題**(不能提的事),不說是**用詞** + * (這個人不會用的字)。混在一起會讓人格以為某個話題不能碰。 + */ +export const IDIOLECT_FACETS = ["自稱", "句尾", "口癖", "不說"]; +/** 每輪最多注入幾格語域(跟語氣樣本一樣:全注入會變成模仿腔)。 */ +export const IDIOLECT_INJECT_MAX = 3; // 每輪最多注入幾條。全注入會變成模仿腔——他會開始照抄自己的舊台詞, // 那比沒有語氣樣本更糟(TODO 故事匯入階段 4 明寫)。 export const VOICE_INJECT_MAX = 3; @@ -3624,6 +3637,7 @@ export const VOICE_KEEP = 400; // 一個檔最多認幾條(手改可以更多 const VOICE_FIELDS = { sample: [["text", "原句"], ["to", "對象"], ["scene", "場合"]], reaction: [["event", "事件"], ["action", "反應"], ["emotion", "情緒"]], + idiolect: [["facet", "格"], ["value", "值"]], }; const VOICE_HEADERS = { @@ -3641,6 +3655,15 @@ const VOICE_HEADERS = { "`- 事件:<發生什麼>|反應:<他做了什麼>|情緒:<當時的情緒>`", "", ], + idiolect: [ + "# 語域", + "", + "把名字遮掉,光看三句話能認出是誰——靠的是這四格:", + "`- 格:自稱|值:我、俺`(其餘:句尾/口癖/不說)", + "", + "「不說」是**用詞**(這個人不會用的字),不是話題禁忌。", + "", + ], }; export function voicePath(slug, kind = "sample") { @@ -3716,10 +3739,22 @@ export const addVoiceSample = (slug, { text, to = "", scene = "" } = {}) => export const addVoiceReaction = (slug, { event, action = "", emotion = "" } = {}) => addVoiceLine(slug, "reaction", { event, action, emotion }); +/** + * 加一格語域。 + * + * `facet` 必須是 `IDIOLECT_FACETS` 其中一格——自由欄位會長出「語氣」「風格」這種 + * 什麼都能塞的格子,注入端就不知道該怎麼講給人格聽。 + */ +export function addVoiceIdiolect(slug, { facet, value } = {}) { + const key = String(facet || "").trim(); + if (!IDIOLECT_FACETS.includes(key)) return null; + return addVoiceLine(slug, "idiolect", { facet: key, value }); +} + /** 讀整份語氣檔(`{ samples, reactions }`)。壞行、缺主欄位的行直接跳過。 */ export function loadVoice(slug) { - const out = { samples: [], reactions: [] }; - const bucket = { sample: "samples", reaction: "reactions" }; + const out = { samples: [], reactions: [], idiolect: [] }; + const bucket = { sample: "samples", reaction: "reactions", idiolect: "idiolect" }; for (const kind of Object.keys(VOICE_KINDS)) { let text; try { @@ -3783,6 +3818,39 @@ export function voiceBrief(slug, { limit = VOICE_INJECT_MAX, to = null, hint = " return lines.join("\n"); } +/** + * 這一輪要注入的語域(最多 `IDIOLECT_INJECT_MAX` 格)。 + * + * 四格輪替,起點由**第幾輪**算出來(`felt.jsonl` 的筆數),不用隨機數—— + * 隨機是這個專案明文禁止的充人味手段,而且會讓同一輪重跑出現不同結果。 + * + * 一輪塞四格會變成模仿腔:他開始逐條照著演,每句都要有口癖。 + */ +export function idiolectBrief(slug, { limit = IDIOLECT_INJECT_MAX, turn = null } = {}) { + const rows = loadVoice(slug).idiolect; + if (!rows.length) return ""; + const byFacet = new Map(); + for (const row of rows) { + if (!IDIOLECT_FACETS.includes(row.facet) || !row.value) continue; + if (!byFacet.has(row.facet)) byFacet.set(row.facet, []); + byFacet.get(row.facet).push(row.value); + } + const present = IDIOLECT_FACETS.filter((f) => byFacet.has(f)); + if (!present.length) return ""; + const max = Math.max(1, Math.min(Number(limit) || IDIOLECT_INJECT_MAX, IDIOLECT_INJECT_MAX)); + const t = Number.isFinite(Number(turn)) ? Number(turn) : readJsonl(feltPath(slug), 400).length; + const start = ((t % present.length) + present.length) % present.length; + const take = Math.min(max, present.length); + const picked = Array.from({ length: take }, (_, i) => present[(start + i) % present.length]); + const rest = present.length - take; + return ( + `語域(這一輪 ${take} 格${rest > 0 ? `,另外 ${rest} 格下一輪輪到` : ""}):` + + picked.map((f) => `${f}→${byFacet.get(f).slice(-3).join("、")}`).join(";") + "。\n" + + " 這是**加法**:黑名單刪掉的是 AI 腔,剩下的是乾淨的中文、不是他的中文,這幾格把他自己的講法補回去。" + + "照著用,但不要每句都塞——把名字遮掉、光看三句話認得出是誰就夠了。" + ); +} + // --------------------------------------------------------------------------- // // 故事匯入(`memory/import//`):機械的那半 // @@ -5902,6 +5970,10 @@ export function turnContext(slug, sessionId, prompt = "") { try { const voice = voiceBrief(slug, { to: speakerNode(slug)?.name || null, hint: prompt }); if (voice) lines.push(voice); + // 語域是語氣層的**加法面**(自稱/句尾/口癖/不說),跟原句同一個目錄、 + // 同一支 `voice add`——所以 persona-story 與 persona-anime 寫的是同一格。 + const idiolect = idiolectBrief(slug); + if (idiolect) lines.push(idiolect); } catch { /* 語氣檔壞掉不該讓整輪掛掉 */ } diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 7ab3cbd..0006d7b 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -1233,12 +1233,16 @@ commands.voice = ({ flags, positional }) => { const kind = str(flags.kind) || "sample"; if (!(kind in pl.VOICE_KINDS)) { die(`--kind 只能是 ${Object.keys(pl.VOICE_KINDS).join("/")}` + - "(sample=他自己講過的原句,reaction=事件對上他做了什麼)。"); + "(sample=他自己講過的原句,reaction=事件對上他做了什麼,idiolect=語域的四格)。"); } - const render = (entry) => (kind === "sample" - ? `「${entry.text}」${[entry.to ? `對 ${entry.to}` : "", entry.scene].filter(Boolean).join("/") - ? `(${[entry.to ? `對 ${entry.to}` : "", entry.scene].filter(Boolean).join("/")})` : ""}` - : `${entry.event} → ${entry.action || "(沒記)"}${entry.emotion ? `(${entry.emotion})` : ""}`); + const render = (entry) => { + if (kind === "idiolect") return `${entry.facet}→${entry.value}`; + if (kind === "reaction") { + return `${entry.event} → ${entry.action || "(沒記)"}${entry.emotion ? `(${entry.emotion})` : ""}`; + } + const at = [entry.to ? `對 ${entry.to}` : "", entry.scene].filter(Boolean).join("/"); + return `「${entry.text}」${at ? `(${at})` : ""}`; + }; if (action === "show" || action === "list") { requireMember(slug, session, Boolean(flags["as-guest"])); const voice = pl.loadVoice(slug); @@ -1254,7 +1258,7 @@ commands.voice = ({ flags, positional }) => { ]); return; } - const rows = kind === "sample" ? voice.samples : voice.reactions; + const rows = kind === "sample" ? voice.samples : kind === "reaction" ? voice.reactions : voice.idiolect; const limit = num(flags.limit, 20); const shown = Number.isFinite(limit) && limit > 0 ? rows.slice(-limit) : rows; emit({ persona: slug, kind, total: rows.length, entries: shown }, flags.json, [ @@ -1268,11 +1272,16 @@ commands.voice = ({ flags, positional }) => { if (action === "add") { const res = kind === "sample" ? pl.addVoiceSample(slug, { text: str(flags.text), to: str(flags.to), scene: str(flags.scene) }) - : pl.addVoiceReaction(slug, { event: str(flags.event), action: str(flags.action), emotion: str(flags.emotion) }); + : kind === "reaction" + ? pl.addVoiceReaction(slug, { event: str(flags.event), action: str(flags.action), emotion: str(flags.emotion) }) + : pl.addVoiceIdiolect(slug, { facet: str(flags.facet), value: str(flags.value) }); if (!res) { die(kind === "sample" ? "需要 `--text`(他自己講過的原句,照抄不要改寫)。" - : "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。"); + : kind === "reaction" + ? "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。" + : `需要 \`--facet\`(只能是 ${pl.IDIOLECT_FACETS.join("/")})與 \`--value\`。` + + "「不說」寫的是**用詞**(這個人不會用的字),不是話題禁忌。"); } if (!res.added) { ok(`已經有一模一樣的一條了,沒有重複寫入:${res.line}`);