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
+336 -3
View File
@@ -874,6 +874,169 @@ export function touchRecall(slug, names) {
}
}
// --------------------------------------------------------------------------- //
// 說話節制:心裡話(inner voice)、說過的話(said)、句數上限
// --------------------------------------------------------------------------- //
//
// 三條「像正常人聊天」的規則,都靠這一段支撐:
// 1. 推導過程進 inner.jsonl(心裡話),永遠不回顯內容,只回報「心想 N 句」。
// 2. 說出口的話進 said.jsonl,短時間內近似重複會被擋下(room post)或警告(said check)。
// 3. 一次講 1–3 句;超過就是在寫報告,不是在聊天。
export const MAX_SENTENCES = 3;
export const SAID_KEEP = 150;
export const INNER_KEEP = 150;
export const REPEAT_WINDOW_MINUTES = 120; // 「短時間內」的定義
export const REPEAT_THRESHOLD = 0.72; // 字元 bigram Jaccard,超過視為同一句話
export const REPEAT_MIN_CHARS = 8; // 太短的附和(「嗯」「好啊」)不算重複
export const INNER_WINDOW_MINUTES = 240;
export const saidPath = (slug) => path.join(personaDir(slug), "state", "said.jsonl");
export const innerPath = (slug) => path.join(personaDir(slug), "state", "inner.jsonl");
/** 去掉劇場模式的 `emoji 名字(情緒):` 前綴,只留真正說出口的內容。 */
export function stripSpeakerPrefix(line) {
const m = String(line ?? "").match(
/^\s*(?:\S{1,3}\s+)?[^:,。!?!?\n]{1,16}(?:[^\n]{0,32})?\s*[:]\s*(\S.*)$/u,
);
return m ? m[1] : String(line ?? "");
}
/** 比對用的正規化:拿掉前綴、空白與標點,只留語意骨架。 */
export function normalizeSpeech(text) {
return stripSpeakerPrefix(text)
.normalize("NFKC")
.replace(/\s+/g, "")
.replace(/[,。!?、;:,.!?;:~~…「」『』"'()()【】[\]—-]+/g, "")
.toLowerCase();
}
function charBigrams(text) {
const set = new Set();
if (text.length === 1) set.add(text);
for (let i = 0; i + 2 <= text.length; i += 1) set.add(text.slice(i, i + 2));
return set;
}
function jaccard(A, B) {
if (!A.size || !B.size) return 0;
let inter = 0;
for (const item of A) if (B.has(item)) inter += 1;
return inter / (A.size + B.size - inter);
}
/**
* 兩句話的相似度(0–1)。中文沒有空白可切,所以用字元層級的兩個訊號:
* * bigram Jaccard(權重 0.4):看「詞序與搭配」——整句改寫會掉下來。
* * 字集合 Jaccard(權重 0.6):看「用了哪些字」——把同一句話重排也躲不掉。
* 字集合權重較高,是因為要分開的正是這兩種情況:
* 「我等一下把報告寄給你」vs「等一下我會把報告寄給你」→ 0.78 擋(同一件事換句話說:用字幾乎相同)
* 「你今天看起來很累」  vs「你今天看起來很開心」  → 0.69 放行(換了關鍵詞=新資訊)
*/
export function similarity(a, b) {
const normA = normalizeSpeech(a);
const normB = normalizeSpeech(b);
if (!normA || !normB) return 0;
const score =
0.4 * jaccard(charBigrams(normA), charBigrams(normB)) +
0.6 * jaccard(new Set(normA), new Set(normB));
return Math.round(score * 1000) / 1000;
}
/** 句數:以句末標點或換行切;沒有標點的一整串算 1 句。 */
export function sentenceCount(text) {
return String(text ?? "")
.split(/[。!?!?…]+|\n+/)
.map((s) => s.trim())
.filter(Boolean).length;
}
/** 把一則回覆拆成「說出口的句子」:劇場模式一行一句,一般模式整段算一句。 */
export function spokenLines(text, { theater = false } = {}) {
const raw = String(text ?? "").trim();
if (!raw) return [];
if (!theater) return [raw.slice(0, 2000)];
return raw
.split("\n")
.map((line) => stripSpeakerPrefix(line).trim())
.filter(Boolean)
.slice(0, 8);
}
/** 在 entriesneed `ts` / `text`)裡找出與 text 近似的一則;沒有就回 null。 */
export function findRepeat(entries, text, {
minutes = REPEAT_WINDOW_MINUTES,
threshold = REPEAT_THRESHOLD,
minChars = REPEAT_MIN_CHARS,
} = {}) {
if (normalizeSpeech(text).length < minChars) return null;
let best = null;
for (const row of entries || []) {
if (minutes !== null && ageSeconds(row.ts) > minutes * 60) continue;
const score = similarity(text, row.text || "");
if (score < threshold) continue;
if (!best || score > best.similarity) {
best = {
similarity: score,
at: row.ts,
text: String(row.text || "").slice(0, 200),
minutes_ago: Math.round(ageSeconds(row.ts) / 60),
};
}
}
return best;
}
function trimJsonl(file, keep) {
const rows = readJsonl(file);
if (rows.length <= keep * 1.5) return rows.length;
const kept = rows.slice(-keep);
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
return kept.length;
}
/** 記下「說出口的話」。同一句話 10 分鐘內只記一次(避免 room post 與 Stop hook 重複記)。 */
export function recordSaid(slug, text, { room = null, kind = "reply" } = {}) {
const norm = normalizeSpeech(text);
if (!norm) return null;
for (const row of readJsonl(saidPath(slug), 12)) {
if (row.norm === norm.slice(0, 400) && ageSeconds(row.ts) <= 600) return null;
}
const entry = { ts: nowIso(), kind, room, text: String(text).slice(0, 2000), norm: norm.slice(0, 400) };
appendJsonl(saidPath(slug), entry);
trimJsonl(saidPath(slug), SAID_KEEP);
return entry;
}
export const recentSaid = (slug, limit = 5) => readJsonl(saidPath(slug), limit);
export function saidRepeat(slug, text, opts = {}) {
return findRepeat(readJsonl(saidPath(slug), SAID_KEEP), text, opts);
}
/** 同一個發言者在同一個聊天室裡有沒有講過幾乎一樣的話。 */
export function roomRepeat(room, speaker, text, opts = {}) {
const rows = roomRead(room, 80).filter((m) => m.speaker === speaker && m.kind !== "meta");
return findRepeat(rows, text, opts);
}
/** 心裡話:只進自己的 inner.jsonl,永遠不回顯給使用者。 */
export function recordInner(slug, text, { kind = "infer", room = null } = {}) {
const entry = { ts: nowIso(), kind, room, text: String(text ?? "").slice(0, 1200) };
appendJsonl(innerPath(slug), entry);
trimJsonl(innerPath(slug), INNER_KEEP);
return entry;
}
export const recentInner = (slug, limit = 3) => readJsonl(innerPath(slug), limit);
/** 「心想 N 句」的 N:預設算最近 4 小時。 */
export function innerCount(slug, minutes = INNER_WINDOW_MINUTES) {
return readJsonl(innerPath(slug), INNER_KEEP).filter(
(row) => minutes === null || ageSeconds(row.ts) <= minutes * 60,
).length;
}
// --------------------------------------------------------------------------- //
// 心智圖 / 思維導圖 / 人際關係圖
// --------------------------------------------------------------------------- //
@@ -1020,6 +1183,153 @@ export function roomScript(room, { limit = 30, includeMeta = false } = {}) {
return lines.join("\n");
}
// --------------------------------------------------------------------------- //
// 匯出 / 匯入:把一個人格打包成單一檔案(可搬到另一台機器或另一個 AI 助理)
// --------------------------------------------------------------------------- //
//
// bundle 是純 JSON(可再 gzip),不含執行期狀態:
// * 帶走:IDENTITY/SOUL/AGENTS/USER、state/config.json、state/emotion.json、
// state/inner.jsonl、state/said.jsonl、記憶(短期/長期/inbox/INDEX)、
// 心智圖、思維導圖、人際關係圖。
// * 不帶:state/lock.json、state/guests.json(鎖與租約屬於「那台機器的那個程序」),
// journal/(逐字稿很大且屬隱私,要帶請加 --with-journal)。
export const BUNDLE_FORMAT = "jsc-persona/bundle";
export const BUNDLE_VERSION = 1;
export const BUNDLE_SKIP = new Set(["state/lock.json", "state/guests.json"]);
const MAX_BUNDLE_FILE = 5 * 1024 * 1024;
function walkFiles(root, rel = "") {
const out = [];
let entries = [];
try {
entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const next = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) out.push(...walkFiles(root, next));
else if (entry.isFile()) out.push(next);
}
return out;
}
export const bundleChecksum = (files) =>
"sha256:" + crypto.createHash("sha256").update(JSON.stringify(files)).digest("hex");
export function exportBundle(slug, { withJournal = false } = {}) {
if (!personaExists(slug)) throw new Error(`人格 \`${slug}\` 不存在。`);
const root = personaDir(slug);
const files = {};
const skipped = [];
for (const rel of walkFiles(root)) {
if (BUNDLE_SKIP.has(rel) || /(^|\/)\.|\.tmp\d*$/.test(rel)) {
skipped.push(rel);
continue;
}
if (!withJournal && rel.startsWith("journal/")) {
skipped.push(rel);
continue;
}
let buf;
try {
buf = fs.readFileSync(path.join(root, rel));
} catch {
skipped.push(rel);
continue;
}
if (buf.length > MAX_BUNDLE_FILE) {
skipped.push(rel);
continue;
}
const text = buf.toString("utf8");
const isText = Buffer.compare(Buffer.from(text, "utf8"), buf) === 0;
files[rel] = isText ? { encoding: "utf8", content: text } : { encoding: "base64", content: buf.toString("base64") };
}
const bundle = {
format: BUNDLE_FORMAT,
version: BUNDLE_VERSION,
persona: slug,
exported_at: nowIso(),
identity: identityFields(slug),
stats: {
files: Object.keys(files).length,
long_term: longTermEntries(slug).length,
short_term: readJsonl(shortTermPath(slug)).length,
relations: loadRelations(slug).nodes.length,
said: readJsonl(saidPath(slug)).length,
inner: readJsonl(innerPath(slug)).length,
with_journal: Boolean(withJournal),
},
files,
};
bundle.checksum = bundleChecksum(files);
return { bundle, skipped };
}
/** bundle 內的相對路徑必須乖乖待在人格目錄裡(防 `../` 逃逸與絕對路徑)。 */
export function safeBundlePath(rel) {
const value = String(rel ?? "");
if (!value || path.isAbsolute(value) || value.includes("\\")) return null;
const parts = value.split("/");
if (parts.some((p) => !p || p === "." || p === "..")) return null;
return parts.join(path.sep);
}
export function validateBundle(bundle) {
const problems = [];
if (!bundle || typeof bundle !== "object") problems.push("不是合法的 JSON 物件");
else {
if (bundle.format !== BUNDLE_FORMAT) problems.push(`format 必須是 ${BUNDLE_FORMAT}(實際:${bundle.format}`);
if (Number(bundle.version) > BUNDLE_VERSION) problems.push(`bundle 版本 ${bundle.version} 比本版 (${BUNDLE_VERSION}) 新`);
if (!bundle.files || typeof bundle.files !== "object") problems.push("缺少 files");
else if (!bundle.files["IDENTITY.md"]) problems.push("缺少 IDENTITY.md(人格的最低要件)");
}
const checksumOk = !bundle?.checksum || bundle.checksum === bundleChecksum(bundle.files || {});
return { ok: problems.length === 0, problems, checksumOk };
}
export function importBundle(bundle, targetSlug, { session = null } = {}) {
const slug = targetSlug || bundle.persona;
if (!validSlug(slug)) throw new Error(`slug \`${slug}\` 不合法(小寫英數與連字號,最長 48 字)。`);
const { ok, problems } = validateBundle(bundle);
if (!ok) throw new Error(`bundle 不合法:${problems.join("")}`);
const root = ensurePersonaDirs(slug);
const written = [];
const rejected = [];
for (const [rel, entry] of Object.entries(bundle.files)) {
if (BUNDLE_SKIP.has(rel)) continue;
const safe = safeBundlePath(rel);
if (!safe) {
rejected.push(rel);
continue;
}
const target = path.join(root, safe);
const buf =
entry?.encoding === "base64"
? Buffer.from(String(entry.content || ""), "base64")
: Buffer.from(String(entry?.content ?? ""), "utf8");
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, buf);
written.push(safe);
}
// 換名匯入時,config 要跟著改名,並留下來歷
const config = readJson(configPath(slug), {}) ?? {};
config.persona = slug;
config.imported_at = nowIso();
config.imported_from = { persona: bundle.persona, exported_at: bundle.exported_at || null };
if (session) config.imported_by_session = session;
writeJson(configPath(slug), config);
rebuildIndex(slug);
try {
renderRelations(slug);
} catch {
/* 沒有關係圖就算了 */
}
return { persona: slug, written, rejected };
}
// --------------------------------------------------------------------------- //
// guard:跨人格隔離 + 鎖驗證的判斷核心
// --------------------------------------------------------------------------- //
@@ -1036,9 +1346,13 @@ const PATH_TOOL_FIELDS = {
LS: ["path"],
};
export const GUEST_SAFE_SUBCOMMANDS = new Set(["show", "status", "list", "recall", "room", "remember", "leave", "brief"]);
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢),不算跨人格讀取
export const OWNER_EXEMPT_SUBCOMMANDS = new Set(["create", "list", "status", "gc", "invite", "load", "leave"]);
export const GUEST_SAFE_SUBCOMMANDS = new Set([
"show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said",
]);
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格),不算跨人格讀取
export const OWNER_EXEMPT_SUBCOMMANDS = new Set([
"create", "list", "status", "gc", "invite", "load", "leave", "import",
]);
const MUTATING_SHELL =
/(>>?|\|\s*tee\b|\brm\b|\bmv\b|\bcp\b|\btruncate\b|\bdd\b|\bchmod\b|\bchown\b|\bsed\b[^|;]*-i|\btouch\b|\bmkdir\b|\bln\b)/;
@@ -1333,7 +1647,24 @@ export function turnContext(slug, sessionId, prompt = "") {
`人格:\`${slug}\` ${identityBrief(slug)}`,
`人格倉庫:${personaDir(slug)}(唯一可讀寫的人格資料範圍)`,
emotionBrief(slug, state),
`說話規則:回使用者 ${MAX_SENTENCES} 句以內(劇場模式每人每輪也一樣);` +
"推導、比對、盤算一律走心裡話(`persona.mjs think`),不要說給使用者聽——" +
"要讓他知道你在想,就只報「心想 N 句」,不報內容。",
];
const inner = recentInner(slug, 3);
if (inner.length) {
lines.push(`心裡話(只有自己知道;近 ${INNER_WINDOW_MINUTES / 60} 小時心想 ${innerCount(slug)} 句):`);
for (const row of inner) {
lines.push(` - 💭 ${String(row.text || "").replace(/\n/g, " ").slice(0, 90)}`);
}
}
const said = recentSaid(slug, 5);
if (said.length) {
lines.push(`最近說過的話(${REPEAT_WINDOW_MINUTES} 分鐘內不要再說一次,要嘛換角度、要嘛推進話題):`);
for (const row of said) {
lines.push(` - ${String(row.text || "").replace(/\n/g, " ").slice(0, 80)}`);
}
}
const recents = recentShort(slug, 6);
if (recents.length) {
lines.push("短期記憶(最近):");
@@ -1361,6 +1692,8 @@ export function turnContext(slug, sessionId, prompt = "") {
`🎭 多人聊天模式(劇場)進行中:聊天室 ${JSON.stringify(rooms)}`,
" 對使用者的輸出**只能有人格對話**(每行 `emoji 名字(情緒):內容`):",
" 不得出現指令、指令輸出、狀態說明、進度、摘要、分析或旁白;所有 CLI 一律加 `--quiet` 並把輸出丟掉。",
` 每個人格每輪 ${MAX_SENTENCES} 句以內;推導走 \`think\`(心裡話);`,
" 近似重複的台詞 `room post` 會直接擋下,換個說法或推進話題,不要硬講同一句。",
" 想結束請等使用者說,或由使用者說「結束對話」後才做收尾與摘要。",
);
} else {