diff --git a/README.md b/README.md index 6d10c2e..71aa021 100644 --- a/README.md +++ b/README.md @@ -367,11 +367,29 @@ export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設 token 本人 export PERSONA_GITEA=off # 需要時整個關掉 node scripts/persona.mjs code assign --session --romaji Asuna --rename # 既有人格遷移 -node scripts/persona.mjs sync status|init|push|pull --session [--area files|wiki|all] +node scripts/persona.mjs sync status|init|push|pull|verify --session [--area files|wiki|all] ``` 存取庫**預設私有**——人格裡是使用者的個人記憶,公開必須由使用者明講(`--public`)。 +### 換一台機器接續同一個人格 + +本機**已經有**這個人格 → `load` 再 `sync pull` 就好。 +本機**還沒有**它 → 用 `clone`:`sync` 的每個動作都要求先載入該人格,而本機沒有它就 load 不了它, +所以另開一個只驗 session、不驗 host 的入口(跟 `import` 同一類)。 + +```bash +node scripts/persona.mjs clone --session # 遠端有哪些人格、哪些本機還沒有 +node scripts/persona.mjs clone --code ASUNA-01 --session # 整個拉回來 +node scripts/persona.mjs clone --code ASUNA-01 --session --persona asuna-copy --load +``` + +- 遠端人格清單 = `GET /user/repos` 裡**名稱符合編號格式**的存取庫(會分頁撈完)。 +- 拉回來的是完整人格:Wiki 區帶回身分與長期記憶,檔案區帶回情緒與短期記憶, + 拉完補寫 `config.code`、重建長期記憶索引與關係圖。鎖與租約那類執行期狀態不跟著跑。 +- 本機已有同名人格時**預設不覆蓋**(`--force` 才蓋,`--persona` 可並存兩份); + 拉回來的東西沒有 `IDENTITY.md` 就中止並清掉半成品。 + ## 匯出 / 匯入(人格搬家) ```bash @@ -384,6 +402,7 @@ node scripts/persona.mjs import --session --file ~/backup/lumi.persona.json - 不帶 `state/lock.json`/`guests.json`(鎖屬於那台機器的那個程序);`journal/` 預設不帶。 - 換名匯入(`--persona <新 slug>`)可讓同一個人格並存兩份,`config.json` 會記下來歷。 - bundle 內的 `../` 逃逸路徑一律拒收;checksum 不符要 `--force` 才吃。 +- 有 Gitea 的話不必經過檔案:`clone --code <編號>` 直接從存取庫拉一份回來(見上一節)。 ## 劇場模式(多人格對話只顯示對話) diff --git a/scripts/persona-gitea.mjs b/scripts/persona-gitea.mjs index bb2126c..7610f04 100644 --- a/scripts/persona-gitea.mjs +++ b/scripts/persona-gitea.mjs @@ -40,21 +40,47 @@ export function normalizeRomaji(romaji) { export const codePrefix = (code) => String(code ?? "").split("-")[0] || ""; -/** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */ -export function nextCode(romaji) { +/** + * 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 + * `taken` 可以補上「本機看不到但已經發出去的編號」(例如遠端 Gitea 上的存取庫)。 + */ +export function nextCode(romaji, { taken = [] } = {}) { const prefix = normalizeRomaji(romaji); if (!prefix) return null; let max = 0; + const consider = (candidate) => { + if (!validCode(candidate) || codePrefix(candidate) !== prefix) return; + max = Math.max(max, Number(candidate.split("-")[1]) || 0); + }; for (const slug of pl.listPersonas()) { - for (const candidate of [pl.loadConfig(slug).code, slug]) { - if (!validCode(candidate) || codePrefix(candidate) !== prefix) continue; - max = Math.max(max, Number(candidate.split("-")[1]) || 0); - } + consider(pl.loadConfig(slug).code); + consider(slug); } + for (const candidate of taken) consider(candidate); if (max >= 99) return null; return `${prefix}-${String(max + 1).padStart(2, "0")}`; } +/** + * 跨機器的下一個編號。 + * `nextCode` 只看得到本機,換一台機器就會把同一個號再發一次(兩個人格搶同一個存取庫)。 + * 遠端的存取庫名稱正好就是「已經發出去的編號」,所以發號前先問遠端。 + * Gitea 關掉或連不上時退回本機答案,並在 `checked_remote` 標明沒問成——不因為同步失敗就不給編號。 + */ +export async function nextCodeAcrossMachines(romaji, { owner = null } = {}) { + const local = nextCode(romaji); + const problem = giteaProblem(); + if (problem) return { code: local, checked_remote: false, reason: problem }; + try { + const remote = await listRemotePersonas({ owner }); + if (!remote.ok) return { code: local, checked_remote: false, reason: remote.reason }; + const taken = remote.personas.map((p) => p.code); + return { code: nextCode(romaji, { taken }), checked_remote: true, taken }; + } catch (err) { + return { code: local, checked_remote: false, reason: String(err.message || err) }; + } +} + /** 這個人格的編號:config.code 優先,其次目錄名本身就是編號。 */ export function personaCode(slug) { const code = pl.loadConfig(slug).code; @@ -164,10 +190,9 @@ async function api(method, route, body = null) { const ownerCachePath = () => path.join(pl.runtimeDir(), "gitea.json"); -/** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號(會快取)。 */ -export async function resolveOwner() { +/** token 本人的帳號(**不受** `PERSONA_GITEA_OWNER` 影響;會快取)。 */ +export async function giteaLogin() { const env = giteaEnv(); - if (env.owner) return env.owner; const cached = pl.readJson(ownerCachePath(), {}) ?? {}; if (cached.host === env.host && cached.login) return cached.login; const res = await api("GET", "/user"); @@ -176,6 +201,62 @@ export async function resolveOwner() { return res.json.login; } +/** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號。 */ +export async function resolveOwner() { + const env = giteaEnv(); + if (env.owner) return env.owner; + return giteaLogin(); +} + +/** 分頁把 owner 底下的存取庫全部撈回來(Gitea 一頁上限 50)。 */ +async function listRepos(owner, me) { + const route = owner === me ? "/user/repos" : `/users/${encodeURIComponent(owner)}/repos`; + const out = []; + for (let page = 1; page <= 40; page += 1) { + const res = await api("GET", `${route}?page=${page}&limit=50`); + if (!res.ok) throw new Error(`列出 ${owner} 的存取庫失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`); + const batch = Array.isArray(res.json) ? res.json : []; + out.push(...batch); + if (batch.length < 50) break; + } + return out; +} + +/** 本機已經用掉的編號 → 人格目錄名。 */ +export function localCodes() { + const map = new Map(); + for (const slug of pl.listPersonas()) { + const code = personaCode(slug); + if (code) map.set(code, slug); + } + return map; +} + +/** + * Gitea 上有哪些人格。 + * 存取庫名稱就是人格編號,所以「列出 owner 底下的存取庫再用編號格式過濾」 + * 就是遠端的人格清單——換一台機器時,這是唯一能知道「有什麼可以拉」的方法。 + */ +export async function listRemotePersonas({ owner = null } = {}) { + const problem = giteaProblem(); + if (problem) return { ok: false, skipped: true, reason: problem, owner: null, personas: [] }; + const me = await giteaLogin(); + const theOwner = owner || (await resolveOwner()); + const mine = localCodes(); + const personas = (await listRepos(theOwner, me)) + .filter((repo) => validCode(repo?.name) && (!repo.owner?.login || repo.owner.login === theOwner)) + .map((repo) => ({ + code: repo.name, + description: repo.description || "", + private: Boolean(repo.private), + html_url: repo.html_url || "", + updated_at: repo.updated_at || null, + local: mine.get(repo.name) || null, + })) + .sort((a, b) => a.code.localeCompare(b.code)); + return { ok: true, owner: theOwner, personas }; +} + export async function getRepo(owner, code) { const res = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}`); return res.ok ? res.json : null; @@ -186,7 +267,9 @@ export async function ensureRepo(owner, code, { description = "", private_ = tru const existing = await getRepo(owner, code); if (existing) return { repo: existing, created: false }; const env = giteaEnv(); - const me = await resolveOwner(); + // 建庫的路由要看「owner 是不是 token 本人」,不能拿 resolveOwner() + // (它在有 PERSONA_GITEA_OWNER 時只會把那個值原封不動還回來,組織就永遠走成 /user/repos) + const me = await giteaLogin(); const route = owner === me ? "/user/repos" : `/orgs/${owner}/repos`; const res = await api("POST", route, { name: code, @@ -770,6 +853,86 @@ export async function verifyIconInWiki(slug, opts = {}) { return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 }; } +/** + * 把一個**本機還沒有**的人格從 Gitea 整個拉回來。 + * + * 兩區加起來就是一個完整的人格:Wiki 區帶回身分與長期結構(IDENTITY/SOUL/長期記憶/ + * 心智圖/關係圖),檔案區帶回活狀態(編號、情緒、短期記憶、逐字)。沒收的只有執行期狀態 + * (lock/guests/sleepers/sync),那本來就該由這台機器自己產生。 + * + * `pullArea` 只管檔案搬運,不管「拉回來的到底是不是一個人格」,所以這裡要補上 + * `validateBundle` 那條最低要件(IDENTITY.md)與 `importBundle` 的收尾(config/索引/關係圖)。 + */ +export async function importFromRemote(code, { owner = null, slug = null, force = false } = {}) { + const problem = giteaProblem(); + if (problem) throw new Error(problem); + if (!validCode(code)) throw new Error(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`); + const target = slug || code; + if (!pl.validSlug(target)) throw new Error(`人格目錄名 \`${target}\` 不合法(英數與連字號,最長 48 字)。`); + if (pl.personaExists(target) && !force) { + throw new Error(`本機已經有人格 \`${target}\`。要以遠端覆蓋本機請加 --force,或用 --persona <別的目錄名> 拉成另一份。`); + } + const theOwner = owner || (await resolveOwner()); + // API 問得到就先確認存取庫真的存在(錯的編號要在動硬碟之前就擋下來); + // API 連不上時不擋——讓 git 的結果說話,離線/自架環境照樣拉得動。 + let repo = null; + let apiUp = true; + try { + repo = await getRepo(theOwner, code); + } catch { + apiUp = false; + } + if (apiUp && !repo) { + throw new Error(`Gitea 上沒有 ${theOwner}/${code}(不帶 --code 可以列出有哪些人格)。`); + } + const fresh = !fs.existsSync(pl.personaDir(target)); + pl.ensurePersonaDirs(target); + const results = {}; + for (const area of AREA_KEYS) { + try { + // 本機是空的,衝突判定沒有意義;覆蓋既有人格時使用者已經明講了 --force + results[area] = await pullArea(target, area, { code, owner: theOwner, force: true }); + } catch (err) { + results[area] = { ok: false, area, reason: String(err.message || err) }; + } + } + if (!pl.personaExists(target)) { + // 半個人格比沒有人格更糟:清掉自己建的東西,並說清楚兩區各自發生什麼事 + if (fresh) fs.rmSync(pl.personaDir(target), { recursive: true, force: true }); + const detail = AREA_KEYS + .map((key) => `${AREAS[key].label}:${results[key]?.ok + ? `${results[key].written?.length ?? 0} 個檔案` + : String(results[key]?.reason || "失敗").slice(0, 80)}`) + .join(";"); + throw new Error( + `從 ${theOwner}/${code} 拉回來的內容沒有 IDENTITY.md(人格的最低要件),已中止${fresh ? "並清掉半成品" : ""}。${detail}`, + ); + } + // 收尾:編號與來歷寫進 config,索引與關係圖重建(跟 importBundle 一樣) + const config = pl.loadConfig(target); + config.persona = target; + config.code = code; + config.romaji = codePrefix(code); + config.schema = config.schema || 2; + config.imported_at = pl.nowIso(); + config.imported_from = { gitea: `${theOwner}/${code}`, repo_url: repo?.html_url || null }; + pl.writeJson(pl.configPath(target), config); + pl.rebuildIndex(target); + try { + pl.renderRelations(target); + } catch { + /* 沒有關係圖就算了 */ + } + const state = loadSyncState(target); + state.code = code; + state.owner = theOwner; + if (repo?.html_url) state.repo_url = repo.html_url; + state.imported_at = pl.nowIso(); + saveSyncState(target, state); + const written = [...new Set(AREA_KEYS.flatMap((key) => results[key]?.written || []))].sort(); + return { persona: target, code, owner: theOwner, repo, results, written, overwrote_local: !fresh }; +} + /** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */ export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) { const problem = giteaProblem(); diff --git a/scripts/persona-lib.mjs b/scripts/persona-lib.mjs index 1ee6819..3a00a28 100644 --- a/scripts/persona-lib.mjs +++ b/scripts/persona-lib.mjs @@ -2723,7 +2723,7 @@ export const GUEST_SAFE_SUBCOMMANDS = new Set([ ]); // owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格/設定預設人格),不算跨人格讀取 export const OWNER_EXEMPT_SUBCOMMANDS = new Set([ - "create", "list", "status", "gc", "invite", "load", "leave", "import", "default", "sleep", + "create", "list", "status", "gc", "invite", "load", "leave", "import", "clone", "default", "sleep", ]); // sleeper(睡眠 sub agent)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。 // 不准 load/release(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、 diff --git a/scripts/persona.mjs b/scripts/persona.mjs index 20b9ea5..57f78fd 100644 --- a/scripts/persona.mjs +++ b/scripts/persona.mjs @@ -233,6 +233,7 @@ commands.create = async ({ flags }) => { const session = requireSession(flags); // 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。 let code = str(flags.code); + let codeNote = ""; if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\`。`); if (!code) { const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona)); @@ -242,8 +243,12 @@ commands.create = async ({ flags }) => { "中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。", ); } - code = gt.nextCode(base); + // 發號前先問遠端:`nextCode` 只掃本機,換一台機器就會把同一個號再發一次 + const next = flags["no-gitea"] ? { code: gt.nextCode(base), checked_remote: false, reason: "--no-gitea" } + : await gt.nextCodeAcrossMachines(base, { owner: str(flags.owner) || null }); + code = next.code; if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`); + if (!next.checked_remote) codeNote = ` ⚠ 編號只對過本機,沒對過 Gitea(${next.reason})——別台機器可能已經用掉這個號。`; } // 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格) const slug = str(flags.persona) || code; @@ -288,6 +293,7 @@ commands.create = async ({ flags }) => { pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null }); pl.bindHost(session, slug, { cwd: str(flags.cwd) || null }); ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`); + if (codeNote) say(codeNote); say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`); // Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。 if (!flags["no-gitea"] && !gt.giteaProblem()) { @@ -1520,6 +1526,104 @@ commands.import = ({ flags }) => { emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines); }; +/** + * 從 Gitea 匯入一個**本機還沒有**的人格(換一台機器時的第一步)。 + * + * 這是唯一不需要「先載入該人格」的同步入口,而且非如此不可:`sync` 的每個動作都要 + * `requireOwner`,而 `requireOwner` 的第一件事是「本機沒有這個人格就 die」——本機沒有它, + * 就 load 不了它,就永遠拉不回來。所以走 `import` 那條路:只驗 session,不驗 host。 + */ +commands.clone = async ({ flags, positional }) => { + const session = requireSession(flags); + const problem = gt.giteaProblem(); + if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`); + const owner = str(flags.owner) || null; + const code = str(flags.code) || positional[0] || ""; + + // 不指定編號 → 列出遠端有哪些人格讓使用者挑 + if (!code) { + let listed; + try { + listed = await gt.listRemotePersonas({ owner }); + } catch (err) { + die(`列不出 Gitea 上的人格:${err.message}`); + } + const rows = listed.personas; + const missing = rows.filter((r) => !r.local); + emit({ owner: listed.owner, personas: rows }, flags.json, [ + `Gitea \`${listed.owner}\` 底下的人格:${rows.length} 個,其中 ${missing.length} 個本機還沒有。`, + ...(rows.length + ? rows.map((r) => + `${r.local ? " ✔" : " ⬇"} \`${r.code}\`` + + (r.local ? ` 本機已有${r.local === r.code ? "" : `(目錄 ${r.local})`}` : " 本機還沒有") + + `|${r.private ? "私有" : "公開"}|最後更新 ${String(r.updated_at || "").slice(0, 10) || "—"}` + + (r.description ? `|${r.description}` : "")) + : ["(一個都沒有——存取庫名稱要是人格編號才算得上人格,例如 `ASUNA-01`)"]), + "", + "匯入:`persona.mjs clone --code <編號> --session `(本機還沒有的那些)", + ]); + return; + } + + if (!gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`); + const target = str(flags.persona) || code; + const data = pl.loadSession(session); + const exists = pl.personaExists(target); + if (exists && !flags.force) { + die( + `人格 \`${target}\` 本機已經有了。要以遠端覆蓋本機請加 --force(本機還沒推上去的改動會不見),` + + "或用 `--persona <另一個目錄名>` 拉成第二份;只是想更新的話請 `load` 之後跑 `sync pull`。", + ); + } + if (exists) { + // 覆寫既有人格:跟 `import` 一樣的兩道保護 + if (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 = await gt.importFromRemote(code, { owner, slug: target, force: Boolean(flags.force) }); + } catch (err) { + die(err.message); + } + const lines = [ + `✔ 人格 \`${result.persona}\`(\`${result.code}\`)已從 Gitea 匯入${exists ? ",覆寫本機既有資料" : ""}。`, + ` 來源:${result.owner}/${result.code}|${result.written.length} 個檔案|${pl.identityBrief(result.persona) || "(無身分欄位)"}`, + ...gt.AREA_KEYS.map((key) => { + const res = result.results[key] || {}; + return ` ${gt.AREAS[key].label}:` + + (res.ok ? `${res.written?.length ?? 0} 個檔案${res.empty ? "(遠端是空的)" : ""}` + : `⚠ ${String(res.reason || "失敗").slice(0, 120)}`); + }), + ` 長期記憶 ${pl.longTermEntries(result.persona).length} 則|短期 ${pl.readJsonl(pl.shortTermPath(result.persona)).length} 筆` + + `|關係人 ${pl.loadRelations(result.persona).nodes.length} 位`, + ]; + 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, flags.json, lines); +}; + /** * 人格編號:英文名全大寫 + 兩位索引(同名才遞增),也就是 Gitea 存取庫的名稱。 * 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。 @@ -1530,9 +1634,14 @@ commands.code = async ({ flags, positional }) => { if (action === "next") { const base = gt.normalizeRomaji(str(flags.romaji)); if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。"); - const next = gt.nextCode(base); - if (!next) die(`\`${base}\` 的編號已經用到 99。`); - emit({ romaji: base, code: next }, flags.json, [`\`${base}\` 的下一個可用編號:\`${next}\``]); + const next = await gt.nextCodeAcrossMachines(base, { owner: str(flags.owner) || null }); + if (!next.code) die(`\`${base}\` 的編號已經用到 99。`); + emit({ romaji: base, ...next }, flags.json, [ + `\`${base}\` 的下一個可用編號:\`${next.code}\``, + next.checked_remote + ? ` (已對過 Gitea 上的 ${next.taken.length} 個編號)` + : ` ⚠ 只看了本機,沒對過 Gitea(${next.reason})——換機器可能會撞號。`, + ]); return; } const slug = hostOf(flags, session); @@ -1551,12 +1660,18 @@ commands.code = async ({ flags, positional }) => { const existing = gt.personaCode(slug); if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`); let code = str(flags.code); + let codeNote = ""; if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`); if (!code) { const base = gt.normalizeRomaji(str(flags.romaji) || slug); if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。"); - code = gt.nextCode(base); + // 發號前先問遠端:本機看不到別台機器已經發出去的編號 + const next = await gt.nextCodeAcrossMachines(base, { owner: str(flags.owner) || null }); + code = next.code; if (!code) die(`\`${base}\` 的編號已經用到 99。`); + codeNote = next.checked_remote + ? ` (已對過 Gitea 上的 ${next.taken.length} 個編號,不會跟別台機器撞號)` + : ` ⚠ 編號只對過本機,沒對過 Gitea(${next.reason})——別台機器可能已經用掉這個號。`; } const config = pl.loadConfig(slug); config.persona = slug; @@ -1564,7 +1679,7 @@ commands.code = async ({ flags, positional }) => { config.romaji = gt.codePrefix(code); config.schema = 2; pl.writeJson(pl.configPath(slug), config); - const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\`。`]; + const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\`。`, ...(codeNote ? [codeNote] : [])]; let current = slug; if (flags.rename && slug !== code) { if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`); @@ -2061,7 +2176,13 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 編號與 Gitea(存取庫名稱 = 人格編號): - code show|assign|next --session [--romaji <英文名> --code --rename --force --public] + code show|assign|next --session [--romaji <英文名> --code --rename --force --public --owner] + 發新編號前會先問遠端有哪些編號(避免換機器撞號);Gitea 連不上就只看本機並明講 + clone --session [--code |] [--persona <目錄名> --owner --force --load --json] + 把一個**本機還沒有**的人格從 Gitea 整個拉回來(換機器接續同一個人格的第一步)。 + 不帶 --code 就列出遠端有哪些人格、哪些本機還沒有。 + 唯一不用先載入該人格的同步入口(本機沒有它就 load 不了它)。 + 本機已有同名人格時預設不覆蓋:要蓋加 --force,或用 --persona 拉成第二份。 sync status|init|push|pull|verify --session [--area files|wiki|all --if-due --force --message --owner] verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步) 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push @@ -2071,9 +2192,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C 環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER), PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔 -搬家(離線檔案): +搬家: export --session [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格 - import --session --file <檔案> [--persona <新 slug> --force --load] + import --session --file <檔案> [--persona <新 slug> --force --load] 從 bundle 檔匯入 + clone --session [--code ] 從 Gitea 匯入(見上) 維護: gc 清理死鎖與過期租約 diff --git a/scripts/selftest.mjs b/scripts/selftest.mjs index a7e3e89..8e41acb 100644 --- a/scripts/selftest.mjs +++ b/scripts/selftest.mjs @@ -1492,6 +1492,163 @@ console.log("\n劇場模式:心裡話不能外流"); cli(["release", "--session", S_INNER]); } +console.log("\n㉒ 從 Gitea 匯入本機還沒有的人格(換一台機器接續同一個人格)"); +{ + // 「假的 Gitea」:兩個裸倉庫 + file:// 當 host。不碰網路、更不碰真的 Gitea。 + // API(getRepo)在 file:// 下必然失敗,而 importFromRemote 對此的處理就是 + // 「API 問不到不擋,讓 git 的結果說話」——所以整條匯入路徑在這裡是真的跑起來的。 + const FAKE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-fakegitea-")); + const bare = (name) => path.join(FAKE, "me", name); + const initBare = (code) => { + for (const suffix of [".git", ".wiki.git"]) { + spawnSync("git", ["init", "--quiet", "--bare", "-b", "main", bare(`${code}${suffix}`)]); + } + }; + const fakeGitea = () => { + process.env.PERSONA_GITEA = ""; + process.env.PERSONA_GITEA_HOST = `file://${FAKE}`; + process.env.PERSONA_GITEA_TOKEN = "selftest-token"; + process.env.PERSONA_GITEA_OWNER = "me"; + }; + const noGitea = () => { + process.env.PERSONA_GITEA = "off"; + delete process.env.PERSONA_GITEA_HOST; + delete process.env.PERSONA_GITEA_TOKEN; + delete process.env.PERSONA_GITEA_OWNER; + }; + fs.mkdirSync(path.join(FAKE, "me"), { recursive: true }); + initBare("ZETA-01"); + + // ── 第一台機器:人格在這裡長出來,然後推上去 + const S_CLONE = "sess-clone-7777"; + cli(["create", "--persona", "ZETA-01", "--romaji", "Zeta", "--session", S_CLONE, "--name", "Zeta", + "--creature", "海邊的燈塔", "--vibe", "安靜而準時", "--emoji", "🛰", "--no-gitea"]); + cli(["remember", "--session", S_CLONE, "--text", "第一台機器上說過的話", "--salience", "70"]); + cli(["consolidate", "--session", S_CLONE, "--name", "跨機器的記憶", "--body", "這則要能跟著人格走"]); + cli(["relation", "node", "--session", S_CLONE, "--name", "使用者", "--kind", "human", + "--bond", "partner", "--closeness", "70"]); + // 睡眠會把太久沒動的思維導圖收進 mindmap/threads/archive/——那是子資料夾 + const archive = path.join(pl.personaDir("ZETA-01"), "mindmap", "threads", "archive"); + fs.mkdirSync(archive, { recursive: true }); + fs.writeFileSync(path.join(archive, "old-thread.mmd"), "%% 收起來的舊思維導圖\nflowchart TD\n"); + + fakeGitea(); + const pushFiles = await gt.pushArea("ZETA-01", "files", { message: "selftest: 第一台機器" }); + const pushWiki = await gt.pushArea("ZETA-01", "wiki", { message: "selftest: 第一台機器" }); + check("兩區都推得上去(file:// 假存取庫)", pushFiles.ok && pushWiki.ok, + `${pushFiles.reason || ""} ${pushWiki.reason || ""}`); + check("子資料夾的檔案真的進了存取庫", (() => { + const ls = spawnSync("git", ["-C", bare("ZETA-01.git"), "ls-tree", "-r", "--name-only", "main"], + { encoding: "utf8" }); + return String(ls.stdout).includes("mindmap/threads/archive/old-thread.mmd"); + })()); + + // ── 第二台機器:本機什麼都沒有 + cli(["release", "--session", S_CLONE]); + fs.rmSync(pl.personaDir("ZETA-01"), { recursive: true, force: true }); + check("本機已經沒有這個人格了", !pl.personaExists("ZETA-01")); + check("本機沒有它就 load 不了(這就是雞生蛋的那道牆)", + cli(["load", "--persona", "ZETA-01", "--session", S_CLONE], { expectOk: false }).status !== 0); + check("沒 load 就 sync pull 也會被 requireOwner 擋下", + cli(["sync", "pull", "--persona", "ZETA-01", "--session", S_CLONE], { expectOk: false }).status !== 0); + + const cloned = cli(["clone", "--code", "ZETA-01", "--session", S_CLONE, "--json"]); + check("clone 把人格整個拉回本機", cloned.status === 0 && pl.personaExists("ZETA-01"), + String(cloned.stderr).slice(0, 200)); + check("身分(Wiki 區)回來了", pl.identityBrief("ZETA-01").includes("Name: Zeta"), + pl.identityBrief("ZETA-01")); + check("長期記憶(Wiki 區)回來了", + pl.longTermEntries("ZETA-01").some((m) => String(m._name).includes("跨機器的記憶")), + pl.longTermEntries("ZETA-01").map((m) => m._name).join(", ")); + check("活狀態(檔案區)回來了:情緒、短期記憶、編號", + fs.existsSync(pl.emotionPath("ZETA-01")) && + pl.readJsonl(pl.shortTermPath("ZETA-01")).some((r) => String(r.text).includes("第一台機器")) && + pl.loadConfig("ZETA-01").code === "ZETA-01", + JSON.stringify(pl.loadConfig("ZETA-01")).slice(0, 160)); + check("收尾有做:長期記憶索引與關係圖都重建了", + fs.readFileSync(path.join(pl.personaDir("ZETA-01"), "memory", "INDEX.md"), "utf8").includes("跨機器的記憶") && + pl.loadRelations("ZETA-01").nodes.some((n) => n.name === "使用者")); + check("子資料夾的檔案也拉得回來", fs.existsSync(path.join(archive, "old-thread.mmd"))); + check("執行期狀態不跟著跑(沒有把別台機器的鎖拉回來)", !fs.existsSync(pl.lockPath("ZETA-01"))); + check("來歷記在 config 裡(跟 import 一樣可追)", + (pl.loadConfig("ZETA-01").imported_from || {}).gitea === "me/ZETA-01", + JSON.stringify(pl.loadConfig("ZETA-01").imported_from)); + check("本機已經有同名人格時預設不覆蓋", + cli(["clone", "--code", "ZETA-01", "--session", S_CLONE], { expectOk: false }).status !== 0); + check("--persona 可以拉成第二份(不動到原本那個)", (() => { + const second = cli(["clone", "--code", "ZETA-01", "--persona", "zeta-copy", "--session", S_CLONE]); + return second.status === 0 && pl.personaExists("zeta-copy") && + pl.loadConfig("zeta-copy").persona === "zeta-copy" && pl.personaExists("ZETA-01"); + })()); + check("編號格式不合法就擋在動硬碟之前", + cli(["clone", "--code", "zeta-01", "--session", S_CLONE], { expectOk: false }).status !== 0); + check("拉回來的東西不成人格(沒有 IDENTITY.md)就中止並清掉半成品", (() => { + initBare("EMPTY-01"); + const res = cli(["clone", "--code", "EMPTY-01", "--session", S_CLONE], { expectOk: false }); + return res.status !== 0 && res.stderr.includes("IDENTITY.md") && !fs.existsSync(pl.personaDir("EMPTY-01")); + })()); + check("guard:clone 不算跨人格操作(已載入別的人格時也放行)", + guard({ session_id: S_HOST, tool_name: "Bash", + tool_input: { command: `node persona.mjs clone --code ZETA-01 --persona ZETA-01 --session ${S_HOST}` } }) === "pass"); + + // ── 發號:本機看不到別台機器已經用掉的編號 + check("發號時可以把「遠端已經用掉的編號」算進去", + gt.nextCode("Zeta") === "ZETA-02" && gt.nextCode("Zeta", { taken: ["ZETA-05"] }) === "ZETA-06", + `${gt.nextCode("Zeta")} / ${gt.nextCode("Zeta", { taken: ["ZETA-05"] })}`); + const nextOffline = await gt.nextCodeAcrossMachines("Zeta"); + check("問不到遠端時照樣發得出編號,但明講沒對過遠端", + nextOffline.code === "ZETA-02" && nextOffline.checked_remote === false && Boolean(nextOffline.reason), + JSON.stringify(nextOffline)); + + // ── push 撞到別台機器:以本機為準,但必須記帳 + const other = path.join(FAKE, "other-machine"); + spawnSync("git", ["clone", "--quiet", bare("ZETA-01.git"), other]); + fs.writeFileSync(path.join(other, "state", "emotion.json"), JSON.stringify({ levels: { joy: 99 } }, null, 2)); + spawnSync("git", ["-C", other, "add", "-A"]); + spawnSync("git", ["-C", other, "-c", "user.email=o@x", "-c", "user.name=other", + "commit", "-q", "-m", "另一台機器改了情緒"]); + spawnSync("git", ["-C", other, "push", "-q", "origin", "HEAD"]); + const remoteSha = String(spawnSync("git", ["-C", bare("ZETA-01.git"), "rev-parse", "main"], + { encoding: "utf8" }).stdout).trim(); + pl.writeJson(pl.emotionPath("ZETA-01"), { levels: { joy: 11 }, note: "本機的版本" }); + const clash = await gt.pushArea("ZETA-01", "files", { message: "selftest: 本機也改了" }); + check("push 撞到別台機器時仍以本機為準推上去", clash.ok && clash.changed, + JSON.stringify(clash).slice(0, 200)); + check("但會回報覆蓋了哪些檔案、上一版是哪個 commit", + Boolean(clash.overwrote) && clash.overwrote.files.includes("state/emotion.json") && + clash.overwrote.previous === remoteSha, + JSON.stringify(clash.overwrote)); + check("覆蓋紀錄落在 sync.json 等人認領(背景 push 的輸出是丟掉的)", (() => { + const pending = gt.pendingOverwrites("ZETA-01"); + return pending.length === 1 && pending[0].files.includes("state/emotion.json") && + pending[0].area === "files"; + })(), JSON.stringify(gt.pendingOverwrites("ZETA-01"))); + check("被蓋掉的內容還取得回來(上一版留在 clone 裡)", (() => { + const show = spawnSync("git", ["-C", gt.syncDir("ZETA-01", "files"), "show", + `${remoteSha}:state/emotion.json`], { encoding: "utf8" }); + return show.status === 0 && show.stdout.includes("99"); + })()); + cli(["load", "--persona", "ZETA-01", "--session", S_CLONE]); + const stop1 = hook("turn_end.mjs", { session_id: S_CLONE, last_assistant_message: "嗯。" }); + const stop2 = hook("turn_end.mjs", { session_id: S_CLONE, last_assistant_message: "嗯。" }); + check("Stop hook 會把覆蓋回報給使用者,而且同一次只吵一次", + String(stop1.systemMessage || "").includes("覆蓋了遠端") && + !String(stop2.systemMessage || "").includes("覆蓋了遠端"), + `${stop1.systemMessage || "(無)"} | ${stop2.systemMessage || "(無)"}`); + check("sync status 也列得出覆蓋紀錄", (() => { + const res = cli(["sync", "status", "--session", S_CLONE]); + return res.stdout.includes("本機曾覆蓋遠端") && res.stdout.includes("state/emotion.json"); + })()); + cli(["release", "--session", S_CLONE]); + + noGitea(); + check("Gitea 沒設定時 clone 是「尚未設定」而不是崩潰", (() => { + const res = cli(["clone", "--session", S_CLONE], { expectOk: false }); + return res.status !== 0 && res.stderr.includes("尚未設定"); + })()); + fs.rmSync(FAKE, { recursive: true, force: true }); +} + console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); process.exit(failed ? 1 : 0); diff --git a/skills/persona-sync/SKILL.md b/skills/persona-sync/SKILL.md index 8fb4011..c27ce91 100644 --- a/skills/persona-sync/SKILL.md +++ b/skills/persona-sync/SKILL.md @@ -23,6 +23,8 @@ description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大 - 中文/日文名字要先轉**羅馬拼音**:由你提議拼法(`亞絲娜 → Asuna`、`沈宇 → Shen Yu`), **拿給使用者確認再送出**——拼錯了會變成一個很難改的編號。 - 查下一個可用編號:`code next --romaji Asuna --session `。 + 發號前會先問遠端 Gitea 已經用掉哪些編號(本機看不到別台機器發過的號); + Gitea 連不上時照樣發得出來,但輸出會明講「只對過本機」——那就有撞號的風險,要轉告使用者。 ## 資料放哪裡(依更新頻率切) @@ -85,13 +87,31 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" code assign \ ### 在另一台機器接續同一個人格 -在新機器上設好 `GITEA_HOST`/`GITEA_TOKEN`,然後: +在新機器上設好 `GITEA_HOST`/`GITEA_TOKEN`。**本機已經有這個人格**(只是舊了): ```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona <編號> --session node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session ``` -(人格目錄還不存在的話,先用 `/jsc-persona:persona-transfer` 匯入一份,或手動 clone 存取庫。) +**本機還沒有這個人格**(全新的機器):`sync` 的每個動作都要求「先載入那個人格」, +而本機沒有它就 load 不了它,所以走 `clone`——它只驗 session,不驗 host: + +```bash +# 1) 遠端有哪些人格、哪些本機還沒有 +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" clone --session + +# 2) 挑一個拉回來(存取庫名稱就是編號) +node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" clone --code ASUNA-01 --session +``` + +拉回來的是**完整的人格**:Wiki 區帶回身分與長期記憶,檔案區帶回情緒與短期記憶, +拉完自動重建長期記憶索引與關係圖。沒帶回來的只有鎖與租約那類執行期狀態——那本來就該由這台機器自己產生。 + +- 本機已經有同名人格時**預設不覆蓋**。真的要以遠端為準才加 `--force`(本機沒推上去的改動會不見), + 想並存兩份就用 `--persona <別的目錄名>`。 +- 加 `--load` 可以拉完直接載入開聊。 +- 拉回來的東西沒有 `IDENTITY.md`(人格的最低要件)就會中止並清掉半成品,不留半個人格在本機。 ## 衝突 @@ -129,6 +149,7 @@ sleeper 連自己的人格目錄都不能用 shell 寫,那條路本來就是 ## 邊界 - 只能同步「本 session 目前載入的人格」——跨人格同步等於跨人格讀取,會被 hook 擋下。 + 唯一的例外是 `clone`:它只會**新增本機還沒有的人格**,讀不到任何既有人格的資料,所以不受此限。 - guest(`persona-guest` sub agent)不能同步,它對人格檔案唯讀。 - `.sync/` 是兩區的 git clone 快取,**不要手動編輯**;砍掉它不會掉資料,下次 push 會重新 clone。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 diff --git a/skills/persona-transfer/SKILL.md b/skills/persona-transfer/SKILL.md index 9c41dce..f459425 100644 --- a/skills/persona-transfer/SKILL.md +++ b/skills/persona-transfer/SKILL.md @@ -10,6 +10,10 @@ description: 匯出與匯入人格:把一個人格(身分、靈魂、十二 bundle 是**單一 JSON 檔**(可 gzip),不依賴任何外部工具,複製到隨身碟、貼進 git、 傳給另一台機器都可以。 +> 只是要在另一台機器接續同一個人格、而且兩邊都連得到 Gitea 的話,**不必經過檔案**: +> 用 `/jsc-persona:persona-sync` 的 `clone --code <編號>` 直接從存取庫拉一份回來。 +> 這裡的 bundle 是給離線、換助理、或想留一份快照的情境。 + --- ## bundle 帶走什麼、不帶什麼