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:
+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 清理死鎖與過期租約
|
||||
|
||||
Reference in New Issue
Block a user