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 {
|
||||
|
||||
+217
-8
@@ -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("guest(sub 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 清理死鎖與過期租約
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -262,6 +263,149 @@ const takeover = cli(["load", "--persona", "alpha", "--session", "sess-fresh-999
|
||||
check("死鎖可自動接手並回報", takeover.stdout.includes("接手"), takeover.stdout.slice(0, 200));
|
||||
pl.gcRuntime();
|
||||
|
||||
console.log("⑪ 說話節制:心裡話 / 不重複 / 一到三句");
|
||||
const S_SPEAK = "sess-fresh-9999"; // ⑩ 接手 alpha 的那個 session
|
||||
check("句數以句末標點計算", pl.sentenceCount("好。我想想。") === 2 && pl.sentenceCount("嗯") === 1);
|
||||
check("劇場前綴不影響比對",
|
||||
pl.normalizeSpeech("🪼 Alpha(喜悅42/期待31):今天的風很輕") === pl.normalizeSpeech("今天的風很輕。"));
|
||||
// 重複判定的校準對照表:改 similarity() 或 REPEAT_THRESHOLD 時,這張表必須維持全綠
|
||||
const REPEAT_CASES = [
|
||||
[true, "我等一下把報告寄給你", "等一下我會把報告寄給你"], // 語序重排=同一件事
|
||||
[true, "你這次的鐘修得比上次穩", "這次的鐘你修得比上次穩"],
|
||||
[true, "那你先忙,我在這裡等你。", "我在這裡等你,你先忙吧。"],
|
||||
[false, "你今天看起來很累", "你今天看起來很開心"], // 換關鍵詞=新資訊
|
||||
[false, "報告我明天寄", "報告我後天寄"],
|
||||
[false, "那台舊鐘修好了嗎", "那台舊鐘賣掉了嗎"],
|
||||
[false, "早上的會議又被排進來了", "我幫你把會議挪到下午三點"],
|
||||
[false, "我等一下把報告寄給你", "今天午餐想吃什麼"],
|
||||
[false, "我不同意這個做法", "我不同意,因為它會讓值班的人扛下全部風險"],
|
||||
];
|
||||
const misjudged = REPEAT_CASES.filter(([want, a, b]) => (pl.similarity(a, b) >= pl.REPEAT_THRESHOLD) !== want);
|
||||
check("重複判定校準:換句話說同一件事會擋、只換關鍵詞放行",
|
||||
misjudged.length === 0,
|
||||
misjudged.map(([, a, b]) => `${a}/${b}=${pl.similarity(a, b)}`).join(" | "));
|
||||
const think = cli(["think", "--session", S_SPEAK, "--kind", "infer",
|
||||
"--text", "他今天語氣比昨天急,deadline 可能提前了"]);
|
||||
check("think 只回報「心想 N 句」,不回顯內容",
|
||||
think.stdout.includes("心想") && !think.stdout.includes("deadline"), JSON.stringify(think.stdout));
|
||||
check("心裡話寫進 inner.jsonl", pl.readJsonl(pl.innerPath("alpha")).length === 1);
|
||||
check("心裡話不會混進短期記憶",
|
||||
!pl.readJsonl(pl.shortTermPath("alpha")).some((r) => String(r.text || "").includes("deadline 可能提前")));
|
||||
cli(["said", "check", "--session", S_SPEAK, "--text", "我等一下把報告寄給你,別擔心。", "--record"]);
|
||||
const dup = cli(["said", "check", "--session", S_SPEAK, "--json",
|
||||
"--text", "等一下我會把報告寄給你,別擔心。"]);
|
||||
check("said check 抓到短時間內的重複", (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(dup.stdout);
|
||||
return parsed.ok === false && parsed.repeat && parsed.repeat.similarity >= pl.REPEAT_THRESHOLD;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(), dup.stdout.slice(0, 160));
|
||||
const longSay = cli(["said", "check", "--session", S_SPEAK, "--json",
|
||||
"--text", "第一句在這。第二句在這。第三句在這。第四句在這。"]);
|
||||
check("said check 抓到超過三句", (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(longSay.stdout);
|
||||
return parsed.sentences === 4 && parsed.ok === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(), longSay.stdout.slice(0, 160));
|
||||
const ctxSpeak = pl.turnContext("alpha", S_SPEAK, "報告");
|
||||
check("turnContext 注入說話規則 / 心裡話 / 最近說過",
|
||||
ctxSpeak.includes("說話規則") && ctxSpeak.includes("💭") && ctxSpeak.includes("最近說過的話"),
|
||||
ctxSpeak.slice(0, 400));
|
||||
cli(["invite", "--session", S_SPEAK, "--guest", "beta", "--topic", "節制測試"]);
|
||||
const room2 = pl.loadSession(S_SPEAK).guests?.beta?.room;
|
||||
check("劇場模式的規則也注入了(每輪三句、重複會被擋)", (() => {
|
||||
const ctx = pl.turnContext("alpha", S_SPEAK, "繼續");
|
||||
return ctx.includes("句以內") && ctx.includes("重複");
|
||||
})());
|
||||
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha", "--text", "你這次的鐘修得比上次穩。"]);
|
||||
const repeatPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||
"--text", "這次的鐘你修得比上次穩。"], { expectOk: false });
|
||||
check("room post 擋下近似重複的台詞",
|
||||
repeatPost.status !== 0 && repeatPost.stderr.includes("說過"), repeatPost.stderr.slice(0, 160));
|
||||
check("--allow-repeat 可以放行",
|
||||
cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||
"--text", "這次的鐘你修得比上次穩。", "--allow-repeat"]).status === 0);
|
||||
const longPost = cli(["room", "post", "--session", S_SPEAK, "--room", room2, "--as", "alpha",
|
||||
"--text", "第一句在這。第二句在這。第三句在這。第四句在這。"], { expectOk: false });
|
||||
check("room post 擋下超過三句的發言",
|
||||
longPost.status !== 0 && longPost.stderr.includes("超過"), longPost.stderr.slice(0, 160));
|
||||
check("說出口的話有進 said.jsonl",
|
||||
pl.readJsonl(pl.saidPath("alpha")).some((r) => String(r.text).includes("鐘修得比上次穩")));
|
||||
hook("turn_end.mjs", { session_id: S_SPEAK, last_assistant_message: "🪼 Alpha(平靜50):那就這樣說定了。" });
|
||||
check("Stop hook 會把說出口的話記進 said(去掉名字前綴)",
|
||||
pl.readJsonl(pl.saidPath("alpha")).some((r) => r.text === "那就這樣說定了。"),
|
||||
JSON.stringify(pl.recentSaid("alpha", 2)));
|
||||
const guestThink = cli(["think", "--persona", "beta", "--session", S_SPEAK, "--as-guest",
|
||||
"--room", room2, "--text", "alpha 好像在試探我,先不要接這句"]);
|
||||
check("guest 的心裡話只回報數量",
|
||||
guestThink.status === 0 && guestThink.stdout.includes("心想") && !guestThink.stdout.includes("試探"),
|
||||
JSON.stringify(guestThink.stdout));
|
||||
check("guest 的心裡話進自己的 inbox,不動自己的狀態",
|
||||
pl.readJsonl(pl.inboxPath("beta", room2)).some((r) => r.role === "inner") &&
|
||||
pl.readJsonl(pl.innerPath("beta")).length === 0);
|
||||
check("guard:guest 可以 think / said(白名單)",
|
||||
["think", "said"].every((sub) =>
|
||||
guard({ session_id: S_SPEAK, agent_id: "guest-2", agent_type: "jsc-persona:persona-guest", tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs ${sub} --persona beta --session ${S_SPEAK} --as-guest --text x` } }) === "pass"));
|
||||
cli(["leave", "--session", S_SPEAK, "--guest", "beta"]);
|
||||
|
||||
console.log("⑫ 匯出 / 匯入");
|
||||
const OUT = fs.mkdtempSync(path.join(os.tmpdir(), "persona-bundle-"));
|
||||
const bundleFile = path.join(OUT, "alpha.persona.json");
|
||||
cli(["export", "--session", S_SPEAK, "--out", bundleFile]);
|
||||
check("匯出產生 bundle 檔", fs.existsSync(bundleFile));
|
||||
const bundle = JSON.parse(fs.readFileSync(bundleFile, "utf8"));
|
||||
check("bundle 格式與 checksum 正確",
|
||||
bundle.format === pl.BUNDLE_FORMAT && pl.validateBundle(bundle).ok && pl.validateBundle(bundle).checksumOk);
|
||||
check("bundle 帶走身分 / 記憶 / 心智圖 / 關係圖",
|
||||
["IDENTITY.md", "SOUL.md", "state/emotion.json", "memory/long-term/hates-morning-meetings.md",
|
||||
"memory/INDEX.md", "mindmap/semantic.mmd", "relations/graph.json"].every((f) => f in bundle.files));
|
||||
check("bundle 不含載入鎖與 guest 租約",
|
||||
!("state/lock.json" in bundle.files) && !("state/guests.json" in bundle.files));
|
||||
check("bundle 預設不含 journal(隱私)",
|
||||
!Object.keys(bundle.files).some((f) => f.startsWith("journal/")));
|
||||
const gzFile = path.join(OUT, "alpha.persona.json.gz");
|
||||
cli(["export", "--session", S_SPEAK, "--out", gzFile, "--with-journal", "--gzip"]);
|
||||
const gzBundle = JSON.parse(zlib.gunzipSync(fs.readFileSync(gzFile)).toString("utf8"));
|
||||
check("--gzip 可解回同一份,--with-journal 會帶 journal",
|
||||
gzBundle.persona === "alpha" && gzBundle.stats.with_journal === true &&
|
||||
Object.keys(gzBundle.files).some((f) => f.startsWith("journal/")));
|
||||
check("匯入既有人格未加 --force 會被擋",
|
||||
cli(["import", "--session", S_SPEAK, "--file", bundleFile], { expectOk: false }).status !== 0);
|
||||
cli(["import", "--session", S_SPEAK, "--file", bundleFile, "--persona", "alpha-copy"]);
|
||||
check("換名匯入成功", pl.personaExists("alpha-copy"));
|
||||
check("匯入後長期記憶與 INDEX 都在",
|
||||
fs.existsSync(path.join(pl.longTermDir("alpha-copy"), "hates-morning-meetings.md")) &&
|
||||
fs.readFileSync(pl.indexPath("alpha-copy"), "utf8").includes("hates-morning-meetings"));
|
||||
check("匯入後身分與情緒都在",
|
||||
pl.identityBrief("alpha-copy").includes("Alpha") && pl.loadEmotion("alpha-copy").levels.joy > 0);
|
||||
check("匯入的人格不帶鎖(不會綁在原機器的程序上)", !pl.lockStatus("alpha-copy").locked);
|
||||
check("config 留下來歷", (pl.loadConfig("alpha-copy").imported_from || {}).persona === "alpha");
|
||||
const evil = JSON.parse(JSON.stringify(bundle));
|
||||
evil.files["../../../etc/persona-pwned"] = { encoding: "utf8", content: "x" };
|
||||
const evilResult = pl.importBundle(evil, "alpha-evil");
|
||||
check("bundle 內的 ../ 逃逸路徑會被拒絕",
|
||||
evilResult.rejected.includes("../../../etc/persona-pwned") && !fs.existsSync("/etc/persona-pwned"));
|
||||
const tampered = JSON.parse(JSON.stringify(bundle));
|
||||
tampered.files["SOUL.md"] = { encoding: "utf8", content: "被改過的靈魂" };
|
||||
const tamperedFile = path.join(OUT, "tampered.persona.json");
|
||||
fs.writeFileSync(tamperedFile, JSON.stringify(tampered), "utf8");
|
||||
check("checksum 不符會被擋(除非 --force)",
|
||||
cli(["import", "--session", S_SPEAK, "--file", tamperedFile, "--persona", "alpha-tampered"],
|
||||
{ expectOk: false }).status !== 0);
|
||||
check("guard:不得匯出別的人格(跨人格外洩)",
|
||||
guard({ session_id: S_SPEAK, tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs export --persona beta --session ${S_SPEAK} --out /tmp/b.json` } }) === "deny");
|
||||
check("guard:匯入新人格允許(只寫新目錄,不讀別人)",
|
||||
guard({ session_id: S_SPEAK, tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs import --persona gamma --session ${S_SPEAK} --file /tmp/b.json` } }) === "pass");
|
||||
fs.rmSync(OUT, { recursive: true, force: true });
|
||||
|
||||
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user