feat: 心裡話與不重複發言、劇場模式同規則、人格匯出匯入

讓人格講話更像正常人,並讓人格可以搬家。

1. 心裡話(inner voice)
   - 新增 `think` 子指令:推導/盤算寫進 state/inner.jsonl,
     只印「💭 心想 N 句」,內容永不回顯給使用者。
   - <persona-context> 每輪帶回最近三句,推論因此有連續性。

2. 短時間內不重複
   - 說出口的話由 Stop hook 自動記進 state/said.jsonl。
   - 新增 `said check|list`:事前確認這句是不是又要說一次。
   - 相似度 = 字元 bigram Jaccard(0.4) + 字集合 Jaccard(0.6),
     門檻 0.72;字集合權重較高才分得開「重排語序」(要擋)
     與「換掉關鍵詞」(是新資訊,放行)。

3. 回話一到三句 + 劇場模式同樣適用
   - 規則注入 turnContext 與 UserPromptSubmit hint。
   - 劇場模式的 CLI 都帶 --quiet,警告會被丟掉,所以 `room post`
     對近似重複與超過三句的發言直接拒收(--allow-repeat / --force 例外),
     host 與 guest 走同一支 CLI,一視同仁。

4. 人格匯出匯入(新 skill persona-transfer)
   - `export` / `import`:單一 JSON bundle(可 gzip)+ sha256 checksum,
     無外部依賴。不帶載入鎖與 guest 租約,journal/ 需明確 --with-journal。
   - 匯出只能匯出本 session 已載入的人格,否則等於跨人格讀取的後門;
     bundle 內的 ../ 逃逸路徑一律拒收。

順帶修正:agents/persona-guest.md 的 CLI 範例都少了 --as-guest,
照著跑會被 requireMember 擋下,guest 根本無法認識自己或發言。

selftest 102 項全綠(新增 ⑪ 說話節制、⑫ 匯出匯入,含重複判定校準對照表)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:59:16 +00:00
co-authored by Claude Opus 5
parent 2005f26c94
commit 52a73e81c8
14 changed files with 956 additions and 48 deletions
+217 -8
View File
@@ -11,6 +11,7 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import zlib from "node:zlib";
import { fileURLToPath } from "node:url";
import * as pl from "./persona-lib.mjs";
@@ -46,6 +47,7 @@ function emit(payload, asJson, lines) {
const FLAGS = new Set([
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
"with-journal", "gzip", "record", "load", "allow-repeat",
]);
function parseArgs(argv) {
@@ -416,6 +418,75 @@ commands.recall = ({ flags }) => {
emit({ persona: slug, long_term: hits, short_term: recents }, flags.json, lines);
};
/**
* 心裡話:推導、盤算、對記憶的比對……全部寫在這裡,不說出口。
* 永遠只回報「心想 N 句」,不回顯內容——使用者看到的是狀態,不是你的內心。
*/
commands.think = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const [, role] = requireMember(slug, session, Boolean(flags["as-guest"]));
const text = str(flags.text);
if (!text) die("需要 `--text`(心裡話內容;不會顯示給使用者)。");
const kind = str(flags.kind) || "infer";
const room = str(flags.room) || null;
let count;
if (role === "guest") {
// guest 對自己的人格檔案唯讀,心裡話跟見聞一樣先進 inbox,回家再消化
if (!room) die("guestsub agent)的心裡話要帶 `--room`,會寫進自己的 inbox。");
pl.appendJsonl(pl.inboxPath(slug, room), {
ts: pl.nowIso(), role: "inner", kind, text, room, salience: num(flags.salience, 35),
});
count = pl.readJsonl(pl.inboxPath(slug, room)).filter((r) => r.role === "inner").length;
} else {
pl.recordInner(slug, text, { kind, room });
count = pl.innerCount(slug);
}
emit({ persona: slug, kind, count }, flags.json, [`💭 心想 ${count}`]);
};
/** 說過的話:查最近說了什麼、以及「這句是不是又要再說一次」。 */
commands.said = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireMember(slug, session, Boolean(flags["as-guest"]));
const action = positional[0] || "check";
if (action === "list") {
const rows = pl.recentSaid(slug, num(flags.limit, 8));
emit({ persona: slug, said: rows }, flags.json, [
`\`${slug}\` 最近說過的話(${rows.length} 則):`,
...rows.map((r) => ` - [${r.ts}] ${String(r.text || "").replace(/\n/g, " ").slice(0, 100)}`),
]);
return;
}
if (action === "check") {
const text = str(flags.text);
if (!text) die("需要 `--text`(你打算說的話)。");
const opts = {
minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES),
threshold: num(flags.threshold, pl.REPEAT_THRESHOLD),
};
const repeat = pl.saidRepeat(slug, text, opts);
const sentences = pl.sentenceCount(text);
const tooLong = sentences > pl.MAX_SENTENCES;
const lines = [];
if (repeat) {
lines.push(
`⚠ 這句跟 ${repeat.minutes_ago} 分鐘前說過的話相似 ${repeat.similarity}:「${repeat.text.slice(0, 60)}`,
" → 換個角度、補新資訊,或直接推進話題;不要再說一次。",
);
}
if (tooLong) lines.push(`⚠ 這段有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限 → 砍到重點。`);
if (!repeat && !tooLong) {
lines.push(`✔ 沒說過,${sentences} 句,可以說。`);
if (flags.record) pl.recordSaid(slug, text, { kind: "reply" });
}
emit({ persona: slug, repeat, sentences, ok: !repeat && !tooLong }, flags.json, lines);
return;
}
die(`未知 action${action}(可用 check/list`);
};
/** 短期 → 長期的「轉入條件」評估:列出達標的候選與依據。 */
commands.candidates = ({ flags }) => {
const session = requireSession(flags);
@@ -640,13 +711,12 @@ commands.invite = ({ flags }) => {
if (!guest) die("需要 `--guest <slug>`。");
if (guest === host) die("不能邀請自己。");
if (!pl.personaExists(guest)) die(`人格 \`${guest}\` 不存在。可用:${pl.listPersonas().join(", ")}`);
// guest 租約是唯讀的(`mode: "guest-readonly"`),與 exclusive 載入鎖並存不會互相覆寫:
// guest 在 sub agent 裡被 PreToolUse hook 擋掉所有寫入工具,記憶只能進 inbox、情緒不得更動。
// 因此這裡**不以 exclusive 的標準驗鎖**,只在對方仍活著時提示使用者「你看到的是唯讀旁聽」。
const lock = pl.readJson(pl.lockPath(guest)) ?? {};
if (Object.keys(lock).length && lock.session_id !== session && !pl.lockIsDead(lock)) {
die(
`人格 \`${guest}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd})。` +
"同一人格同時只能被一個程序載入,無法邀請。",
);
}
const heldElsewhere =
Boolean(Object.keys(lock).length) && lock.session_id !== session && !pl.lockIsDead(lock);
const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14);
const room = str(flags.room) || `${host}-${guest}-${stamp}`;
pl.createRoom(room, host, session, str(flags.topic));
@@ -660,8 +730,14 @@ commands.invite = ({ flags }) => {
data.theater = flags.theater === false || flags.theater === "off" ? false : true;
pl.saveSession(session, data);
if (str(flags.topic)) pl.roomPost(room, "system", `主題:${str(flags.topic)}`, { kind: "meta" });
emit({ room, guest, host, dir: pl.roomDir(room), theater: data.theater }, flags.json, [
emit({ room, guest, host, dir: pl.roomDir(room), theater: data.theater, held_elsewhere: heldElsewhere }, flags.json, [
`✔ 已邀請人格 \`${guest}\` 以 guest(唯讀)身分加入聊天室 \`${room}\``,
...(heldElsewhere
? [
` ℹ 它同時被另一個程序 exclusive 載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd});` +
"你這邊拿到的是唯讀旁聽,寫入仍由那個程序獨占,兩者不會互相覆寫。",
]
: []),
` 聊天室路徑:${pl.roomDir(room)}`,
` 🎭 劇場模式已${data.theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉"}`,
" 請用 Agent 工具、subagent_type=\"jsc-persona:persona-guest\" 啟動它,prompt 內帶:",
@@ -715,10 +791,35 @@ commands.room = ({ flags, positional }) => {
if (action === "post") {
const speaker = str(flags.as) || data.host;
if (!speaker) die("需要 `--as <persona>`。");
requireMember(speaker, session, Boolean(flags["as-guest"]));
const [, speakerRole] = requireMember(speaker, session, Boolean(flags["as-guest"]));
let text = str(flags.text);
if (flags["text-file"]) text = fs.readFileSync(str(flags["text-file"]), "utf8").trim();
if (!text) die("需要 `--text` 或 `--text-file`。");
// 劇場模式也要像正常人聊天:一次 1–3 句、短時間內不重複同一句話。
// 這裡用 die 擋,因為劇場模式的 CLI 都帶 --quiet,警告訊息會被丟掉。
if (!flags["allow-repeat"] && !flags.force) {
const opts = {
minutes: num(flags.minutes, pl.REPEAT_WINDOW_MINUTES),
threshold: num(flags.threshold, pl.REPEAT_THRESHOLD),
};
const repeat =
pl.roomRepeat(room, speaker, text, opts) ||
(speakerRole === "guest" ? null : pl.saidRepeat(speaker, text, opts));
if (repeat) {
die(
`\`${speaker}\`${repeat.minutes_ago} 分鐘前說過幾乎一樣的話(相似度 ${repeat.similarity}):` +
`${repeat.text.slice(0, 60)}」。換個說法、補新東西或推進話題;` +
"真的需要重複(例如被追問)才加 `--allow-repeat`。",
);
}
}
const sentences = pl.sentenceCount(text);
if (sentences > pl.MAX_SENTENCES && !flags.force) {
die(
`這句有 ${sentences} 句,超過 ${pl.MAX_SENTENCES} 句上限——聊天不是報告,砍到重點再發。` +
"(真的需要長段落才加 `--force`。)",
);
}
let emotion = str(flags.emotion);
if (!emotion && pl.personaExists(speaker)) {
emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
@@ -726,6 +827,8 @@ commands.room = ({ flags, positional }) => {
.join("/");
}
const entry = pl.roomPost(room, speaker, text, { emotion });
// guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到)
if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" });
ok(`\`${speaker}\` 已發言於 \`${room}\`(情緒 ${emotion})。`);
if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`);
return;
@@ -749,6 +852,105 @@ commands.room = ({ flags, positional }) => {
die(`未知 action${action}(可用 post/read/script/list/theater`);
};
/**
* 匯出人格成單一 bundle 檔(可 gzip)。
* 只能匯出「本 session 目前載入的人格」——否則就成了跨人格資料外洩的後門。
*/
commands.export = ({ flags }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
requireOwner(slug, session);
const gzip = Boolean(flags.gzip);
const stamp = pl.nowIso().replace(/[-:TZ]/g, "").slice(0, 14);
const out = path.resolve(str(flags.out) || `${slug}-${stamp}.persona.json${gzip ? ".gz" : ""}`);
if (fs.existsSync(out) && !flags.force) die(`${out} 已存在。要覆寫請加 --force。`);
const { bundle, skipped } = pl.exportBundle(slug, { withJournal: Boolean(flags["with-journal"]) });
const json = JSON.stringify(bundle, null, gzip ? 0 : 2);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, gzip ? zlib.gzipSync(Buffer.from(json, "utf8")) : json, gzip ? undefined : "utf8");
const size = fs.statSync(out).size;
emit({ persona: slug, file: out, bytes: size, stats: bundle.stats, checksum: bundle.checksum, skipped }, flags.json, [
`✔ 人格 \`${slug}\` 已匯出到 ${out}${(size / 1024).toFixed(1)} KB${gzip ? "gzip" : ""})。`,
` 內容:${bundle.stats.files} 個檔案|長期記憶 ${bundle.stats.long_term}|短期 ${bundle.stats.short_term}` +
`|關係節點 ${bundle.stats.relations}journal ${bundle.stats.with_journal ? "含" : "不含(要帶請加 --with-journal"}`,
" 不含載入鎖與 guest 租約(那是執行期狀態)。匯入:`persona.mjs import --file <檔案> --session <id>`",
]);
};
/** 從 bundle 匯入人格。可用 `--persona <新 slug>` 換名匯入(同一個人格要並存兩份時很有用)。 */
commands.import = ({ flags }) => {
const session = requireSession(flags);
const file = str(flags.file);
if (!file) die("需要 `--file <bundle.json|.gz>`。");
let buf;
try {
buf = fs.readFileSync(path.resolve(file));
} catch (err) {
die(`讀不到 ${file}${err.message}`);
}
if (buf[0] === 0x1f && buf[1] === 0x8b) {
try {
buf = zlib.gunzipSync(buf);
} catch (err) {
die(`gzip 解壓失敗:${err.message}`);
}
}
let bundle;
try {
bundle = JSON.parse(buf.toString("utf8"));
} catch (err) {
die(`不是合法的 bundle JSON${err.message}`);
}
const { ok: valid, problems, checksumOk } = pl.validateBundle(bundle);
if (!valid) die(`bundle 不合法:${problems.join("")}`);
if (!checksumOk && !flags.force) die("checksum 不符(檔案可能損毀或被改過)。確定要匯入請加 --force。");
const target = str(flags.persona) || bundle.persona;
if (!pl.validSlug(target)) die(`slug \`${target}\` 不合法。請用 \`--persona <新 slug>\` 指定。`);
const data = pl.loadSession(session);
const exists = pl.personaExists(target);
if (exists && !flags.force) {
die(`人格 \`${target}\` 已存在。換名匯入請加 \`--persona <新 slug>\`,覆寫請加 --force。`);
}
if (exists && data.host && data.host !== target) {
die(`本 session 載入的是 \`${data.host}\`,不得覆寫另一個既有人格 \`${target}\`;請先 release 再匯入。`);
}
const lock = pl.readJson(pl.lockPath(target)) ?? {};
if (Object.keys(lock).length && lock.session_id !== session && !pl.lockIsDead(lock)) {
die(
`人格 \`${target}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,cwd ${lock.cwd}),` +
"不能覆寫它的資料。",
);
}
let result;
try {
result = pl.importBundle(bundle, target, { session });
} catch (err) {
die(err.message);
}
const lines = [
`✔ 人格 \`${result.persona}\` 已匯入(${result.written.length} 個檔案${exists ? ",覆寫既有資料" : ""})。`,
` 來源:\`${bundle.persona}\`|匯出於 ${bundle.exported_at || "?"}${pl.identityBrief(result.persona) || "(無身分欄位)"}`,
];
if (result.rejected.length) lines.push(` ⚠ 略過 ${result.rejected.length} 個路徑不合法的項目:${result.rejected.slice(0, 3).join(", ")}`);
if (!checksumOk) lines.push(" ⚠ checksum 不符(--force 略過):內容可能被改過,請自行確認。");
if (flags.load) {
if (data.host && data.host !== result.persona) {
lines.push(` ⚠ 本 session 已載入 \`${data.host}\`,未自動載入;要用它請先 release。`);
} else {
try {
pl.acquireLock(result.persona, session, { cwd: str(flags.cwd) || null });
pl.bindHost(session, result.persona, { cwd: str(flags.cwd) || null });
lines.push(` 已載入 \`${result.persona}\`,可以直接開始聊。`);
} catch (err) {
lines.push(` ⚠ 自動載入失敗:${err.message}`);
}
}
} else {
lines.push(` 下一步:/jsc-persona:persona-chat ${result.persona}`);
}
emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines);
};
commands.gc = ({ flags }) => {
const removed = pl.gcRuntime();
emit(removed, flags.json, [
@@ -791,6 +993,8 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
記憶:
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --scope short|inbox --room]
recall --session <id> --query <q> [--limit]
think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容
said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit]
candidates --session <id> 列出達到「短期→長期」條件的候選與依據
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --emotion --rules --source --forget]
prune / reindex --session <id>
@@ -804,6 +1008,11 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
invite --session <id> --guest <slug> [--host --room --topic] (自動開啟劇場模式)
leave --session <id> --guest <slug> [--room]
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
搬家:
export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
import --session <id> --file <檔案> [--persona <新 slug> --force --load]
維護:
gc 清理死鎖與過期租約