Files
persona/scripts/persona-gitea.mjs
T
jiantw83andClaude Opus 5 15e4eea8ac feat: 圖示改為人物形象圖(有臉),並讓 Wiki 保存 SVG + PNG
1. 圖示三種樣式,優先看得到臉
   photo    有參考圖且工具齊全 → 從官方視覺自動偵測並**裁出臉**,圓角+瞳色外框
   portrait 有參考圖但缺工具   → **有五官的向量人物**(髮型/瞳色/服裝色都取自那張圖)
   badge    完全沒有參考圖     → 舊的雙色漸層 + 編號字母

   portrait 與 badge 共用同一份「圖形清單」,SVG 與自寫柵格器從同一份資料畫,
   兩邊不可能長得不一樣;photo 的 SVG 以 base64 內嵌裁好的 PNG,一樣不外連。

2. 缺工具會「提示安裝」,不靜默降級
   新增 scripts/portrait.py(選用):Pillow 解碼裁切、OpenCV + lbpcascade_animeface
   偵測動漫臉。toolReport() 會列出缺什麼、為什麼要、怎麼裝(venv 免 sudo),
   CLI 在退回向量形象時把這些印出來。
   注意:**OpenCV 5 拿掉了 CascadeClassifier,必須裝 4.x**。
   plugin 本體仍然零依賴——沒有這些工具照樣產得出有臉的形象圖。

   新增 `icon faces`:列出參考圖裡偵測到的所有臉。多角色的主視覺一定要先看再挑
   (--pick <索引>|largest|leftmost|rightmost 或 --face x,y,w,h),挑錯就是別人的臉。

3. Wiki 保存形象圖
   icon.svg 與 icon.png 都在 Wiki 區,另外自動產生一頁 Icon:同時展示兩種格式,
   並列出樣式、調色盤、來源網址、造型說明與裁切框。PNG 同時設為存取庫頭像。

已套用到兩個真實人格(皆為真實裁臉):
  ASUNA-01  《Unanswered//butterfly》(2026) 官方主視覺,臉 #1(anime-cascade)
  YUI-01    AniList 官方角色圖(frontal-cascade)

selftest 148 項全綠(新增第 ⑯ 節:形象圖真的畫了五官、SVG/PNG 出自同一份清單、
工具偵測與安裝提示、Wiki 形象圖專頁與來源)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:39:58 +00:00

657 lines
27 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
// --------------------------------------------------------------------------- //
// 人格編號:英文名全大寫 + 兩位索引(同名才遞增)
// --------------------------------------------------------------------------- //
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] || "";
/** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */
export function nextCode(romaji) {
const prefix = normalizeRomaji(romaji);
if (!prefix) return null;
let max = 0;
for (const slug of pl.listPersonas()) {
for (const candidate of [pl.loadConfig(slug).code, slug]) {
if (!validCode(candidate) || codePrefix(candidate) !== prefix) continue;
max = Math.max(max, Number(candidate.split("-")[1]) || 0);
}
}
if (max >= 99) return null;
return `${prefix}-${String(max + 1).padStart(2, "0")}`;
}
/** 這個人格的編號: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",
"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",
"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");
/** 存取庫的擁有者:`PERSONA_GITEA_OWNER` 優先,否則用 token 本人的帳號(會快取)。 */
export async function resolveOwner() {
const env = giteaEnv();
if (env.owner) return env.owner;
const cached = pl.readJson(ownerCachePath(), {}) ?? {};
if (cached.host === env.host && cached.login) return cached.login;
const res = await api("GET", "/user");
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;
}
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();
const me = await resolveOwner();
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;
}
/** 確保 `<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
// --------------------------------------------------------------------------- //
function listAreaFiles(root, area) {
const out = [];
for (const rel of AREAS[area].paths) {
const abs = path.join(root, rel);
if (rel.endsWith("/")) {
let entries = [];
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;
}
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel);
}
return out.sort();
}
function listTrackedFiles(dir) {
const res = git(["ls-files"], dir);
return res.ok ? res.stdout.split("\n").map((s) => s.trim()).filter(Boolean).sort() : [];
}
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;
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"));
const styleLabel = {
photo: "真實照片裁臉",
portrait: "向量人物形象(有臉),配色取自參考照片",
badge: "編號徽章(沒有參考照片時的樣式)",
}[icon.style || "badge"];
return [
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} 的形象圖`,
"",
`\`${code}\` ${styleLabel}`,
"",
...(hasSvg || hasPng
? [
"| SVG(向量,可無限放大) | PNG(點陣,Gitea 頭像用) |",
"| --- | --- |",
`| ${hasSvg ? '<img src="icon.svg" alt="icon.svg" width="200">' : "(缺)"} ` +
`| ${hasPng ? '<img src="icon.png" alt="icon.png" width="200">' : "(缺)"} |`,
`| [icon.svg](icon.svg) | [icon.png](icon.png) |`,
"",
]
: ["(尚未產生形象圖,執行 `/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.svg"));
const lines = [
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``,
"",
...(hasIcon ? [`<img src="icon.svg" alt="${code}" width="128" height="128">`, ""] : []),
"> 由 jsc-persona 自動產生的人格設定百科。**低頻資料**(身分、長期記憶、心智圖、關係圖)放這裡;",
"> 每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿)在存取庫的檔案區。",
"",
"| 欄位 | 內容 |",
"| --- | --- |",
`| 編號 | \`${code}\` |`,
...["Name", "Creature", "Vibe", "Emoji", "Avatar"]
.filter((k) => ident[k])
.map((k) => `| ${k} | ${ident[k]} |`),
`| 長期記憶 | ${longTerm.length} 則 |`,
`| 關係人 | ${relations.nodes.length} 位 |`,
`| 最後同步 | ${pl.nowIso()} |`,
"",
"## 頁面",
"",
"- [IDENTITY](IDENTITY) — 身分卡(Name / Creature / 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` — 原始資料",
"- `_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);
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);
if (!pushed.ok) {
// 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源,
// 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。
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) {
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));
}
git(["add", "-A"], dir);
if (!git(["diff", "--cached", "--quiet"], dir).ok) {
git(["commit", "-q", "-m", `${message || "sync"}(與遠端合併後重推)`], 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 };
saveSyncState(slug, state);
return { ok: true, changed: true, files: staged.length, area, code: theCode };
}
/**
* 拉回遠端內容。
* 衝突判定: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,才看得出本機動過什麼
const localChanged = new Set(
git(["status", "--porcelain"], dir)
.stdout.split("\n")
.map((l) => l.slice(3).trim())
.filter(Boolean),
);
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 = git(["diff", "--name-only", "HEAD", remoteRef], dir).stdout.split("\n").filter(Boolean);
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 };
}
/** 建立 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;
}
}