Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
980 lines
41 KiB
JavaScript
980 lines
41 KiB
JavaScript
// persona-gitea.mjs — 人格的 Gitea 儲存層(人格編號 = 存取庫名稱)
|
||
//
|
||
// 設計:
|
||
// * 本機 `~/.claude/personas/<CODE>/` 仍是**工作副本**,hook 每輪照常讀寫本機檔案(零延遲)。
|
||
// * Gitea 上每個人格一個私有存取庫,名稱就是人格編號(例如 `ASUNA-01`)。
|
||
// * 依**更新頻率**分兩區:
|
||
// - 檔案區(主存取庫):每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿…)
|
||
// - Wiki 區:低頻的身分與長期結構(IDENTITY/SOUL、長期記憶、心智圖、關係圖),當設定百科看
|
||
// * 兩區各自 clone 在 `<persona>/.sync/<area>/`,push 前把工作副本的檔案複製進去再 commit。
|
||
// (不在人格目錄本身放 .git:一個目錄要同時屬於兩個 repo 是行不通的。)
|
||
//
|
||
// 網路一律**失敗不阻斷**:Gitea 掛掉、沒設 token、離線,人格照樣能聊天。
|
||
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import crypto from "node:crypto";
|
||
import { spawn, spawnSync } from "node:child_process";
|
||
import * as pl from "./persona-lib.mjs";
|
||
|
||
export const SYNC_DIRNAME = ".sync";
|
||
export const DEFAULT_MIN_PUSH_SECONDS = 60;
|
||
// sync.json 裡「本機覆蓋遠端」的紀錄留幾筆
|
||
export const OVERWRITE_LOG_KEEP = 10;
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 人格編號:英文名全大寫 + 兩位索引(同名才遞增)
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const CODE_RE = /^[A-Z][A-Z0-9]{0,23}-\d{2}$/;
|
||
|
||
export const validCode = (code) => typeof code === "string" && CODE_RE.test(code);
|
||
|
||
/** `Asuna` / `asuna sao` / `Shen Yu` → `ASUNA` / `ASUNASAO` / `SHENYU`。非拉丁字元一律拒絕。 */
|
||
export function normalizeRomaji(romaji) {
|
||
const raw = String(romaji ?? "").normalize("NFKD").replace(/[̀-ͯ]/g, "");
|
||
const letters = raw.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
||
if (!letters || !/^[A-Z]/.test(letters)) return null;
|
||
return letters.slice(0, 24);
|
||
}
|
||
|
||
export const codePrefix = (code) => String(code ?? "").split("-")[0] || "";
|
||
|
||
/**
|
||
* 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。
|
||
* `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()) {
|
||
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;
|
||
if (validCode(code)) return code;
|
||
return validCode(slug) ? slug : null;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 兩個儲存區:依更新頻率切
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export const AREAS = {
|
||
// 高頻:每輪對話都在變 → 主存取庫的檔案區,Gitea 網頁上一眼看到最新狀態
|
||
files: {
|
||
key: "files",
|
||
label: "檔案區",
|
||
why: "高頻:每輪對話都在變",
|
||
paths: [
|
||
"state/config.json",
|
||
"state/emotion.json",
|
||
"state/inner.jsonl",
|
||
"state/said.jsonl",
|
||
"state/felt.jsonl",
|
||
"state/sleep.json",
|
||
"memory/short-term.jsonl",
|
||
"memory/inbox/",
|
||
"mindmap/threads/",
|
||
"journal/",
|
||
],
|
||
},
|
||
// 低頻:身分與長期結構 → Wiki,當「設定百科」讀
|
||
wiki: {
|
||
key: "wiki",
|
||
label: "Wiki 區",
|
||
why: "低頻:身分與長期結構,當設定百科看",
|
||
paths: [
|
||
"IDENTITY.md",
|
||
"SOUL.md",
|
||
"AGENTS.md",
|
||
"USER.md",
|
||
"icon.svg",
|
||
"icon.png",
|
||
"icon/",
|
||
"memory/INDEX.md",
|
||
"memory/long-term/",
|
||
"mindmap/semantic.mmd",
|
||
"relations/graph.json",
|
||
"relations/graph.mmd",
|
||
],
|
||
},
|
||
};
|
||
|
||
export const AREA_KEYS = Object.keys(AREAS);
|
||
|
||
export const syncDir = (slug, area) => path.join(pl.personaDir(slug), SYNC_DIRNAME, area);
|
||
export const syncStatePath = (slug) => path.join(pl.personaDir(slug), "state", "sync.json");
|
||
|
||
export function loadSyncState(slug) {
|
||
const data = pl.readJson(syncStatePath(slug), {}) ?? {};
|
||
data.areas ??= {};
|
||
for (const key of AREA_KEYS) data.areas[key] ??= {};
|
||
return data;
|
||
}
|
||
|
||
export const saveSyncState = (slug, data) => pl.writeJson(syncStatePath(slug), data);
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 環境與 API
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export function giteaEnv() {
|
||
const host = String(process.env.PERSONA_GITEA_HOST || process.env.GITEA_HOST || "").replace(/\/+$/, "");
|
||
const token = String(process.env.PERSONA_GITEA_TOKEN || process.env.GITEA_TOKEN || "");
|
||
const owner = String(process.env.PERSONA_GITEA_OWNER || "");
|
||
const off = /^(0|off|false|no)$/i.test(String(process.env.PERSONA_GITEA || ""));
|
||
return { host, token, owner, enabled: Boolean(host && token) && !off, disabled: off };
|
||
}
|
||
|
||
export function giteaProblem() {
|
||
const env = giteaEnv();
|
||
if (env.disabled) return "PERSONA_GITEA 被設為關閉。";
|
||
if (!env.host) return "沒有 `PERSONA_GITEA_HOST`/`GITEA_HOST`。";
|
||
if (!env.token) return "沒有 `PERSONA_GITEA_TOKEN`/`GITEA_TOKEN`。";
|
||
return null;
|
||
}
|
||
|
||
async function api(method, route, body = null) {
|
||
const { host, token } = giteaEnv();
|
||
const res = await fetch(`${host}/api/v1${route}`, {
|
||
method,
|
||
headers: {
|
||
Authorization: `token ${token}`,
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: body === null ? undefined : JSON.stringify(body),
|
||
});
|
||
const text = await res.text();
|
||
let json = null;
|
||
try {
|
||
json = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
json = null;
|
||
}
|
||
return { ok: res.ok, status: res.status, json, text };
|
||
}
|
||
|
||
const ownerCachePath = () => path.join(pl.runtimeDir(), "gitea.json");
|
||
|
||
/** token 本人的帳號(**不受** `PERSONA_GITEA_OWNER` 影響;會快取)。 */
|
||
export async function giteaLogin() {
|
||
const env = giteaEnv();
|
||
const cached = pl.readJson(ownerCachePath(), {}) ?? {};
|
||
if (cached.host === env.host && cached.login) return cached.login;
|
||
const res = await api("GET", "/user");
|
||
if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${res.text.slice(0, 120)}`);
|
||
pl.writeJson(ownerCachePath(), { host: env.host, login: res.json.login, cached_at: pl.nowIso() });
|
||
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;
|
||
}
|
||
|
||
/** 建立(或沿用)人格的私有存取庫。存取庫名稱 = 人格編號。 */
|
||
export async function ensureRepo(owner, code, { description = "", private_ = true } = {}) {
|
||
const existing = await getRepo(owner, code);
|
||
if (existing) return { repo: existing, created: false };
|
||
const env = giteaEnv();
|
||
// 建庫的路由要看「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,
|
||
private: private_,
|
||
description: description || `jsc-persona 人格 ${code}`,
|
||
auto_init: false,
|
||
});
|
||
if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
|
||
void env;
|
||
return { repo: res.json, created: true };
|
||
}
|
||
|
||
/** Wiki 的 git repo 要有第一頁才會存在,用 API 建 Home 頁。 */
|
||
export async function ensureWikiHome(owner, code, content) {
|
||
const page = await api("GET", `/repos/${owner}/${encodeURIComponent(code)}/wiki/page/Home`);
|
||
if (page.ok) return false;
|
||
const res = await api("POST", `/repos/${owner}/${encodeURIComponent(code)}/wiki/new`, {
|
||
title: "Home",
|
||
content_base64: Buffer.from(content, "utf8").toString("base64"),
|
||
message: "init: 人格設定百科",
|
||
});
|
||
if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
|
||
return true;
|
||
}
|
||
|
||
/** 把人格圖示設成存取庫頭像(Gitea 各處的清單就會顯示這個人格的臉)。 */
|
||
export async function setRepoAvatar(owner, code, pngBuffer) {
|
||
const res = await api("POST", `/repos/${owner}/${encodeURIComponent(code)}/avatar`, {
|
||
image: Buffer.from(pngBuffer).toString("base64"),
|
||
});
|
||
return res.ok;
|
||
}
|
||
|
||
export const repoUrl = (host, owner, code, area) =>
|
||
`${host}/${owner}/${encodeURIComponent(code)}${area === "wiki" ? ".wiki" : ""}.git`;
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// git(用 CLI,認證走 http.extraHeader,不把 token 寫進 .git/config 也不塞進參數)
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
function gitEnv() {
|
||
const { token } = giteaEnv();
|
||
const env = {
|
||
...process.env,
|
||
GIT_TERMINAL_PROMPT: "0",
|
||
GIT_AUTHOR_NAME: process.env.PERSONA_GIT_NAME || "jsc-persona",
|
||
GIT_AUTHOR_EMAIL: process.env.PERSONA_GIT_EMAIL || "persona@localhost",
|
||
GIT_COMMITTER_NAME: process.env.PERSONA_GIT_NAME || "jsc-persona",
|
||
GIT_COMMITTER_EMAIL: process.env.PERSONA_GIT_EMAIL || "persona@localhost",
|
||
};
|
||
if (token) {
|
||
env.GIT_CONFIG_COUNT = "1";
|
||
env.GIT_CONFIG_KEY_0 = "http.extraHeader";
|
||
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
|
||
}
|
||
return env;
|
||
}
|
||
|
||
export function git(args, cwd = null) {
|
||
const proc = spawnSync("git", args, { cwd: cwd || undefined, env: gitEnv(), encoding: "utf8" });
|
||
return {
|
||
ok: proc.status === 0,
|
||
status: proc.status,
|
||
stdout: String(proc.stdout || "").trim(),
|
||
stderr: String(proc.stderr || "").trim(),
|
||
};
|
||
}
|
||
|
||
function gitOrThrow(args, cwd, what) {
|
||
const res = git(args, cwd);
|
||
if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${res.stderr || res.stdout}`);
|
||
return res;
|
||
}
|
||
|
||
// 這些 clone 只有本 CLI 會動,而且每個 git 都是同步跑完才回來,所以一個超過
|
||
// STALE_LOCK_SECONDS 還在的 index.lock 一定是上一次跑到一半被砍掉留下的殘骸。
|
||
// 不自己清的話整區會一直失敗,而 sleeper 被隔離 hook 擋著、連自己的鎖都刪不掉。
|
||
const STALE_LOCK_SECONDS = 30;
|
||
|
||
export function clearStaleIndexLock(dir) {
|
||
const lock = path.join(dir, ".git", "index.lock");
|
||
let stat;
|
||
try {
|
||
stat = fs.statSync(lock);
|
||
} catch {
|
||
return null;
|
||
}
|
||
const ageSeconds = (Date.now() - stat.mtimeMs) / 1000;
|
||
if (ageSeconds < STALE_LOCK_SECONDS) return null;
|
||
try {
|
||
fs.rmSync(lock);
|
||
} catch (err) {
|
||
return { cleared: false, ageSeconds, reason: String(err.message || err) };
|
||
}
|
||
return { cleared: true, ageSeconds };
|
||
}
|
||
|
||
/** 確保 `<persona>/.sync/<area>/` 是該區的 clone;空存取庫也能處理。 */
|
||
export function ensureClone(slug, area, url) {
|
||
const dir = syncDir(slug, area);
|
||
if (fs.existsSync(path.join(dir, ".git"))) {
|
||
git(["remote", "set-url", "origin", url], dir);
|
||
return dir;
|
||
}
|
||
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
||
fs.rmSync(dir, { recursive: true, force: true });
|
||
const cloned = git(["clone", "--quiet", url, dir]);
|
||
if (!cloned.ok) {
|
||
// 空存取庫 clone 會警告但成功;真的失敗才自己 init
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
gitOrThrow(["init", "--quiet", "-b", "main"], dir, "初始化");
|
||
gitOrThrow(["remote", "add", "origin", url], dir, "設定 remote");
|
||
}
|
||
return dir;
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 檔案搬運:工作副本 <-> clone
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
/**
|
||
* 這一區在工作副本裡有哪些檔案。
|
||
* 資料夾要**遞迴**收:`mindmap/threads/archive/`(睡眠時收起來的舊思維導圖)
|
||
* 是子資料夾,只看第一層的話它整個不會被同步。
|
||
*/
|
||
export function listAreaFiles(root, area) {
|
||
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) {
|
||
if (rel.endsWith("/")) {
|
||
walk(rel.replace(/\/$/, ""));
|
||
continue;
|
||
}
|
||
const abs = path.join(root, rel);
|
||
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel);
|
||
}
|
||
return out.sort();
|
||
}
|
||
|
||
// `-z`:路徑用 NUL 分隔、不做跳脫。少了它,含非 ASCII 的檔名(長期記憶的檔名就是中文的)
|
||
// 會被 git 引號跳脫成 `"Memory-\350\267\250…"`,跟工作副本比對不上——結果是那些檔案
|
||
// pull 時被靜靜略過、本機刪掉後也不會從遠端消失。
|
||
function listTrackedFiles(dir) {
|
||
const res = git(["ls-files", "-z"], dir);
|
||
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";
|
||
const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]);
|
||
const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" };
|
||
|
||
/**
|
||
* Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404),
|
||
* 所以低頻區的檔案要攤平成根層檔名(`memory/long-term/x.md` → `Memory-x.md`,
|
||
* 網頁上顯示為「Memory x」),再用 `_paths.json` 記住原本的路徑,pull 時才還原得回去。
|
||
*/
|
||
export function wikiName(rel) {
|
||
if (!rel.includes("/")) return rel;
|
||
// 只有 .md 需要攤平——Gitea 只把「根目錄的 .md」當成頁面。
|
||
// 其他附件(圖片、mmd、json)放子資料夾沒問題:實測 /wiki/raw/icon/portrait.svg 取得到,
|
||
// 而且 Markdown 圖片語法會被自動改寫成那個 raw 路徑。
|
||
if (!/\.md$/i.test(rel)) return rel;
|
||
const parts = rel.split("/");
|
||
const file = parts.pop();
|
||
return `${WIKI_PREFIX[parts[0]] || parts[0]}-${file}`;
|
||
}
|
||
|
||
/** 工作副本相對路徑 → clone 內的檔名。攤平後撞名的話補上短雜湊。 */
|
||
function buildNameMap(root, area) {
|
||
const map = new Map();
|
||
const taken = new Map();
|
||
for (const rel of listAreaFiles(root, area)) {
|
||
let name = area === "wiki" ? wikiName(rel) : rel;
|
||
if (taken.has(name) && taken.get(name) !== rel) {
|
||
const ext = path.extname(name);
|
||
const hash = crypto.createHash("md5").update(rel).digest("hex").slice(0, 6);
|
||
name = `${name.slice(0, name.length - ext.length)}~${hash}${ext}`;
|
||
}
|
||
taken.set(name, rel);
|
||
map.set(rel, name);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
const readManifest = (area, dir) =>
|
||
area === "wiki" ? pl.readJson(path.join(dir, WIKI_MANIFEST), {}) ?? {} : {};
|
||
|
||
/** clone 內的檔名 → 工作副本相對路徑。 */
|
||
export function cloneNameToRel(area, dir, name) {
|
||
if (area !== "wiki") return name;
|
||
return readManifest(area, dir)[name] || name;
|
||
}
|
||
|
||
/** 把工作副本裡屬於這一區的檔案複製進 clone;clone 裡多出來的(已刪除的)一併移除。 */
|
||
function stageArea(slug, area, dir) {
|
||
const root = pl.personaDir(slug);
|
||
const map = buildNameMap(root, area);
|
||
const keep = new Set(map.values());
|
||
if (area === "wiki") for (const name of WIKI_RESERVED) keep.add(name);
|
||
for (const [rel, name] of map) {
|
||
const target = path.join(dir, name);
|
||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||
fs.copyFileSync(path.join(root, rel), target);
|
||
}
|
||
if (area === "wiki") {
|
||
const manifest = {};
|
||
for (const [rel, name] of map) manifest[name] = rel;
|
||
pl.writeText(path.join(dir, WIKI_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
||
}
|
||
for (const name of listTrackedFiles(dir)) {
|
||
if (keep.has(name)) continue;
|
||
fs.rmSync(path.join(dir, name), { force: true });
|
||
}
|
||
return [...map.keys()];
|
||
}
|
||
|
||
/** 把 clone 裡的檔案寫回工作副本(wiki 區依 `_paths.json` 還原成原本的路徑)。 */
|
||
function unstageArea(slug, area, dir, only = null) {
|
||
const root = pl.personaDir(slug);
|
||
const manifest = readManifest(area, dir);
|
||
const written = [];
|
||
for (const name of listTrackedFiles(dir)) {
|
||
if (area === "wiki" && WIKI_RESERVED.has(name)) continue;
|
||
if (only && !only.has(name)) continue;
|
||
const src = path.join(dir, name);
|
||
if (!fs.existsSync(src)) continue;
|
||
const rel = manifest[name] || name;
|
||
const target = path.join(root, rel);
|
||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||
fs.copyFileSync(src, target);
|
||
written.push(rel);
|
||
}
|
||
return written;
|
||
}
|
||
|
||
/**
|
||
* Wiki 的「形象圖」頁:把 icon.svg 與 icon.png 都保存在 Wiki 並展示出來,
|
||
* 附上這張圖是從哪張參考照片來的(可查證)。
|
||
*/
|
||
export function wikiIconPage(slug, code) {
|
||
const ident = pl.identityFields(slug);
|
||
const config = pl.loadConfig(slug);
|
||
const icon = config.icon || {};
|
||
const src = icon.source || {};
|
||
const root = pl.personaDir(slug);
|
||
const hasSvg = fs.existsSync(path.join(root, "icon.svg"));
|
||
const hasPng = fs.existsSync(path.join(root, "icon.png"));
|
||
let renders = [];
|
||
try {
|
||
renders = fs.readdirSync(path.join(root, "icon"))
|
||
.filter((n) => /\.(svg|png)$/i.test(n))
|
||
.sort()
|
||
.map((name) => ({
|
||
name,
|
||
ext: name.split(".").pop(),
|
||
note: /(\d+)/.test(name) ? `${name.match(/(\d+)/)[1]}×${name.match(/(\d+)/)[1]} 高解析度` : "向量原稿",
|
||
}));
|
||
} catch {
|
||
renders = [];
|
||
}
|
||
const styleLabel = {
|
||
photo: "真實照片裁臉",
|
||
portrait: "向量人物形象(有臉),配色取自參考照片",
|
||
badge: "編號徽章(沒有參考照片時的樣式)",
|
||
}[icon.style || "badge"];
|
||
return [
|
||
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} 的形象圖`,
|
||
"",
|
||
`\`${code}\` ${styleLabel}`,
|
||
"",
|
||
// Gitea 只會改寫 **Markdown 圖片語法** 的路徑(→ /wiki/raw/...);
|
||
// 用 HTML <img> 會被瀏覽器當成相對於頁面網址,變成 303 破圖。
|
||
...(hasSvg || hasPng
|
||
? [
|
||
...(hasPng ? [``, ""] : []),
|
||
"| 格式 | 檔案 | 用途 |",
|
||
"| --- | --- | --- |",
|
||
...(hasSvg ? [`| SVG | [icon.svg](icon.svg) | 向量,可無限放大 |`] : []),
|
||
...(hasPng ? [`| PNG | [icon.png](icon.png) | 點陣,存取庫頭像 |`] : []),
|
||
...renders.map((r) => `| ${r.ext.toUpperCase()} | [icon/${r.name}](icon/${r.name}) | ${r.note} |`),
|
||
"",
|
||
]
|
||
: ["(尚未產生形象圖,執行 `/jsc-persona:persona-icon`。)", ""]),
|
||
"## 這張圖怎麼來的",
|
||
"",
|
||
"| 欄位 | 內容 |",
|
||
"| --- | --- |",
|
||
`| 樣式 | ${icon.style || "badge"} |`,
|
||
`| 尺寸 | ${icon.size || "?"}×${icon.size || "?"} |`,
|
||
`| 調色盤 | ${icon.palette || "(由編號雜湊)"} |`,
|
||
`| 參考來源 | ${src.url ? `[${src.url}](${src.url})` : "(無)"} |`,
|
||
`| 造型說明 | ${src.note || "—"} |`,
|
||
`| 參考日期 | ${src.date || "—"} |`,
|
||
`| 產生時間 | ${icon.generated_at || "—"} |`,
|
||
"",
|
||
icon.style === "photo"
|
||
? "> 由 jsc-persona 從上述參考圖**裁出臉部**產生(自動臉部偵測),並套上圓角與瞳色外框。"
|
||
: "> 由 jsc-persona 產生:配色取自角色**最新一次登場**的官方視覺。",
|
||
"> 這裡保存的是產生出來的形象圖(SVG + PNG),供人格身分辨識使用。",
|
||
"",
|
||
].join("\n");
|
||
}
|
||
|
||
/** Wiki 首頁:讓 Gitea 上點進去就看得懂這是誰。 */
|
||
export function wikiHome(slug, code) {
|
||
const ident = pl.identityFields(slug);
|
||
const longTerm = pl.longTermEntries(slug);
|
||
const relations = pl.loadRelations(slug);
|
||
const hasIcon = fs.existsSync(path.join(pl.personaDir(slug), "icon.png"));
|
||
const lines = [
|
||
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``,
|
||
"",
|
||
...(hasIcon ? [``, ""] : []),
|
||
"> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;",
|
||
"> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。",
|
||
"",
|
||
"| 欄位 | 內容 |",
|
||
"| --- | --- |",
|
||
`| 編號 | \`${code}\` |`,
|
||
...["Name", "Creature", "Gender", "Vibe", "Emoji", "Avatar"]
|
||
.filter((k) => ident[k])
|
||
.map((k) => `| ${k} | ${ident[k]} |`),
|
||
`| 長期記憶 | ${longTerm.length} 則 |`,
|
||
`| 關係人 | ${relations.nodes.length} 位 |`,
|
||
`| 圖示更新 | ${pl.loadConfig(slug).icon?.generated_at || "—"} |`,
|
||
"",
|
||
"## 頁面",
|
||
"",
|
||
"- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / Gender / Vibe / Emoji / Avatar)",
|
||
"- [SOUL](SOUL) — 靈魂:Core Truths / Boundaries / Vibe / Continuity",
|
||
"- [AGENTS](AGENTS) — 操作規則 / [USER](USER) — 對使用者的理解",
|
||
"- [Icon](Icon) — 人格形象圖(SVG + PNG)與它的來源",
|
||
"- [Memory INDEX](Memory-INDEX) — 長期記憶索引",
|
||
"",
|
||
"### 長期記憶",
|
||
"",
|
||
];
|
||
const memos = [...longTerm].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
|
||
for (const meta of memos.slice(0, 50)) {
|
||
const page = wikiName(`memory/long-term/${path.basename(meta._path)}`).replace(/\.md$/, "");
|
||
const first = (meta._body || "").split("\n")[0] || "";
|
||
lines.push(`- [${meta._name}](${page})|${meta.type || "fact"}|顯著度 ${meta.salience ?? "?"}|${first.slice(0, 60)}`);
|
||
}
|
||
if (!memos.length) lines.push("(還沒有長期記憶)");
|
||
if (memos.length > 50) lines.push(`…以及另外 ${memos.length - 50} 則,見 [Memory INDEX](Memory-INDEX)。`);
|
||
lines.push(
|
||
"",
|
||
"### 其他",
|
||
"",
|
||
"- `Mindmap-semantic.mmd` — 心智圖(Mermaid)",
|
||
"- `Relations-graph.mmd` — 人際關係圖(Mermaid)/ `Relations-graph.json` — 原始資料",
|
||
"- `icon/` — 高解析度形象圖(多尺寸) - `_paths.json` — 攤平前的路徑對照(勿手改)",
|
||
"",
|
||
"> Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以子目錄的檔案在這裡是攤平的檔名。",
|
||
"",
|
||
);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// push / pull
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
export function minPushSeconds() {
|
||
const raw = Number(process.env.PERSONA_SYNC_MIN_SECONDS);
|
||
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_MIN_PUSH_SECONDS;
|
||
}
|
||
|
||
export function pushDue(slug, area) {
|
||
const last = loadSyncState(slug).areas[area]?.pushed_at;
|
||
return !last || pl.ageSeconds(last) >= minPushSeconds();
|
||
}
|
||
|
||
export async function pushArea(slug, area, { message = "", code = null, owner = null } = {}) {
|
||
const problem = giteaProblem();
|
||
if (problem) return { ok: false, skipped: true, reason: problem };
|
||
const theCode = code || personaCode(slug);
|
||
if (!theCode) return { ok: false, skipped: true, reason: `人格 \`${slug}\` 還沒有編號,先跑 \`code assign\`。` };
|
||
const theOwner = owner || (await resolveOwner());
|
||
const { host } = giteaEnv();
|
||
const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
|
||
if (area === "wiki") {
|
||
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
|
||
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
|
||
}
|
||
const staged = stageArea(slug, area, dir);
|
||
clearStaleIndexLock(dir);
|
||
gitOrThrow(["add", "-A"], dir, "git add");
|
||
const dirty = git(["diff", "--cached", "--quiet"], dir);
|
||
if (dirty.ok) {
|
||
const state = loadSyncState(slug);
|
||
state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() };
|
||
saveSyncState(slug, state);
|
||
return { ok: true, changed: false, files: staged.length, area, code: theCode };
|
||
}
|
||
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit");
|
||
let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
|
||
let overwrote = null;
|
||
if (!pushed.ok) {
|
||
// 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源,
|
||
// 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。
|
||
//
|
||
// 但這條路徑**等同 force**:對方推上去的內容會被本機取代。所以要算出「蓋掉了哪幾個檔案、
|
||
// 上一版是哪個 commit」,一路回報到 sync 狀態裡——這條路可以走,但不能安靜地走。
|
||
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) {
|
||
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);
|
||
stageArea(slug, area, dir);
|
||
if (area === "wiki") {
|
||
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
|
||
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
|
||
}
|
||
clearStaleIndexLock(dir);
|
||
git(["add", "-A"], 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);
|
||
}
|
||
if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout };
|
||
const state = loadSyncState(slug);
|
||
state.code = theCode;
|
||
state.owner = theOwner;
|
||
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);
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 拉回遠端內容。
|
||
* 衝突判定:clone 裡「還沒 commit 的本機改動」若正好也被遠端改到 → 停下來,不覆蓋本機。
|
||
*/
|
||
export async function pullArea(slug, area, { code = null, owner = null, force = false } = {}) {
|
||
const problem = giteaProblem();
|
||
if (problem) return { ok: false, skipped: true, reason: problem };
|
||
const theCode = code || personaCode(slug);
|
||
if (!theCode) return { ok: false, skipped: true, reason: `人格 \`${slug}\` 還沒有編號。` };
|
||
const theOwner = owner || (await resolveOwner());
|
||
const { host } = giteaEnv();
|
||
const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
|
||
stageArea(slug, area, dir); // 先把本機現況放進 clone,才看得出本機動過什麼
|
||
clearStaleIndexLock(dir);
|
||
const localChanged = new Set(statusPaths(dir));
|
||
const fetched = git(["fetch", "--quiet", "origin"], dir);
|
||
if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" };
|
||
const head = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
|
||
const remoteRef = `origin/${head}`;
|
||
const exists = git(["rev-parse", "--verify", "--quiet", remoteRef], dir);
|
||
if (!exists.ok) return { ok: true, area, code: theCode, empty: true, changed: [] };
|
||
const incoming = diffPaths(dir, ["HEAD", remoteRef]);
|
||
const conflicts = incoming.filter((f) => localChanged.has(f));
|
||
if (conflicts.length && !force) {
|
||
git(["checkout", "--", "."], dir);
|
||
return { ok: false, area, code: theCode, conflicts };
|
||
}
|
||
git(["checkout", "--", "."], dir);
|
||
const reset = git(["reset", "--hard", "--quiet", remoteRef], dir);
|
||
if (!reset.ok) return { ok: false, area, reason: reset.stderr };
|
||
// 只寫回「遠端真的改過的」與「本機缺少的」。
|
||
// 不能無差別覆蓋:本機有較新但還沒 push 的內容時,那會把它蓋掉。
|
||
const root = pl.personaDir(slug);
|
||
const restore = new Set(incoming);
|
||
for (const name of listTrackedFiles(dir)) {
|
||
if (area === "wiki" && WIKI_RESERVED.has(name)) continue;
|
||
if (!fs.existsSync(path.join(root, cloneNameToRel(area, dir, name)))) restore.add(name);
|
||
}
|
||
const written = unstageArea(slug, area, dir, restore);
|
||
const state = loadSyncState(slug);
|
||
state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() };
|
||
saveSyncState(slug, state);
|
||
return { ok: true, area, code: theCode, changed: incoming, written };
|
||
}
|
||
|
||
/**
|
||
* 驗證某一區「本機 = 遠端」。
|
||
* push 回報成功不等於遠端真的有東西(網路中斷、權限、非快轉都可能),
|
||
* 形象圖這種一定要出現在 Wiki 的檔案更需要一個明確的檢查點。
|
||
*/
|
||
export async function verifyArea(slug, area, { code = null, owner = null } = {}) {
|
||
const problem = giteaProblem();
|
||
if (problem) return { ok: false, skipped: true, area, reason: problem };
|
||
const theCode = code || personaCode(slug);
|
||
if (!theCode) return { ok: false, skipped: true, area, reason: "沒有人格編號" };
|
||
const theOwner = owner || (await resolveOwner());
|
||
const { host } = giteaEnv();
|
||
const dir = ensureClone(slug, area, repoUrl(host, theOwner, theCode, area));
|
||
if (!git(["fetch", "--quiet", "origin"], dir).ok) {
|
||
return { ok: false, area, reason: "fetch 失敗(連不上遠端)" };
|
||
}
|
||
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
|
||
const local = git(["rev-parse", "HEAD"], dir).stdout;
|
||
const remote = git(["rev-parse", `origin/${branch}`], dir).stdout;
|
||
// 再把工作副本疊上去,看看還有沒有沒推的差異
|
||
if (area === "wiki") {
|
||
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
|
||
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
|
||
}
|
||
const files = stageArea(slug, area, dir);
|
||
const dirty = statusPaths(dir);
|
||
git(["checkout", "--", "."], dir);
|
||
git(["clean", "-qfd"], dir);
|
||
return {
|
||
ok: Boolean(local) && local === remote && dirty.length === 0,
|
||
area,
|
||
code: theCode,
|
||
files: files.length,
|
||
local,
|
||
remote,
|
||
pending: dirty,
|
||
};
|
||
}
|
||
|
||
/** 形象圖(SVG + PNG)是不是真的在 Wiki 上、而且和本機一致。 */
|
||
export async function verifyIconInWiki(slug, opts = {}) {
|
||
const res = await verifyArea(slug, "wiki", opts);
|
||
if (res.skipped || !res.code) return res;
|
||
const missing = ["icon.svg", "icon.png"].filter((f) => res.pending.includes(f));
|
||
const dir = syncDir(slug, "wiki");
|
||
const present = ["icon.svg", "icon.png"].filter((f) => fs.existsSync(path.join(dir, f)));
|
||
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 || "失敗").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,並把兩區都推上去。 */
|
||
export async function initRemote(slug, { code = null, owner = null, private_ = true } = {}) {
|
||
const problem = giteaProblem();
|
||
if (problem) throw new Error(problem);
|
||
const theCode = code || personaCode(slug);
|
||
if (!validCode(theCode)) throw new Error(`人格 \`${slug}\` 沒有合法編號(需 ASUNA-01 這種格式)。`);
|
||
const theOwner = owner || (await resolveOwner());
|
||
const ident = pl.identityFields(slug);
|
||
const { repo, created } = await ensureRepo(theOwner, theCode, {
|
||
description: `jsc-persona 人格 ${theCode}${ident.Name ? `(${ident.Name})` : ""}`,
|
||
private_,
|
||
});
|
||
const wikiCreated = await ensureWikiHome(theOwner, theCode, wikiHome(slug, theCode));
|
||
const results = {};
|
||
for (const area of AREA_KEYS) {
|
||
results[area] = await pushArea(slug, area, {
|
||
code: theCode,
|
||
owner: theOwner,
|
||
message: `init(${area}): ${AREAS[area].why}`,
|
||
});
|
||
}
|
||
const state = loadSyncState(slug);
|
||
state.code = theCode;
|
||
state.owner = theOwner;
|
||
state.repo_url = repo.html_url;
|
||
state.initialized_at = state.initialized_at || pl.nowIso();
|
||
saveSyncState(slug, state);
|
||
return { code: theCode, owner: theOwner, repo, created, wikiCreated, results };
|
||
}
|
||
|
||
/** 背景 push(給 hook 用):不等結果、不阻斷這一輪。 */
|
||
export function pushInBackground(slug, area, sessionId, cliPath) {
|
||
try {
|
||
const child = spawn(
|
||
process.execPath,
|
||
[cliPath, "sync", "push", "--persona", slug, "--session", sessionId, "--area", area, "--if-due", "--quiet"],
|
||
{ detached: true, stdio: "ignore", env: process.env },
|
||
);
|
||
child.unref();
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|