feat(故事匯入): voice 語氣層與 novel 機械指令,記憶不再被劇情日期洗掉
小說匯入分兩半(使用者拍板):機械的進 CLI,判斷的留給 skill。這批是機械那半。 voice/ 新一層:samples.md 收他自己講過的原句(附對象與場合)、reactions.md 收 「事件 → 他做了什麼」。匯入流程可以直接寫這一層,SOUL.md 維持只有使用者能改—— 權限界線要有實體隔離,不靠自律。每輪最多注入 3 條,全注入會變成照抄舊台詞。 歸 Wiki 區(低頻設定,跟 IDENTITY/SOUL 同一區)。 novel 子指令:init/scan/name/skip/candidate/merge/write/report/baseline。 工作區在 memory/import/<work-slug>/,歸檔案區——收斂中換一台機器要接得下去。 正名表不請使用者手打:scan 從章節抽人名候選(對話歸屬、敬稱、片假名、高頻詞), 附出現次數與上下文,猜關係節點並分 exact/alias/fuzzy/unknown 四種信心度。 names.json 分 map 與 ignore 兩塊,已確認或已 ignore 的下次掃不再列。 視角界線擋在候選驗證:know_level 除了 canon 之外必填(did/saw/told/later/none), none 是「他不在場也沒人告訴他」,只能配 canon。**擋下來而不是自動改成 canon**—— 自動改會讓抽錯的人永遠不知道自己抽錯。這是整條流程唯一沒有第二道防線的錯。 劇情時間與記憶新鮮度分成兩組欄位(踩過一次才發現):first_seen/last_seen 是 記憶的新鮮度時鐘(memoryStrength 拿 last_seen 算衰減),happened_at/happened_until 才是劇情時間。混在一起的話 2024 年劇情的記憶匯進來的那一秒就是模糊 0%,而且掉到 0% 之後 touchRecall 不再更新它,永遠回不來——實測 40 到 79 分的 event 全部出生即死。 其他:情緒只固化進欄位,一次都不呼叫 applyEmotion(逐則重放等於讓最後一章決定他的 性格);他的原句接在內文後面而不是另開 front matter 欄位,這樣它跟細節一起衰減; writeLongTermMemory 抽出來給 consolidate 與 novel write 共用,欄位順序與預設值只有一份。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -110,6 +110,9 @@ export const AREAS = {
|
|||||||
"state/probe.jsonl",
|
"state/probe.jsonl",
|
||||||
"memory/short-term.jsonl",
|
"memory/short-term.jsonl",
|
||||||
"memory/inbox/",
|
"memory/inbox/",
|
||||||
|
// 故事匯入的工作區:一章一批 append 的候選、跳過紀錄、正名表。
|
||||||
|
// 收斂完就沒用了,但收斂中換一台機器要接得下去,所以跟著高頻區走。
|
||||||
|
"memory/import/",
|
||||||
"mindmap/threads/",
|
"mindmap/threads/",
|
||||||
"journal/",
|
"journal/",
|
||||||
],
|
],
|
||||||
@@ -129,6 +132,8 @@ export const AREAS = {
|
|||||||
"icon/",
|
"icon/",
|
||||||
"memory/INDEX.md",
|
"memory/INDEX.md",
|
||||||
"memory/long-term/",
|
"memory/long-term/",
|
||||||
|
// 語氣層跟 IDENTITY/SOUL 同一區:它是低頻的設定,不是每輪都在變的活狀態
|
||||||
|
"voice/",
|
||||||
"mindmap/semantic.mmd",
|
"mindmap/semantic.mmd",
|
||||||
"relations/graph.json",
|
"relations/graph.json",
|
||||||
"relations/graph.mmd",
|
"relations/graph.mmd",
|
||||||
@@ -465,7 +470,7 @@ function statusPaths(dir) {
|
|||||||
|
|
||||||
export const WIKI_MANIFEST = "_paths.json";
|
export const WIKI_MANIFEST = "_paths.json";
|
||||||
const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]);
|
const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]);
|
||||||
const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" };
|
const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations", voice: "Voice" };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404),
|
* Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404),
|
||||||
|
|||||||
@@ -1309,6 +1309,9 @@ export const PERSONA_SUBDIRS = [
|
|||||||
"memory/inbox",
|
"memory/inbox",
|
||||||
"mindmap/threads",
|
"mindmap/threads",
|
||||||
"relations",
|
"relations",
|
||||||
|
// 語氣層自己一層:它跟 SOUL.md 的改動權限不同(匯入流程可以寫語氣,個性只有使用者能改),
|
||||||
|
// 權限界線要有實體隔離,不靠自律——混在同一個檔裡,寫語氣就會順手寫到個性。
|
||||||
|
"voice",
|
||||||
"journal",
|
"journal",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -2232,6 +2235,9 @@ export function memoryStrength(meta, now = Date.now()) {
|
|||||||
}
|
}
|
||||||
const strength = clamp(numOr(meta?.strength, MEMORY_STRENGTH_DEFAULT));
|
const strength = clamp(numOr(meta?.strength, MEMORY_STRENGTH_DEFAULT));
|
||||||
const count = Math.max(0, Math.floor(numOr(meta?.recall_count, 0)));
|
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;
|
const seen = meta?.last_seen || meta?.first_seen || null;
|
||||||
// 日期讀不出來時 `ageSeconds` 回 Infinity → 那則記憶會瞬間全糊掉。
|
// 日期讀不出來時 `ageSeconds` 回 Infinity → 那則記憶會瞬間全糊掉。
|
||||||
// 讀不出來只代表**不知道多舊**,不代表很舊,所以當成 0 天(維持現狀)。
|
// 讀不出來只代表**不知道多舊**,不代表很舊,所以當成 0 天(維持現狀)。
|
||||||
@@ -2349,6 +2355,94 @@ export function migrateLongTerm(slug, { dryRun = false } = {}) {
|
|||||||
return stats;
|
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) {
|
export function rebuildIndex(slug) {
|
||||||
const entries = longTermEntries(slug);
|
const entries = longTermEntries(slug);
|
||||||
const lines = [
|
const lines = [
|
||||||
@@ -3174,6 +3268,833 @@ export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) {
|
|||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// 語氣層(`voice/`):他講過的原句、以及「事件 → 他做了什麼」
|
||||||
|
//
|
||||||
|
// 這一層跟 `SOUL.md` 刻意分開放。SOUL 是個性(Core Truths/Boundaries/Vibe),
|
||||||
|
// 只有使用者拍板才動;語氣是表面(他會說的字、遇到事會做的動作),故事匯入可以直接寫。
|
||||||
|
//
|
||||||
|
// 兩個檔都是給人讀的 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 從候選清單移掉幾個 token(confirm/ignore/reject 都要做這件事)。 */
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
// 心智圖 / 思維導圖 / 人際關係圖
|
// 心智圖 / 思維導圖 / 人際關係圖
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
@@ -4532,6 +5453,15 @@ export function turnContext(slug, sessionId, prompt = "") {
|
|||||||
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
|
// 語氣層:由「使用者在關係圖裡是誰」+ bond × 親近度算出來,不靠人格自己記得。
|
||||||
const tone = toneDirective(slug);
|
const tone = toneDirective(slug);
|
||||||
if (tone) lines.push(tone);
|
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);
|
const inner = recentInner(slug, 3);
|
||||||
if (inner.length) {
|
if (inner.length) {
|
||||||
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);
|
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);
|
||||||
|
|||||||
+545
-48
@@ -54,6 +54,10 @@ const FLAGS = new Set([
|
|||||||
"json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all",
|
"json", "quiet", "force", "takeover", "as-guest", "as-sleeper", "on", "off", "with-meta", "all",
|
||||||
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
|
"with-journal", "gzip", "record", "load", "allow-repeat", "clear", "release", "keep-lock", "contact",
|
||||||
"if-due", "no-gitea", "public", "rename", "from-source",
|
"if-due", "no-gitea", "public", "rename", "from-source",
|
||||||
|
// 故事匯入:`--stdin` 吃管線進來的候選 JSON、`--accept-exact` 整批收下對得上節點的人名候選。
|
||||||
|
// `--apply`(novel merge)**故意不列**:`emotion --apply joy=+10` 也叫這個名字,
|
||||||
|
// 列進來會讓那個旗標變成布林,情緒就再也套不進去(踩過一次)。
|
||||||
|
"stdin", "accept-exact",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
@@ -953,7 +957,6 @@ commands.consolidate = ({ flags }) => {
|
|||||||
if (!rawName) die("需要 `--name`(長期記憶的檔名/識別)。");
|
if (!rawName) die("需要 `--name`(長期記憶的檔名/識別)。");
|
||||||
const name = pl.slugify(rawName);
|
const name = pl.slugify(rawName);
|
||||||
const file = path.join(pl.longTermDir(slug), `${name}.md`);
|
const file = path.join(pl.longTermDir(slug), `${name}.md`);
|
||||||
const today = pl.nowIso().slice(0, 10);
|
|
||||||
let existing = {};
|
let existing = {};
|
||||||
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
|
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
|
||||||
// slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。
|
// slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。
|
||||||
@@ -980,54 +983,30 @@ commands.consolidate = ({ flags }) => {
|
|||||||
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
|
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
|
||||||
];
|
];
|
||||||
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`);
|
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}。`);
|
||||||
// 情境戳章與主旨/細節切分:兩者都可以用旗標明寫,沒寫就自動推。
|
|
||||||
const stamp = pl.contextStamp(slug);
|
|
||||||
// `--gist` 明寫時,`--detail` 沒給就是**沒有細節**——退回整份 body 會讓主旨之外
|
|
||||||
// 再抄一次全文,那是把「衰減先吃細節」這件事整個抵銷掉。
|
|
||||||
const splitBody = str(flags.gist)
|
|
||||||
? { gist: str(flags.gist), detail: str(flags.detail) }
|
|
||||||
: pl.splitGistDetail(body);
|
|
||||||
const about = csv(flags.about).length ? csv(flags.about) : ["user"];
|
const about = csv(flags.about).length ? csv(flags.about) : ["user"];
|
||||||
// `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與
|
// front matter 的組裝在 `writeLongTermMemory`:`novel write` 也走同一套,
|
||||||
// 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。
|
// 欄位順序與預設值只能有一份(見那個函式的說明)。
|
||||||
// `resolveRelationRefs` 已經把不能寫進 front matter 的 id 濾掉了(帶換行的節點 id
|
pl.writeLongTermMemory(slug, {
|
||||||
// 可以在這裡多插一行、覆寫下面的 `type`,把一則 fact 變成不該被遺忘的 canon)。
|
name,
|
||||||
const aboutIds = pl.resolveRelationRefs(slug, about);
|
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠 title 認出「不是同一則」。
|
||||||
// front matter 的每個值都只能是一行:帶換行的旗標值同樣能偽造出別的欄位,
|
title: rawName,
|
||||||
// 而 `parseFrontMatter` 取後出現的值 → 後面宣告的 type/salience 會被前面偽造的蓋掉。
|
type,
|
||||||
const fm = (value) => pl.injectSafeLine(value);
|
body,
|
||||||
const front = [
|
// `--gist` 明寫時,`--detail` 沒給就是**沒有細節**(見 writeLongTermMemory)
|
||||||
"---",
|
gist: str(flags.gist) || null,
|
||||||
`name: ${name}`,
|
detail: str(flags.detail),
|
||||||
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。
|
about,
|
||||||
`title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
|
topics: csv(flags.topics),
|
||||||
`type: ${type}`,
|
salience: num(flags.salience, 60),
|
||||||
`about: [${about.map(fm).filter(Boolean).join(", ")}]`,
|
// 沒帶 --strength 就交給既有檔案裡長出來的強度(null = 沿用)
|
||||||
...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []),
|
strength: num(flags.strength, null),
|
||||||
`topics: [${csv(flags.topics).map(fm).filter(Boolean).join(", ")}]`,
|
emotion: str(flags.emotion),
|
||||||
`salience: ${num(flags.salience, 60)}`,
|
when: str(flags.when),
|
||||||
// 回想強度:連續衰減的起點。已經存在的記憶保留自己長出來的強度,
|
where: str(flags.where),
|
||||||
// 不要因為重寫一次就把「被想起過很多次」的歷史抹平。
|
mood: str(flags.mood),
|
||||||
`strength: ${num(flags.strength, Number(existing.strength ?? pl.MEMORY_STRENGTH_DEFAULT))}`,
|
rules: str(flags.rules),
|
||||||
`emotion: ${fm(str(flags.emotion) || "none")}`,
|
source: str(flags.source),
|
||||||
// 情境索引:什麼時候、在哪裡、什麼心情記下來的(回想時的非語意線索)。
|
});
|
||||||
// 推不出來的欄位就不寫——全專案一致:沒有值不留空欄位。
|
|
||||||
...["when", "where", "mood"]
|
|
||||||
.map((k) => [k, fm(str(flags[k]) || existing[k] || stamp[k] || "")])
|
|
||||||
.filter(([, v]) => v)
|
|
||||||
.map(([k, v]) => `${k}: ${v}`),
|
|
||||||
`rules: ${fm(str(flags.rules) || "manual")}`,
|
|
||||||
`first_seen: ${existing.first_seen || today}`,
|
|
||||||
`last_seen: ${today}`,
|
|
||||||
`recall_count: ${existing.recall_count || 0}`,
|
|
||||||
`source: ${fm(str(flags.source) || "short-term")}`,
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
// 主旨/細節兩層:衰減先吃細節,主旨最後才掉。沒有明寫就用第一段當主旨。
|
|
||||||
pl.joinGistDetail(splitBody.gist, splitBody.detail),
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
pl.writeText(file, front.join("\n"));
|
|
||||||
const total = pl.rebuildIndex(slug);
|
const total = pl.rebuildIndex(slug);
|
||||||
const forget = num(flags.forget, null);
|
const forget = num(flags.forget, null);
|
||||||
if (forget !== null) {
|
if (forget !== null) {
|
||||||
@@ -1184,6 +1163,486 @@ commands.probe = ({ flags, positional }) => {
|
|||||||
die("用法:`probe add|confirm|deny|audit`。");
|
die("用法:`probe add|confirm|deny|audit`。");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// voice:語氣樣本與情緒反應(`voice/`)
|
||||||
|
//
|
||||||
|
// 這一層匯入流程可以直接寫,個性(SOUL.md)不行——權限界線靠檔案分開,不靠自律。
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
commands.voice = ({ flags, positional }) => {
|
||||||
|
const session = requireSession(flags);
|
||||||
|
const slug = hostOf(flags, session);
|
||||||
|
const action = (positional[0] || "show").toLowerCase();
|
||||||
|
const kind = str(flags.kind) || "sample";
|
||||||
|
if (!(kind in pl.VOICE_KINDS)) {
|
||||||
|
die(`--kind 只能是 ${Object.keys(pl.VOICE_KINDS).join("/")}` +
|
||||||
|
"(sample=他自己講過的原句,reaction=事件對上他做了什麼)。");
|
||||||
|
}
|
||||||
|
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})` : ""}`);
|
||||||
|
if (action === "show" || action === "list") {
|
||||||
|
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||||
|
const voice = pl.loadVoice(slug);
|
||||||
|
if (action === "show") {
|
||||||
|
const brief = pl.voiceBrief(slug, {
|
||||||
|
limit: num(flags.limit, pl.VOICE_INJECT_MAX),
|
||||||
|
to: str(flags.to) || null,
|
||||||
|
hint: str(flags.scene) || str(flags.text),
|
||||||
|
});
|
||||||
|
emit({ persona: slug, brief, samples: voice.samples.length, reactions: voice.reactions.length }, flags.json, [
|
||||||
|
`\`${slug}\` 這一輪會注入的語氣(最多 ${pl.VOICE_INJECT_MAX} 條——全注入會變成照抄舊台詞):`,
|
||||||
|
brief || " (語氣檔還是空的:先用 `voice add` 累積他自己講過的原句)",
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = kind === "sample" ? voice.samples : voice.reactions;
|
||||||
|
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, [
|
||||||
|
`\`${slug}\` 的${pl.VOICE_LABELS[kind]} ${rows.length} 條(${pl.voicePath(slug, kind)}):`,
|
||||||
|
...shown.map((entry) => ` - ${render(entry)}`),
|
||||||
|
...(rows.length ? [] : [" (還沒有。手改那個檔也可以:一行一筆,行首 `- `)"]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireOwner(slug, session);
|
||||||
|
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) });
|
||||||
|
if (!res) {
|
||||||
|
die(kind === "sample"
|
||||||
|
? "需要 `--text`(他自己講過的原句,照抄不要改寫)。"
|
||||||
|
: "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。");
|
||||||
|
}
|
||||||
|
if (!res.added) {
|
||||||
|
ok(`已經有一模一樣的一條了,沒有重複寫入:${res.line}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ok(`${pl.VOICE_LABELS[kind]}加一條:${res.line}`);
|
||||||
|
pushWikiLater(slug, session, flags); // voice/ 屬於 Wiki 區(低頻設定)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
die("用法:`voice add|list|show`。");
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// novel:故事匯入的機械那半
|
||||||
|
//
|
||||||
|
// 判斷的那半留在 skill(在場與知情、切場景、第一人稱摘要、個性校正提案),
|
||||||
|
// 這裡只做機械的:抽人名候選、正名、跳過紀錄、欄位驗證、去重合併、配額重定標、
|
||||||
|
// 批次寫入。一律不改 SOUL.md,也不套情緒 delta——情緒只固化進欄位(TODO 2.4)。
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
commands.novel = ({ flags, positional }) => {
|
||||||
|
const session = requireSession(flags);
|
||||||
|
const slug = hostOf(flags, session);
|
||||||
|
const action = (positional[0] || "report").toLowerCase();
|
||||||
|
const sub = (positional[1] || "").toLowerCase();
|
||||||
|
// `init` 的 --work 是書名,其他子指令的 --work 是 init 定下來的 work-slug
|
||||||
|
const openWork = () => {
|
||||||
|
const key = str(flags.work);
|
||||||
|
if (!key) die("需要 `--work <work-slug>`(`novel init` 建立時回報的那個 slug)。");
|
||||||
|
const work = pl.loadNovelWork(slug, key);
|
||||||
|
if (!work) {
|
||||||
|
const have = pl.listNovelWorks(slug).map((w) => w.slug);
|
||||||
|
die(`找不到作品工作區 \`${key}\`` +
|
||||||
|
`${have.length ? `(現有:${have.join("、")})` : "(還沒有任何工作區)"}。` +
|
||||||
|
"\n 先 `novel init --work \"<書名>\"`。");
|
||||||
|
}
|
||||||
|
return work;
|
||||||
|
};
|
||||||
|
const candidatesOf = (work) => pl.readJsonl(pl.novelCandidatesPath(slug, work.slug));
|
||||||
|
|
||||||
|
if (action === "report") {
|
||||||
|
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||||
|
const work = openWork();
|
||||||
|
const rows = candidatesOf(work);
|
||||||
|
const skipped = pl.readJsonl(pl.novelSkippedPath(slug, work.slug));
|
||||||
|
const book = pl.loadNovelNameBook(slug, work.slug);
|
||||||
|
const proposals = pl.loadNovelProposals(slug, work.slug);
|
||||||
|
const usage = pl.novelQuotaUsage(rows, work.quota);
|
||||||
|
const unwritten = rows.filter((r) => !r.written_at);
|
||||||
|
const duplicates = rows.length - pl.mergeNovelCandidates(rows).rows.length;
|
||||||
|
emit({
|
||||||
|
persona: slug,
|
||||||
|
work: work.work,
|
||||||
|
slug: work.slug,
|
||||||
|
candidates: rows.length,
|
||||||
|
unwritten: unwritten.length,
|
||||||
|
duplicates,
|
||||||
|
skipped: skipped.length,
|
||||||
|
names: Object.keys(book.map).length,
|
||||||
|
ignored: book.ignore.length,
|
||||||
|
pending_names: proposals.length,
|
||||||
|
quota: usage,
|
||||||
|
baseline: work.baseline ?? null,
|
||||||
|
}, flags.json, [
|
||||||
|
`《${work.work}》(\`${work.slug}\`)匯入現況:`,
|
||||||
|
` 記憶候選 ${rows.length} 則|同名還沒合併 ${duplicates} 則|還沒寫入長期記憶 ${unwritten.length} 則`,
|
||||||
|
` 跳過 ${skipped.length} 章|正名表 ${Object.keys(book.map).length} 條|` +
|
||||||
|
`標成不是人名 ${book.ignore.length} 個|還沒確認的人名候選 ${proposals.length} 個`,
|
||||||
|
...usage.map((u) => ` 配額 ${u.label}:${u.used}/${u.limit}` +
|
||||||
|
`${u.over ? ` ⚠ 超出 ${u.over} 則 → 跑 \`novel merge\`` : ""}`),
|
||||||
|
work.baseline
|
||||||
|
? ` 情緒基線上次調整:${work.baseline.at}${work.baseline.forced ? "(--force 蓋過門檻)" : ""}`
|
||||||
|
: " 情緒基線還沒動過。",
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requireOwner(slug, session);
|
||||||
|
|
||||||
|
if (action === "init") {
|
||||||
|
const title = str(flags.work);
|
||||||
|
if (!title) die("需要 `--work \"<書名>\"`。");
|
||||||
|
const quota = flags.quota ? pl.parseNovelQuota(str(flags.quota)) : null;
|
||||||
|
if (flags.quota && !quota) die("`--quota` 看不懂:格式是 `90=5,80=20`(顯著度門檻=最多幾則)。");
|
||||||
|
const key = pl.slugify(str(flags.slug) || title);
|
||||||
|
const before = pl.loadNovelWork(slug, key);
|
||||||
|
if (before && !flags.force) {
|
||||||
|
die(`工作區 \`${key}\` 已經存在(《${before.work}》,${before.created_at})。` +
|
||||||
|
"\n 要改配額就重跑一次並加 --force(候選與跳過紀錄不會動)。");
|
||||||
|
}
|
||||||
|
const work = pl.initNovelWork(slug, { work: title, workSlug: key, quota });
|
||||||
|
emit({ persona: slug, work }, flags.json, [
|
||||||
|
`✔ 工作區建好了:${pl.novelWorkDir(slug, key)}`,
|
||||||
|
` 作品《${work.work}》 slug \`${work.slug}\`(之後的 --work 都用這個)`,
|
||||||
|
` 顯著度配額:${pl.novelQuotaUsage([], work.quota).map((u) => `${u.label} 最多 ${u.limit} 則`).join("、")}`,
|
||||||
|
" 下一步:`novel scan --work <slug> --file <章節檔>` 抽人名候選,再 `novel name review` 確認。",
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "scan") {
|
||||||
|
const work = openWork();
|
||||||
|
const files = [...csv(flags.file), ...positional.slice(1)];
|
||||||
|
if (str(flags.dir)) {
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(str(flags.dir), { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
die(`讀不到 --dir \`${str(flags.dir)}\`:${String(err.message).slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
for (const entry of entries.filter((e) => e.isFile()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
files.push(path.join(str(flags.dir), entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!files.length) die("需要 `--file <章節檔[,第二檔]>` 或 `--dir <目錄>`。");
|
||||||
|
const texts = [];
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
texts.push(fs.readFileSync(file, "utf8"));
|
||||||
|
} catch (err) {
|
||||||
|
die(`讀不到 \`${file}\`:${String(err.message).slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = pl.scanNovelNames(slug, work.slug, texts);
|
||||||
|
emit({ persona: slug, work: work.slug, files: files.length, fresh: res.fresh.length, candidates: res.candidates }, flags.json, [
|
||||||
|
`掃了 ${files.length} 個章節檔:新增 ${res.fresh.length} 個人名候選,` +
|
||||||
|
`待確認共 ${res.candidates.length} 個(${pl.novelProposedPath(slug, work.slug)})。`,
|
||||||
|
...res.candidates.slice(0, 30).map((c) => ` [${c.confidence}] ${c.token}(${c.count} 次)` +
|
||||||
|
`${c.guess ? ` → 猜是「${c.guess}」` : ""}`),
|
||||||
|
...(res.candidates.length > 30 ? [` (還有 ${res.candidates.length - 30} 個,用 \`novel name review\` 看全部)`] : []),
|
||||||
|
" 已經在正名表或 ignore 裡的不再列出——第二次掃只會看到新出現的。",
|
||||||
|
" 下一步:`novel name review`(確認)/`novel name confirm --accept-exact`(整批收下對得上的)。",
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "name") {
|
||||||
|
const work = openWork();
|
||||||
|
if (sub === "add" || sub === "confirm") {
|
||||||
|
if (flags["accept-exact"]) {
|
||||||
|
const done = pl.acceptExactNovelNames(slug, work.slug);
|
||||||
|
emit({ persona: slug, work: work.slug, confirmed: done }, flags.json, [
|
||||||
|
`✔ 收下 ${done.length} 條 exact 候選(等於關係節點的名字,沒有判斷空間):`,
|
||||||
|
...done.map((d) => ` ${d.from} → ${d.to}`),
|
||||||
|
...(done.length ? [] : [" (沒有 exact 候選;fuzzy 與 unknown 要一條一條看)"]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = str(flags.from);
|
||||||
|
const to = str(flags.to);
|
||||||
|
if (!from || !to) die("需要 `--from \"<書裡的名字>\"` 與 `--to \"<關係節點 name>\"`(或 `--accept-exact`)。");
|
||||||
|
const res = sub === "confirm"
|
||||||
|
? pl.confirmNovelName(slug, work.slug, { from, to })
|
||||||
|
: pl.addNovelName(slug, work.slug, { from, to });
|
||||||
|
if (!res) {
|
||||||
|
die(`關係圖裡找不到 \`${to}\`——記憶的 \`about\` 對不上節點就等於沒記到人身上。\n` +
|
||||||
|
` 先建節點:\`relation node --name "${to}" --kind human --session <id>\`` +
|
||||||
|
"(親近度查 persona-relation/reference/closeness.md),再回來正名。");
|
||||||
|
}
|
||||||
|
ok(`正名表:${res.from} → ${res.to}(節點 \`${res.node_id}\`)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "ignore" || sub === "reject") {
|
||||||
|
const token = str(flags.token) || str(flags.from);
|
||||||
|
if (!token) die("需要 `--token \"<候選詞>\"`。");
|
||||||
|
if (sub === "ignore") {
|
||||||
|
if (!pl.ignoreNovelName(slug, work.slug, token)) die("`--token` 是空的。");
|
||||||
|
ok(`\`${token}\` 標成不是人名了(進 ignore,下次掃不會再列)。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removed = pl.rejectNovelName(slug, work.slug, token);
|
||||||
|
ok(removed
|
||||||
|
? `\`${token}\` 從候選移除了(沒進 ignore:下次掃還會再出現)。`
|
||||||
|
: `候選裡沒有 \`${token}\`,什麼都沒動。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "review") {
|
||||||
|
const rows = pl.loadNovelProposals(slug, work.slug);
|
||||||
|
const groups = ["exact", "alias", "fuzzy", "unknown"];
|
||||||
|
const lines = [`《${work.work}》還沒確認的人名候選 ${rows.length} 個:`];
|
||||||
|
for (const level of groups) {
|
||||||
|
const group = rows.filter((r) => r.confidence === level);
|
||||||
|
if (!group.length) continue;
|
||||||
|
lines.push(` ${level}(${group.length} 個):`);
|
||||||
|
for (const row of group) {
|
||||||
|
lines.push(` ${row.token}(${row.count} 次)${row.guess ? ` → 猜是「${row.guess}」` : ""}`);
|
||||||
|
for (const sample of (row.samples || []).slice(0, 2)) lines.push(` ⋯${sample}⋯`);
|
||||||
|
}
|
||||||
|
if (level === "exact") lines.push(" → 這一組可以 `novel name confirm --accept-exact` 整批收下。");
|
||||||
|
if (level === "unknown") {
|
||||||
|
lines.push(" → 對不上任何節點。這是新人物就先 `relation node --name \"<會被說出口的完整稱呼>\"`," +
|
||||||
|
"再回來 confirm;不是人名就 `novel name ignore --token \"<詞>\"`。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rows.length) lines.push(" (沒有待確認的。要抽新的就 `novel scan --file <章節檔>`。)");
|
||||||
|
emit({ persona: slug, work: work.slug, pending: rows }, flags.json, lines);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "list" || !sub) {
|
||||||
|
const book = pl.loadNovelNameBook(slug, work.slug);
|
||||||
|
const rows = Object.entries(book.map);
|
||||||
|
emit({ persona: slug, work: work.slug, map: book.map, ignore: book.ignore }, flags.json, [
|
||||||
|
`《${work.work}》的正名表 ${rows.length} 條(${pl.novelNamesPath(slug, work.slug)}):`,
|
||||||
|
...rows.map(([from, to]) => ` ${from} → ${to}`),
|
||||||
|
...(rows.length ? [] : [" (還是空的:`novel scan` 抽候選,`novel name review` 確認)"]),
|
||||||
|
...(book.ignore.length ? [` 標成不是人名的:${book.ignore.join("、")}`] : []),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
die("用法:`novel name add|list|review|confirm|ignore|reject`。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "skip") {
|
||||||
|
const work = openWork();
|
||||||
|
if (sub === "list") {
|
||||||
|
const rows = pl.readJsonl(pl.novelSkippedPath(slug, work.slug));
|
||||||
|
emit({ persona: slug, work: work.slug, skipped: rows }, flags.json, [
|
||||||
|
`《${work.work}》跳過的章節 ${rows.length} 章:`,
|
||||||
|
...rows.map((r) => ` ${r.chapter}——${r.reason}(${String(r.at).slice(0, 10)})`),
|
||||||
|
...(rows.length ? [] : [" (還沒有跳過紀錄)"]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = pl.addNovelSkip(slug, work.slug, { chapter: str(flags.chapter), reason: str(flags.reason) });
|
||||||
|
if (!entry) die("需要 `--chapter \"<章節>\"` 與 `--reason \"<理由>\"`——靜默跳過會漏章,事後查不出來。");
|
||||||
|
ok(`記下跳過:${entry.chapter}——${entry.reason}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "candidate") {
|
||||||
|
const work = openWork();
|
||||||
|
if (sub !== "add") die("用法:`novel candidate add --work <slug> --file <候選 JSON>`(或 `--stdin`)。");
|
||||||
|
let raw = "";
|
||||||
|
if (flags.stdin) {
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(0, "utf8");
|
||||||
|
} catch {
|
||||||
|
raw = "";
|
||||||
|
}
|
||||||
|
} else if (str(flags.file)) {
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(str(flags.file), "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
die(`讀不到 \`${str(flags.file)}\`:${String(err.message).slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
die("需要 `--file <候選 JSON 檔>` 或 `--stdin`。");
|
||||||
|
}
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
die(`候選 JSON 解析不了:${String(err.message).slice(0, 160)}`);
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
const res = pl.addNovelCandidates(slug, work.slug, rows);
|
||||||
|
const lines = [`《${work.work}》收下 ${res.added.length}/${rows.length} 則記憶候選。`];
|
||||||
|
for (const fail of res.failed) {
|
||||||
|
lines.push(`✖ 第 ${fail.index + 1} 筆(${fail.name})沒過:`);
|
||||||
|
for (const err of fail.errors) lines.push(` ${err.field}:${err.reason}`);
|
||||||
|
}
|
||||||
|
if (res.failed.length) lines.push(" 沒過的**沒有寫進去**:修好那幾筆再送一次(其餘已經收下,不會重複)。");
|
||||||
|
emit({ persona: slug, work: work.slug, added: res.added.length, failed: res.failed }, flags.json, lines);
|
||||||
|
// 驗不過的不能只印一行就當成功——呼叫端(skill)要靠結束碼知道這一章沒收完
|
||||||
|
if (res.failed.length) process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "merge") {
|
||||||
|
const work = openWork();
|
||||||
|
const file = pl.novelCandidatesPath(slug, work.slug);
|
||||||
|
const rows = pl.readJsonl(file);
|
||||||
|
if (!rows.length) die("這個工作區還沒有記憶候選(先 `novel candidate add`)。");
|
||||||
|
const { rows: dedup, merged } = pl.mergeNovelCandidates(rows);
|
||||||
|
const { rows: final, demoted } = pl.requotaNovelCandidates(dedup, work.quota);
|
||||||
|
const usage = pl.novelQuotaUsage(final, work.quota);
|
||||||
|
const apply = Boolean(flags.apply);
|
||||||
|
// 合併會少列、重定標只改欄位——長度沒變就不寫檔,所以一律帶 force
|
||||||
|
if (apply) pl.rewriteJsonl(file, () => final, { force: true });
|
||||||
|
emit({
|
||||||
|
persona: slug, work: work.slug, applied: apply, before: rows.length, after: final.length, merged, demoted, quota: usage,
|
||||||
|
}, flags.json, [
|
||||||
|
apply ? `《${work.work}》已收斂並寫回:` : `《${work.work}》收斂試跑(**沒有寫檔**,要落地加 --apply):`,
|
||||||
|
` ${rows.length} 則 → ${final.length} 則(同名合併掉 ${merged} 則)`,
|
||||||
|
` 依配額壓下來 ${demoted.length} 則:`,
|
||||||
|
...demoted.slice(0, 12).map((d) => ` ${d.name}|${d.from} → ${d.to}(${d.tier} 那一級滿了)`),
|
||||||
|
...(demoted.length > 12 ? [` (還有 ${demoted.length - 12} 則)`] : []),
|
||||||
|
...usage.map((u) => ` 配額 ${u.label}:${u.used}/${u.limit}`),
|
||||||
|
" 合併規則:salience 取高、first_seen 取最早、last_seen 取最晚、topics/about 取聯集、quote 留最長的。",
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "write") {
|
||||||
|
const work = openWork();
|
||||||
|
const file = pl.novelCandidatesPath(slug, work.slug);
|
||||||
|
const rows = pl.readJsonl(file);
|
||||||
|
const pending = rows.filter((r) => !r.written_at);
|
||||||
|
const limit = num(flags.limit, null);
|
||||||
|
const batch = Number.isFinite(limit) ? pending.slice(0, Math.max(0, limit)) : pending;
|
||||||
|
const dryRun = Boolean(flags["dry-run"]);
|
||||||
|
const written = [];
|
||||||
|
const skipped = [];
|
||||||
|
for (const row of batch) {
|
||||||
|
const name = pl.slugify(row.name);
|
||||||
|
const target = path.join(pl.longTermDir(slug), `${name}.md`);
|
||||||
|
const title = row.title || row.name;
|
||||||
|
// 兩則不同的候選 slugify 成同一個檔名時**不覆蓋**:那會靜默換掉別則記憶的內文
|
||||||
|
let prior = null;
|
||||||
|
if (fs.existsSync(target)) [prior] = pl.parseFrontMatter(fs.readFileSync(target, "utf8"));
|
||||||
|
const priorTitle = prior ? (prior.title ?? prior.name ?? null) : null;
|
||||||
|
if (priorTitle !== null && priorTitle !== title && !flags.force) {
|
||||||
|
skipped.push({ name, reason: `\`${name}.md\` 已經是「${priorTitle}」的記憶(要蓋過加 --force)` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!dryRun) {
|
||||||
|
pl.writeLongTermMemory(slug, {
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
type: row.type,
|
||||||
|
// 他自己說過的原句接在內文後面,不另開 front matter 欄位——這樣它會跟著
|
||||||
|
// 「細節」一起衰減(記得吵過但忘了他原話是對的),而不是永遠清晰地掛在標頭上。
|
||||||
|
// 語氣那一份另外抄進 `voice/samples.md`,兩邊用途不同。
|
||||||
|
body: row.quote ? `${row.body}\n\n他當時說:「${row.quote}」` : row.body,
|
||||||
|
about: row.about,
|
||||||
|
topics: row.topics,
|
||||||
|
salience: row.salience,
|
||||||
|
// 情緒只固化進欄位:這裡一次都不呼叫 applyEmotion。逐則重放 delta
|
||||||
|
// 等於讓最後一章決定他的性格(TODO 2.4/3B.2),基線要走 `novel baseline`。
|
||||||
|
emotion: row.emotion,
|
||||||
|
when: row.when,
|
||||||
|
where: row.where,
|
||||||
|
mood: row.mood,
|
||||||
|
rules: "novel-import",
|
||||||
|
source: row.source || [work.work, row.chapter].filter(Boolean).join("|"),
|
||||||
|
// **劇情內時間不可以寫進 first_seen/last_seen。** 那兩欄是記憶的
|
||||||
|
// 新鮮度時鐘(`memoryStrength()` 拿 last_seen 算衰減),劇情日期塞進去
|
||||||
|
// 等於宣告「這則記憶上次被想起是兩年前」——2024 年的劇情匯進來的那一秒
|
||||||
|
// 就是模糊 0%,而且掉到 0% 之後 touchRecall 不再更新它,永遠回不來。
|
||||||
|
// 實測 40 到 79 分的 event 全部出生即死,只有 canon 與 80 分以上靠保護清單活著。
|
||||||
|
//
|
||||||
|
// 所以兩種語意分成兩組欄位:
|
||||||
|
// first_seen/last_seen 這則記憶什麼時候形成、上次什麼時候想起(真實時間)
|
||||||
|
// happened_at/_until 故事裡這件事什麼時候發生(劇情時間,配 date_source)
|
||||||
|
// 匯入當下就是這則記憶形成的時刻,所以 first/last 都是今天(給預設值)。
|
||||||
|
extra: {
|
||||||
|
date_source: row.date_source,
|
||||||
|
know_level: row.know_level,
|
||||||
|
happened_at: row.first_seen,
|
||||||
|
...(row.last_seen && row.last_seen !== row.first_seen ? { happened_until: row.last_seen } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
written.push({ name, file: target });
|
||||||
|
}
|
||||||
|
let total = null;
|
||||||
|
if (!dryRun && written.length) {
|
||||||
|
const stamp = pl.nowIso();
|
||||||
|
const done = new Set(written.map((w) => w.name));
|
||||||
|
// 就地改欄位:長度沒變的話 `rewriteJsonl` 一個位元組都不會寫出去,所以要 force
|
||||||
|
pl.rewriteJsonl(file, (all) => all.map(
|
||||||
|
(r) => (done.has(pl.slugify(r.name)) && !r.written_at ? { ...r, written_at: stamp } : r),
|
||||||
|
), { force: true });
|
||||||
|
total = pl.rebuildIndex(slug);
|
||||||
|
}
|
||||||
|
emit({
|
||||||
|
persona: slug, work: work.slug, dry_run: dryRun, written: written.map((w) => w.name), skipped, long_term_total: total,
|
||||||
|
}, flags.json, [
|
||||||
|
dryRun
|
||||||
|
? `《${work.work}》批次寫入試跑:${written.length} 則會寫進長期記憶(**沒有寫檔**)。`
|
||||||
|
: `✔ 《${work.work}》寫進長期記憶 ${written.length} 則(共 ${total ?? "?"} 則,INDEX.md 已重建)。`,
|
||||||
|
...written.slice(0, 20).map((w) => ` ${w.name}`),
|
||||||
|
...(written.length > 20 ? [` (還有 ${written.length - 20} 則)`] : []),
|
||||||
|
...skipped.map((s) => ` ⚠ 跳過 ${s.name}:${s.reason}`),
|
||||||
|
` 還沒寫入的還有 ${pending.length - written.length} 則。`,
|
||||||
|
" 情緒只固化進欄位,沒有套進基線——基線要另外走 `novel baseline --propose`。",
|
||||||
|
]);
|
||||||
|
if (!dryRun && written.length) pushWikiLater(slug, session, flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "baseline") {
|
||||||
|
const work = openWork();
|
||||||
|
const proposed = parseDeltas(flags.propose);
|
||||||
|
if (!Object.keys(proposed).length) {
|
||||||
|
die("需要 `--propose \"joy=30,anger=12\"`(全書統計出來的**基線值**,不是 delta)。");
|
||||||
|
}
|
||||||
|
const diff = pl.novelBaselineDiff(slug, proposed);
|
||||||
|
if (diff.unknown.length) die(`不認得這幾種情緒:${diff.unknown.join("、")}(可用 ${pl.EMOTION_KEYS.join("/")})。`);
|
||||||
|
const table = diff.rows.map((r) => ` ${r.zh}(${r.key}):現在 ${r.now} → 提案 ${r.next}` +
|
||||||
|
`|差 ${r.diff > 0 ? "+" : ""}${r.diff}${Math.abs(r.diff) >= diff.gap ? " ⚠" : ""}`);
|
||||||
|
const blocked = diff.over.length > 0 && !flags.force;
|
||||||
|
const payload = {
|
||||||
|
persona: slug, work: work.slug, gap: diff.gap, rows: diff.rows,
|
||||||
|
over: diff.over.map((r) => r.key), blocked, applied: false,
|
||||||
|
};
|
||||||
|
if (blocked) {
|
||||||
|
// 基線是氣質,改了等於換一個人。差太多就停在這裡(非零結束),讓人看一眼
|
||||||
|
if (flags.json) {
|
||||||
|
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
die([
|
||||||
|
`情緒基線提案有 ${diff.over.length} 格差 ${diff.gap} 以上,**沒有寫入**:`,
|
||||||
|
...table,
|
||||||
|
` 基線是氣質,改了等於換一個人。看過還是要改就加 --force;` +
|
||||||
|
"只想改差得少的那幾格就把超標的從 --propose 拿掉。",
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
pl.applyNovelBaseline(slug, proposed);
|
||||||
|
// 提案留在 work.json:下一本書要接續時看得出這次是照哪一批統計調的
|
||||||
|
pl.updateJson(pl.novelWorkPath(slug, work.slug), (prev) => ({
|
||||||
|
...(prev && typeof prev === "object" ? prev : {}),
|
||||||
|
baseline: { at: pl.nowIso(), values: proposed, forced: Boolean(flags.force) },
|
||||||
|
updated_at: pl.nowIso(),
|
||||||
|
}), {});
|
||||||
|
payload.applied = true;
|
||||||
|
emit(payload, flags.json, [
|
||||||
|
`✔ 情緒基線已更新(${diff.rows.length} 格${flags.force && diff.over.length ? ",--force 蓋過門檻" : ""}):`,
|
||||||
|
...table,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
die("用法:`novel init|scan|name|skip|candidate|merge|write|report|baseline`。");
|
||||||
|
};
|
||||||
|
|
||||||
commands.reindex = ({ flags }) => {
|
commands.reindex = ({ flags }) => {
|
||||||
const session = requireSession(flags);
|
const session = requireSession(flags);
|
||||||
const slug = hostOf(flags, session);
|
const slug = hostOf(flags, session);
|
||||||
@@ -2546,10 +3005,48 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
loop add|done|drop|touch|sweep|list --session <id> [--text --kind question|promise|topic|mine --id --note]
|
loop add|done|drop|touch|sweep|list --session <id> [--text --kind question|promise|topic|mine --id --note]
|
||||||
懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。
|
懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。
|
||||||
7 天沒進展自動收掉並留一則「沒下文」。mine 那種同時是自我議程的來源。
|
7 天沒進展自動收掉並留一則「沒下文」。mine 那種同時是自我議程的來源。
|
||||||
|
voice add|list|show --session <id> --kind sample|reaction
|
||||||
|
[--text --to --scene] sample:他自己講過的原句(照抄不改寫)
|
||||||
|
[--event --action --emotion] reaction:事件 → 他做了什麼(不記「他感覺到什麼」)
|
||||||
|
show 看這一輪會注入什麼(每輪最多 3 條——全注入會變成照抄舊台詞)。
|
||||||
|
寫在 voice/ 這一層:匯入流程可以直接寫,個性(SOUL.md)只有你能改。
|
||||||
probe add|confirm|deny|audit --session <id> [--text --memory --note --id --limit]
|
probe add|confirm|deny|audit --session <id> [--text --memory --note --id --limit]
|
||||||
模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。
|
模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。
|
||||||
audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。
|
audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。
|
||||||
|
|
||||||
|
故事匯入(機械的那半;判斷的那半在 skill:在場與知情、切場景、第一人稱摘要、個性提案):
|
||||||
|
novel init --session <id> --work "<書名>" [--slug <work-slug> --quota "90=5,80=20" --force]
|
||||||
|
建 memory/import/<work-slug>/:work.json / names.json / skipped.jsonl / candidates.jsonl
|
||||||
|
novel scan --session <id> --work <slug> [--file <章節檔[,第二檔]>] [--dir <目錄>]
|
||||||
|
從章節裡抽人名候選(對話歸屬/敬稱/片假名/高頻詞四條規則)→ names-proposed.json
|
||||||
|
已經在正名表或 ignore 裡的不再列:第二次掃只看到新出現的
|
||||||
|
novel name review|confirm|ignore|reject|add|list --session <id> --work <slug>
|
||||||
|
review 待確認的候選,按 exact / alias / fuzzy / unknown 分組
|
||||||
|
confirm --from --to 收下一條(--to 對不上關係節點就擋下來)
|
||||||
|
confirm --accept-exact 把等於節點名字的整批收下
|
||||||
|
ignore --token 這不是人名(下次掃不再列)
|
||||||
|
reject --token 先跳過(不進 ignore,下次掃還會出現)
|
||||||
|
add --from --to 手動補一條(掃不到的漏網名字)
|
||||||
|
novel skip [list] --session <id> --work <slug> [--chapter "<章節>" --reason "<理由>"]
|
||||||
|
跳過紀錄。靜默跳過會漏章,事後查不出來,所以理由是必填
|
||||||
|
novel candidate add --session <id> --work <slug> --file <候選 JSON> | --stdin
|
||||||
|
欄位驗證(name/type/body/first_seen 必填、first_seen 要 YYYY-MM-DD、
|
||||||
|
date_source 只能 canon/derived/guess)+依正名表正名 about。
|
||||||
|
know_level 除了 canon 之外都必填(did/saw/told/later/none);
|
||||||
|
none 是「他不在場也沒人告訴他」,只能配 type canon——他不知道的事不能變成他的經歷
|
||||||
|
about 裡有還沒確認的名字就擋下來;沒過的逐筆回報並以非零結束
|
||||||
|
novel merge --session <id> --work <slug> [--apply]
|
||||||
|
跨章去重(salience 取高/first_seen 最早/last_seen 最晚/topics 與 about 聯集/
|
||||||
|
quote 留最長)+依配額重定標(超額的從低分往下壓一級)。不帶 --apply 只印報告
|
||||||
|
novel write --session <id> --work <slug> [--dry-run --limit N --force]
|
||||||
|
批次寫進長期記憶(一則一檔,跟 consolidate 同一套 front matter,
|
||||||
|
多寫 date_source/know_level/happened_at)。情緒只固化進欄位,不套進基線。
|
||||||
|
劇情日期落在 happened_at;first_seen/last_seen 是記憶的新鮮度時鐘,
|
||||||
|
一律是匯入當天——劇情日期塞進去的話舊劇情匯進來就已經想不起來了
|
||||||
|
novel report --session <id> --work <slug> [--json] 候選/跳過/正名/配額/還沒寫入的
|
||||||
|
novel baseline --session <id> --work <slug> --propose "joy=30,anger=12,..." [--force]
|
||||||
|
情緒基線提案跟現況比差值:任一格差 10 以上就停下來給你看(非零結束)
|
||||||
|
|
||||||
情緒與圖:
|
情緒與圖:
|
||||||
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
emotion --session <id> [--apply joy=+10,...] [--baseline ...] [--trigger <why>]
|
||||||
[--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta
|
[--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta
|
||||||
|
|||||||
Reference in New Issue
Block a user