feat: 預設人格自動載入 + 睡眠(sleep 指令與 persona-sleeper sub agent)
兩件事,共用「關係圖 last_contact_at」這個零件,所以放同一個 commit (兩者在 persona-lib/persona.mjs/README 裡的 hunk 是交錯的,硬拆會拆壞)。 ## 預設人格(開新 session 自動載入) - 新增 `default` 子指令:查詢/`--persona <slug>` 設定/`--clear` 取消。 - 設定存在 `<PERSONA_HOME>/.runtime/settings.json` 的 `default_persona`, 環境變數 `PERSONA_DEFAULT` 優先(`off` 可臨時關掉)。 - `SessionStart` hook 四條路徑:沒設定 → 維持原本「等使用者指定」;設定了 → 取鎖、綁 host、 prune、reindex 並注入人格狀態;拿不到鎖 → 只回報 owner 與心跳,不自作主張 takeover; 人格不存在 → 警告並改列可用人格。 - 「不替使用者挑一個人格附身」仍然是預設行為,這只是讓他能明示地推翻它。 ## 睡眠 - 新增 `sleep` 子指令(機械性收尾,順序即相依):關係時間戳 → 裁短期記憶 → 收起太久沒動的思維導圖 → 套用一次 8 小時的情緒衰減 → 重建索引 → 修剪 said → 壓縮舊 journal → 寫 state/sleep.json → Gitea 兩區 push 並驗證。 預設保留載入鎖(`--release` 才收工),每一步各自 try/catch,一步壞掉不放棄整場睡眠。 - 需要判斷的部分(固化什麼、忘掉什麼、日記寫什麼)留給人格自己,由 skill 驅動。 - 新增 `persona-sleeper` sub agent 型別:**那個人格本人在睡**。對自己可寫但被 pin 住 (連叫它來的主人格都不能碰)、只准跑 15 個收尾子指令、不得用 Write/Edit 或 shell 改檔案。 - 新增 sleeper 租約(`state/sleepers.json`,300 秒):沒活鎖就取得、同 session 直接睡、 死鎖可接手、**別的程序活鎖住則拒絕**(硬睡會讓兩邊的記憶互相覆蓋)。 - `--json` 回傳刻意很窮:只有 persona/ok/slept_at/steps/sync/kept_lock。 sub agent 的回傳值會進主人格的上下文,是一條會從正門繞過跨人格隔離的通道, 所以在 CLI 這一層封死,不靠提示詞自律。 - 關係圖新增 `last_contact_at`(`relation node --contact`/睡眠自動蓋), 並用 `staleContacts()` 算出「很久沒接觸又很親近的人」——人格主動提議去關心誰的依據。 輕量邀請用既有的 `invite --theater off`(不切走畫面),但要先問使用者一句。 - 新增 skill `persona-sleep`;`SubagentStop` 會還掉 sleeper 租約。 ## 驗證 `node scripts/selftest.mjs` → 210 項全綠(新增第 ⑳ 節 12 項、第 ㉑ 節 23 項), 其中包含「回傳值不含任何記憶內容」與 sleeper 的六條權限邊界。 版本號 0.0.5。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+335
-5
@@ -12,6 +12,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 路徑
|
||||
@@ -21,6 +22,8 @@ export const RUNTIME_DIRNAME = ".runtime";
|
||||
export const ROOMS_DIRNAME = ".rooms";
|
||||
export const LEASE_SECONDS = 900; // 15 分鐘沒有 heartbeat 視為死鎖,可被接手
|
||||
export const GUEST_LEASE_SECONDS = 1800; // guest(sub agent)租約
|
||||
export const SLEEPER_LEASE_SECONDS = 300; // sleeper(睡眠 sub agent)租約:只夠做完收尾
|
||||
export const SLEEP_DECAY_MINUTES = 480; // 睡一次=套用一次 8 小時的情緒衰減
|
||||
|
||||
function expandUser(p) {
|
||||
if (p === "~") return os.homedir();
|
||||
@@ -88,6 +91,40 @@ export function personaExists(slug) {
|
||||
return validSlug(slug) && fs.existsSync(path.join(personaDir(slug), "IDENTITY.md"));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 全域設定(跨人格、跨 session)
|
||||
// --------------------------------------------------------------------------- //
|
||||
//
|
||||
// 目前只有一個鍵:`default_persona`——開新 session 時要不要自動載入某個人格。
|
||||
// 預設是**沒有設定**(等使用者指定),這是刻意的:不該替使用者挑一個人格附身。
|
||||
|
||||
export const homeSettingsPath = () => path.join(runtimeDir(), "settings.json");
|
||||
|
||||
export function loadHomeSettings() {
|
||||
return readJson(homeSettingsPath(), {}) ?? {};
|
||||
}
|
||||
|
||||
export function saveHomeSettings(patch) {
|
||||
const data = { ...loadHomeSettings(), ...patch, updated_at: nowIso() };
|
||||
writeJson(homeSettingsPath(), data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 要自動載入的人格;沒設定就回 null(呼叫端應該「什麼都不做」)。
|
||||
* 環境變數 `PERSONA_DEFAULT` 優先於設定檔,`off`/`none`/空字串代表關閉。
|
||||
*/
|
||||
export function defaultPersona() {
|
||||
const env = String(process.env.PERSONA_DEFAULT ?? "").trim();
|
||||
const raw = env || String(loadHomeSettings().default_persona ?? "").trim();
|
||||
if (!raw || ["off", "none", "false", "0"].includes(raw.toLowerCase())) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function setDefaultPersona(slug) {
|
||||
return saveHomeSettings({ default_persona: slug || null });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 時間與檔案 IO
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -245,6 +282,27 @@ export function decayEmotion(state, now = new Date()) {
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 明確套用「經過 N 分鐘」的衰減(睡眠用)。
|
||||
*
|
||||
* 跟 `decayEmotion` 不同:那個看的是距離上次更新過了多久(真實時間),
|
||||
* 這個是「就算你才剛聊完,也讓情緒像過了一夜」。強度大的負向情緒半衰期本來就長,
|
||||
* 所以睡一覺不會把羞愧與悲傷抹平——這是刻意的。
|
||||
*/
|
||||
export function decayEmotionBy(state, minutes = SLEEP_DECAY_MINUTES) {
|
||||
const mins = Math.max(0, Number(minutes) || 0);
|
||||
if (!mins) return state;
|
||||
for (const key of EMOTION_KEYS) {
|
||||
const half = Number(state.half_life_minutes[key] ?? EMOTIONS[key].halfLife);
|
||||
const base = Number(state.baseline[key] ?? DEFAULT_BASELINE[key]);
|
||||
const level = Number(state.levels[key] ?? base);
|
||||
const factor = half > 0 ? Math.pow(0.5, mins / half) : 0;
|
||||
state.levels[key] = Math.round((base + (level - base) * factor) * 100) / 100;
|
||||
}
|
||||
state.updated_at = nowIso();
|
||||
return state;
|
||||
}
|
||||
|
||||
export function applyEmotion(state, deltas, trigger = "") {
|
||||
state = decayEmotion(state);
|
||||
const applied = {};
|
||||
@@ -473,6 +531,163 @@ export function dropGuestLease(slug, sessionId, room = null) {
|
||||
writeJson(file, data);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// sleeper 租約(睡眠用的短期寫入權)
|
||||
// --------------------------------------------------------------------------- //
|
||||
//
|
||||
// 為什麼不用 exclusive 鎖:主人格叫別人去睡的時候,自己並沒有 release,
|
||||
// 而「一個 session 只能載入一個 host 人格」——所以睡眠需要另一種、更短的寫入權。
|
||||
//
|
||||
// 為什麼還是要驗鎖:睡眠會 prune/reindex/push。如果目標人格此刻正被另一個程序
|
||||
// 載入著對話,兩邊會同時改同一批檔案,記憶會互相覆蓋。所以:
|
||||
// * 沒有活著的鎖 → 直接取得 sleeper 租約(最常見)
|
||||
// * 鎖屬於同一個 session → 可以(自己睡自己)
|
||||
// * 鎖是死的(心跳過期)→ 可以接手(睡眠本來就是收尾動作)
|
||||
// * 鎖活著且屬於別人 → 拒絕,回報 owner 讓使用者決定
|
||||
|
||||
export const sleepersPath = (slug) => path.join(personaDir(slug), "state", "sleepers.json");
|
||||
|
||||
export function liveSleepers(slug) {
|
||||
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||||
return (data.sleepers || []).filter((s) => ageSeconds(s.heartbeat_at) <= SLEEPER_LEASE_SECONDS);
|
||||
}
|
||||
|
||||
/** 取得睡眠寫入權;拿不到就丟 LockError(附 owner 資訊)。 */
|
||||
export function acquireSleepLease(slug, sessionId, { agentId = null } = {}) {
|
||||
ensurePersonaDirs(slug);
|
||||
const lock = readJson(lockPath(slug)) ?? {};
|
||||
const held = Boolean(Object.keys(lock).length);
|
||||
if (held && lock.session_id !== sessionId && !lockIsDead(lock)) {
|
||||
throw new LockError(
|
||||
`人格 \`${slug}\` 正被另一個程序載入(session ${String(lock.session_id).slice(0, 8)}…,` +
|
||||
`cwd ${lock.cwd},最後心跳 ${lock.heartbeat_at})。它可能正在對話中,` +
|
||||
"現在睡眠會與它的寫入互相覆蓋,所以不做。請等它結束或請使用者決定。",
|
||||
{ session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at },
|
||||
);
|
||||
}
|
||||
const others = liveSleepers(slug).filter((s) => !(s.session_id === sessionId && s.agent_id === agentId));
|
||||
if (others.length) {
|
||||
throw new LockError(`人格 \`${slug}\` 已經有另一個 sleeper 在收尾(session ${String(others[0].session_id).slice(0, 8)}…)。`, others[0]);
|
||||
}
|
||||
const lease = {
|
||||
session_id: sessionId,
|
||||
agent_id: agentId,
|
||||
started_at: nowIso(),
|
||||
heartbeat_at: nowIso(),
|
||||
lease_seconds: SLEEPER_LEASE_SECONDS,
|
||||
took_over_dead_lock: held && lockIsDead(lock),
|
||||
};
|
||||
writeJson(sleepersPath(slug), { sleepers: [lease] });
|
||||
return lease;
|
||||
}
|
||||
|
||||
export function dropSleepLease(slug, sessionId, agentId = null) {
|
||||
const data = readJson(sleepersPath(slug), {}) ?? {};
|
||||
data.sleepers = (data.sleepers || []).filter(
|
||||
(s) => !(s.session_id === sessionId && (agentId === null || s.agent_id === agentId)),
|
||||
);
|
||||
writeJson(sleepersPath(slug), data);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 睡眠會做的機械性收尾
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const THREAD_STALE_DAYS = 7;
|
||||
export const SAID_KEEP_HOURS = 24;
|
||||
export const SAID_KEEP_LINES = 300;
|
||||
|
||||
/** 太久沒動的思維導圖收進 `mindmap/threads/archive/`(不刪,只是收起來)。 */
|
||||
export function archiveStaleThreads(slug, days = THREAD_STALE_DAYS) {
|
||||
const dir = path.join(personaDir(slug), "mindmap", "threads");
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(dir).filter((f) => f.endsWith(".mmd"));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const cutoff = Date.now() - days * 86_400_000;
|
||||
const archive = path.join(dir, "archive");
|
||||
let moved = 0;
|
||||
for (const name of names) {
|
||||
const file = path.join(dir, name);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(file);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.mtimeMs >= cutoff) continue;
|
||||
fs.mkdirSync(archive, { recursive: true });
|
||||
fs.renameSync(file, path.join(archive, name));
|
||||
moved += 1;
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** `said.jsonl` 只服務「不要重講」判定,留最近 24 小時/300 行就夠。 */
|
||||
export function trimSaid(slug, { hours = SAID_KEEP_HOURS, lines = SAID_KEEP_LINES } = {}) {
|
||||
const file = saidPath(slug);
|
||||
const rows = readJsonl(file);
|
||||
if (!rows.length) return 0;
|
||||
const cutoff = Date.now() - hours * 3_600_000;
|
||||
let kept = rows.filter((r) => (parseIso(r.ts)?.getTime() ?? Date.now()) >= cutoff);
|
||||
kept = kept.slice(-lines);
|
||||
if (kept.length !== rows.length) {
|
||||
writeText(file, kept.map((r) => JSON.stringify(r)).join("\n") + (kept.length ? "\n" : ""));
|
||||
}
|
||||
return kept.length;
|
||||
}
|
||||
|
||||
/** 把「上個月以前」的 journal 壓成 .jsonl.gz(同步時省流量,也不再被讀)。 */
|
||||
export function archiveJournals(slug) {
|
||||
const dir = path.join(personaDir(slug), "journal");
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(dir).filter((f) => /^\d{4}-\d{2}\.jsonl$/.test(f));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const current = path.basename(journalPath(slug));
|
||||
let gzipped = 0;
|
||||
for (const name of names) {
|
||||
if (name === current) continue;
|
||||
const file = path.join(dir, name);
|
||||
const target = `${file}.gz`;
|
||||
if (fs.existsSync(target)) continue;
|
||||
fs.writeFileSync(target, zlib.gzipSync(fs.readFileSync(file)));
|
||||
fs.rmSync(file);
|
||||
gzipped += 1;
|
||||
}
|
||||
return gzipped;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 睡眠狀態
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const sleepStatePath = (slug) => path.join(personaDir(slug), "state", "sleep.json");
|
||||
|
||||
export function loadSleepState(slug) {
|
||||
const data = readJson(sleepStatePath(slug), {}) ?? {};
|
||||
data.last_slept_at ??= null;
|
||||
data.count ??= 0;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function saveSleepState(slug, patch) {
|
||||
const data = { ...loadSleepState(slug), ...patch };
|
||||
writeJson(sleepStatePath(slug), data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 距離上次睡眠幾小時(沒睡過回 null)。 */
|
||||
export function hoursAwake(slug) {
|
||||
const at = loadSleepState(slug).last_slept_at;
|
||||
if (!at) return null;
|
||||
return Math.round((ageSeconds(at) / 3600) * 10) / 10;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// session 綁定:誰是 host、邀了哪些 guest、sub agent pin、劇場模式
|
||||
// --------------------------------------------------------------------------- //
|
||||
@@ -1055,6 +1270,43 @@ export function loadRelations(slug) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 蓋上「最後一次接觸」的時間戳;找不到那個人就回 false(不會憑空建節點)。 */
|
||||
export function stampContact(slug, nameOrId, at = nowIso()) {
|
||||
const data = loadRelations(slug);
|
||||
const key = slugify(String(nameOrId || ""));
|
||||
const node = data.nodes.find((n) => n.id === key || slugify(n.name || "") === key);
|
||||
if (!node) return false;
|
||||
node.last_contact_at = at;
|
||||
node.updated_at = nowIso();
|
||||
writeJson(relationsJson(slug), data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 很久沒聯絡、但關係很近的人——這是「主動想起某個人」的客觀依據。
|
||||
* 沒有 `last_contact_at` 的節點用 `created_at` 當起點;分數 = 親近度 × 沉默天數。
|
||||
*/
|
||||
export function staleContacts(slug, { days = 3, minCloseness = 60, limit = 3 } = {}) {
|
||||
const data = loadRelations(slug);
|
||||
const out = [];
|
||||
for (const node of data.nodes) {
|
||||
const closeness = Number(node.closeness ?? 0);
|
||||
if (closeness < minCloseness) continue;
|
||||
const since = node.last_contact_at || node.created_at || null;
|
||||
const silentDays = since ? Math.floor(ageSeconds(since) / 86_400) : null;
|
||||
if (silentDays === null || silentDays < days) continue;
|
||||
out.push({
|
||||
id: node.id,
|
||||
name: node.name || node.id,
|
||||
kind: node.kind || "human",
|
||||
closeness,
|
||||
silent_days: silentDays,
|
||||
score: closeness * silentDays,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||
}
|
||||
|
||||
export function upsertRelationNode(slug, node) {
|
||||
const data = loadRelations(slug);
|
||||
const nodeId = node.id || slugify(node.name || "");
|
||||
@@ -1351,9 +1603,16 @@ const PATH_TOOL_FIELDS = {
|
||||
export const GUEST_SAFE_SUBCOMMANDS = new Set([
|
||||
"show", "status", "list", "recall", "room", "remember", "leave", "brief", "think", "said",
|
||||
]);
|
||||
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格),不算跨人格讀取
|
||||
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格/設定預設人格),不算跨人格讀取
|
||||
export const OWNER_EXEMPT_SUBCOMMANDS = new Set([
|
||||
"create", "list", "status", "gc", "invite", "load", "leave", "import",
|
||||
"create", "list", "status", "gc", "invite", "load", "leave", "import", "default", "sleep",
|
||||
]);
|
||||
// sleeper(睡眠 sub agent)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
|
||||
// 不准 load/release(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、
|
||||
// 不准 export/import(那是把記憶搬出去)、不准 remember(收尾階段不再新增短期記憶)。
|
||||
export const SLEEPER_SAFE_SUBCOMMANDS = new Set([
|
||||
"sleep", "candidates", "consolidate", "prune", "reindex", "mindmap", "relation",
|
||||
"emotion", "recall", "show", "brief", "status", "said", "think", "sync",
|
||||
]);
|
||||
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)/;
|
||||
@@ -1448,12 +1707,31 @@ export function cliInvocation(command) {
|
||||
* * 主程序(無 agent_id)與一般 sub agent → host 人格,可讀寫。
|
||||
* * persona-guest 型 sub agent → 只能碰被邀請的 guest 人格,且唯讀;
|
||||
* 第一次觸碰哪個 guest 就 pin 住(first-touch pinning),之後不得換人。
|
||||
* * persona-sleeper 型 sub agent → **就是那個人格自己在睡**:對目標人格可寫,
|
||||
* 但被 pin 在它身上(連 host 都不能碰),而且只准跑收尾用的子指令。
|
||||
* 它回傳給主人格的只有「睡完了沒、哪一步出錯」,不含任何記憶內容。
|
||||
*/
|
||||
export function resolveScope(sessionId, agentId, agentType) {
|
||||
const data = loadSession(sessionId);
|
||||
const host = data.host;
|
||||
const guests = Object.keys(data.guests || {});
|
||||
const isGuestAgent = Boolean(agentType) && String(agentType).includes("persona-guest");
|
||||
const type = String(agentType || "");
|
||||
const isGuestAgent = Boolean(agentType) && type.includes("persona-guest");
|
||||
const isSleeperAgent = Boolean(agentType) && type.includes("persona-sleeper");
|
||||
if (isSleeperAgent) {
|
||||
const pinned = (data.pins || {})[agentId || ""];
|
||||
return {
|
||||
role: "sleeper",
|
||||
// 還沒 pin:允許 first-touch(碰到誰就定誰);pin 之後不得換人
|
||||
allowed: pinned ? [pinned] : null,
|
||||
readonly: false,
|
||||
host,
|
||||
guests,
|
||||
pinned,
|
||||
rooms: [],
|
||||
session: data,
|
||||
};
|
||||
}
|
||||
if (!isGuestAgent) {
|
||||
return {
|
||||
role: "owner",
|
||||
@@ -1510,6 +1788,23 @@ export function guardDecide(event) {
|
||||
);
|
||||
}
|
||||
const sub = info.subcommand || "";
|
||||
if (scope.role === "sleeper") {
|
||||
if (!SLEEPER_SAFE_SUBCOMMANDS.has(sub)) {
|
||||
return deny(
|
||||
`睡眠 sub agent 只能執行 ${[...SLEEPER_SAFE_SUBCOMMANDS].sort().join("/")},不得執行 \`${sub}\`。` +
|
||||
"它的任務是把自己的一天收尾,不是聊天、載入或搬移記憶。",
|
||||
);
|
||||
}
|
||||
for (const slug of info.personas) {
|
||||
if (scope.allowed && !scope.allowed.includes(slug)) {
|
||||
return deny(
|
||||
`這個睡眠 sub agent 已經被綁在 \`${scope.allowed[0]}\`,不得再碰 \`${slug}\`(一次只睡一個人格)。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// first-touch pinning:第一個提到的人格就是它要睡的那個,之後不得換人
|
||||
if (!scope.allowed && agentId && info.personas.length) pinAgent(sessionId, agentId, info.personas[0]);
|
||||
}
|
||||
if (scope.role === "guest") {
|
||||
if (!GUEST_SAFE_SUBCOMMANDS.has(sub)) {
|
||||
return deny(
|
||||
@@ -1538,10 +1833,14 @@ export function guardDecide(event) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (MUTATING_SHELL.test(command) && scope.role === "guest") {
|
||||
if (MUTATING_SHELL.test(command) && (scope.role === "guest" || scope.role === "sleeper")) {
|
||||
for (const target of extractPaths(tool, toolInput, cwd)) {
|
||||
if (personaSlugOf(target)) {
|
||||
return deny("guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。");
|
||||
return deny(
|
||||
scope.role === "sleeper"
|
||||
? "睡眠 sub agent 不得用 shell 改人格倉庫的檔案,收尾請走 persona CLI 的子指令。"
|
||||
: "guest 人格對人格倉庫唯讀,寫入請透過 `persona.mjs room post` 或 `remember --scope inbox`。",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1567,6 +1866,23 @@ export function guardDecide(event) {
|
||||
return deny("`.runtime/` 是鎖與綁定的內部狀態,只能由 persona CLI 維護。");
|
||||
}
|
||||
if (!slug) continue;
|
||||
if (scope.role === "sleeper") {
|
||||
// 睡眠 sub agent:被 pin 在目標人格(連 host 都不能碰),而且只能讀;
|
||||
// 所有寫入都要走 CLI 的白名單子指令,不准自己動手改檔案。
|
||||
if (scope.allowed && !scope.allowed.includes(slug)) {
|
||||
return deny(
|
||||
`這個睡眠 sub agent 只能碰 \`${scope.allowed[0]}\`,不得讀寫 \`${slug}\` 的資料(跨人格資料隔離)。`,
|
||||
);
|
||||
}
|
||||
if (!scope.allowed && agentId) pinAgent(sessionId, agentId, slug);
|
||||
if (MUTATING_TOOLS.has(tool)) {
|
||||
return deny(
|
||||
`睡眠 sub agent 不得直接改檔案(\`${slug}\`)。收尾的每一步都要走 persona CLI,` +
|
||||
"這樣才會經過鎖、索引與同步的處理。",
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!scope.allowed.length) {
|
||||
return deny(
|
||||
"尚未載入任何人格。請先執行 `persona.mjs load --persona <slug> --session <session_id>`(或 /jsc-persona:persona-chat)。",
|
||||
@@ -1689,6 +2005,20 @@ export function turnContext(slug, sessionId, prompt = "") {
|
||||
const rel = relationsBrief(slug, prompt ? keywords(prompt, 6) : null);
|
||||
if (rel) lines.push(`人際關係:${rel}`);
|
||||
|
||||
// 很久沒聯絡但很親近的人 → 這是「主動提議去關心某人」的依據(不是每輪都要提)
|
||||
const stale = staleContacts(slug);
|
||||
if (stale.length) {
|
||||
lines.push(
|
||||
`很久沒接觸的人:${stale.map((s) => `${s.name}(親近 ${s.closeness}/沉默 ${s.silent_days} 天)`).join("、")}`,
|
||||
" 想去看看誰是可以的:對人格用 /jsc-persona:persona-invite(`invite --theater off` 只換一輪、不進劇場)," +
|
||||
"但**先問使用者一句**再邀,不要自己把畫面切走。",
|
||||
);
|
||||
}
|
||||
const awake = hoursAwake(slug);
|
||||
if (awake !== null && awake >= 16) {
|
||||
lines.push(`距離上次睡眠已經 ${awake} 小時(短期記憶會越積越多)→ 可以提議 /jsc-persona:persona-sleep。`);
|
||||
}
|
||||
|
||||
if (session.theater && (session.rooms || []).length) {
|
||||
const rooms = session.rooms;
|
||||
lines.push(
|
||||
|
||||
Reference in New Issue
Block a user