feat(記憶與目標): 專案目標三條 + 固化候選不再重算(0.2.1) #17

Merged
admin merged 41 commits from develop into master 2026-08-03 09:15:21 +00:00
2 changed files with 93 additions and 12 deletions
Showing only changes of commit 45a6f2cad9 - Show all commits
+76 -4
View File
@@ -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/<work>/`):機械的那半
//
@@ -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 {
/* 語氣檔壞掉不該讓整輪掛掉 */
}
+17 -8
View File
@@ -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}`);