Files
persona/scripts/persona-gitea.mjs
T
jiantw83andClaude Opus 5 5d27051293 fix(gitea): push 撞到別台機器時是本機贏,但不能靜靜地贏
`pushArea` 遇到 non-fast-forward 時會 `git reset --hard origin/<branch>`、
把本機工作副本重新疊上去再推一次,然後回 `{ok:true, changed:true}`——
沒有任何衝突訊號。而 Stop hook 每一輪都在背景 push,所以兩台機器同時聊同一個
人格時,對方的 emotion.json/short-term.jsonl 會被靜默取代,誰都不知道。

「本機工作副本是這台機器的真相來源」這個設計選擇保留,但那條路徑現在要記帳:

* 算出「對方在分歧後改過、而我們正要蓋掉」的檔案交集,連同覆蓋前的遠端 sha
  一起回傳 `overwrote`,並寫進 state/sync.json(留最近 10 筆)。
* `sync push` 一律往 stderr 寫一行警告(--quiet 也寫,背景 push 才有痕跡),
  正常輸出與 `sync status` 都列得出「蓋掉幾個檔案、上一版是誰」。
* 背景 push 是 detached、輸出丟掉的,所以由 Stop hook 認領未回報的紀錄,
  講給使用者聽一次(劇場模式也照講——那是資料被蓋掉)。
* commit 訊息也寫進「覆蓋 N 個檔案,上一版 <sha>」,被蓋掉的內容仍可用
  `git -C <人格>/.sync/<區> show <sha>:<檔案>` 取回。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:34:29 +00:00

817 lines
34 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;
// 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] || "";
/** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */
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",
"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");
/** 存取庫的擁有者:`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;
}
// 這些 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 ? [`![${code} 形象圖](icon.png)`, ""] : []),
"| 格式 | 檔案 | 用途 |",
"| --- | --- | --- |",
...(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 ? [`![${code}](icon.png)`, ""] : []),
"> 由 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,並把兩區都推上去。 */
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;
}
}