merge(故事匯入): feat/persona-story-import 併入 develop

voice 語氣層、novel 機械指令與 persona-story 技能併進 develop,跟 G2/G3 那批合流。
四個共同修改的檔(README、persona-lib、persona.mjs、selftest)全部自動合併,無衝突。

驗證:selftest 646 + 63 = 709 項全綠,零重疊也零回歸。

併進來同時解掉 TARGET.md 兩條卡在跨分支的項目:5.7 的記憶行為欄位要跟
persona-story 的 1.12 對齊、5.6 的語域要吃 3A 的語氣統計——兩邊現在同一棵樹了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 08:12:12 +00:00
co-authored by Claude Opus 5
10 changed files with 2321 additions and 50 deletions
+930
View File
@@ -1439,6 +1439,9 @@ export const PERSONA_SUBDIRS = [
"memory/inbox",
"mindmap/threads",
"relations",
// 語氣層自己一層:它跟 SOUL.md 的改動權限不同(匯入流程可以寫語氣,個性只有使用者能改),
// 權限界線要有實體隔離,不靠自律——混在同一個檔裡,寫語氣就會順手寫到個性。
"voice",
"journal",
];
@@ -2362,6 +2365,9 @@ export function memoryStrength(meta, now = Date.now()) {
}
const strength = clamp(numOr(meta?.strength, MEMORY_STRENGTH_DEFAULT));
const count = Math.max(0, Math.floor(numOr(meta?.recall_count, 0)));
// **這個時鐘只認真實時間**:`last_seen` 是「上次想起這則記憶」,不是
// 「故事裡這件事什麼時候發生」。劇情時間有自己的欄位(`happened_at`),
// 混在一起的話一則 2024 年劇情的記憶會在匯入的那一秒就掉到 0%(踩過一次)。
const seen = meta?.last_seen || meta?.first_seen || null;
// 日期讀不出來時 `ageSeconds` 回 Infinity → 那則記憶會瞬間全糊掉。
// 讀不出來只代表**不知道多舊**,不代表很舊,所以當成 0 天(維持現狀)。
@@ -2479,6 +2485,94 @@ export function migrateLongTerm(slug, { dryRun = false } = {}) {
return stats;
}
/**
* 寫一則長期記憶檔一則一檔`consolidate` 與故事匯入`novel write`都走這裡
*
* front matter 的欄位順序與預設值只能有一份兩邊各寫一次的話多一個欄位就會有一邊
* 漏掉而漏掉的那邊要等到 `memoryStrength` 算出怪數字才會被發現
*
* `name` 要先 `slugify` 呼叫端得拿它做撞名檢查 consolidate`extra` 是額外的
* 一行一欄位故事匯入的 `date_source``know_level`值一樣只能是一行
*/
export function writeLongTermMemory(slug, {
name,
title = "",
type = "fact",
body = "",
gist = null,
detail = "",
about = [],
topics = [],
salience = 60,
strength = null,
emotion = "",
when = "",
where = "",
mood = "",
rules = "",
source = "",
firstSeen = "",
lastSeen = "",
extra = {},
} = {}) {
const file = path.join(longTermDir(slug), `${name}.md`);
let existing = {};
if (fs.existsSync(file)) [existing] = parseFrontMatter(fs.readFileSync(file, "utf8"));
const today = nowIso().slice(0, 10);
const stamp = contextStamp(slug);
// `gist` 明寫時,`detail` 沒給就是**沒有細節**——退回整份 body 會讓主旨之外
// 再抄一次全文,那是把「衰減先吃細節」這件事整個抵銷掉。
const split = gist === null || gist === "" ? splitGistDetail(body) : { gist, detail };
// front matter 的每個值都只能是一行:帶換行的值能偽造出別的欄位,
// 而 `parseFrontMatter` 取後出現的值 → 後面宣告的 type/salience 會被前面偽造的蓋掉。
const fm = (value) => injectSafeLine(value);
// `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與
// 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。
// `resolveRelationRefs` 已經把不能寫進 front matter 的 id 濾掉了(帶換行的節點 id
// 可以在這裡多插一行、覆寫下面的 `type`,把一則 fact 變成不該被遺忘的 canon)。
const aboutIds = resolveRelationRefs(slug, asList(about));
const stamped = { when, where, mood };
const front = [
"---",
`name: ${name}`,
// 沒被 slugify 吃掉的那個名字。下次撞名時就是靠這行認出「不是同一則」。
`title: ${String(title || name).replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
`type: ${type}`,
`about: [${asList(about).map(fm).filter(Boolean).join(", ")}]`,
...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []),
`topics: [${asList(topics).map(fm).filter(Boolean).join(", ")}]`,
`salience: ${salience}`,
// 回想強度:連續衰減的起點。已經存在的記憶保留自己長出來的強度,
// 不要因為重寫一次就把「被想起過很多次」的歷史抹平。
`strength: ${strength ?? Number(existing.strength ?? MEMORY_STRENGTH_DEFAULT)}`,
`emotion: ${fm(emotion) || "none"}`,
// 情境索引:什麼時候、在哪裡、什麼心情記下來的(回想時的非語意線索)。
// 推不出來的欄位就不寫——全專案一致:沒有值不留空欄位。
...["when", "where", "mood"]
.map((k) => [k, fm(stamped[k] || existing[k] || stamp[k] || "")])
.filter(([, v]) => v)
.map(([k, v]) => `${k}: ${v}`),
`rules: ${fm(rules) || "manual"}`,
`first_seen: ${fm(firstSeen) || existing.first_seen || today}`,
`last_seen: ${fm(lastSeen) || today}`,
`recall_count: ${existing.recall_count || 0}`,
`source: ${fm(source) || "short-term"}`,
// 匯入專用的追溯欄位(`date_source` 分得出「原作明寫」與「推算的」,
// `know_level` 記他是怎麼知道這件事的)。沒有值的一律不寫。
...Object.entries(extra)
.map(([k, v]) => [fm(k), fm(v)])
.filter(([k, v]) => k && v)
.map(([k, v]) => `${k}: ${v}`),
"---",
"",
// 主旨/細節兩層:衰減先吃細節,主旨最後才掉。沒有明寫就用第一段當主旨。
joinGistDetail(split.gist, split.detail),
"",
];
writeText(file, front.join("\n"));
return { file, name, existing };
}
export function rebuildIndex(slug) {
const entries = longTermEntries(slug);
const lines = [
@@ -3395,6 +3489,833 @@ export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) {
).length;
}
// --------------------------------------------------------------------------- //
// 語氣層(`voice/`):他講過的原句、以及「事件 → 他做了什麼」
//
// 這一層跟 `SOUL.md` 刻意分開放。SOUL 是個性(Core TruthsBoundariesVibe),
// 只有使用者拍板才動;語氣是表面(他會說的字、遇到事會做的動作),故事匯入可以直接寫。
//
// 兩個檔都是給人讀的 markdown 清單(手改是預期用法),所以解析寬鬆:只認行首的 `- `、
// 欄位用全形 `|` 分隔、標籤缺了就照順序補位。而它們會被 `sync pull``import` 覆蓋,
// 所以讀進來的每一欄都是外部輸入 → 一律過 `injectSafeLine`。
// --------------------------------------------------------------------------- //
export const VOICE_KINDS = { sample: "samples.md", reaction: "reactions.md" };
export const VOICE_LABELS = { sample: "語氣樣本", reaction: "情緒反應" };
// 每輪最多注入幾條。全注入會變成模仿腔——他會開始照抄自己的舊台詞,
// 那比沒有語氣樣本更糟(TODO 故事匯入階段 4 明寫)。
export const VOICE_INJECT_MAX = 3;
export const VOICE_KEEP = 400; // 一個檔最多認幾條(手改可以更多,注入端只看最後這些)
// 欄位順序=手改時可以省略標籤的順序。第一欄是主欄位,缺了整行就不算一筆。
const VOICE_FIELDS = {
sample: [["text", "原句"], ["to", "對象"], ["scene", "場合"]],
reaction: [["event", "事件"], ["action", "反應"], ["emotion", "情緒"]],
};
const VOICE_HEADERS = {
sample: [
"# 語氣樣本",
"",
"一行一句,他自己講過的原句(照抄不改寫):",
"`- 「原句」|對象:<誰>|場合:<戰鬥/日常/道別>`",
"",
],
reaction: [
"# 情緒反應",
"",
"一行一筆,事件對上他實際做了什麼(不要記「他感到什麼」):",
"`- 事件:<發生什麼>|反應:<他做了什麼>|情緒:<當時的情緒>`",
"",
],
};
export function voicePath(slug, kind = "sample") {
const file = VOICE_KINDS[kind];
if (!file) throw new Error(`未知的語氣檔類型 \`${kind}\`(可用 ${Object.keys(VOICE_KINDS).join("")}`);
return path.join(personaDir(slug), "voice", file);
}
/** 一行清單 → 欄位物件。標籤(`對象:`)優先,沒帶標籤的照 `VOICE_FIELDS` 的順序補位。 */
function parseVoiceLine(kind, line) {
const fields = VOICE_FIELDS[kind] || [];
const out = {};
let next = 0;
for (const cell of String(line).replace(/^\s*[-*]\s+/, "").split("")) {
const one = injectSafeLine(cell);
if (!one) continue;
const m = one.match(/^([^:]{1,8})[:]\s*(.+)$/);
const named = m ? fields.find(([, label]) => label === m[1].trim()) : null;
if (named) {
out[named[0]] = m[2].trim();
continue;
}
while (next < fields.length && out[fields[next][0]] !== undefined) next += 1;
if (next >= fields.length) continue;
out[fields[next][0]] = one.replace(/^[「『]/, "").replace(/[」』]$/, "");
next += 1;
}
return out;
}
/** 欄位物件 → 一行清單(寫入端也過 `injectSafeLine`:換行會把一筆變成兩筆)。 */
function renderVoiceLine(kind, entry) {
const fields = VOICE_FIELDS[kind] || [];
const cells = [];
for (const [key, label] of fields) {
const value = injectSafeLine(entry?.[key], 240);
if (!value) continue;
if (key === fields[0][0]) cells.push(kind === "sample" ? `${value}` : value);
else cells.push(`${label}${value}`);
}
return `- ${cells.join("")}`;
}
/**
* 往語氣檔加一筆同一筆匯入兩次不再多留一行匯入流程會重跑同一章
* 而重複的原句會讓 `voiceBrief` 每次都挑到同一句
*/
function addVoiceLine(slug, kind, entry) {
const fields = VOICE_FIELDS[kind];
if (!fields) throw new Error(`未知的語氣檔類型 \`${kind}\``);
if (!injectSafeLine(entry?.[fields[0][0]])) return null;
const file = voicePath(slug, kind);
const line = renderVoiceLine(kind, entry);
return withFileLock(file, () => {
let text = null;
try {
text = fs.readFileSync(file, "utf8");
} catch {
text = null;
}
if (text !== null && text.split("\n").some((l) => l.trim() === line)) {
return { file, kind, line, added: false };
}
const head = text === null ? VOICE_HEADERS[kind].join("\n") : text.replace(/\s*$/, "");
writeText(file, `${head}\n${line}\n`);
return { file, kind, line, added: true };
});
}
export const addVoiceSample = (slug, { text, to = "", scene = "" } = {}) =>
addVoiceLine(slug, "sample", { text, to, scene });
export const addVoiceReaction = (slug, { event, action = "", emotion = "" } = {}) =>
addVoiceLine(slug, "reaction", { event, action, emotion });
/** 讀整份語氣檔(`{ samples, reactions }`)。壞行、缺主欄位的行直接跳過。 */
export function loadVoice(slug) {
const out = { samples: [], reactions: [] };
const bucket = { sample: "samples", reaction: "reactions" };
for (const kind of Object.keys(VOICE_KINDS)) {
let text;
try {
text = fs.readFileSync(voicePath(slug, kind), "utf8");
} catch {
continue;
}
const rows = [];
for (const raw of text.split("\n")) {
if (!/^\s*[-*]\s+/.test(raw)) continue;
const entry = parseVoiceLine(kind, raw);
if (!entry[VOICE_FIELDS[kind][0][0]]) continue;
rows.push(entry);
}
out[bucket[kind]] = rows.slice(-VOICE_KEEP);
}
return out;
}
/**
* 這一輪要注入的語氣最多 `VOICE_INJECT_MAX`
*
* 挑法對得上這一輪對象或話題的優先同分取後加入的新的原句比舊的像現在的他
* 條數是硬上限不是建議值`--limit` 只能往下調
*/
export function voiceBrief(slug, { limit = VOICE_INJECT_MAX, to = null, hint = "" } = {}) {
const voice = loadVoice(slug);
if (!voice.samples.length && !voice.reactions.length) return "";
const max = Math.max(1, Math.min(Number(limit) || VOICE_INJECT_MAX, VOICE_INJECT_MAX));
const who = injectSafeLine(to);
const text = String(hint || "");
const score = (entry) => {
let s = 0;
if (who && entry.to && (entry.to === who || who.includes(entry.to) || entry.to.includes(who))) s += 3;
if (entry.scene && text.includes(entry.scene)) s += 2;
if (entry.event && text.includes(entry.event)) s += 2;
return s;
};
const pick = (rows, n) => (n <= 0 ? [] : rows
.map((entry, i) => ({ entry, i, s: score(entry) }))
.sort((a, b) => (b.s - a.s) || (b.i - a.i))
.slice(0, n)
.map((r) => r.entry));
// 反應最多帶一條:它是「遇到事會做什麼」,一輪露一個就夠,多了就變成照劇本演。
const reactions = pick(voice.reactions, Math.min(1, max));
const samples = pick(voice.samples, max - reactions.length);
const lines = [];
if (samples.length) {
lines.push("他自己講過的原句(**學語氣,不要照抄這幾句**):");
for (const s of samples) {
const at = [s.to ? `${s.to}` : "", s.scene].filter(Boolean).join("");
lines.push(` - 「${s.text}${at ? `${at}` : ""}`);
}
}
for (const r of reactions) {
lines.push(
`遇到「${r.event}」他做的是:${r.action || "(沒記下來)"}` +
`${r.emotion ? `(當時 ${r.emotion}` : ""}——**演出來,不要旁白說明**。`,
);
}
return lines.join("\n");
}
// --------------------------------------------------------------------------- //
// 故事匯入(`memory/import/<work>/`):機械的那半
//
// 分工照 TODO 故事匯入 Q2:判斷留給 skill(在場與知情、切場景、第一人稱摘要、
// 個性校正提案),機械的進這裡(正名、欄位驗證、跨章去重、配額重定標、批次寫入)。
// 一部作品一個工作區,四個檔:
//
// work.json 書名、slug、建立時間、顯著度配額
// names.json 正名表:譯名/簡稱/原文名 → 關係節點的 name
// skipped.jsonl 跳過紀錄(靜默跳過會漏章,事後查不出來)
// candidates.jsonl 記憶候選(一章一批 append,全書跑完才收斂)
// --------------------------------------------------------------------------- //
/** 記憶候選的 `type`:只有這幾種。`fact`/`diary` 不在裡面——那兩種不是從書裡讀來的。 */
export const NOVEL_TYPES = ["canon", "event", "insight", "promise", "boundary"];
/**
* `first_seen` 的來源換算成西元日期換來一個新風險猜出來的日期看起來跟原作
* 明寫的一模一樣所以日期旁邊一定要有這一欄TODO 故事匯入 Q3 追加的規則
*/
export const NOVEL_DATE_SOURCES = {
canon: "原作明寫的日期",
derived: "由明寫的日期推算",
guess: "只能抓大概(回答日期類問題時不可以斷言)",
};
/**
* 知情層級這一欄決定那則記憶能不能被他當成自己的事講出來
*
* 這是整個匯入流程**唯一真正危險的一欄**小說裡大半的資訊是作者寫給讀者看的
* 別人的內心話他不在場那一幕的細節寫成他的 `event` 之後他會拿來回答問題
* 而且沒有任何機械檢查得出來所以 `none` 在寫入前就要擋成 `canon`
*
* 鍵用英文 `type``date_source` 一致front matter 不混中英
*/
export const NOVEL_KNOW_LEVELS = {
did: "我做的",
saw: "我看到的",
told: "別人告訴我的",
later: "事後才知道",
none: "他不在場也沒人告訴他(只能進 canon)",
};
/** 顯著度配額:`{ 門檻: 最多幾則 }`。避免整本書都是 80。 */
export const NOVEL_DEFAULT_QUOTA = { 90: 5, 80: 20 };
export const NOVEL_DEMOTE_STEP = 5; // 超過配額就往下壓一級(90+ → 85、80-89 → 75
export const NOVEL_BASELINE_GAP = 10; // 情緒基線任一格差這麼多就要停下來給人看(TODO Q4)
export const novelDir = (slug) => path.join(personaDir(slug), "memory", "import");
export const novelWorkDir = (slug, work) => path.join(novelDir(slug), slugify(work));
export const novelWorkPath = (slug, work) => path.join(novelWorkDir(slug, work), "work.json");
export const novelNamesPath = (slug, work) => path.join(novelWorkDir(slug, work), "names.json");
export const novelSkippedPath = (slug, work) => path.join(novelWorkDir(slug, work), "skipped.jsonl");
export const novelCandidatesPath = (slug, work) => path.join(novelWorkDir(slug, work), "candidates.jsonl");
export const novelProposedPath = (slug, work) => path.join(novelWorkDir(slug, work), "names-proposed.json");
export function listNovelWorks(slug) {
let entries = [];
try {
entries = fs.readdirSync(novelDir(slug), { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory())
.map((e) => readJson(novelWorkPath(slug, e.name)))
.filter(Boolean);
}
export const loadNovelWork = (slug, work) => readJson(novelWorkPath(slug, work));
/** `"90=5,80=20"` → `{ 90: 5, 80: 20 }`。看不懂的段落直接跳過(配額寫錯不該讓匯入停擺)。 */
export function parseNovelQuota(raw) {
const out = {};
for (const chunk of String(raw ?? "").split(",")) {
const [key, value] = chunk.split("=").map((s) => String(s ?? "").trim());
const floor = Number(key);
const limit = Number(value);
if (!Number.isFinite(floor) || floor < 0 || floor > 100) continue;
if (!Number.isFinite(limit) || limit < 0) continue;
out[Math.round(floor)] = Math.round(limit);
}
return Object.keys(out).length ? out : null;
}
export function initNovelWork(slug, { work, workSlug = "", quota = null } = {}) {
const title = injectSafeLine(work, 120);
if (!title) return null;
const key = slugify(workSlug || title);
const file = novelWorkPath(slug, key);
return updateJson(file, (prev) => ({
work: title,
slug: key,
created_at: prev?.created_at || nowIso(),
updated_at: nowIso(),
quota: quota || prev?.quota || { ...NOVEL_DEFAULT_QUOTA },
baseline: prev?.baseline ?? null,
}));
}
/**
* 正名表`{ map, ignore }`
*
* map 譯名簡稱原文名 關係節點的 `name``about` 照這張表正名
* ignore 使用者確認過這不是人名的詞地名系統詞抽名字時的誤判
*
* `ignore` 存在的理由是抽名字的啟發式**擋不完**擋不完是正常的
* 所以要有地方記住這個看過了不是人不然每加一章就要重看同一批誤判
*/
export function loadNovelNameBook(slug, work) {
const data = readJson(novelNamesPath(slug, work), {}) ?? {};
return {
map: data.map && typeof data.map === "object" ? data.map : {},
ignore: asList(data.ignore).map((t) => injectSafeLine(t, 80)).filter(Boolean),
};
}
export const loadNovelNames = (slug, work) => loadNovelNameBook(slug, work).map;
/**
* 正名表加一條`from`譯名簡稱 `to`關係節點的 `name`
*
* 存的是節點的 `name` 而不是呼叫端給的字串`findRelationNode` 最後一段是子字串比對
* 亞絲娜對得上結城明日奈這種節點時存原字串會讓 `about` 之後又對不上
* `resolveRelationRefs` 只認完全相等正名的意義就是把它一次對死
*/
export function addNovelName(slug, work, { from, to } = {}) {
const src = injectSafeLine(from, 80);
const node = findRelationNode(slug, injectSafeLine(to, 80));
if (!src || !node) return null;
const name = injectSafeLine(node.name || node.id, 80);
updateJson(novelNamesPath(slug, work), (prev) => {
const data = prev && typeof prev === "object" ? prev : {};
data.map = data.map && typeof data.map === "object" ? data.map : {};
data.map[src] = name;
data.updated_at = nowIso();
return data;
}, {});
return { from: src, to: name, node_id: node.id };
}
// --------------------------------------------------------------------------- //
// 正名表的主路徑:從章節裡掃人名候選 → 使用者確認
//
// 問使用者「你這批是哪個譯本、有哪些譯名」是要他猜;直接讀章節裡真的出現什麼名字
// 才是看得見的字。所以 `novel name add` 只留著補漏,主路徑是掃描加確認兩段。
// --------------------------------------------------------------------------- //
const NOVEL_HAN = "\\u3400-\\u4dbf\\u4e00-\\u9fff";
const NOVEL_KATAKANA = "\\u30a1-\\u30fa\\u30fc";
// 說話動詞與敬稱刻意只列這些:這兩條是誤判率最低的來源(會說話的、被加敬稱的,
// 幾乎一定是人),列得越寬就越像第 4 條那種高頻詞規則,誤判要使用者一條一條看。
const NOVEL_SPEECH_VERBS = "說|道|問|喊|笑|點頭|低聲|回答";
const NOVEL_HONORIFICS = "先生|小姐|桑|君|醬|大人|隊長|團長";
export const NOVEL_NAME_MIN_COUNT = 3; // 高頻詞規則的門檻:出現這麼多次才算候選
/** 高頻詞規則最常誤判的那幾類(地名與方位、系統詞、一般名詞)。擋不完,所以另有 ignore。 */
export const NOVEL_NAME_STOPWORDS = new Set([
"迷宮區", "主街區", "圈內", "圈外", "樓層",
"視窗", "選單", "道具", "技能", "任務", "公會", "玩家", "等級", "經驗值",
"時候", "事情", "樣子", "東西", "意思", "感覺", "聲音", "身體", "眼睛",
]);
// 中文沒有詞界:`([HAN]{2,4})說` 這種規則會從動詞往前吃滿四個字,於是
// 「然後亞絲娜說」抓到的是「後亞絲娜」。名字幾乎不會用這些字開頭,所以只要
// 詞還剩兩個字以上就把它剝掉——這是停用詞表的同一件事,只是作用在第一個字上。
const NOVEL_NAME_LEAD_STRIP = new Set([
..."然後是也就又都而則卻才還再便只並把被個的了在和與但可要會",
]);
/**
* 從一章的文字抽人名候選 `Map<token, { count, samples }>`
*
* 四條啟發式只有這四條
* 1. 對話歸屬`「…」` 前後緊接的 2 4 字詞 + 說話動詞
* 2. 敬稱結尾token 收整個字面書裡出現的是小林先生不是小林
* 3. 片假名連續 2 字以上日文原文來源的名字
* 4. 高頻的 2 4 字連續詞出現 >= 3 次且不在停用詞表裡
*
* `count` 是那個詞在全文出現的次數不是命中幾條規則使用者要判斷這是不是人
* 看的是它出現得多不多而不是我用哪條規則抓到它
*/
export function extractNovelNameTokens(text) {
const src = String(text ?? "");
const found = new Set();
const verbTail = new RegExp(`(?:${NOVEL_SPEECH_VERBS})$`);
const push = (value) => {
let one = injectSafeLine(value, 40);
// 動詞也可能是兩個字(低聲):「桐人低聲問」的前四個字就是「桐人低聲」。
// 尾巴的動詞剝掉,剩下的才是名字。
while (one.length > 2 && verbTail.test(one)) one = one.replace(verbTail, "");
while (one.length > 2 && NOVEL_NAME_LEAD_STRIP.has(one[0])) one = one.slice(1);
if (one.length >= 2) found.add(one);
};
const speechAfter = new RegExp(`\\s*([${NOVEL_HAN}]{2,4})\\s*(?:${NOVEL_SPEECH_VERBS})`, "g");
const speechBefore = new RegExp(`([${NOVEL_HAN}]{2,4})\\s*(?:${NOVEL_SPEECH_VERBS})[^「」]{0,4}「`, "g");
for (const m of src.matchAll(speechAfter)) push(m[1]);
for (const m of src.matchAll(speechBefore)) push(m[1]);
for (const m of src.matchAll(new RegExp(`[${NOVEL_HAN}]{1,4}(?:${NOVEL_HONORIFICS})`, "g"))) push(m[0]);
for (const m of src.matchAll(new RegExp(`[${NOVEL_KATAKANA}]{2,}`, "g"))) push(m[0]);
// 第 4 條:n-gram 一定會連碎片一起達標(「亞絲娜」出現幾次,「亞絲」「絲娜」就出現幾次)。
// 被更長的候選包住、又沒有比它更常出現的,就是同一個名字的碎片——留著只會讓
// 使用者看三遍同一個人,所以長的優先、短的丟掉。
const grams = new Map();
for (const run of src.match(new RegExp(`[${NOVEL_HAN}]{2,}`, "g")) || []) {
for (let n = 4; n >= 2; n -= 1) {
for (let i = 0; i + n <= run.length; i += 1) {
const gram = run.slice(i, i + n);
grams.set(gram, (grams.get(gram) || 0) + 1);
}
}
}
// 停用詞不是「跳過」而是「留著擋碎片」:「迷宮區」被擋掉之後,它的碎片「迷宮」
// 「宮區」次數一樣多,照樣會達標——所以達標的詞全部進 kept 當覆蓋範圍,
// 只是停用詞本身不列成候選。
const kept = [];
for (const [gram, count] of [...grams.entries()]
.filter(([, c]) => c >= NOVEL_NAME_MIN_COUNT)
.sort((a, b) => b[0].length - a[0].length || b[1] - a[1])) {
if (kept.some(([long, longCount]) => long.includes(gram) && longCount >= count)) continue;
kept.push([gram, count]);
if (!NOVEL_NAME_STOPWORDS.has(gram)) push(gram);
}
const out = new Map();
for (const token of found) {
if (NOVEL_NAME_STOPWORDS.has(token)) continue;
const samples = [];
let idx = src.indexOf(token);
while (idx >= 0 && samples.length < 2) {
const from = Math.max(0, idx - 24);
const sample = injectSafeLine(src.slice(from, from + 60).replace(/\s+/g, " "), 60);
if (sample) samples.push(sample);
idx = src.indexOf(token, idx + token.length + 40);
}
out.set(token, { count: src.split(token).length - 1, samples });
}
return out;
}
/**
* 候選 token 對得上關係圖多少
* exact 等於某個節點的 name id沒有判斷空間可以整批收
* alias 等於某個節點的 tags或在它的 note 裡出現過
* fuzzy 跟某個節點的名字共用兩個以上的字差一個字的譯名帶敬稱的稱呼
* unknown 對不上任何節點這是新人物要先建節點
*/
export function novelNameConfidence(token, nodes) {
const key = String(token ?? "").trim();
const pool = nodes || [];
const nameOf = (node) => injectSafeLine(node.name || node.id, 80);
if (!key) return { confidence: "unknown", guess: null };
const exact = pool.find(
(n) => String(n?.name ?? "").trim() === key || String(n?.id ?? "").trim() === key,
);
if (exact) return { confidence: "exact", guess: nameOf(exact) };
const alias = pool.find(
(n) => asList(n?.tags).some((t) => String(t ?? "").trim() === key) || String(n?.note ?? "").includes(key),
);
if (alias) return { confidence: "alias", guess: nameOf(alias) };
const chars = new Set([...key]);
let best = null;
for (const node of pool) {
let shared = 0;
for (const ch of new Set([...String(node?.name ?? "")])) if (chars.has(ch)) shared += 1;
if (shared >= 2 && (!best || shared > best.shared)) best = { node, shared };
}
if (best) return { confidence: "fuzzy", guess: nameOf(best.node) };
return { confidence: "unknown", guess: null };
}
const NOVEL_CONFIDENCE_ORDER = { exact: 0, alias: 1, fuzzy: 2, unknown: 3 };
/**
* 掃幾份章節文字把還沒確認的人名候選累積進 `names-proposed.json`
*
* 已經在 `map` `ignore` 裡的 token 一律跳過第二次掃只會列出新出現的
* 不然每加一章就要重看同一批
*/
export function scanNovelNames(slug, work, texts) {
const { map, ignore } = loadNovelNameBook(slug, work);
const nodes = loadRelations(slug).nodes || [];
const known = new Set([...Object.keys(map), ...ignore]);
let result = { candidates: [], fresh: [] };
updateJson(novelProposedPath(slug, work), (prev) => {
const byToken = new Map();
for (const row of asList(prev?.candidates)) {
const token = injectSafeLine(row?.token, 40);
if (token && !known.has(token)) byToken.set(token, { ...row, token });
}
const fresh = [];
for (const text of asList(texts)) {
for (const [token, hit] of extractNovelNameTokens(text)) {
if (known.has(token)) continue;
const conf = novelNameConfidence(token, nodes);
const exists = byToken.get(token);
if (exists) {
exists.count = (Number(exists.count) || 0) + hit.count;
exists.confidence = conf.confidence;
exists.guess = conf.guess;
if (!asList(exists.samples).length) exists.samples = hit.samples;
continue;
}
const row = { token, count: hit.count, samples: hit.samples, guess: conf.guess, confidence: conf.confidence };
byToken.set(token, row);
fresh.push(row);
}
}
const candidates = [...byToken.values()].sort(
(a, b) => (NOVEL_CONFIDENCE_ORDER[a.confidence] ?? 9) - (NOVEL_CONFIDENCE_ORDER[b.confidence] ?? 9)
|| (Number(b.count) || 0) - (Number(a.count) || 0),
);
result = { candidates, fresh };
return { updated_at: nowIso(), candidates };
}, {});
return result;
}
export function loadNovelProposals(slug, work) {
return asList(readJson(novelProposedPath(slug, work), {})?.candidates).filter(
(row) => row && injectSafeLine(row.token, 40),
);
}
/** 從候選清單移掉幾個 tokenconfirmignorereject 都要做這件事)。 */
function dropNovelProposals(slug, work, tokens) {
const drop = new Set(asList(tokens).map((t) => injectSafeLine(t, 40)).filter(Boolean));
if (!drop.size) return 0;
let removed = 0;
updateJson(novelProposedPath(slug, work), (prev) => {
const all = asList(prev?.candidates);
const rows = all.filter((r) => !drop.has(injectSafeLine(r?.token, 40)));
removed = all.length - rows.length;
return { updated_at: nowIso(), candidates: rows };
}, {});
return removed;
}
/** 收下一條候選:寫進正名表,並從候選清單移掉。 */
export function confirmNovelName(slug, work, { from, to } = {}) {
const res = addNovelName(slug, work, { from, to });
if (!res) return null;
dropNovelProposals(slug, work, [res.from]);
return res;
}
/** `confidence: exact` 的整批收下——那些等於節點的名字,沒有判斷空間。 */
export function acceptExactNovelNames(slug, work) {
const done = [];
for (const row of loadNovelProposals(slug, work)) {
if (row.confidence !== "exact" || !row.guess) continue;
const res = confirmNovelName(slug, work, { from: row.token, to: row.guess });
if (res) done.push(res);
}
return done;
}
/** 標成「不是人名」:進 ignore,下次掃不再列。 */
export function ignoreNovelName(slug, work, token) {
const one = injectSafeLine(token, 80);
if (!one) return null;
updateJson(novelNamesPath(slug, work), (prev) => {
const data = prev && typeof prev === "object" ? prev : {};
data.map = data.map && typeof data.map === "object" ? data.map : {};
data.ignore = asList(data.ignore).map((t) => injectSafeLine(t, 80)).filter(Boolean);
if (!data.ignore.includes(one)) data.ignore.push(one);
data.updated_at = nowIso();
return data;
}, {});
dropNovelProposals(slug, work, [one]);
return one;
}
/** 從候選移除但**不**進 ignore:下次掃還會再出現(「先跳過,待會再想」)。 */
export const rejectNovelName = (slug, work, token) => dropNovelProposals(slug, work, [token]);
/**
* `about` 裡還沒確認的名字
*
* 正名沒做完就寫候選等於把錯的名字帶進 `about`要等 `relation doctor` 才發現
* 那時整批都得重跑三種算已確認正名表的 key已經是關係節點的名字不必正名
* 以及使用者標成不是人名ignore它不是人不該擋著寫入
*/
export function unconfirmedNovelNames(about, { names = {}, ignore = [], nodes = null } = {}) {
if (!nodes) return [];
const known = new Set([...Object.keys(names), ...Object.values(names), ...asList(ignore)]);
const out = [];
for (const raw of asList(about)) {
const one = injectSafeLine(raw, 80);
if (!one || known.has(one) || out.includes(one)) continue;
if (matchRelationNodes(nodes, one).length === 1) continue;
out.push(one);
}
return out;
}
export function addNovelSkip(slug, work, { chapter, reason } = {}) {
const entry = {
at: nowIso(),
chapter: injectSafeLine(chapter, 120),
reason: injectSafeLine(reason, 200),
};
if (!entry.chapter || !entry.reason) return null;
appendJsonl(novelSkippedPath(slug, work), entry);
return entry;
}
/** `YYYY-MM-DD`,而且真的是那一天(`2022-02-30` 不算)。 */
export function isYmd(value) {
const one = String(value ?? "").trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(one)) return false;
const date = new Date(`${one}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === one;
}
/**
* 驗一筆記憶候選並依正名表正名 `about`
*
* `{ ok, entry, errors: [{ field, reason }] }`**逐欄位回報**不整批丟掉
* 一批 40 則裡有一則日期寫錯呼叫端要能講出是哪一則的哪一欄
*
* 帶了 `nodes` 就順便擋`about` 裡有還沒確認的名字 `unconfirmedNovelNames`
* 不帶就只驗欄位
*/
export function validateNovelCandidate(row, { names = {}, ignore = [], nodes = null } = {}) {
const src = row && typeof row === "object" && !Array.isArray(row) ? row : {};
const errors = [];
const bad = (field, reason) => errors.push({ field, reason });
const one = (value, limit = 200) => injectSafeLine(value, limit);
const rawName = one(src.name, 120);
const name = slugify(rawName);
if (!rawName) bad("name", "必填(檔名用的 slug");
const type = one(src.type, 20);
if (!type) bad("type", "必填");
else if (!NOVEL_TYPES.includes(type)) bad("type", `只能是 ${NOVEL_TYPES.join("")},收到 \`${type}\``);
// body 是唯一允許多行的欄位(主旨/細節兩層要靠換行切),所以只中和注入標記。
const body = stripInjectionMarkers(src.body ?? "").trim();
if (!body) bad("body", "必填(第一人稱摘要)");
const firstSeen = one(src.first_seen, 20);
if (!firstSeen) bad("first_seen", "必填(故事內時間,換算成西元日期)");
else if (!isYmd(firstSeen)) bad("first_seen", `要 YYYY-MM-DD 的西元日期,收到 \`${firstSeen}\``);
const lastSeen = one(src.last_seen, 20);
if (lastSeen && !isYmd(lastSeen)) bad("last_seen", `要 YYYY-MM-DD 的西元日期,收到 \`${lastSeen}\``);
const dateSource = one(src.date_source, 20) || "derived";
if (!(dateSource in NOVEL_DATE_SOURCES)) {
bad("date_source", `只能是 ${Object.keys(NOVEL_DATE_SOURCES).join("")},收到 \`${dateSource}\``);
}
// 知情層級。`canon` 是世界設定(誰知道都一樣),所以不必填;
// 其餘型別是**他的經歷**,一定要講清楚他是怎麼知道的——沒填就擋下來。
//
// 為什麼不給預設值:填錯與沒填要分得開。沉默地當成 `none` 的話,
// 抽的人永遠不知道自己漏了一欄;沉默地當成 `saw` 更糟,那是替他捏造在場。
const knowLevel = one(src.know_level, 20) || (type === "canon" ? "none" : "");
if (!knowLevel) {
bad("know_level", `\`type: ${type}\` 是他的經歷,必須講清楚他怎麼知道的:` +
`${Object.entries(NOVEL_KNOW_LEVELS).map(([k, v]) => `${k}${v}`).join("")}` +
"——判斷不出來就填 `none` 並把 type 改成 `canon`");
} else if (!(knowLevel in NOVEL_KNOW_LEVELS)) {
bad("know_level", `只能是 ${Object.keys(NOVEL_KNOW_LEVELS).join("")},收到 \`${knowLevel}\``);
} else if (knowLevel === "none" && type && type !== "canon") {
// `none` 是「他不在場也沒人告訴他」,那種事不能變成他的經歷。
// 這裡**擋下來而不是自動改成 canon**:自動改會讓抽錯的人永遠不知道自己抽錯了。
bad("know_level", "`none`(他不在場也沒人告訴他)只能配 `type: canon`" +
`收到 \`${type}\`——他不知道的事不可以變成他的經歷`);
}
const salienceRaw = src.salience === undefined || src.salience === null || src.salience === "" ? 60 : Number(src.salience);
if (!Number.isFinite(salienceRaw)) bad("salience", `要 0 到 100 的數字,收到 \`${one(src.salience, 20)}\``);
const rename = (value) => {
const key = one(value, 80);
return key ? (names[key] || key) : "";
};
const entry = {
name,
title: one(src.title, 120) || rawName,
type,
about: asList(src.about).map(rename).filter(Boolean),
topics: asList(src.topics).map((t) => one(t, 40)).filter(Boolean),
salience: Number.isFinite(salienceRaw) ? clamp(salienceRaw) : 60,
emotion: one(src.emotion, 60),
first_seen: firstSeen,
date_source: dateSource,
source: one(src.source, 160),
chapter: one(src.chapter, 120),
body,
quote: one(src.quote, 240),
know_level: knowLevel,
when: one(src.when, 40),
where: one(src.where, 80),
mood: one(src.mood, 40),
added_at: nowIso(),
};
if (lastSeen) entry.last_seen = lastSeen;
const unconfirmed = unconfirmedNovelNames(entry.about, { names, ignore, nodes });
if (unconfirmed.length) {
bad("about", `這幾個名字還沒確認:${unconfirmed.join("、")}` +
"——先 `novel scan` → `novel name review` → `novel name confirm`(新人物要先 `relation node`");
}
// 沒有值的欄位不留空鍵:報告與 front matter 都是「沒有值就不寫」
for (const key of Object.keys(entry)) {
if (entry[key] === "" || (Array.isArray(entry[key]) && !entry[key].length)) delete entry[key];
}
return { ok: errors.length === 0, entry, errors };
}
/** 一批候選寫進 `candidates.jsonl`。驗不過的不寫,逐筆回報。 */
export function addNovelCandidates(slug, work, rows) {
const list = Array.isArray(rows) ? rows : [rows];
const added = [];
const failed = [];
const { map: names, ignore } = loadNovelNameBook(slug, work);
// 關係節點讀一次傳下去(每一筆的每個 about 都要對一次)
const nodes = loadRelations(slug).nodes || [];
list.forEach((row, i) => {
const res = validateNovelCandidate(row, { names, ignore, nodes });
if (!res.ok) {
failed.push({ index: i, name: injectSafeLine(row?.name, 80) || "(沒有 name", errors: res.errors });
return;
}
appendJsonl(novelCandidatesPath(slug, work), res.entry);
added.push(res.entry);
});
return { added, failed };
}
const olderYmd = (a, b) => (!a ? b : !b ? a : (a < b ? a : b));
const newerYmd = (a, b) => (!a ? b : !b ? a : (a > b ? a : b));
const unionList = (a, b) => {
const out = [];
for (const value of [...asList(a), ...asList(b)]) if (value && !out.includes(value)) out.push(value);
return out;
};
/**
* 跨章去重合併TODO 故事匯入 2.1同一個 `name` 只留一則
*
* `salience` 取高`first_seen` 取最早`last_seen` 取最晚`topics``about` 取聯集
* `quote` 留最長的一句短的那句通常是同一段話被截斷的版本
* 其餘欄位以**先到的為準**空的才補先到的那一章就是它第一次發生的地方
*/
export function mergeNovelCandidates(rows) {
const byName = new Map();
let merged = 0;
for (const row of Array.isArray(rows) ? rows : []) {
const key = String(row?.name ?? "");
if (!key) continue;
const prev = byName.get(key);
if (!prev) {
byName.set(key, { ...row });
continue;
}
merged += 1;
prev.salience = Math.max(Number(prev.salience) || 0, Number(row.salience) || 0);
prev.first_seen = olderYmd(prev.first_seen, row.first_seen);
const last = newerYmd(prev.last_seen || prev.first_seen, row.last_seen || row.first_seen);
if (last) prev.last_seen = last;
const topics = unionList(prev.topics, row.topics);
if (topics.length) prev.topics = topics;
const about = unionList(prev.about, row.about);
if (about.length) prev.about = about;
if (String(row.quote ?? "").length > String(prev.quote ?? "").length) prev.quote = row.quote;
for (const [k, v] of Object.entries(row)) {
if (prev[k] === undefined || prev[k] === "" || (Array.isArray(prev[k]) && !prev[k].length)) prev[k] = v;
}
}
return { rows: [...byName.values()], merged };
}
/**
* 依配額重新定標TODO 故事匯入 2.2超過配額的從低分往下壓一級
*
* 由高到低跑所以壓下來的會落進下一級再一起受下一級的配額約束
* 90+ 壓成 85 之後就是 80-89 的人要跟原本的 80 分們一起排隊
*/
export function requotaNovelCandidates(rows, quota = NOVEL_DEFAULT_QUOTA) {
const out = (Array.isArray(rows) ? rows : []).map((r) => ({ ...r }));
const tiers = Object.keys(quota || {}).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => b - a);
const demoted = [];
for (let i = 0; i < tiers.length; i += 1) {
const floor = tiers[i];
const ceil = i === 0 ? Infinity : tiers[i - 1]; // 上一級的門檻就是這一級的上界
const limit = Math.max(0, Number(quota[floor]) || 0);
const band = out.filter((r) => Number(r.salience) >= floor && Number(r.salience) < ceil);
if (band.length <= limit) continue;
// 從低分往下壓;同分用 name 排,換一台機器跑要壓到同一批
band.sort((a, b) => (Number(a.salience) - Number(b.salience)) || String(a.name).localeCompare(String(b.name)));
for (const row of band.slice(0, band.length - limit)) {
const from = Number(row.salience);
row.salience = Math.max(0, floor - NOVEL_DEMOTE_STEP);
demoted.push({ name: row.name, tier: floor, from, to: row.salience });
}
}
return { rows: out, demoted, tiers };
}
/** 每一級的配額用掉多少(`novel report` 與 `novel merge` 共用)。 */
export function novelQuotaUsage(rows, quota = NOVEL_DEFAULT_QUOTA) {
const tiers = Object.keys(quota || {}).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => b - a);
return tiers.map((floor, i) => {
const ceil = i === 0 ? Infinity : tiers[i - 1];
const used = (Array.isArray(rows) ? rows : []).filter(
(r) => Number(r.salience) >= floor && Number(r.salience) < ceil,
).length;
return {
tier: floor,
label: i === 0 ? `${floor}+` : `${floor}-${ceil - 1}`,
limit: Math.max(0, Number(quota[floor]) || 0),
used,
over: Math.max(0, used - Math.max(0, Number(quota[floor]) || 0)),
};
});
}
/**
* 情緒基線提案跟現況比差值TODO 3B.5
*
* 基線是氣質改了等於換一個人所以這裡只算差不寫入任一格差
* `NOVEL_BASELINE_GAP` 以上就要停下來給人看Q4 拍板的門檻
*/
export function novelBaselineDiff(slug, proposed) {
const state = loadEmotion(slug);
const rows = [];
const unknown = [];
for (const [key, value] of Object.entries(proposed || {})) {
if (!(key in EMOTIONS)) {
unknown.push(injectSafeLine(key, 20));
continue;
}
const now = state.baseline[key];
const next = clamp(value);
rows.push({ key, zh: EMOTIONS[key].zh, now, next, diff: Math.round((next - now) * 10) / 10 });
}
const over = rows.filter((r) => Math.abs(r.diff) >= NOVEL_BASELINE_GAP);
return { rows, over, unknown, gap: NOVEL_BASELINE_GAP };
}
/** 把提案寫進基線(`novel baseline --force` 或差值都在門檻內時才會走到這裡)。 */
export function applyNovelBaseline(slug, proposed) {
return updateEmotion(slug, (s) => {
for (const [key, value] of Object.entries(proposed || {})) {
if (key in EMOTIONS) s.baseline[key] = clamp(value);
}
return s;
});
}
// --------------------------------------------------------------------------- //
// 心智圖 / 思維導圖 / 人際關係圖
// --------------------------------------------------------------------------- //
@@ -4813,6 +5734,15 @@ export function turnContext(slug, sessionId, prompt = "") {
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
const tone = toneDirective(slug);
if (tone) lines.push(tone);
// 語氣層:他講過的原句與「遇到事會做什麼」。每輪最多 2 到 3 條,而且挑跟這一輪
// 對象/話題對得上的——全注入會變成表演(他開始照抄自己的舊台詞)。
// 整段包起來:語氣檔是手改與 `sync pull` 都會碰的檔,壞掉不該讓整輪掛掉。
try {
const voice = voiceBrief(slug, { to: speakerNode(slug)?.name || null, hint: prompt });
if (voice) lines.push(voice);
} catch {
/* 語氣檔壞掉不該讓整輪掛掉 */
}
const inner = recentInner(slug, 3);
if (inner.length) {
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);