feat: 人格編號(ASUNA-01)與 Gitea 儲存,依更新頻率分檔案區/Wiki 區
1. 人格編號
- 編號 = 英文名全大寫 + 兩位索引,同名才遞增:ASUNA-01 / YUI-01 / ASUNA-02。
- 編號同時是新人格的本機目錄名與 Gitea 存取庫名稱(`create` 不給
--persona 就用編號當目錄名;既有小寫 slug 仍然合法,不會被鎖在門外)。
- 中文名由 skill 提議羅馬拼音、使用者確認後帶 `--romaji` 進來;
CLI 只吃拉丁字母,避免拼音在程式裡亂猜。
- 新增 `code show|assign|next`;`code assign --rename` 可把既有人格的
目錄名一併改成編號(會轉移鎖與 session 綁定)。
2. Gitea 儲存(scripts/persona-gitea.mjs)
- 每個人格一個私有存取庫,庫名 = 編號。本機仍是工作副本,hook 每輪
讀寫本機檔案,**同步失敗永遠不阻斷對話**。
- 依更新頻率分區:
檔案區(高頻)emotion / short-term / inner / said / inbox /
mindmap threads / journal → 每輪由 Stop hook 背景 push
Wiki 區(低頻)IDENTITY SOUL AGENTS USER / 長期記憶 / INDEX /
心智圖 / 關係圖 → 固化、改身分關係、release 時 push
- Gitea 的 wiki 只有根目錄 .md 會變成頁面(1.27 實測子目錄頁面 404),
所以 Wiki 區攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`,
pull 時還原;Home 頁自動列出所有長期記憶連結。
- 新增 `sync status|init|push|pull`;載入時先 pull,兩邊都改過同一個
檔案就停下來不覆蓋本機,交由使用者決定。
- push 遇到 non-fast-forward 會對齊遠端後把工作副本重新疊上去再推。
- 認證走 http.extraHeader(GIT_CONFIG_* 環境變數),token 不寫進
.git/config 也不進 process 參數。
3. 順帶修正
- guard 的 `--persona` 解析原本只吃小寫,大寫編號會漏掉整個跨人格檢查。
- pull 原本會無差別覆蓋本機檔案,本機較新但還沒 push 的內容會被蓋掉;
改成只寫回「遠端真的改過的」與「本機缺少的」。
- `create` 原本吞掉 Gitea 首次推送的結果,失敗是靜默的。
新增 skill:persona-sync(編號、同步、既有人格遷移、衝突處理)。
selftest 116 項全綠(新增 ⑬,含「分區不重不漏」檢查;測試強制 PERSONA_GITEA=off)。
另對真實 Gitea 跑過端對端(建庫→兩區推送→改動→拉回→release),測試用存取庫已刪除。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
// 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",
|
||||
"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;
|
||||
}
|
||||
|
||||
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", 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 首頁:讓 Gitea 上點進去就看得懂這是誰。 */
|
||||
export function wikiHome(slug, code) {
|
||||
const ident = pl.identityFields(slug);
|
||||
const longTerm = pl.longTermEntries(slug);
|
||||
const relations = pl.loadRelations(slug);
|
||||
const lines = [
|
||||
`# ${ident.Emoji ? `${ident.Emoji} ` : ""}${ident.Name || slug} \`${code}\``,
|
||||
"",
|
||||
"> 由 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) — 對使用者的理解",
|
||||
"- [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));
|
||||
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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,9 @@ export const sessionsDir = () => path.join(runtimeDir(), "sessions");
|
||||
export const roomsDir = () => path.join(personaHome(), ROOMS_DIRNAME);
|
||||
export const personaDir = (slug) => path.join(personaHome(), slug);
|
||||
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
||||
// 人格目錄名。新建的人格一律是**人格編號**(`ASUNA-01`:英文名全大寫+兩位索引),
|
||||
// 但舊的小寫 slug(`asuna-sao`)仍然合法,才不會把既有人格鎖在門外。
|
||||
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,47}$/;
|
||||
const RESERVED_SLUGS = new Set([RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."]);
|
||||
|
||||
export function validSlug(slug) {
|
||||
@@ -1433,7 +1435,8 @@ export function cliInvocation(command) {
|
||||
};
|
||||
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
|
||||
if (sub) info.subcommand = sub[1];
|
||||
info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([a-z0-9-]+)/g)].map((m) => m[1]);
|
||||
// 人格名可能是大寫的編號(ASUNA-01),漏掉大寫等於漏掉整個跨人格檢查
|
||||
info.personas = [...command.matchAll(/--(?:persona|guest|host|as)[= ]+['"]?([A-Za-z0-9-]+)/g)].map((m) => m[1]);
|
||||
const sess = command.match(/--session[= ]+['"]?([^\s'"]+)/);
|
||||
if (sess) info.session = sess[1];
|
||||
return info;
|
||||
|
||||
+272
-13
@@ -14,9 +14,11 @@ import process from "node:process";
|
||||
import zlib from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as pl from "./persona-lib.mjs";
|
||||
import * as gt from "./persona-gitea.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
|
||||
const SELF = path.join(HERE, "persona.mjs");
|
||||
|
||||
let QUIET = false;
|
||||
|
||||
@@ -48,6 +50,7 @@ function emit(payload, asJson, lines) {
|
||||
const FLAGS = new Set([
|
||||
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
|
||||
"with-journal", "gzip", "record", "load", "allow-repeat",
|
||||
"if-due", "no-gitea", "public", "rename",
|
||||
]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -154,6 +157,15 @@ function requireMember(slug, sessionId, asGuest = false) {
|
||||
|
||||
const hostOf = (flags, session) => str(flags.persona) || pl.loadSession(session).host;
|
||||
|
||||
/**
|
||||
* 里程碑事件(記憶固化、身分/關係變更)之後,把 Wiki 區推上去。
|
||||
* 背景執行、失敗不阻斷:同步永遠不該卡住對話。
|
||||
*/
|
||||
function pushWikiLater(slug, session, flags) {
|
||||
if (flags["no-gitea"] || gt.giteaProblem() || !gt.personaCode(slug)) return;
|
||||
gt.pushInBackground(slug, "wiki", session, SELF);
|
||||
}
|
||||
|
||||
function renderTemplate(name, mapping) {
|
||||
let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8");
|
||||
for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value));
|
||||
@@ -166,10 +178,25 @@ function renderTemplate(name, mapping) {
|
||||
|
||||
const commands = {};
|
||||
|
||||
commands.create = ({ flags }) => {
|
||||
commands.create = async ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = str(flags.persona);
|
||||
if (!pl.validSlug(slug)) die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。");
|
||||
// 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。
|
||||
let code = str(flags.code);
|
||||
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法,格式是「英文名全大寫-兩位數」,例如 \`ASUNA-01\`。`);
|
||||
if (!code) {
|
||||
const base = gt.normalizeRomaji(str(flags.romaji) || str(flags.persona));
|
||||
if (!base) {
|
||||
die(
|
||||
"需要 `--romaji <英文名>`:人格編號是英文名全大寫加索引(例:Asuna → `ASUNA-01`)。" +
|
||||
"中文名請先轉成羅馬拼音並跟使用者確認拼法,再帶進來。",
|
||||
);
|
||||
}
|
||||
code = gt.nextCode(base);
|
||||
if (!code) die(`\`${base}\` 的編號已經用到 99,請換一個英文名。`);
|
||||
}
|
||||
// 沒指定 --persona 就用編號當目錄名(一個識別走到底);指定了就沿用(相容既有人格)
|
||||
const slug = str(flags.persona) || code;
|
||||
if (!pl.validSlug(slug)) die("人格目錄名只能是英數與連字號(最長 48 字),建議直接用編號,例如 `ASUNA-01`。");
|
||||
if (pl.personaExists(slug) && !flags.force) {
|
||||
die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --force。`);
|
||||
}
|
||||
@@ -191,12 +218,14 @@ commands.create = ({ flags }) => {
|
||||
pl.writeJson(pl.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline)));
|
||||
pl.writeJson(pl.configPath(slug), {
|
||||
persona: slug,
|
||||
code,
|
||||
romaji: gt.codePrefix(code),
|
||||
display_name: mapping.NAME,
|
||||
created_at: pl.nowIso(),
|
||||
created_by_session: session,
|
||||
origin: str(flags.origin) || "custom",
|
||||
source_work: str(flags.work),
|
||||
schema: 1,
|
||||
schema: 2,
|
||||
});
|
||||
pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] });
|
||||
pl.writeText(
|
||||
@@ -206,8 +235,28 @@ commands.create = ({ flags }) => {
|
||||
pl.rebuildIndex(slug);
|
||||
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
|
||||
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
|
||||
ok(`人格 \`${slug}\` 建立於 ${root},已取得載入鎖並綁定本 session。`);
|
||||
ok(`人格 \`${slug}\`(編號 \`${code}\`)建立於 ${root},已取得載入鎖並綁定本 session。`);
|
||||
say(` 下一步:補完 ${root}/IDENTITY.md 與 SOUL.md,再用 /jsc-persona:persona-chat 開始對話。`);
|
||||
// Gitea 上的存取庫名稱就是編號。身分還沒補完,這裡只開庫;內容之後由各時機自動 push。
|
||||
if (!flags["no-gitea"] && !gt.giteaProblem()) {
|
||||
try {
|
||||
const info = await gt.initRemote(slug, { code, private_: !flags.public });
|
||||
say(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"},${info.repo.private ? "私有" : "公開"})`);
|
||||
for (const key of gt.AREA_KEYS) {
|
||||
const res = info.results[key] || {};
|
||||
say(
|
||||
res.ok
|
||||
? ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):${res.changed ? `已推送 ${res.files} 個檔案` : "目前沒有內容"}`
|
||||
: ` ⚠ ${gt.AREAS[key].label}推送失敗:${String(res.reason).slice(0, 160)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
say(` ⚠ Gitea 存取庫建立失敗(不影響本機使用):${err.message}`);
|
||||
say(" 之後可用 `sync init` 補建。");
|
||||
}
|
||||
} else if (!flags["no-gitea"]) {
|
||||
say(` ℹ 未同步到 Gitea:${gt.giteaProblem()}`);
|
||||
}
|
||||
};
|
||||
|
||||
commands.list = ({ flags }) => {
|
||||
@@ -221,6 +270,7 @@ commands.list = ({ flags }) => {
|
||||
}
|
||||
return {
|
||||
persona: slug,
|
||||
code: gt.personaCode(slug),
|
||||
identity: pl.identityBrief(slug),
|
||||
locked: status.locked,
|
||||
stale: status.stale,
|
||||
@@ -236,16 +286,17 @@ commands.list = ({ flags }) => {
|
||||
for (const r of rows) {
|
||||
const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒";
|
||||
lines.push(
|
||||
`- \`${r.persona}\` ${state}` +
|
||||
`- \`${r.persona}\`${r.code && r.code !== r.persona ? `(編號 ${r.code})` : ""} ${state}` +
|
||||
(r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") +
|
||||
`|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
|
||||
(r.code ? "" : "|⚠ 尚無編號") +
|
||||
(r.identity ? `|${r.identity}` : ""),
|
||||
);
|
||||
}
|
||||
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
|
||||
};
|
||||
|
||||
commands.load = ({ flags }) => {
|
||||
commands.load = async ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = str(flags.persona);
|
||||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||||
@@ -264,9 +315,32 @@ commands.load = ({ flags }) => {
|
||||
die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`);
|
||||
}
|
||||
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
|
||||
// 載入時先把遠端拉回來(別台機器可能動過),衝突就停下來讓使用者決定
|
||||
const pulled = [];
|
||||
if (!flags["no-gitea"] && !gt.giteaProblem() && gt.personaCode(slug)) {
|
||||
for (const area of gt.AREA_KEYS) {
|
||||
try {
|
||||
pulled.push(await gt.pullArea(slug, area));
|
||||
} catch (err) {
|
||||
pulled.push({ ok: false, area, reason: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
pl.pruneShortTerm(slug);
|
||||
pl.rebuildIndex(slug);
|
||||
const lines = [`✔ 已載入人格 \`${slug}\`(exclusive,session ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s)`];
|
||||
for (const res of pulled) {
|
||||
if (res.conflicts?.length) {
|
||||
lines.push(
|
||||
`⚠ ${gt.AREAS[res.area].label}有衝突,**沒有覆蓋本機**:${res.conflicts.slice(0, 5).join(", ")}` +
|
||||
"。請告訴使用者:本機與 Gitea 都改過同一份資料,要保留哪一邊(`sync pull --force` 會以遠端為準)。",
|
||||
);
|
||||
} else if (res.ok && res.written?.length) {
|
||||
lines.push(`↓ ${gt.AREAS[res.area].label}從 Gitea 拉回 ${res.written.length} 個檔案。`);
|
||||
} else if (!res.ok && !res.skipped) {
|
||||
lines.push(`⚠ ${gt.AREAS[res.area]?.label || res.area}同步失敗(不影響本機):${String(res.reason).slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
if (lock.took_over_from) {
|
||||
const prev = lock.took_over_from;
|
||||
lines.push(
|
||||
@@ -279,11 +353,23 @@ commands.load = ({ flags }) => {
|
||||
emit({ persona: slug, lock, context }, flags.json, lines);
|
||||
};
|
||||
|
||||
commands.release = ({ flags }) => {
|
||||
commands.release = async ({ flags }) => {
|
||||
const session = requireSession(flags);
|
||||
const data = pl.loadSession(session);
|
||||
const slug = str(flags.persona) || data.host;
|
||||
if (!slug) die("本 session 沒有載入任何人格。");
|
||||
// 收工前把兩區都推上去(失敗不阻斷釋放,人格不能被鎖在網路問題裡)
|
||||
if (!flags["no-gitea"] && !gt.giteaProblem() && pl.personaExists(slug) && gt.personaCode(slug)) {
|
||||
for (const area of gt.AREA_KEYS) {
|
||||
try {
|
||||
const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowIso()}` });
|
||||
if (res.ok && res.changed) say(` ↑ ${gt.AREAS[area].label}已推上 Gitea。`);
|
||||
else if (!res.ok && !res.skipped) say(` ⚠ ${gt.AREAS[area].label}推送失敗:${String(res.reason).slice(0, 120)}`);
|
||||
} catch (err) {
|
||||
say(` ⚠ ${gt.AREAS[area].label}推送失敗:${err.message.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const released = pl.unbindSession(session);
|
||||
ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest:${released.guests.join(", ")}` : "。"}`);
|
||||
};
|
||||
@@ -557,6 +643,7 @@ commands.consolidate = ({ flags }) => {
|
||||
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`);
|
||||
}
|
||||
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
|
||||
pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了
|
||||
};
|
||||
|
||||
commands.prune = ({ flags }) => {
|
||||
@@ -572,6 +659,7 @@ commands.reindex = ({ flags }) => {
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`);
|
||||
pushWikiLater(slug, session, flags);
|
||||
};
|
||||
|
||||
commands.emotion = ({ flags }) => {
|
||||
@@ -672,6 +760,7 @@ commands.relation = ({ flags, positional }) => {
|
||||
});
|
||||
pl.renderRelations(slug);
|
||||
ok(`關係節點 \`${name}\` 已更新。`);
|
||||
pushWikiLater(slug, session, flags);
|
||||
return;
|
||||
}
|
||||
if (action === "edge") {
|
||||
@@ -685,6 +774,7 @@ commands.relation = ({ flags, positional }) => {
|
||||
});
|
||||
pl.renderRelations(slug);
|
||||
ok(`關係連線 ${str(flags.from) || "self"} → ${to} 已更新。`);
|
||||
pushWikiLater(slug, session, flags);
|
||||
return;
|
||||
}
|
||||
if (action === "render") {
|
||||
@@ -951,6 +1041,164 @@ commands.import = ({ flags }) => {
|
||||
emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines);
|
||||
};
|
||||
|
||||
/**
|
||||
* 人格編號:英文名全大寫 + 兩位索引(同名才遞增),也就是 Gitea 存取庫的名稱。
|
||||
* 既有人格用 `code assign --romaji <英文名>` 補編號,加 `--rename` 連目錄名一起改成編號。
|
||||
*/
|
||||
commands.code = async ({ flags, positional }) => {
|
||||
const session = requireSession(flags);
|
||||
const action = positional[0] || "show";
|
||||
if (action === "next") {
|
||||
const base = gt.normalizeRomaji(str(flags.romaji));
|
||||
if (!base) die("需要 `--romaji <英文名>`(只能是拉丁字母與數字)。");
|
||||
const next = gt.nextCode(base);
|
||||
if (!next) die(`\`${base}\` 的編號已經用到 99。`);
|
||||
emit({ romaji: base, code: next }, flags.json, [`\`${base}\` 的下一個可用編號:\`${next}\``]);
|
||||
return;
|
||||
}
|
||||
const slug = hostOf(flags, session);
|
||||
if (action === "show") {
|
||||
requireMember(slug, session, Boolean(flags["as-guest"]));
|
||||
const code = gt.personaCode(slug);
|
||||
const state = gt.loadSyncState(slug);
|
||||
emit({ persona: slug, code, sync: state }, flags.json, [
|
||||
`人格 \`${slug}\` 編號:${code ? `\`${code}\`` : "(尚未指派,用 `code assign --romaji <英文名>`)"}`,
|
||||
code ? ` Gitea 存取庫:${state.repo_url || `${gt.giteaEnv().host}/${state.owner || "?"}/${code}`}` : "",
|
||||
].filter(Boolean));
|
||||
return;
|
||||
}
|
||||
if (action === "assign") {
|
||||
requireOwner(slug, session);
|
||||
const existing = gt.personaCode(slug);
|
||||
if (existing && !flags.force) die(`人格 \`${slug}\` 已有編號 \`${existing}\`。要重新指派請加 --force。`);
|
||||
let code = str(flags.code);
|
||||
if (code && !gt.validCode(code)) die(`編號 \`${code}\` 不合法(格式:ASUNA-01)。`);
|
||||
if (!code) {
|
||||
const base = gt.normalizeRomaji(str(flags.romaji) || slug);
|
||||
if (!base) die("需要 `--romaji <英文名>`(中文名請先轉羅馬拼音並跟使用者確認拼法)。");
|
||||
code = gt.nextCode(base);
|
||||
if (!code) die(`\`${base}\` 的編號已經用到 99。`);
|
||||
}
|
||||
const config = pl.loadConfig(slug);
|
||||
config.persona = slug;
|
||||
config.code = code;
|
||||
config.romaji = gt.codePrefix(code);
|
||||
config.schema = 2;
|
||||
pl.writeJson(pl.configPath(slug), config);
|
||||
const lines = [`✔ 人格 \`${slug}\` 的編號指派為 \`${code}\`。`];
|
||||
let current = slug;
|
||||
if (flags.rename && slug !== code) {
|
||||
if (pl.personaExists(code)) die(`目錄 \`${code}\` 已存在,無法改名。`);
|
||||
// 目錄名改成編號:先放掉自己的鎖 → 改名 → 重新取得鎖並重綁 session
|
||||
pl.releaseLock(slug, session);
|
||||
fs.renameSync(pl.personaDir(slug), pl.personaDir(code));
|
||||
fs.rmSync(path.join(pl.personaDir(code), gt.SYNC_DIRNAME), { recursive: true, force: true });
|
||||
const renamed = pl.loadConfig(code);
|
||||
renamed.persona = code;
|
||||
pl.writeJson(pl.configPath(code), renamed);
|
||||
pl.acquireLock(code, session, { cwd: str(flags.cwd) || null });
|
||||
pl.bindHost(session, code, { cwd: str(flags.cwd) || null });
|
||||
current = code;
|
||||
lines.push(` 目錄已改名:${pl.personaDir(code)}(.sync 快取已清掉,下次 push 會重新 clone)`);
|
||||
}
|
||||
if (!flags["no-gitea"] && !gt.giteaProblem()) {
|
||||
try {
|
||||
const info = await gt.initRemote(current, { code, private_: !flags.public });
|
||||
lines.push(` 📦 Gitea:${info.repo.html_url}(${info.created ? "已建立" : "沿用既有"})`);
|
||||
} catch (err) {
|
||||
lines.push(` ⚠ Gitea 存取庫建立失敗(不影響本機):${err.message}`);
|
||||
}
|
||||
}
|
||||
emit({ persona: current, code }, flags.json, lines);
|
||||
return;
|
||||
}
|
||||
die(`未知 action:${action}(可用 show/assign/next)`);
|
||||
};
|
||||
|
||||
/** 人格與 Gitea 的同步:檔案區=高頻活狀態,Wiki 區=低頻設定。 */
|
||||
commands.sync = async ({ flags, positional }) => {
|
||||
const session = requireSession(flags);
|
||||
const slug = hostOf(flags, session);
|
||||
requireOwner(slug, session);
|
||||
const action = positional[0] || "status";
|
||||
const area = str(flags.area) || "all";
|
||||
if (area !== "all" && !gt.AREA_KEYS.includes(area)) die(`--area 只能是 ${gt.AREA_KEYS.join("/")}/all。`);
|
||||
const areas = area === "all" ? gt.AREA_KEYS : [area];
|
||||
|
||||
if (action === "status") {
|
||||
const state = gt.loadSyncState(slug);
|
||||
const problem = gt.giteaProblem();
|
||||
emit({ persona: slug, code: gt.personaCode(slug), problem, sync: state }, flags.json, [
|
||||
`人格 \`${slug}\`|編號 ${gt.personaCode(slug) || "(無)"}|Gitea ${problem ? `⚠ ${problem}` : "✔ 已設定"}`,
|
||||
` 存取庫:${state.repo_url || "(尚未建立,跑 \`sync init\`)"}`,
|
||||
...gt.AREA_KEYS.map((key) => {
|
||||
const info = state.areas[key] || {};
|
||||
return ` ${gt.AREAS[key].label}(${gt.AREAS[key].why}):` +
|
||||
`最後 push ${info.pushed_at || "—"}|最後 pull ${info.pulled_at || "—"}|${info.files ?? "?"} 個檔案`;
|
||||
}),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const problem = gt.giteaProblem();
|
||||
if (problem) die(`Gitea 尚未設定:${problem}(設 GITEA_HOST 與 GITEA_TOKEN,或用 PERSONA_GITEA_* 覆寫)`);
|
||||
|
||||
if (action === "init") {
|
||||
let code = gt.personaCode(slug);
|
||||
if (!code) die(`人格 \`${slug}\` 還沒有編號。先跑 \`code assign --romaji <英文名>\`。`);
|
||||
const info = await gt.initRemote(slug, { code, owner: str(flags.owner) || null, private_: !flags.public });
|
||||
emit(info, flags.json, [
|
||||
`✔ 人格 \`${slug}\`(\`${info.code}\`)已對應到 Gitea:${info.repo.html_url}`,
|
||||
` ${info.created ? "存取庫已建立" : "沿用既有存取庫"}|${info.repo.private ? "私有" : "公開"}|Wiki ${info.wikiCreated ? "已建立" : "已存在"}`,
|
||||
...gt.AREA_KEYS.map((key) =>
|
||||
` ${gt.AREAS[key].label}:${info.results[key]?.ok ? `${info.results[key].files} 個檔案已推送` : `⚠ ${info.results[key]?.reason || "失敗"}`}`),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (action === "push") {
|
||||
const out = [];
|
||||
for (const key of areas) {
|
||||
if (flags["if-due"] && !gt.pushDue(slug, key)) {
|
||||
out.push({ area: key, ok: true, skipped: true, reason: "未到最小間隔" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
out.push(await gt.pushArea(slug, key, { message: str(flags.message) }));
|
||||
} catch (err) {
|
||||
out.push({ area: key, ok: false, reason: err.message });
|
||||
}
|
||||
}
|
||||
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
|
||||
r.skipped ? ` ${gt.AREAS[r.area].label}:略過(${r.reason})`
|
||||
: r.ok ? `✔ ${gt.AREAS[r.area].label}:${r.changed ? `已推送 ${r.files} 個檔案` : "沒有變更"}`
|
||||
: `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`));
|
||||
return;
|
||||
}
|
||||
if (action === "pull") {
|
||||
const out = [];
|
||||
for (const key of areas) {
|
||||
try {
|
||||
out.push(await gt.pullArea(slug, key, { force: Boolean(flags.force) }));
|
||||
} catch (err) {
|
||||
out.push({ area: key, ok: false, reason: err.message });
|
||||
}
|
||||
}
|
||||
pl.rebuildIndex(slug);
|
||||
emit({ persona: slug, results: out }, flags.json, out.map((r) =>
|
||||
r.conflicts?.length
|
||||
? `✖ ${gt.AREAS[r.area].label}:本機與遠端都改過 ${r.conflicts.slice(0, 5).join(", ")};` +
|
||||
"沒有覆蓋本機。確定要以遠端為準才加 --force。"
|
||||
: r.ok
|
||||
? `✔ ${gt.AREAS[r.area].label}:` +
|
||||
(r.empty
|
||||
? "遠端還是空的"
|
||||
: `遠端有 ${r.changed?.length || 0} 個檔案更新,寫回本機 ${r.written?.length || 0} 個` +
|
||||
`${(r.written?.length || 0) > (r.changed?.length || 0) ? "(含補回本機缺少的檔案)" : ""}`)
|
||||
: `✖ ${gt.AREAS[r.area].label}:${String(r.reason).slice(0, 160)}`));
|
||||
return;
|
||||
}
|
||||
die(`未知 action:${action}(可用 init/push/pull/status)`);
|
||||
};
|
||||
|
||||
commands.gc = ({ flags }) => {
|
||||
const removed = pl.gcRuntime();
|
||||
emit(removed, flags.json, [
|
||||
@@ -981,7 +1229,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
用法:node persona.mjs <subcommand> [options]
|
||||
|
||||
人格與鎖:
|
||||
create --persona <slug> --session <id> [--name --creature --vibe --emoji --avatar --baseline --origin --work]
|
||||
create --romaji <英文名> --session <id> [--persona <目錄名> --code <ASUNA-01> --name --creature
|
||||
--vibe --emoji --avatar --baseline --origin --work --no-gitea --public]
|
||||
人格編號 = 英文名全大寫 + 兩位索引(同名才遞增),也是 Gitea 存取庫的名稱;
|
||||
不指定 --persona 就用編號當目錄名。
|
||||
load --persona <slug> --session <id> [--takeover]
|
||||
release --session <id> [--persona <slug>]
|
||||
list 列出人格與鎖狀態
|
||||
@@ -1010,7 +1261,15 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
|
||||
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
||||
|
||||
搬家:
|
||||
編號與 Gitea(存取庫名稱 = 人格編號):
|
||||
code show|assign|next --session <id> [--romaji <英文名> --code <ASUNA-01> --rename --force --public]
|
||||
sync status|init|push|pull --session <id> [--area files|wiki|all --if-due --force --message --owner]
|
||||
檔案區=高頻活狀態(情緒/短期記憶/心裡話/逐字),每輪對話後背景 push
|
||||
Wiki 區=低頻設定(IDENTITY/SOUL/長期記憶/心智圖/關係圖),固化或改身分時 push
|
||||
環境變數:GITEA_HOST / GITEA_TOKEN(或 PERSONA_GITEA_HOST / _TOKEN / _OWNER),
|
||||
PERSONA_GITEA=off 可整個關掉,PERSONA_SYNC_MIN_SECONDS 調 push 間隔
|
||||
|
||||
搬家(離線檔案):
|
||||
export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
|
||||
import --session <id> --file <檔案> [--persona <新 slug> --force --load]
|
||||
|
||||
@@ -1021,7 +1280,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
||||
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
|
||||
`;
|
||||
|
||||
function main(argv) {
|
||||
async function main(argv) {
|
||||
const sub = argv[0];
|
||||
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
|
||||
process.stdout.write(HELP);
|
||||
@@ -1032,7 +1291,7 @@ function main(argv) {
|
||||
const parsed = parseArgs(argv.slice(1));
|
||||
QUIET = Boolean(parsed.flags.quiet);
|
||||
try {
|
||||
command({ flags: parsed.flags, positional: parsed._ });
|
||||
await command({ flags: parsed.flags, positional: parsed._ });
|
||||
} catch (err) {
|
||||
if (err instanceof pl.LockError) die(err.message);
|
||||
throw err;
|
||||
@@ -1040,4 +1299,4 @@ function main(argv) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
process.exit(await main(process.argv.slice(2)));
|
||||
|
||||
@@ -14,8 +14,11 @@ import { fileURLToPath } from "node:url";
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STORE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-selftest-"));
|
||||
process.env.PERSONA_HOME = STORE;
|
||||
// 自我測試絕對不碰真的 Gitea:不建存取庫、不 push。同步邏輯只測不需要網路的部分。
|
||||
process.env.PERSONA_GITEA = "off";
|
||||
|
||||
const pl = await import("./persona-lib.mjs");
|
||||
const gt = await import("./persona-gitea.mjs");
|
||||
|
||||
const CLI = path.join(HERE, "persona.mjs");
|
||||
const HOOKS = path.join(HERE, "..", "hooks");
|
||||
@@ -406,6 +409,74 @@ check("guard:匯入新人格允許(只寫新目錄,不讀別人)",
|
||||
tool_input: { command: `node persona.mjs import --persona gamma --session ${S_SPEAK} --file /tmp/b.json` } }) === "pass");
|
||||
fs.rmSync(OUT, { recursive: true, force: true });
|
||||
|
||||
console.log("⑬ 人格編號與 Gitea 分區");
|
||||
check("羅馬拼音正規化:只吃拉丁字母",
|
||||
gt.normalizeRomaji("Asuna") === "ASUNA" && gt.normalizeRomaji("shen yu") === "SHENYU" &&
|
||||
gt.normalizeRomaji("亞絲娜") === null && gt.normalizeRomaji("") === null);
|
||||
check("編號格式:英文名全大寫 + 兩位索引",
|
||||
gt.validCode("ASUNA-01") && gt.validCode("SHENYU-12") &&
|
||||
!gt.validCode("asuna-01") && !gt.validCode("ASUNA-1") && !gt.validCode("ASUNA"));
|
||||
check("建立人格時自動產生編號(alpha → ALPHA-01)", gt.personaCode("alpha") === "ALPHA-01",
|
||||
String(gt.personaCode("alpha")));
|
||||
check("同名才遞增,不同名各自從 01 開始",
|
||||
gt.nextCode("Alpha") === "ALPHA-02" && gt.nextCode("Beta") === "BETA-02" && gt.nextCode("Lumi") === "LUMI-01",
|
||||
`${gt.nextCode("Alpha")} / ${gt.nextCode("Beta")} / ${gt.nextCode("Lumi")}`);
|
||||
const S_CODE = "sess-code-4444";
|
||||
cli(["create", "--persona", "gamma", "--romaji", "Gamma", "--session", S_CODE, "--name", "Gamma", "--emoji", "🜂"]);
|
||||
check("編號寫進 config.json", pl.loadConfig("gamma").code === "GAMMA-01" && pl.loadConfig("gamma").romaji === "GAMMA");
|
||||
const codeShow = cli(["code", "show", "--session", S_CODE, "--json"]);
|
||||
check("code show 回報編號", (() => {
|
||||
try {
|
||||
return JSON.parse(codeShow.stdout).code === "GAMMA-01";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})(), codeShow.stdout.slice(0, 120));
|
||||
cli(["code", "assign", "--session", S_CODE, "--code", "GAMMA-01", "--rename", "--force"]);
|
||||
check("--rename 把目錄名改成編號", pl.personaExists("GAMMA-01") && !pl.personaExists("gamma"));
|
||||
check("改名後鎖與 session 綁定都跟著轉移",
|
||||
pl.lockStatus("GAMMA-01").locked && pl.loadSession(S_CODE).host === "GAMMA-01" &&
|
||||
pl.loadConfig("GAMMA-01").persona === "GAMMA-01");
|
||||
check("大寫編號目錄一樣受跨人格隔離保護",
|
||||
guard({ session_id: S_HOST, tool_name: "Read", tool_input: { file_path: `${H}/GAMMA-01/SOUL.md` } }) === "deny");
|
||||
check("guard 認得大寫編號的 --persona(不會漏掉跨人格檢查)",
|
||||
guard({ session_id: S_CODE, tool_name: "Bash",
|
||||
tool_input: { command: `node persona.mjs recall --persona ALPHA-01 --session ${S_CODE} --query x` } }) === "deny");
|
||||
// 分區必須「不重不漏」:人格產生的每個檔案都要恰好屬於一區,否則同步會默默漏資料
|
||||
const AREA_EXEMPT = new Set(["state/lock.json", "state/guests.json", "state/sync.json"]);
|
||||
const covered = (rel) =>
|
||||
gt.AREA_KEYS.filter((key) =>
|
||||
gt.AREAS[key].paths.some((p) => (p.endsWith("/") ? rel.startsWith(p) : rel === p)));
|
||||
const allFiles = [];
|
||||
(function walk(dir, base = "") {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
const rel = base ? `${base}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) walk(path.join(dir, entry.name), rel);
|
||||
else allFiles.push(rel);
|
||||
}
|
||||
})(pl.personaDir("alpha"));
|
||||
const uncovered = allFiles.filter((f) => !AREA_EXEMPT.has(f) && covered(f).length !== 1);
|
||||
check("檔案區/Wiki 區的切分不重不漏(每個檔案恰好屬於一區)", uncovered.length === 0,
|
||||
uncovered.map((f) => `${f}→${covered(f).length}`).join(", "));
|
||||
check("高頻資料在檔案區、低頻資料在 Wiki 區",
|
||||
covered("state/emotion.json")[0] === "files" && covered("memory/short-term.jsonl")[0] === "files" &&
|
||||
covered("journal/2026-01.jsonl")[0] === "files" && covered("state/said.jsonl")[0] === "files" &&
|
||||
covered("IDENTITY.md")[0] === "wiki" && covered("memory/long-term/x.md")[0] === "wiki" &&
|
||||
covered("relations/graph.json")[0] === "wiki");
|
||||
check("沒設定 Gitea 時同步只是略過,不會爆炸", (() => {
|
||||
const res = cli(["sync", "status", "--session", S_CODE]);
|
||||
const push = cli(["sync", "push", "--session", S_CODE], { expectOk: false });
|
||||
return res.status === 0 && res.stdout.includes("Gitea") && push.status !== 0 &&
|
||||
push.stderr.includes("尚未設定");
|
||||
})());
|
||||
check("匯出不會把 .sync 的 git clone 打包進去", (() => {
|
||||
fs.mkdirSync(path.join(pl.personaDir("GAMMA-01"), ".sync", "files"), { recursive: true });
|
||||
fs.writeFileSync(path.join(pl.personaDir("GAMMA-01"), ".sync", "files", "junk.txt"), "x");
|
||||
const { bundle: b } = pl.exportBundle("GAMMA-01");
|
||||
return !Object.keys(b.files).some((f) => f.startsWith(".sync"));
|
||||
})());
|
||||
|
||||
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user