merge(gitea): 同步漏檔/push 衝突回報/clone 新功能/錯誤訊息

衝突:scripts/selftest.mjs 檔尾兩份都各自 append 一個測試區塊
(guard 的「注入區塊不可被人格檔案逸出(S6)」與 gitea 的「㉒ 從 Gitea
匯入本機還沒有的人格」)。兩塊都保留,各自收好自己的 `}`。
This commit is contained in:
2026-07-31 14:29:10 +00:00
8 changed files with 657 additions and 50 deletions
+24 -1
View File
@@ -352,6 +352,10 @@ node scripts/persona.mjs sync verify --session <id> --area wiki # 確認 Wiki
- **本機永遠是工作副本**:hook 每輪讀寫本機檔案,不經網路;Gitea 掛掉照樣能聊天。 - **本機永遠是工作副本**:hook 每輪讀寫本機檔案,不經網路;Gitea 掛掉照樣能聊天。
**同步失敗永遠不阻斷對話。** **同步失敗永遠不阻斷對話。**
- 載入人格時會先 `pull`;兩邊都改過同一個檔案就**停下來不覆蓋本機**,由使用者決定保留哪一邊。 - 載入人格時會先 `pull`;兩邊都改過同一個檔案就**停下來不覆蓋本機**,由使用者決定保留哪一邊。
- `push` 撞到「遠端比較新」時以**本機為準**覆蓋遠端(本機才是這台機器的真相來源),
但會回報**蓋掉哪些檔案、上一版是哪個 commit**,記在 `state/sync.json`
`sync status` 列得出來、下一輪的 `Stop` hook 提醒一次;舊版仍可從 clone 的歷史取回。
- 發新編號前會先問遠端已經用掉哪些編號——`nextCode` 只看本機,換一台機器會重複發號。
- Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面(1.27 實測子目錄頁面 404),所以 - Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面(1.27 實測子目錄頁面 404),所以
`memory/long-term/xxx.md` 攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json` `memory/long-term/xxx.md` 攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`
Wiki 首頁自動列出所有長期記憶的連結,變成真的讀得下去的「設定百科」。 Wiki 首頁自動列出所有長期記憶的連結,變成真的讀得下去的「設定百科」。
@@ -363,11 +367,29 @@ export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設 token 本人
export PERSONA_GITEA=off # 需要時整個關掉 export PERSONA_GITEA=off # 需要時整個關掉
node scripts/persona.mjs code assign --session <id> --romaji Asuna --rename # 既有人格遷移 node scripts/persona.mjs code assign --session <id> --romaji Asuna --rename # 既有人格遷移
node scripts/persona.mjs sync status|init|push|pull --session <id> [--area files|wiki|all] node scripts/persona.mjs sync status|init|push|pull|verify --session <id> [--area files|wiki|all]
``` ```
存取庫**預設私有**——人格裡是使用者的個人記憶,公開必須由使用者明講(`--public`)。 存取庫**預設私有**——人格裡是使用者的個人記憶,公開必須由使用者明講(`--public`)。
### 換一台機器接續同一個人格
本機**已經有**這個人格 → `load` 再 `sync pull` 就好。
本機**還沒有**它 → 用 `clone``sync` 的每個動作都要求先載入該人格,而本機沒有它就 load 不了它,
所以另開一個只驗 session、不驗 host 的入口(跟 `import` 同一類)。
```bash
node scripts/persona.mjs clone --session <id> # 遠端有哪些人格、哪些本機還沒有
node scripts/persona.mjs clone --code ASUNA-01 --session <id> # 整個拉回來
node scripts/persona.mjs clone --code ASUNA-01 --session <id> --persona asuna-copy --load
```
- 遠端人格清單 = `GET /user/repos` 裡**名稱符合編號格式**的存取庫(會分頁撈完)。
- 拉回來的是完整人格:Wiki 區帶回身分與長期記憶,檔案區帶回情緒與短期記憶,
拉完補寫 `config.code`、重建長期記憶索引與關係圖。鎖與租約那類執行期狀態不跟著跑。
- 本機已有同名人格時**預設不覆蓋**(`--force` 才蓋,`--persona` 可並存兩份);
拉回來的東西沒有 `IDENTITY.md` 就中止並清掉半成品。
## 匯出 / 匯入(人格搬家) ## 匯出 / 匯入(人格搬家)
```bash ```bash
@@ -380,6 +402,7 @@ node scripts/persona.mjs import --session <id> --file ~/backup/lumi.persona.json
- 不帶 `state/lock.json``guests.json`(鎖屬於那台機器的那個程序);`journal/` 預設不帶。 - 不帶 `state/lock.json``guests.json`(鎖屬於那台機器的那個程序);`journal/` 預設不帶。
- 換名匯入(`--persona <新 slug>`)可讓同一個人格並存兩份,`config.json` 會記下來歷。 - 換名匯入(`--persona <新 slug>`)可讓同一個人格並存兩份,`config.json` 會記下來歷。
- bundle 內的 `../` 逃逸路徑一律拒收;checksum 不符要 `--force` 才吃。 - bundle 內的 `../` 逃逸路徑一律拒收;checksum 不符要 `--force` 才吃。
- 有 Gitea 的話不必經過檔案:`clone --code <編號>` 直接從存取庫拉一份回來(見上一節)。
## 劇場模式(多人格對話只顯示對話) ## 劇場模式(多人格對話只顯示對話)
+13
View File
@@ -45,11 +45,24 @@ if (!gt.giteaProblem() && gt.personaCode(host) && gt.pushDue(host, "files")) {
} }
const out = { suppressOutput: true }; const out = { suppressOutput: true };
// 背景 push 撞到別台機器時是「本機覆蓋遠端」。那條路徑會把帳記在 sync.json,
// 但它是 detached 跑的、輸出丟掉,所以由這裡認領並回報一次——劇場模式也照報(那是資料被蓋掉)。
const overwrites = gt.pendingOverwrites(host);
if (overwrites.length) {
gt.markOverwritesReported(host);
const files = [...new Set(overwrites.flatMap((o) => o.files || []))];
const last = overwrites.at(-1);
out.systemMessage =
`[jsc-persona] ⚠ \`${host}\` 同步到 Gitea 時**以本機為準覆蓋了遠端** ${files.length} 個檔案` +
`${files.slice(0, 5).join(", ")}${files.length > 5 ? "…" : ""}),上一版是 ${String(last.previous).slice(0, 8)}` +
"別台機器可能正在用同一個人格;細節見 `sync status`。";
}
if (!theater) { if (!theater) {
const { total, candidates } = pl.promotionCandidates(host); const { total, candidates } = pl.promotionCandidates(host);
if (candidates.length) { if (candidates.length) {
const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/"); const rules = [...new Set(candidates.flatMap((c) => c.rules))].sort().join("/");
out.systemMessage = out.systemMessage =
`${out.systemMessage ? `${out.systemMessage}\n` : ""}` +
`[jsc-persona] \`${host}\` 短期記憶 ${total} 筆,${candidates.length} 組已達固化條件(${rules}` + `[jsc-persona] \`${host}\` 短期記憶 ${total} 筆,${candidates.length} 組已達固化條件(${rules}` +
"→ 建議執行 /jsc-persona:persona-memory。"; "→ 建議執行 /jsc-persona:persona-memory。";
} }
+259 -36
View File
@@ -19,6 +19,8 @@ import * as pl from "./persona-lib.mjs";
export const SYNC_DIRNAME = ".sync"; export const SYNC_DIRNAME = ".sync";
export const DEFAULT_MIN_PUSH_SECONDS = 60; export const DEFAULT_MIN_PUSH_SECONDS = 60;
// sync.json 裡「本機覆蓋遠端」的紀錄留幾筆
export const OVERWRITE_LOG_KEEP = 10;
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// 人格編號:英文名全大寫 + 兩位索引(同名才遞增) // 人格編號:英文名全大寫 + 兩位索引(同名才遞增)
@@ -38,21 +40,47 @@ export function normalizeRomaji(romaji) {
export const codePrefix = (code) => String(code ?? "").split("-")[0] || ""; export const codePrefix = (code) => String(code ?? "").split("-")[0] || "";
/** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */ /**
export function nextCode(romaji) { * 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。
* `taken` 可以補上「本機看不到但已經發出去的編號」(例如遠端 Gitea 上的存取庫)。
*/
export function nextCode(romaji, { taken = [] } = {}) {
const prefix = normalizeRomaji(romaji); const prefix = normalizeRomaji(romaji);
if (!prefix) return null; if (!prefix) return null;
let max = 0; 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 slug of pl.listPersonas()) {
for (const candidate of [pl.loadConfig(slug).code, slug]) { consider(pl.loadConfig(slug).code);
if (!validCode(candidate) || codePrefix(candidate) !== prefix) continue; consider(slug);
max = Math.max(max, Number(candidate.split("-")[1]) || 0);
}
} }
for (const candidate of taken) consider(candidate);
if (max >= 99) return null; if (max >= 99) return null;
return `${prefix}-${String(max + 1).padStart(2, "0")}`; 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 優先,其次目錄名本身就是編號。 */ /** 這個人格的編號:config.code 優先,其次目錄名本身就是編號。 */
export function personaCode(slug) { export function personaCode(slug) {
const code = pl.loadConfig(slug).code; const code = pl.loadConfig(slug).code;
@@ -162,10 +190,9 @@ async function api(method, route, body = null) {
const ownerCachePath = () => path.join(pl.runtimeDir(), "gitea.json"); const ownerCachePath = () => path.join(pl.runtimeDir(), "gitea.json");
/** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號(會快取)。 */ /** token 本人的帳號(**不受** `PERSONA_GITEA_OWNER` 影響;會快取)。 */
export async function resolveOwner() { export async function giteaLogin() {
const env = giteaEnv(); const env = giteaEnv();
if (env.owner) return env.owner;
const cached = pl.readJson(ownerCachePath(), {}) ?? {}; const cached = pl.readJson(ownerCachePath(), {}) ?? {};
if (cached.host === env.host && cached.login) return cached.login; if (cached.host === env.host && cached.login) return cached.login;
const res = await api("GET", "/user"); const res = await api("GET", "/user");
@@ -174,6 +201,62 @@ export async function resolveOwner() {
return res.json.login; 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) { export async function getRepo(owner, code) {
const res = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}`); const res = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}`);
return res.ok ? res.json : null; return res.ok ? res.json : null;
@@ -184,7 +267,9 @@ export async function ensureRepo(owner, code, { description = "", private_ = tru
const existing = await getRepo(owner, code); const existing = await getRepo(owner, code);
if (existing) return { repo: existing, created: false }; if (existing) return { repo: existing, created: false };
const env = giteaEnv(); 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 route = owner === me ? "/user/repos" : `/orgs/${owner}/repos`;
const res = await api("POST", route, { const res = await api("POST", route, {
name: code, name: code,
@@ -305,30 +390,57 @@ export function ensureClone(slug, area, url) {
// 檔案搬運:工作副本 <-> clone // 檔案搬運:工作副本 <-> clone
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
function listAreaFiles(root, area) { /**
* 這一區在工作副本裡有哪些檔案。
* 資料夾要**遞迴**收:`mindmap/threads/archive/`(睡眠時收起來的舊思維導圖)
* 是子資料夾,只看第一層的話它整個不會被同步。
*/
export function listAreaFiles(root, area) {
const out = []; const out = [];
const walk = (relDir) => {
let entries = [];
try {
entries = fs.readdirSync(path.join(root, relDir), { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const rel = path.posix.join(relDir, entry.name);
if (entry.isDirectory()) walk(rel);
else if (entry.isFile()) out.push(rel);
}
};
for (const rel of AREAS[area].paths) { for (const rel of AREAS[area].paths) {
const abs = path.join(root, rel);
if (rel.endsWith("/")) { if (rel.endsWith("/")) {
let entries = []; walk(rel.replace(/\/$/, ""));
try {
entries = fs.readdirSync(abs, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isFile()) out.push(path.posix.join(rel.replace(/\/$/, ""), entry.name));
}
continue; continue;
} }
const abs = path.join(root, rel);
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel); if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel);
} }
return out.sort(); return out.sort();
} }
// `-z`:路徑用 NUL 分隔、不做跳脫。少了它,含非 ASCII 的檔名(長期記憶的檔名就是中文的)
// 會被 git 引號跳脫成 `"Memory-\350\267\250…"`,跟工作副本比對不上——結果是那些檔案
// pull 時被靜靜略過、本機刪掉後也不會從遠端消失。
function listTrackedFiles(dir) { function listTrackedFiles(dir) {
const res = git(["ls-files"], dir); const res = git(["ls-files", "-z"], dir);
return res.ok ? res.stdout.split("\n").map((s) => s.trim()).filter(Boolean).sort() : []; return res.ok ? res.stdout.split("\0").filter(Boolean).sort() : [];
}
/** `git diff --name-only -z` → 檔名清單(理由同上)。 */
function diffPaths(dir, args) {
return git(["diff", "--name-only", "-z", ...args], dir).stdout.split("\0").filter(Boolean);
}
/** `git status --porcelain -z` → 檔名清單(同樣為了非 ASCII 檔名而用 -z)。 */
function statusPaths(dir) {
return git(["status", "--porcelain", "-z"], dir)
.stdout.split("\0")
// git() 會把輸出整個 trim 掉,所以第一筆的狀態欄前導空白可能已經不見了(" M x" → "M x"
.map((entry) => entry.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim())
.filter(Boolean);
} }
export const WIKI_MANIFEST = "_paths.json"; export const WIKI_MANIFEST = "_paths.json";
@@ -581,11 +693,21 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
} }
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit"); gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit");
let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
let overwrote = null;
if (!pushed.ok) { if (!pushed.ok) {
// 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源, // 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源,
// 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。 // 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。
//
// 但這條路徑**等同 force**:對方推上去的內容會被本機取代。所以要算出「蓋掉了哪幾個檔案、
// 上一版是哪個 commit」,一路回報到 sync 狀態裡——這條路可以走,但不能安靜地走。
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) { if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) {
const previous = git(["rev-parse", `origin/${branch}`], dir).stdout;
const base = git(["merge-base", "HEAD", `origin/${branch}`], dir).stdout;
// 分歧之後「對方」動過的檔案
const theirs = new Set(
base ? diffPaths(dir, [base, `origin/${branch}`]) : [],
);
git(["reset", "--hard", "--quiet", `origin/${branch}`], dir); git(["reset", "--hard", "--quiet", `origin/${branch}`], dir);
stageArea(slug, area, dir); stageArea(slug, area, dir);
if (area === "wiki") { if (area === "wiki") {
@@ -594,8 +716,13 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
} }
clearStaleIndexLock(dir); clearStaleIndexLock(dir);
git(["add", "-A"], dir); git(["add", "-A"], dir);
if (!git(["diff", "--cached", "--quiet"], dir).ok) { // 疊上本機工作副本後仍與遠端不同的檔案 = 這次要改寫的;其中對方也動過的 = 真的被蓋掉的
git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], dir); const ours = diffPaths(dir, ["--cached"]);
const clobbered = ours.filter((f) => theirs.has(f)).sort();
if (clobbered.length) overwrote = { files: clobbered, previous, branch, area };
if (ours.length) {
git(["commit", "-q", "-m",
`${message || "sync"}(以本機為準覆蓋遠端 ${clobbered.length} 個檔案,上一版 ${previous.slice(0, 8)}`], dir);
} }
} }
pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir); pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
@@ -605,8 +732,31 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
state.code = theCode; state.code = theCode;
state.owner = theOwner; state.owner = theOwner;
state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length }; state.areas[area] = { pushed_at: pl.nowIso(), checked_at: pl.nowIso(), files: staged.length };
if (overwrote) {
// 每輪對話後的 push 是背景執行、輸出丟掉的,所以覆蓋紀錄一定要落地:
// 留在 sync.json 裡等人來認領(`sync status` 會列,Stop hook 會提醒一次)。
state.overwrites = [...(state.overwrites || []), { at: pl.nowIso(), ...overwrote }].slice(-OVERWRITE_LOG_KEEP);
}
saveSyncState(slug, state); saveSyncState(slug, state);
return { ok: true, changed: true, files: staged.length, area, code: theCode }; return { ok: true, changed: true, files: staged.length, area, code: theCode, overwrote };
}
/** 還沒回報給使用者的「本機覆蓋遠端」紀錄。 */
export function pendingOverwrites(slug) {
return (loadSyncState(slug).overwrites || []).filter((entry) => !entry.reported_at);
}
/** 標記為已回報(同一次覆蓋只吵一次)。回傳這次標掉幾筆。 */
export function markOverwritesReported(slug) {
const state = loadSyncState(slug);
let marked = 0;
for (const entry of state.overwrites || []) {
if (entry.reported_at) continue;
entry.reported_at = pl.nowIso();
marked += 1;
}
if (marked) saveSyncState(slug, state);
return marked;
} }
/** /**
@@ -623,19 +773,14 @@ export async function pullArea(slug, area, { code = null, owner = null, force =
const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area)); const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼 stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼
clearStaleIndexLock(dir); clearStaleIndexLock(dir);
const localChanged = new Set( const localChanged = new Set(statusPaths(dir));
git(["status", "--porcelain"], dir)
.stdout.split("\n")
.map((l) => l.slice(3).trim())
.filter(Boolean),
);
const fetched = git(["fetch", "--quiet", "origin"], dir); const fetched = git(["fetch", "--quiet", "origin"], dir);
if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" }; if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" };
const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main"; const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
const remoteRef = `origin/${head}`; const remoteRef = `origin/${head}`;
const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir); const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir);
if (!exists.ok) return { ok: true, area, code: theCode, empty: true, changed: [] }; if (!exists.ok) return { ok: true, area, code: theCode, empty: true, changed: [] };
const incoming = git(["diff", "--name-only", "HEAD", remoteRef], dir).stdout.split("\n").filter(Boolean); const incoming = diffPaths(dir, ["HEAD", remoteRef]);
const conflicts = incoming.filter((f) => localChanged.has(f)); const conflicts = incoming.filter((f) => localChanged.has(f));
if (conflicts.length && !force) { if (conflicts.length && !force) {
git(["checkout", "--", "."], dir); git(["checkout", "--", "."], dir);
@@ -684,9 +829,7 @@ export async function verifyArea(slug, area, { code = null, owner = null } = {})
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode)); pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
} }
const files = stageArea(slug, area, dir); const files = stageArea(slug, area, dir);
const dirty = git(["status", "--porcelain"], dir) const dirty = statusPaths(dir);
// git() 會把輸出 trim 掉,所以 porcelain 開頭那個空白可能已經不見了(" M x" → "M x"
.stdout.split("\n").map((l) => l.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()).filter(Boolean);
git(["checkout", "--", "."], dir); git(["checkout", "--", "."], dir);
git(["clean", "-qfd"], dir); git(["clean", "-qfd"], dir);
return { return {
@@ -710,6 +853,86 @@ export async function verifyIconInWiki(slug, opts = {}) {
return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 }; return { ...res, icon_present: present, icon_pending: missing, ok: res.ok && present.length === 2 };
} }
/**
* 把一個**本機還沒有**的人格從 Gitea 整個拉回來。
*
* 兩區加起來就是一個完整的人格:Wiki 區帶回身分與長期結構(IDENTITY/SOUL/長期記憶/
* 心智圖/關係圖),檔案區帶回活狀態(編號、情緒、短期記憶、逐字)。沒收的只有執行期狀態
* lockguestssleeperssync),那本來就該由這台機器自己產生。
*
* `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 || "失敗").replace(/\s+/g, " ").slice(0, 100)}`)
.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,並把兩區都推上去。 */ /** 建立 Gitea 上的存取庫與 Wiki,並把兩區都推上去。 */
export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) { export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) {
const problem = giteaProblem(); const problem = giteaProblem();
+1 -1
View File
@@ -2925,7 +2925,7 @@ export const GUEST_SAFE_SUBCOMMANDS = new Set([
]); ]);
// owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格/設定預設人格),不算跨人格讀取 // owner 這些子指令本來就要提到別的人格名字(邀請/離場/查詢/匯入新人格/設定預設人格),不算跨人格讀取
export const OWNER_EXEMPT_SUBCOMMANDS = new Set([ 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)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。 // sleeper(睡眠 sub agent)只准做收尾:整理自己的記憶與圖、衰減情緒、同步、寫睡眠狀態。
// 不准 loadrelease(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、 // 不准 loadrelease(它用的是 sleeper 租約)、不准 invite/room(它不是去聊天的)、
+155 -10
View File
@@ -264,6 +264,7 @@ commands.create = async ({ flags }) => {
const session = requireSession(flags); const session = requireSession(flags);
// 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。 // 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。
let code = str(flags.code); let code = str(flags.code);
let codeNote = "";
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\``); if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\``);
if (!code) { if (!code) {
const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona)); const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona));
@@ -273,8 +274,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 (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`);
if (!next.checked_remote) codeNote = ` ⚠ 編號只對過本機,沒對過 Gitea(${next.reason})——別台機器可能已經用掉這個號。`;
} }
// 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格) // 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格)
const slug = str(flags.persona) || code; const slug = str(flags.persona) || code;
@@ -319,6 +324,7 @@ commands.create = async ({ flags }) => {
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null }); pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null }); pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`); ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`);
if (codeNote) say(codeNote);
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`); say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
// Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。 // Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。
if (!flags["no-gitea"] && !gt.giteaProblem()) { if (!flags["no-gitea"] && !gt.giteaProblem()) {
@@ -1586,6 +1592,104 @@ commands.import = ({ flags }) => {
emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines); 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 <id>`(本機還沒有的那些)",
]);
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 存取庫的名稱。 * 人格編號:英文名全大寫 + 兩位索引(同名才遞增),也就是 Gitea 存取庫的名稱。
* 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。 * 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。
@@ -1596,9 +1700,14 @@ commands.code = async ({ flags, positional }) => {
if (action === "next") { if (action === "next") {
const base = gt.normalizeRomaji(str(flags.romaji)); const base = gt.normalizeRomaji(str(flags.romaji));
if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。"); if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。");
const next = gt.nextCode(base); const next = await gt.nextCodeAcrossMachines(base, { owner: str(flags.owner) || null });
if (!next) die(`\`${base}\` 的編號已經用到 99。`); if (!next.code) die(`\`${base}\` 的編號已經用到 99。`);
emit({ romaji: base, code: next }, flags.json, [`\`${base}\` 的下一個可用編號:\`${next}\``]); emit({ romaji: base, ...next }, flags.json, [
`\`${base}\` 的下一個可用編號:\`${next.code}\``,
next.checked_remote
? ` (已對過 Gitea 上的 ${next.taken.length} 個編號)`
: ` ⚠ 只看了本機,沒對過 Gitea(${next.reason})——換機器可能會撞號。`,
]);
return; return;
} }
const slug = hostOf(flags, session); const slug = hostOf(flags, session);
@@ -1617,12 +1726,18 @@ commands.code = async ({ flags, positional }) => {
const existing = gt.personaCode(slug); const existing = gt.personaCode(slug);
if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`); if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`);
let code = str(flags.code); let code = str(flags.code);
let codeNote = "";
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`); if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`);
if (!code) { if (!code) {
const base = gt.normalizeRomaji(str(flags.romaji) || slug); const base = gt.normalizeRomaji(str(flags.romaji) || slug);
if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。"); 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。`); if (!code) die(`\`${base}\` 的編號已經用到 99。`);
codeNote = next.checked_remote
? ` (已對過 Gitea 上的 ${next.taken.length} 個編號,不會跟別台機器撞號)`
: ` ⚠ 編號只對過本機,沒對過 Gitea(${next.reason})——別台機器可能已經用掉這個號。`;
} }
const config = pl.loadConfig(slug); const config = pl.loadConfig(slug);
config.persona = slug; config.persona = slug;
@@ -1630,7 +1745,7 @@ commands.code = async ({ flags, positional }) => {
config.romaji = gt.codePrefix(code); config.romaji = gt.codePrefix(code);
config.schema = 2; config.schema = 2;
pl.writeJson(pl.configPath(slug), config); pl.writeJson(pl.configPath(slug), config);
const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\``]; const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\``, ...(codeNote ? [codeNote] : [])];
let current = slug; let current = slug;
if (flags.rename && slug !== code) { if (flags.rename && slug !== code) {
if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`); if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`);
@@ -1919,6 +2034,7 @@ commands.sync = async ({ flags, positional }) => {
if (action === "status") { if (action === "status") {
const state = gt.loadSyncState(slug); const state = gt.loadSyncState(slug);
const problem = gt.giteaProblem(); const problem = gt.giteaProblem();
const overwrites = state.overwrites || [];
emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [ emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [
`人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}Gitea ${problem ? `${problem}` : "✔ 已設定"}`, `人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}Gitea ${problem ? `${problem}` : "✔ 已設定"}`,
` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`"}`, ` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`"}`,
@@ -1927,6 +2043,15 @@ commands.sync = async ({ flags, positional }) => {
return ` ${gt.AREAS[key].label}${gt.AREAS[key].why}):` + return ` ${gt.AREAS[key].label}${gt.AREAS[key].why}):` +
`最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}${info.files ?? "?"} 個檔案`; `最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}${info.files ?? "?"} 個檔案`;
}), }),
// 「本機是真相來源」的代價:push 撞到別台機器時是本機贏。贏了要記帳。
...(overwrites.length
? [" ⚠ 本機曾覆蓋遠端(別台機器同時在用同一個人格):"]
.concat(overwrites.slice(-5).map((o) =>
` ${o.at} ${gt.AREAS[o.area]?.label || o.area} ${o.files.length} 個檔案` +
`${o.files.slice(0, 4).join(", ")}${o.files.length > 4 ? "…" : ""}` +
` 上一版 ${String(o.previous || "").slice(0, 8)}`))
.concat([` 要看回被蓋掉的內容:\`git -C ${gt.syncDir(slug, overwrites.at(-1).area)} show <上一版>:<檔案>\``])
: []),
]); ]);
return; return;
} }
@@ -1958,9 +2083,20 @@ commands.sync = async ({ flags, positional }) => {
out.push({ area: key, ok: false, reason: err.message }); out.push({ area: key, ok: false, reason: err.message });
} }
} }
// 覆蓋遠端這件事在 --quiet(背景 push)下也要留下痕跡:stderr 一定寫,sync.json 也記著
for (const r of out) {
if (!r.overwrote) continue;
process.stderr.write(
`${gt.AREAS[r.area].label}:以本機為準覆蓋了遠端 ${r.overwrote.files.length} 個檔案` +
`${r.overwrote.files.slice(0, 6).join(", ")}),上一版是 ${String(r.overwrote.previous).slice(0, 8)}\n` +
` 別台機器可能正在用同一個人格。要看回被蓋掉的內容:` +
`git -C ${gt.syncDir(slug, r.area)} show ${String(r.overwrote.previous).slice(0, 8)}:<檔案>\n`,
);
}
emit({ persona: slug, results: out }, flags.json, out.map((r) => emit({ persona: slug, results: out }, flags.json, out.map((r) =>
r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason}` r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason}`
: r.ok ? `${gt.AREAS[r.area].label}${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}` : r.ok ? `${gt.AREAS[r.area].label}${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}` +
(r.overwrote ? ` ⚠ 其中覆蓋了遠端 ${r.overwrote.files.length} 個檔案(上一版 ${String(r.overwrote.previous).slice(0, 8)}` : "")
: `${gt.AREAS[r.area].label}${String(r.reason).slice(0, 160)}`)); : `${gt.AREAS[r.area].label}${String(r.reason).slice(0, 160)}`));
return; return;
} }
@@ -2106,17 +2242,26 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。 產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
編號與 Gitea(存取庫名稱 = 人格編號): 編號與 Gitea(存取庫名稱 = 人格編號):
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public] code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public --owner]
發新編號前會先問遠端有哪些編號(避免換機器撞號);Gitea 連不上就只看本機並明講
clone --session <id> [--code <ASUNA-01>|<ASUNA-01>] [--persona <目錄名> --owner --force --load --json]
把一個**本機還沒有**的人格從 Gitea 整個拉回來(換機器接續同一個人格的第一步)。
不帶 --code 就列出遠端有哪些人格、哪些本機還沒有。
唯一不用先載入該人格的同步入口(本機沒有它就 load 不了它)。
本機已有同名人格時預設不覆蓋:要蓋加 --force,或用 --persona 拉成第二份。
sync status|init|push|pull|verify --session <id> [--area files|wiki|all --if-due --force --message --owner] sync status|init|push|pull|verify --session <id> [--area files|wiki|all --if-due --force --message --owner]
verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步) verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步)
檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push 檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push
Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push
push 撞到別台機器時以**本機為準**覆蓋遠端,並回報蓋掉哪些檔案與上一版 sha
sync.json 記帳、sync status 列得出來、下一輪的 Stop hook 會提醒一次)
環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER), 環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER),
PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔 PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔
搬家(離線檔案) 搬家:
export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格 export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
import --session <id> --file <檔案> [--persona <新 slug> --force --load] import --session <id> --file <檔案> [--persona <新 slug> --force --load] 從 bundle 檔匯入
clone --session <id> [--code <ASUNA-01>] 從 Gitea 匯入(見上)
維護: 維護:
gc 清理死鎖與過期租約 gc 清理死鎖與過期租約
+166
View File
@@ -1134,6 +1134,14 @@ check("高頻資料在檔案區、低頻資料在 Wiki 區",
covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" && covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" &&
covered("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" && covered("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" &&
covered("relations/graph.json")[0] === "wiki"); covered("relations/graph.json")[0] === "wiki");
// 分區的路徑有好幾個是資料夾,而資料夾裡還有資料夾:`mindmap/threads/archive/`
// (睡眠時把太久沒動的思維導圖收進去)只看第一層的話整個不會被同步。
check("同步範圍會遞迴進子資料夾(mindmap/threads/archive 以前整個漏掉)", (() => {
const dir = path.join(pl.personaDir("alpha"), "mindmap", "threads", "archive");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "old-thread.mmd"), "%% 收起來的舊思維導圖\nflowchart TD\n");
return gt.listAreaFiles(pl.personaDir("alpha"), "files").includes("mindmap/threads/archive/old-thread.mmd");
})(), gt.listAreaFiles(pl.personaDir("alpha"), "files").join(", "));
check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => { check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => {
const res = cli(["sync", "status", "--session", S_CODE]); const res = cli(["sync", "status", "--session", S_CODE]);
const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false }); const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false });
@@ -1926,6 +1934,164 @@ console.log("\n注入區塊不可被人格檔案逸出(S6)");
cli(["release", "--session", S_INJ]); cli(["release", "--session", S_INJ]);
} }
// --------------------------------------------------------------------------- //
console.log("\n㉒ 從 Gitea 匯入本機還沒有的人格(換一台機器接續同一個人格)");
{
// 「假的 Gitea」:兩個裸倉庫 + file:// 當 host。不碰網路、更不碰真的 Gitea。
// APIgetRepo)在 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("guardclone 不算跨人格操作(已載入別的人格時也放行)",
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(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`); console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
process.exit(failed ? 1 : 0); process.exit(failed ? 1 : 0);
+35 -2
View File
@@ -23,6 +23,8 @@ description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大
- 中文/日文名字要先轉**羅馬拼音**:由你提議拼法(`亞絲娜 → Asuna``沈宇 → Shen Yu`), - 中文/日文名字要先轉**羅馬拼音**:由你提議拼法(`亞絲娜 → Asuna``沈宇 → Shen Yu`),
**拿給使用者確認再送出**——拼錯了會變成一個很難改的編號。 **拿給使用者確認再送出**——拼錯了會變成一個很難改的編號。
- 查下一個可用編號:`code next --romaji Asuna --session <id>` - 查下一個可用編號:`code next --romaji Asuna --session <id>`
發號前會先問遠端 Gitea 已經用掉哪些編號(本機看不到別台機器發過的號);
Gitea 連不上時照樣發得出來,但輸出會明講「只對過本機」——那就有撞號的風險,要轉告使用者。
## 資料放哪裡(依更新頻率切) ## 資料放哪裡(依更新頻率切)
@@ -85,13 +87,31 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" code assign \
### 在另一台機器接續同一個人格 ### 在另一台機器接續同一個人格
在新機器上設好 `GITEA_HOST``GITEA_TOKEN`,然後 在新機器上設好 `GITEA_HOST``GITEA_TOKEN`。**本機已經有這個人格**(只是舊了)
```bash ```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona <編號> --session <PERSONA_SESSION>
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session <PERSONA_SESSION> node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session <PERSONA_SESSION>
``` ```
(人格目錄還不存在的話,先用 `/jsc-persona:persona-transfer` 匯入一份,或手動 clone 存取庫。) **本機還沒有這個人格**(全新的機器):`sync` 的每個動作都要求「先載入那個人格」,
而本機沒有它就 load 不了它,所以走 `clone`——它只驗 session,不驗 host
```bash
# 1) 遠端有哪些人格、哪些本機還沒有
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" clone --session <PERSONA_SESSION>
# 2) 挑一個拉回來(存取庫名稱就是編號)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" clone --code ASUNA-01 --session <PERSONA_SESSION>
```
拉回來的是**完整的人格**:Wiki 區帶回身分與長期記憶,檔案區帶回情緒與短期記憶,
拉完自動重建長期記憶索引與關係圖。沒帶回來的只有鎖與租約那類執行期狀態——那本來就該由這台機器自己產生。
- 本機已經有同名人格時**預設不覆蓋**。真的要以遠端為準才加 `--force`(本機沒推上去的改動會不見),
想並存兩份就用 `--persona <別的目錄名>`
-`--load` 可以拉完直接載入開聊。
- 拉回來的東西沒有 `IDENTITY.md`(人格的最低要件)就會中止並清掉半成品,不留半個人格在本機。
## 衝突 ## 衝突
@@ -104,6 +124,18 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session <PERSONA_SE
-**本機** 為準 → `sync push`(會蓋掉遠端) -**本機** 為準 → `sync push`(會蓋掉遠端)
3. **不要自己選**。記憶被蓋掉是不可逆的。 3. **不要自己選**。記憶被蓋掉是不可逆的。
### push 撞到別台機器時是本機贏——但會記帳
`sync push`(包含每輪對話後的背景推送)遇到遠端比較新時,會**以本機為準覆蓋遠端**,
因為本機的工作副本才是這台機器的真相來源。這條路可以走,但不會安靜地走:
- 回報「覆蓋了哪幾個檔案、上一版是哪個 commit」,並記進 `state/sync.json`
- `sync status` 列得出來;下一輪的 `Stop` hook 會把它講給使用者聽一次。
- 被蓋掉的內容還在 clone 的歷史裡:
`git -C <人格>/.sync/files show <上一版 sha>:<檔案>`
看到這種回報就**告訴使用者**:另一台機器可能正在用同一個人格。要救回舊版就從上面那行取出來。
## 殘留的 git 鎖 ## 殘留的 git 鎖
`push``pull` 跑到一半被中斷(sub agent 被砍、視窗關掉)會在 clone 裡留下 `.git/index.lock` `push``pull` 跑到一半被中斷(sub agent 被砍、視窗關掉)會在 clone 裡留下 `.git/index.lock`
@@ -117,6 +149,7 @@ sleeper 連自己的人格目錄都不能用 shell 寫,那條路本來就是
## 邊界 ## 邊界
- 只能同步「本 session 目前載入的人格」——跨人格同步等於跨人格讀取,會被 hook 擋下。 - 只能同步「本 session 目前載入的人格」——跨人格同步等於跨人格讀取,會被 hook 擋下。
唯一的例外是 `clone`:它只會**新增本機還沒有的人格**,讀不到任何既有人格的資料,所以不受此限。
- guest`persona-guest` sub agent)不能同步,它對人格檔案唯讀。 - guest`persona-guest` sub agent)不能同步,它對人格檔案唯讀。
- `.sync/` 是兩區的 git clone 快取,**不要手動編輯**;砍掉它不會掉資料,下次 push 會重新 clone。 - `.sync/` 是兩區的 git clone 快取,**不要手動編輯**;砍掉它不會掉資料,下次 push 會重新 clone。
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
+4
View File
@@ -10,6 +10,10 @@ description: 匯出與匯入人格:把一個人格(身分、靈魂、十二
bundle 是**單一 JSON 檔**(可 gzip),不依賴任何外部工具,複製到隨身碟、貼進 git、 bundle 是**單一 JSON 檔**(可 gzip),不依賴任何外部工具,複製到隨身碟、貼進 git、
傳給另一台機器都可以。 傳給另一台機器都可以。
> 只是要在另一台機器接續同一個人格、而且兩邊都連得到 Gitea 的話,**不必經過檔案**:
> 用 `/jsc-persona:persona-sync` 的 `clone --code <編號>` 直接從存取庫拉一份回來。
> 這裡的 bundle 是給離線、換助理、或想留一份快照的情境。
--- ---
## bundle 帶走什麼、不帶什麼 ## bundle 帶走什麼、不帶什麼