merge(gitea): 同步漏檔/push 衝突回報/clone 新功能/錯誤訊息
衝突:scripts/selftest.mjs 檔尾兩份都各自 append 一個測試區塊 (guard 的「注入區塊不可被人格檔案逸出(S6)」與 gitea 的「㉒ 從 Gitea 匯入本機還沒有的人格」)。兩塊都保留,各自收好自己的 `}`。
This commit is contained in:
+155
-10
@@ -264,6 +264,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));
|
||||
@@ -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 (!next.checked_remote) codeNote = ` ⚠ 編號只對過本機,沒對過 Gitea(${next.reason})——別台機器可能已經用掉這個號。`;
|
||||
}
|
||||
// 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格)
|
||||
const slug = str(flags.persona) || code;
|
||||
@@ -319,6 +324,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()) {
|
||||
@@ -1586,6 +1592,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 <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 存取庫的名稱。
|
||||
* 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。
|
||||
@@ -1596,9 +1700,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);
|
||||
@@ -1617,12 +1726,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;
|
||||
@@ -1630,7 +1745,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}\` 已存在,無法改名。`);
|
||||
@@ -1919,6 +2034,7 @@ commands.sync = async ({ flags, positional }) => {
|
||||
if (action === "status") {
|
||||
const state = gt.loadSyncState(slug);
|
||||
const problem = gt.giteaProblem();
|
||||
const overwrites = state.overwrites || [];
|
||||
emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [
|
||||
`人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}|Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`,
|
||||
` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`)"}`,
|
||||
@@ -1927,6 +2043,15 @@ commands.sync = async ({ flags, positional }) => {
|
||||
return ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):` +
|
||||
`最後 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;
|
||||
}
|
||||
@@ -1958,9 +2083,20 @@ commands.sync = async ({ flags, positional }) => {
|
||||
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) =>
|
||||
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)}`));
|
||||
return;
|
||||
}
|
||||
@@ -2106,17 +2242,26 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
產出 icon.svg + icon.png,設為 Gitea 存取庫頭像並同步到 Wiki 區。
|
||||
|
||||
編號與 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]
|
||||
verify 會確認「本機 = Gitea」,不一致就以非零結束(形象圖必須同步)
|
||||
檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push
|
||||
Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push
|
||||
push 撞到別台機器時以**本機為準**覆蓋遠端,並回報蓋掉哪些檔案與上一版 sha
|
||||
(sync.json 記帳、sync status 列得出來、下一輪的 Stop hook 會提醒一次)
|
||||
環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER),
|
||||
PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔
|
||||
|
||||
搬家(離線檔案):
|
||||
搬家:
|
||||
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 清理死鎖與過期租約
|
||||
|
||||
Reference in New Issue
Block a user