Files
persona/scripts/persona-gitea.mjs
T
jiantw83andClaude Opus 5 f9b189dd76 feat(persona 擬真): 記憶會糊掉、情緒有底色與疲勞、人格有懸著的事
三個結構性缺口,這批一次補完(使用者拍板做階段 1+2+3 加自我議程)。

記憶只有「精準」與「沒有」兩態 → 連續衰減出來的三態。
memoryStrength() 由 strength/salience/recall_count/距上次回想多久算 retrievability,
低於門檻不刪、降級成 faded(只剩主旨)與 fuzzy(只剩有這件事)。內文因此分兩層
(主旨/細節),衰減先吃細節。被 recall 命中就 strength +8(spacing effect)。
boundary/promise/canon/salience >= 80 永遠清晰,這條沒動。

人格永遠以對方為中心 → state/loops.json 加自我議程。
同時最多 5 條、四種(他沒回答的、他答應的、被打斷的、我想問的),7 天沒進展自動收掉
並留一則「沒下文」的短期記憶。agendaTick 每 3 輪最多讓人格把話題拉回自己的事一次,
對方有明確急事時一律不觸發。

同一句話任何時刻聽起來都一樣 → 疲勞、當日底色、per-persona 破口。
fatigueLevel() 由 hoursAwake 推,壓 arousal 天花板、句數與單句字數、高張情緒的推力;
state/mood.json 是緩慢漂移的當日底色,只當反應增益不直接改情緒值,睡覺帶 35% 過去;
EMOTION_TELLS 改成預設值,人格可在 IDENTITY.md 的 ## Tells 覆寫。
applyEmotion 另加交互抑制(只有三組互斥)與慣性(連續同向 +8%,封頂 +25%)。

幻覺界線改成「開放試探」(使用者明示):可以說不確定、可以問,不可以斷言。
換來的義務是稽核——每次試探進 state/probe.jsonl,probe audit 看否認率。
放寬界線一定要配一個看得見的數字。

其他:記憶蓋情境戳章(when/where/mood,where 可用 PERSONA_CONTEXT_WHERE=off 關掉);
recall 加情境加權但翻不掉語意命中;migrate 就地升格式(冪等、看不懂的檔跳過);
bundle v2,import 吃得下 v1 並自動補欄位。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 04:22:03 +00:00

1000 lines
42 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] || "";
/**
* 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。
* `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",
"state/mood.json",
"state/loops.json",
"state/probe.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",
"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");
/** 補上預設欄位。內容格式不變,只是保證 `state.areas[area]` 一定拿得到物件。 */
function normalizeSyncState(data) {
const state = data && typeof data === "object" ? data : {};
state.areas ??= {};
for (const key of AREA_KEYS) state.areas[key] ??= {};
return state;
}
export function loadSyncState(slug) {
return normalizeSyncState(pl.readJson(syncStatePath(slug), {}) ?? {});
}
/**
* `sync.json` 的 read-modify-write:整段在檔案鎖裡面做,`mutate(state)` 就地改就好。
*
* 這個檔會被前景指令與背景 `sync push`(Stop hook 每輪都可能起一個)同時寫。
* 以前是各自「讀出來、改幾筆、整份寫回去」,兩邊撞上時後寫的會把前一個的
* `pushed_at``overwrites` 整段蓋掉——覆蓋紀錄就這樣安靜地消失。
*/
export function updateSyncState(slug, mutate) {
return pl.updateJson(syncStatePath(slug), (data) => {
const state = normalizeSyncState(data);
return mutate(state) ?? state;
}, {});
}
// --------------------------------------------------------------------------- //
// 環境與 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 ? [`![${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) {
updateSyncState(slug, (state) => {
state.areas[area] = { ...state.areas[area], checked_at: pl.nowIso() };
});
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 };
updateSyncState(slug, (state) => {
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);
}
});
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) {
let marked = 0;
updateSyncState(slug, (state) => {
for (const entry of state.overwrites || []) {
if (entry.reported_at) continue;
entry.reported_at = pl.nowIso();
marked += 1;
}
});
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);
updateSyncState(slug, (state) => {
state.areas[area] = { ...state.areas[area], pulled_at: pl.nowIso() };
});
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/長期記憶/
* 心智圖/關係圖),檔案區帶回活狀態(編號、情緒、短期記憶、逐字)。沒收的只有執行期狀態
* lockguestssleeperssync),那本來就該由這台機器自己產生。
*
* `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 {
/* 沒有關係圖就算了 */
}
updateSyncState(target, (state) => {
state.code = code;
state.owner = theOwner;
if (repo?.html_url) state.repo_url = repo.html_url;
state.imported_at = pl.nowIso();
});
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}`,
});
}
updateSyncState(slug, (state) => {
state.code = theCode;
state.owner = theOwner;
state.repo_url = repo.html_url;
state.initialized_at = state.initialized_at || pl.nowIso();
});
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;
}
}