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:
+336
-3
@@ -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);
|
||||
}
|
||||
|
||||
/** 在 entries(need `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 {
|
||||
|
||||
Reference in New Issue
Block a user