@@ -158,6 +158,28 @@ function requireMember(slug, sessionId, asGuest = false) {
const hostOf = ( flags , session ) => str ( flags . persona ) || pl . loadSession ( session ) . host ;
function parseGuestList ( flags ) {
const guests = [ ... csv ( flags . guest ) , ... csv ( flags . guests ) ] ;
const out = [ ] ;
const seen = new Set ( ) ;
for ( const guest of guests ) {
if ( ! guest || seen . has ( guest ) ) continue ;
seen . add ( guest ) ;
out . push ( guest ) ;
}
return out ;
}
function parseSleepTargets ( flags , fallbackHost = null ) {
const targets = [ ] ;
for ( const slug of [ ... csv ( flags . persona ) , ... csv ( flags . personas ) ] ) {
if ( ! slug || targets . includes ( slug ) ) continue ;
targets . push ( slug ) ;
}
if ( ! targets . length && fallbackHost ) targets . push ( fallbackHost ) ;
return targets ;
}
/**
* 里程碑事件(記憶固化、身分/關係變更)之後,把 Wiki 區推上去。
* 背景執行、失敗不阻斷:同步永遠不該卡住對話。
@@ -410,115 +432,150 @@ commands.status = ({ flags }) => {
commands . sleep = async ( { flags } ) => {
const session = requireSession ( flags ) ;
const data = pl . loadSession ( session ) ;
const slug = str ( flags . persona ) || data . host ;
if ( ! slug ) die ( "需要 `--persona <slug>`( 或先載入一個人格) 。" ) ;
if ( ! pl . personaExists ( slug ) ) die ( ` 人格 \` ${ slug } \` 不存在。 ` ) ;
// 寫入權:自己睡自己 → 已經有 exclusive 鎖;被叫來睡別人 → 取 sleeper 租約。
// 目標正被另一個程序載入著對話時會在這裡失敗,這是刻意的(避免記憶互相覆蓋)。
const selfSleep = data . host === slug ;
let lease = null ;
if ( ! selfSleep ) {
try {
lease = pl . acquireSleepLease ( slug , session , { agentId : str ( flags [ "agent-id" ] ) || null } ) ;
} catch ( err ) {
const payload = { persona : slug , ok : false , failed _step : "lease" , error : err . message } ;
if ( flags . json ) {
process . stdout . write ( ` ${ JSON . stringify ( payload , null , 2 ) } \n ` ) ;
process . exit ( 1 ) ;
}
die ( err . message ) ;
}
const targets = parseSleepTargets ( flags , data . host ) ;
if ( ! targets . length ) die ( "需要 `--persona <slug>`、`--personas <slug,slug>`, 或先載入一個人格。" ) ;
for ( const slug of targets ) {
if ( ! pl . personaExists ( slug ) ) die ( ` 人格 \` ${ slug } \` 不存在。 ` ) ;
}
const steps = [ ] ;
const run = async ( step , fn ) => {
try {
await fn ( ) ;
steps . push ( { step , ok : true } ) ;
} catch ( err ) {
// 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。
steps . push ( { step , ok : false , error : String ( err . message || err ) . slice ( 0 , 300 ) } ) ;
}
} ;
const hours = pl . hoursAwake ( slug ) ;
await run ( "relation-contact" , ( ) => {
// 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)
const seen = new Set ( ) ;
for ( const row of pl . readJsonl ( pl . shortTermPath ( slug ) , 200 ) ) {
for ( const name of row . entities || [ ] ) seen . add ( name ) ;
}
for ( const name of seen ) pl . stampContact ( slug , name ) ;
pl . renderRelations ( slug ) ;
} ) ;
await run ( "prune-short-term" , ( ) => pl . pruneShortTerm ( slug ) ) ;
await run ( "archive-threads" , ( ) => pl . archiveStaleThreads ( slug ) ) ;
await run ( "emotion-decay" , ( ) => {
const state = pl . decayEmotionBy ( pl . loadEmotion ( slug ) , pl . SLEEP _DECAY _MINUTES ) ;
pl . writeJson ( pl . emotionPath ( slug ) , state ) ;
} ) ;
await run ( "reindex" , ( ) => pl . rebuildIndex ( slug ) ) ;
await run ( "trim-said" , ( ) => pl . trimSaid ( slug ) ) ;
await run ( "archive-journal" , ( ) => pl . archiveJournals ( slug ) ) ;
await run ( "sleep-state" , ( ) => {
const prev = pl . loadSleepState ( slug ) ;
pl . saveSleepState ( slug , {
last _slept _at : pl . nowIso ( ) ,
count : Number ( prev . count || 0 ) + 1 ,
hours _awake : hours ,
} ) ;
} ) ;
// Gitea:兩區都推,推完驗證。沒設定或失敗都不算睡眠失敗(同步永遠不阻斷)。
const sync = { } ;
if ( flags [ "no-gitea" ] || gt . giteaProblem ( ) || ! gt . personaCode ( slug ) ) {
sync . skipped = gt . giteaProblem ( ) || ( gt . personaCode ( slug ) ? "使用者要求略過" : "尚無人格編號" ) ;
} else {
for ( const area of gt . AREA _KEYS ) {
const sleepOne = async ( slug , { release = false } = { } ) => {
// 寫入權:自己睡自己 → 已經有 exclusive 鎖;被叫來睡別人 → 取 sleeper 租約。
// 目標正被另一個程序載入著對話時會在這裡失敗,這是刻意的(避免記憶互相覆蓋)。
const sessionData = pl . loadSession ( session ) ;
const selfSleep = sessionData . host === slug ;
let lease = null ;
if ( ! selfSleep ) {
try {
const res = await gt . pushArea ( slug , area , { message : ` sleep: 收尾 ${ pl . nowIso ( ) } ` } ) ;
sync [ area ] = res . ok ? ( res . changed ? "pushed" : "no-change" ) : ` failed: ${ String ( res . reason ) . slice ( 0 , 80 ) } ` ;
lease = pl . acquireSleepLease ( slug , session , { agentId : str ( flags [ "agent-id" ] ) || null } ) ;
} catch ( err ) {
sync [ area ] = ` failed: ${ String ( err . message ) . slice ( 0 , 80 ) } ` ;
return { persona : slug , ok : false , failed _step : "lease" , error : err . message } ;
}
}
try {
const verdicts = [ ] ;
for ( const area of gt . AREA _KEYS ) verdicts . push ( await gt . verifyArea ( slug , area ) ) ;
sync . verified = verdicts . every ( ( v ) => v . ok !== false ) ;
} catch {
sync . verified = false ;
const steps = [ ] ;
const run = async ( step , fn ) => {
try {
await fn ( ) ;
steps . push ( { step , ok : true } ) ;
} catch ( err ) {
// 一步壞掉不放棄整個睡眠:記下來繼續走,最後在報告裡說明。
steps . push ( { step , ok : false , error : String ( err . message || err ) . slice ( 0 , 300 ) } ) ;
}
} ;
const hours = pl . hoursAwake ( slug ) ;
await run ( "relation-contact" , ( ) => {
// 今天提到過的人 → 蓋上「最後一次接觸」的時間戳(主動關心的依據)
const seen = new Set ( ) ;
for ( const row of pl . readJsonl ( pl . shortTermPath ( slug ) , 200 ) ) {
for ( const name of row . entities || [ ] ) seen . add ( name ) ;
}
for ( const name of seen ) pl . stampContact ( slug , name ) ;
pl . renderRelations ( slug ) ;
} ) ;
await run ( "prune-short-term" , ( ) => pl . pruneShortTerm ( slug ) ) ;
await run ( "archive-threads" , ( ) => pl . archiveStaleThreads ( slug ) ) ;
await run ( "emotion-decay" , ( ) => {
const state = pl . decayEmotionBy ( pl . loadEmotion ( slug ) , pl . SLEEP _DECAY _MINUTES ) ;
pl . writeJson ( pl . emotionPath ( slug ) , state ) ;
} ) ;
await run ( "reindex" , ( ) => pl . rebuildIndex ( slug ) ) ;
await run ( "trim-said" , ( ) => pl . trimSaid ( slug ) ) ;
await run ( "archive-journal" , ( ) => pl . archiveJournals ( slug ) ) ;
await run ( "sleep-state" , ( ) => {
const prev = pl . loadSleepState ( slug ) ;
pl . saveSleepState ( slug , {
last _slept _at : pl . nowIso ( ) ,
count : Number ( prev . count || 0 ) + 1 ,
hours _awake : hours ,
} ) ;
} ) ;
// Gitea:兩區都推,推完驗證。沒設定或失敗都不算睡眠失敗(同步永遠不阻斷)。
const sync = { } ;
if ( flags [ "no-gitea" ] || gt . giteaProblem ( ) || ! gt . personaCode ( slug ) ) {
sync . skipped = gt . giteaProblem ( ) || ( gt . personaCode ( slug ) ? "使用者要求略過" : "尚無人格編號" ) ;
} else {
for ( const area of gt . AREA _KEYS ) {
try {
const res = await gt . pushArea ( slug , area , { message : ` sleep: 收尾 ${ pl . nowIso ( ) } ` } ) ;
sync [ area ] = res . ok ? ( res . changed ? "pushed" : "no-change" ) : ` failed: ${ String ( res . reason ) . slice ( 0 , 80 ) } ` ;
} catch ( err ) {
sync [ area ] = ` failed: ${ String ( err . message ) . slice ( 0 , 80 ) } ` ;
}
}
try {
const verdicts = [ ] ;
for ( const area of gt . AREA _KEYS ) verdicts . push ( await gt . verifyArea ( slug , area ) ) ;
sync . verified = verdicts . every ( ( v ) => v . ok !== false ) ;
} catch {
sync . verified = false ;
}
}
// 預設 --keep-lock:睡完還能繼續聊。真的要收工才 --release。
let released = false ;
if ( release && selfSleep ) {
pl . unbindSession ( session ) ;
released = true ;
}
if ( lease ) pl . dropSleepLease ( slug , session , str ( flags [ "agent-id" ] ) || null ) ;
const failed = steps . filter ( ( s ) => ! s . ok ) ;
return {
persona : slug ,
ok : failed . length === 0 ,
slept _at : pl . nowIso ( ) ,
steps ,
sync ,
kept _lock : ! released ,
} ;
} ;
const host = data . host || null ;
const ordered = [ ... targets . filter ( ( slug ) => slug !== host ) , ... ( host && targets . includes ( host ) ? [ host ] : [ ] ) ] ;
const deferRelease = Boolean ( flags . release ) && ordered . length > 1 && host && targets . includes ( host ) ;
const results = [ ] ;
for ( const slug of ordered ) {
results . push ( await sleepOne ( slug , { release : Boolean ( flags . release ) && ! deferRelease && slug === host } ) ) ;
}
if ( deferRelease ) {
pl . unbindSession ( session ) ;
const hostResult = results . find ( ( r ) => r . persona === host ) ;
if ( hostResult ) {
hostResult . kept _lock = false ;
hostResult . released = true ;
}
}
// 預設 --keep-lock:睡完還能繼續聊。真的要收工才 --release。
let released = false ;
if ( flags . release && selfSleep ) {
pl . unbindSession ( session ) ;
released = true ;
}
if ( lease ) pl . dropSleepLease ( slug , session , str ( flags [ "agent-id" ] ) || null ) ;
const failed = steps . filter ( ( s ) => ! s . ok ) ;
const payload = {
persona : slug ,
ok : failed . length === 0 ,
slept _at : pl . nowIso ( ) ,
steps ,
sync ,
kept _lock : ! released ,
} ;
const payload = results . length === 1 ? results [ 0 ] : { ok : results . every ( ( r ) => r . ok ) , personas : results } ;
if ( flags . json ) {
process . stdout . write ( ` ${ JSON . stringify ( payload , null , 2 ) } \n ` ) ;
if ( ! payload . ok ) process . exit ( 1 ) ;
return ;
}
if ( payload . ok ) ok ( ` 人格 \` ${ slug } \` 已完成睡眠收尾( ${ steps . length } 個步驟,檔案處理無錯誤)。 ` ) ;
else say ( ` ✖ 人格 \` ${ slug } \` 睡眠收尾有 ${ failed . length } 步失敗: ${ failed . map ( ( s ) => s . step ) . join ( ", " ) } ` ) ;
for ( const s of failed ) say ( ` - ${ s . step } : ${ s . error } ` ) ;
say ( ` Gitea: ${ Object . entries ( sync ) . map ( ( [ k , v ] ) => ` ${ k } = ${ v } ` ) . join ( " " ) || "(未同步)" } ` ) ;
say ( ` 載入鎖: ${ released ? "已釋放" : "保留(--release 才收工)" } ` ) ;
if ( results . length === 1 ) {
const single = results [ 0 ] ;
const failed = ( single . steps || [ ] ) . filter ( ( s ) => ! s . ok ) ;
if ( single . ok ) ok ( ` 人格 \` ${ single . persona } \` 已完成睡眠收尾( ${ single . steps . length } 個步驟,檔案處理無錯誤)。 ` ) ;
else say ( ` ✖ 人格 \` ${ single . persona } \` 睡眠收尾有 ${ failed . length } 步失敗: ${ failed . map ( ( s ) => s . step ) . join ( ", " ) } ` ) ;
for ( const s of failed ) say ( ` - ${ s . step } : ${ s . error } ` ) ;
say ( ` Gitea: ${ Object . entries ( single . sync || { } ).map(([k, v]) => ` $ { k } = $ { v } ` ).join(" ") || "(未同步)"} ` ) ;
say ( ` 載入鎖: ${ single . kept _lock ? "保留(--release 才收工)" : "已釋放" } ` ) ;
if ( ! single . ok ) process . exit ( 1 ) ;
return ;
}
say ( "🌙 睡眠完成" ) ;
for ( const item of results ) {
const failed = ( item . steps || [ ] ) . filter ( ( s ) => ! s . ok ) ;
const sync = Object . entries ( item . sync || { } ) . map ( ( [ k , v ] ) => ` ${ k } = ${ v } ` ) . join ( " " ) || "(未同步)" ;
say ( ` - ${ item . persona } : ${ item . ok ? "完成" : ` ⚠ ${ failed . map ( ( s ) => s . step ) . join ( ", " ) } ` } ${ item . released ? "(已釋放鎖)" : "" } ` ) ;
if ( ! item . ok ) for ( const s of failed ) say ( ` - ${ s . step } : ${ s . error } ` ) ;
say ( ` Gitea: ${ sync } ` ) ;
say ( ` 載入鎖: ${ item . kept _lock ? "保留(--release 才收工)" : "已釋放" } ` ) ;
}
if ( ! payload . ok ) process . exit ( 1 ) ;
} ;
@@ -955,43 +1012,69 @@ commands.invite = ({ flags }) => {
const host = str ( flags . host ) || pl . loadSession ( session ) . host ;
if ( ! host ) die ( "本 session 尚未載入 host 人格,無法邀請他人。" ) ;
requireOwner ( host , session ) ;
const guest = str ( flags . guest ) ;
if ( ! guest ) die ( "需要 `--guest <slug>`。" ) ;
if ( guest === host ) die ( "不能邀請自己。" ) ;
if ( ! pl . personaExists ( guest ) ) die ( ` 人格 \` ${ guest } \` 不存在。可用: ${ pl . listPersonas ( ) . join ( ", " ) } ` ) ;
const guestList = parseGuestList ( flags ) ;
if ( ! guestList . length ) die ( "需要 `--guest <slug>` 或 `--guests <slug,slug>` 。" ) ;
if ( guestList . includes ( host ) ) die ( "不能邀請自己。" ) ;
for ( const guest of guestList ) {
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 ) ) ? ? { } ;
const heldElsewhere =
Boolean ( Object . keys ( lock ) . length ) && lock . session _id !== session && ! pl . lockIsDead ( lock ) ;
const guestSummaries = guestList . map ( ( guest ) => {
const lock = pl . readJson ( pl . lockPath ( guest ) ) ? ? { } ;
const heldElsewhere = Boolean ( Object . keys ( lock ) . length ) && lock . session _id !== session && ! pl . lockIsDead ( lock ) ;
return { guest , lock , heldElsewhere } ;
} ) ;
const heldElsewhereGuests = guestSummaries . filter ( ( g ) => g . heldElsewhere ) ;
const stamp = pl . nowIso ( ) . replace ( /[-:TZ]/g , "" ) . slice ( 0 , 14 ) ;
const room = str ( flags . room ) || ` ${ host } - ${ guest } - ${ stamp } ` ;
const room = str ( flags . room ) || ` ${ host } - ${ guestList . join ( "-" ) } - ${ stamp } ` ;
pl . createRoom ( room , host , session , str ( flags . topic ) ) ;
pl . joinRoom ( room , guest ) ;
pl . addGuestLease ( guest , session , room , host ) ;
const data = pl . loadSession ( session ) ;
data . guests ? ? = { } ;
data . guests [ guest ] = { room , joined _at : pl . nowIso ( ) , mode : "guest-readonly" } ;
for ( const { guest } of guestSummaries ) {
pl . joinRoom ( room , guest ) ;
pl . addGuestLease ( guest , session , room , host ) ;
data . guests [ guest ] = { room , joined _at : pl . nowIso ( ) , mode : "guest-readonly" } ;
}
data . rooms ? ? = [ ] ;
if ( ! data . rooms . includes ( room ) ) data . rooms . push ( room ) ;
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 , 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 內帶:" ,
` persona= ${ guest } room=${ room } session=${ session } ` ,
" guest 只能讀自己的人格資料(跨人格隔離),發言請走 `persona.mjs room post`。" ,
] ) ;
const guestLabel = guestList . map ( ( g ) => ` \` ${ g } \` ` ) . join ( "、" ) ;
emit (
{
room ,
guest : guestList [ 0 ] ,
guests : guestList ,
host ,
dir : pl . roomDir ( room ) ,
theater : data . theater ,
held _elsewhere : heldElsewhereGuests . length > 0 ,
held _elsewhere _guests : heldElsewhereGuests . map ( ( { guest , lock } ) => ( {
guest ,
session _id : lock . session _id ,
cwd : lock . cwd ,
} ) ) ,
} ,
flags . json ,
[
` ✔ 已邀請人格 ${ guestLabel } 以 guest(唯讀)身分加入聊天室 \` ${ room } \` 。 ` ,
... ( heldElsewhereGuests . length
? [
` ℹ 唯讀旁聽: ${ heldElsewhereGuests
. map ( ( { guest , lock } ) => ` \ ` ${ guest } \` ( session ${ String ( lock . session _id ) . slice ( 0 , 8 ) } …,cwd ${ lock . cwd } ) ` )
. join ( "、" ) } ; 寫入仍由原本的程序獨占 , 兩邊不會互相覆寫 。 ` ,
]
: []),
` 聊天室路徑 : $ { pl . roomDir ( room ) } ` ,
` 🎭 劇場模式已$ { data . theater ? "開啟:接下來只能輸出人格對話(`名字:內容`),其他訊息一律隱藏" : "關閉" } 。 ` ,
" 請用 Agent 工具、subagent_type= \" jsc-persona:persona-guest \" 逐一啟動它們,prompt 內帶:",
...guestList.map((guest) => ` persona = $ { guest } room = $ { room } session = $ { session } ` ),
" guest 只能讀自己的人格資料(跨人格隔離),發言請走 ` persona . mjs room post ` 。",
],
);
};
commands.leave = ({ flags }) => {
@@ -1671,7 +1754,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
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]
sleep --session <id> [--persona <slug>] [--json] [--release] [--no-gitea]
sleep --session <id> [--persona <slug>] [--personas <slug,slug>] [--json] [--release] [--no-gitea]
睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引
→修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。
預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。
@@ -1685,7 +1768,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
relation node|edge|render|show --session <id> [--name --id --kind --closeness --trust --note --tags --from --to --label --affinity]
多人格對話:
invite --session <id> --guest <slug> [--host --room --topic] (自動開啟劇場模式)
invite --session <id> --guest <slug> [--guests <slug,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)