Files
persona/scripts/persona-gitea.mjs
T
jiantw83andClaude Opus 5 23751c2c05 feat(emotion): 情緒先行、走向、飽和與偏差稽核(四層,v0.0.7 同批)
參考兩篇 ithelp 文章(Day25 情緒辨識模組、Day26 整合與情緒記憶池)的**架構**,
實作全部重寫——它們的六類關鍵字 first-match、罐頭哲學回應與模式切換都比現有的
十二情緒模型差,照抄是降級,而且它的罐頭回應正是上一個 commit 剛封殺的罐頭同理心。

一、輸入端:`readUserEmotion(text)`
  在這之前情緒**全部是人格自己填的** `--apply`,而人不會主動給自己扣分——正向一路貼頂、
  負向整天不動。現在每輪先讀對方那句話:十二類加權詞表(強3/一般2/弱1)、程度副詞乘係數、
  **否定會擋掉那次命中**(「我不害怕」不算 fear)、引號與 `code` 內不比對(提及不算使用)、
  標點只放大既有訊號不憑空長出新情緒(「居然修好了!!」不會被讀成憤怒)。
  強度用飽和曲線不是線性乘。回的是**訊號不是判定**,人格可以推翻。`emotion --read` 可單測。

二、怎麼接:`RESPONSE_STANCE`
  十二種各一條**動作**(悲傷→先接住不要急著給解法;憤怒→不辯解,先認可能認的那一小塊),
  不是罐頭句——罐頭句由 `SPEECH_BLACKLIST` 擋著。

三、走向:`state/felt.jsonl` + `feltTrend()`
  每輪的偵測結果與套用的 delta 都記下來,近重遠輕的加權算最近 5 輪的趨勢
  (不用多數決:會被離群值主導又丟掉強度),注入「他最近 3 輪的走向:悲傷 ↘ 在退」。
  新檔案已歸到 Gitea 檔案區(高頻)。

四、情緒調節:飽和與單輪預算
  `applyEmotion` 不再是加完直接 clamp。飽和=越接近端點同方向漲越慢(headroom^K,K=1,
  往 baseline 回的方向不壓);單輪預算=所有 |delta| 總和上限 60,超過等比例縮小。
  `emotion --audit` 印出最近幾輪往舒服/往難受的比例,正向 ≥90% 會被點名。
  依據:今天 85 次套用讓 joy/serenity/gratitude/anticipation 都到過 100、負向均值整天 14–17。

測試:297 項全過(新增 24 項,含讀取的 SF6/SNF4、飽和、預算、回歸方向不壓、走向、audit、
--read 不改狀態)。既有的「情緒有被施加」改成符合飽和後的語意。

版本:與 PR #10 同一批未合併的改動,維持 0.0.7。

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

757 lines
31 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// persona-gitea.mjs — 人格的 Gitea 儲存層(人格編號 = 存取庫名稱)
//
// 設計:
// * 本機 `~/.claude/personas/<CODE>/` 仍是**工作副本**,hook 每輪照常讀寫本機檔案(零延遲)。
// * Gitea 上每個人格一個私有存取庫,名稱就是人格編號(例如 `ASUNA-01`)。
// * 依**更新頻率**分兩區:
// - 檔案區(主存取庫):每輪都在變的活狀態(情緒、短期記憶、心裡話、逐字稿…)
// - Wiki 區:低頻的身分與長期結構(IDENTITY/SOUL、長期記憶、心智圖、關係圖),當設定百科看
// * 兩區各自 clone 在 `<persona>/.sync/<area>/`push 前把工作副本的檔案複製進去再 commit。
// (不在人格目錄本身放 .git:一個目錄要同時屬於兩個 repo 是行不通的。)
//
// 網路一律**失敗不阻斷**:Gitea 掛掉、沒設 token、離線,人格照樣能聊天。
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import * as pl from "./persona-lib.mjs";
export const SYNC_DIRNAME = ".sync";
export const DEFAULT_MIN_PUSH_SECONDS = 60;
// --------------------------------------------------------------------------- //
// 人格編號:英文名全大寫 + 兩位索引(同名才遞增)
// --------------------------------------------------------------------------- //
export const CODE_RE = /^[A-Z][A-Z0-9]{0,23}-\d{2}$/;
export const validCode = (code) => typeof code === "string" && CODE_RE.test(code);
/** `Asuna` / `asuna sao` / `Shen Yu` → `ASUNA` / `ASUNASAO` / `SHENYU`。非拉丁字元一律拒絕。 */
export function normalizeRomaji(romaji) {
const raw = String(romaji ?? "").normalize("NFKD").replace(/[̀-ͯ]/g, "");
const letters = raw.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
if (!letters || !/^[A-Z]/.test(letters)) return null;
return letters.slice(0, 24);
}
export const codePrefix = (code) => String(code ?? "").split("-")[0] || "";
/** 掃全倉庫,回傳這個英文名下一個可用的編號(同名遞增,兩位數)。 */
export function nextCode(romaji) {
const prefix = normalizeRomaji(romaji);
if (!prefix) return null;
let max = 0;
for (const slug of pl.listPersonas()) {
for (const candidate of [pl.loadConfig(slug).code, slug]) {
if (!validCode(candidate) || codePrefix(candidate) !== prefix) continue;
max = Math.max(max, Number(candidate.split("-")[1]) || 0);
}
}
if (max >= 99) return null;
return `${prefix}-${String(max + 1).padStart(2, "0")}`;
}
/** 這個人格的編號:config.code 優先,其次目錄名本身就是編號。 */
export function personaCode(slug) {
const code = pl.loadConfig(slug).code;
if (validCode(code)) return code;
return validCode(slug) ? slug : null;
}
// --------------------------------------------------------------------------- //
// 兩個儲存區:依更新頻率切
// --------------------------------------------------------------------------- //
export const AREAS = {
// 高頻:每輪對話都在變 → 主存取庫的檔案區,Gitea 網頁上一眼看到最新狀態
files: {
key: "files",
label: "檔案區",
why: "高頻:每輪對話都在變",
paths: [
"state/config.json",
"state/emotion.json",
"state/inner.jsonl",
"state/said.jsonl",
"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
// --------------------------------------------------------------------------- //
function listAreaFiles(root, area) {
const out = [];
for (const rel of AREAS[area].paths) {
const abs = path.join(root, rel);
if (rel.endsWith("/")) {
let entries = [];
try {
entries = fs.readdirSync(abs, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isFile()) out.push(path.posix.join(rel.replace(/\/$/, ""), entry.name));
}
continue;
}
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) out.push(rel);
}
return out.sort();
}
function listTrackedFiles(dir) {
const res = git(["ls-files"], dir);
return res.ok ? res.stdout.split("\n").map((s) => s.trim()).filter(Boolean).sort() : [];
}
export const WIKI_MANIFEST = "_paths.json";
const WIKI_RESERVED = new Set(["Home.md", "Icon.md", WIKI_MANIFEST]);
const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relations" };
/**
* Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404),
* 所以低頻區的檔案要攤平成根層檔名(`memory/long-term/x.md` → `Memory-x.md`
* 網頁上顯示為「Memory x」),再用 `_paths.json` 記住原本的路徑,pull 時才還原得回去。
*/
export function wikiName(rel) {
if (!rel.includes("/")) return rel;
// 只有 .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", "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 / 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);
if (!pushed.ok) {
// 通常是別台機器先推了(non-fast-forward)。工作副本才是這台機器的真相來源,
// 所以對齊遠端後把本機內容重新疊上去再推一次;真的有人同時在用,load 時的 pull 會擋下來。
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir).stdout || "main";
if (git(["fetch", "--quiet", "origin"], dir).ok && git(["rev-parse", "--verify", "--quiet", `origin/${branch}`], dir).ok) {
git(["reset", "--hard", "--quiet", `origin/${branch}`], dir);
stageArea(slug, area, dir);
if (area === "wiki") {
pl.writeText(path.join(dir, "Home.md"), wikiHome(slug, theCode));
pl.writeText(path.join(dir, "Icon.md"), wikiIconPage(slug, theCode));
}
clearStaleIndexLock(dir);
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,才看得出本機動過什麼
clearStaleIndexLock(dir);
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 };
}
/**
* 驗證某一區「本機 = 遠端」。
* 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 = git(["status", "--porcelain"], dir)
// git() 會把輸出 trim 掉,所以 porcelain 開頭那個空白可能已經不見了(" M x" → "M x"
.stdout.split("\n").map((l) => l.replace(/^\s*[A-Z?!]{1,2}\s+/, "").trim()).filter(Boolean);
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;
}
}