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>
657 lines
27 KiB
JavaScript
657 lines
27 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;
|
||
|
||
// --------------------------------------------------------------------------- //
|
||
// 人格編號:英文名全大寫 + 兩位索引(同名才遞增)
|
||
// --------------------------------------------------------------------------- //
|
||
|
||
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;
|
||
}
|
||
}
|