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:
+545
-48
@@ -54,6 +54,10 @@ const FLAGS = new Set([
|
||||
"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",
|
||||
"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) {
|
||||
@@ -953,7 +957,6 @@ commands.consolidate = ({ flags }) => {
|
||||
if (!rawName) die("需要 `--name`(長期記憶的檔名/識別)。");
|
||||
const name = pl.slugify(rawName);
|
||||
const file = path.join(pl.longTermDir(slug), `${name}.md`);
|
||||
const today = pl.nowIso().slice(0, 10);
|
||||
let existing = {};
|
||||
if (fs.existsSync(file)) [existing] = pl.parseFrontMatter(fs.readFileSync(file, "utf8"));
|
||||
// slugify 會把標點吃掉:「我的貓」「我的貓?」「我的貓!!」全都落在 `我的貓.md` 上。
|
||||
@@ -980,54 +983,30 @@ commands.consolidate = ({ flags }) => {
|
||||
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary",
|
||||
];
|
||||
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"];
|
||||
// `about` 是自由字串(原樣保留);對得上關係圖節點的才多寫一行 id,之後 recall 與
|
||||
// 「人際關係」那段才知道講的是同一個人。一個都對不上就不輸出這一行。
|
||||
// `resolveRelationRefs` 已經把不能寫進 front matter 的 id 濾掉了(帶換行的節點 id
|
||||
// 可以在這裡多插一行、覆寫下面的 `type`,把一則 fact 變成不該被遺忘的 canon)。
|
||||
const aboutIds = pl.resolveRelationRefs(slug, about);
|
||||
// front matter 的每個值都只能是一行:帶換行的旗標值同樣能偽造出別的欄位,
|
||||
// 而 `parseFrontMatter` 取後出現的值 → 後面宣告的 type/salience 會被前面偽造的蓋掉。
|
||||
const fm = (value) => pl.injectSafeLine(value);
|
||||
const front = [
|
||||
"---",
|
||||
`name: ${name}`,
|
||||
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠這行認出「不是同一則」。
|
||||
`title: ${rawName.replace(/[\r\n]+/g, " ").replace(/-{3,}/g, "—").trim().slice(0, 120)}`,
|
||||
`type: ${type}`,
|
||||
`about: [${about.map(fm).filter(Boolean).join(", ")}]`,
|
||||
...(aboutIds.length ? [`about_ids: [${aboutIds.join(", ")}]`] : []),
|
||||
`topics: [${csv(flags.topics).map(fm).filter(Boolean).join(", ")}]`,
|
||||
`salience: ${num(flags.salience, 60)}`,
|
||||
// 回想強度:連續衰減的起點。已經存在的記憶保留自己長出來的強度,
|
||||
// 不要因為重寫一次就把「被想起過很多次」的歷史抹平。
|
||||
`strength: ${num(flags.strength, Number(existing.strength ?? pl.MEMORY_STRENGTH_DEFAULT))}`,
|
||||
`emotion: ${fm(str(flags.emotion) || "none")}`,
|
||||
// 情境索引:什麼時候、在哪裡、什麼心情記下來的(回想時的非語意線索)。
|
||||
// 推不出來的欄位就不寫——全專案一致:沒有值不留空欄位。
|
||||
...["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"));
|
||||
// front matter 的組裝在 `writeLongTermMemory`:`novel write` 也走同一套,
|
||||
// 欄位順序與預設值只能有一份(見那個函式的說明)。
|
||||
pl.writeLongTermMemory(slug, {
|
||||
name,
|
||||
// 原本的 --name(沒被 slugify 吃掉的那個)。下次撞名時就是靠 title 認出「不是同一則」。
|
||||
title: rawName,
|
||||
type,
|
||||
body,
|
||||
// `--gist` 明寫時,`--detail` 沒給就是**沒有細節**(見 writeLongTermMemory)
|
||||
gist: str(flags.gist) || null,
|
||||
detail: str(flags.detail),
|
||||
about,
|
||||
topics: csv(flags.topics),
|
||||
salience: num(flags.salience, 60),
|
||||
// 沒帶 --strength 就交給既有檔案裡長出來的強度(null = 沿用)
|
||||
strength: num(flags.strength, null),
|
||||
emotion: str(flags.emotion),
|
||||
when: str(flags.when),
|
||||
where: str(flags.where),
|
||||
mood: str(flags.mood),
|
||||
rules: str(flags.rules),
|
||||
source: str(flags.source),
|
||||
});
|
||||
const total = pl.rebuildIndex(slug);
|
||||
const forget = num(flags.forget, null);
|
||||
if (forget !== null) {
|
||||
@@ -1184,6 +1163,486 @@ commands.probe = ({ flags, positional }) => {
|
||||
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 }) => {
|
||||
const session = requireSession(flags);
|
||||
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]
|
||||
懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。
|
||||
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]
|
||||
模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。
|
||||
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>]
|
||||
[--from <對象>] 這件事是誰引起的:親近度會放大/縮小 delta
|
||||
|
||||
Reference in New Issue
Block a user