feat: 人格編號、Gitea 儲存(檔案區/Wiki 區)與人格圖示 (v0.0.4) #3

Merged
admin merged 8 commits from develop into master 2026-07-30 03:57:40 +00:00
14 changed files with 1145 additions and 29 deletions
Showing only changes of commit e986686d0e - Show all commits
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
"version": "0.0.2",
"version": "0.0.3",
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills",
"author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
"version": "0.0.2",
"version": "0.0.3",
"description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
"skills": "./skills"
}
+8
View File
@@ -27,6 +27,11 @@
這三條在劇場模式一樣生效。
8. **人格可搬家**`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」,
其他人格一律 deny——匯出等於把記憶讀出來。
9. **人格有編號**:英文名全大寫+兩位索引(`ASUNA-01`),同名才遞增。編號同時是新人格的
本機目錄名與 **Gitea 存取庫名稱**。中文名要先轉羅馬拼音並跟使用者確認拼法。
10. **人格存在 Gitea,本機是工作副本**:高頻活狀態進**檔案區**(每輪背景 push),
低頻身分與長期記憶進 **Wiki 區**(固化/改身分/release 時 push)。
**同步失敗永遠不阻斷對話**;沒設 `GITEA_HOST``GITEA_TOKEN` 就純本機運作。
## 慣例
@@ -38,3 +43,6 @@
`exportBundle``importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。
- 改動重複判定門檻(`similarity``REPEAT_THRESHOLD`)後,要用 selftest ⑪ 的對照案例確認
「換句話說同一件事」被擋、「只換關鍵詞」放行。
- 改動同步分區(`persona-gitea.mjs``AREAS`)後,selftest ⑬ 的「不重不漏」檢查必須維持全綠:
人格產生的每個檔案都要**恰好**屬於一區,否則同步會默默漏掉資料。
- selftest 自己會設 `PERSONA_GITEA=off`,**絕對不要**讓測試碰到真的 Gitea。
+62 -8
View File
@@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
---
## 條硬規則
## 十一條硬規則
| 規則 | 怎麼做到 |
| --- | --- |
@@ -24,6 +24,8 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
| **7. 短期記憶轉入長期記憶有成文條件** | `R1``R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 |
| **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 |
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
| **10. 人格有編號** | 編號 = **英文名全大寫 + 兩位索引**(同名才遞增):`ASUNA-01``YUI-01``ASUNA-02`。編號同時是新人格的本機目錄名與 Gitea 存取庫名稱;中文名先轉羅馬拼音並跟使用者確認拼法 |
| **11. 人格存在 Gitea,依更新頻率分區** | 每個人格一個**私有存取庫**(庫名=編號)。**檔案區**放每輪都在變的活狀態(情緒/短期記憶/心裡話/說過的話/逐字),**Wiki 區**放低頻的身分與長期結構(IDENTITY/SOUL/長期記憶/心智圖/關係圖)當設定百科。本機仍是工作副本,同步失敗不阻斷對話 |
---
@@ -43,15 +45,23 @@ flowchart TB
subgraph G["Sub Agent(受邀人格,唯讀)"]
GA["persona-guest"]
end
subgraph S["人格倉庫 ~/.claude/personas"]
PA["alpha/IDENTITY SOUL 記憶 情緒 心智圖 關係圖"]
PB["beta/|…"]
subgraph S["人格倉庫 ~/.claude/personas(工作副本)"]
PA["ASUNA-01/IDENTITY SOUL 記憶 情緒 心智圖 關係圖"]
PB["YUI-01/|…"]
RM[".rooms/room/transcript.jsonl"]
end
subgraph GT["Gitea(存取庫名稱 = 人格編號)"]
GF["ASUNA-01 檔案區<br/>高頻活狀態"]
GW["ASUNA-01 Wiki<br/>低頻設定百科"]
end
P -->|"只能碰自己"| PA
GA -->|"只能碰自己"| PB
P <-->|"唯一合法交流管道"| RM
GA <--> RM
PA -->|"每輪背景 push"| GF
PA -->|"固化/改身分/release"| GW
GF -.->|"載入時 pull"| PA
GW -.-> PA
```
## 人格倉庫(預設 `~/.claude/personas/<slug>/`,可用 `PERSONA_HOME` 覆寫)
@@ -68,7 +78,8 @@ flowchart TB
│ ├── emotion.json # 十二情緒 levels / baseline / 半衰期
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
── config.json
── sync.json # Gitea 同步狀態(最後 push / pull
│ └── config.json # 含人格編號 code
├── memory/
│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天)
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter
@@ -81,6 +92,8 @@ flowchart TB
│ ├── graph.json # 人際關係圖(親近度/信任度/連線)
│ └── graph.mmd # Mermaid 呈現(自動產生)
└── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫)
(另有 `.sync/files/`、`.sync/wiki/`:兩個同步區的 git clone 快取,可安全刪除)
```
## 十二情緒
@@ -129,6 +142,40 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。
## 人格編號與 Gitea 儲存
**編號 = 英文名全大寫 + 兩位索引**,同名才遞增,也就是 Gitea 存取庫的名稱:
```
亞絲娜(第一個) → ASUNA-01 結衣 → YUI-01 另一個亞絲娜 → ASUNA-02
```
每個人格一個**私有存取庫**,內容依**更新頻率**分兩區:
| 區 | 放什麼 | 何時 push |
| --- | --- | --- |
| **檔案區**(主存取庫) | 高頻活狀態:`emotion.json``short-term.jsonl``inner.jsonl``said.jsonl``inbox/``mindmap/threads/``journal/` | 每輪對話後由 `Stop` hook 背景推送(`PERSONA_SYNC_MIN_SECONDS` 節流) |
| **Wiki 區** | 低頻設定:`IDENTITY``SOUL``AGENTS``USER`、長期記憶、`INDEX`、心智圖、關係圖 | 記憶固化、改身分/關係圖、`release` 時 |
- **本機永遠是工作副本**:hook 每輪讀寫本機檔案,不經網路;Gitea 掛掉照樣能聊天。
**同步失敗永遠不阻斷對話。**
- 載入人格時會先 `pull`;兩邊都改過同一個檔案就**停下來不覆蓋本機**,由使用者決定保留哪一邊。
- Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面(1.27 實測子目錄頁面 404),所以
`memory/long-term/xxx.md` 攤平成 `Memory-xxx.md`,原始路徑記在 `_paths.json`
Wiki 首頁自動列出所有長期記憶的連結,變成真的讀得下去的「設定百科」。
```bash
export GITEA_HOST=https://gitea.example.com
export GITEA_TOKEN=<個人存取權杖>
export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設 token 本人
export PERSONA_GITEA=off # 需要時整個關掉
node scripts/persona.mjs code assign --session <id> --romaji Asuna --rename # 既有人格遷移
node scripts/persona.mjs sync status|init|push|pull --session <id> [--area files|wiki|all]
```
存取庫**預設私有**——人格裡是使用者的個人記憶,公開必須由使用者明講(`--public`)。
## 匯出 / 匯入(人格搬家)
```bash
@@ -208,6 +255,12 @@ node scripts/persona.mjs import --session <id> --file ~/backup/lumi.persona.json
- **Claude Code / Antigravity**`/jsc-persona:persona-transfer` **Codex**`$persona-transfer`
### `persona-sync`
人格編號與 Gitea 儲存:指派編號(`ASUNA-01`)、開以編號命名的私有存取庫、高頻活狀態同步到檔案區、低頻身分與長期記憶同步到 Wiki 區,並處理既有人格遷移與同步衝突。
- **Claude Code / Antigravity**`/jsc-persona:persona-sync` **Codex**`$persona-sync`
### `persona-memory`
記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。
@@ -243,12 +296,13 @@ node scripts/persona.mjs candidates --session <PERSONA_SESSION> # 看哪些短
node scripts/persona.mjs think --session <id> --text "<推導>" # 心裡話(只回報「心想 N 句」)
node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是不是又要說一次?
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
node scripts/persona.mjs export --session <id> --out lumi.json # 人格搬家
node scripts/selftest.mjs # 102 項驗證:鎖、隔離、情緒、固化條件、說話節制、劇場模式、匯出匯入、hooks
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
node scripts/selftest.mjs # 116 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、hooks
```
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、
`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。
---
+10
View File
@@ -2,8 +2,13 @@
// Stop:情緒隨時間衰減、續租、記錄逐字,並在達到「短期→長期」條件時提醒固化。
// 劇場模式進行中不發任何提醒(那會破壞「只顯示人格對話」)。
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readEvent, respond } from "./_hook.mjs";
import * as pl from "../scripts/persona-lib.mjs";
import * as gt from "../scripts/persona-gitea.mjs";
const CLI = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "persona.mjs");
const event = readEvent();
if (!event) process.exit(0);
@@ -34,6 +39,11 @@ if (message) {
}
}
// 檔案區=高頻活狀態:每輪結束後背景推上 Gitea(有最小間隔、失敗不阻斷這一輪)
if (!gt.giteaProblem() && gt.personaCode(host) && gt.pushDue(host, "files")) {
gt.pushInBackground(host, "files", sessionId, CLI);
}
const out = { suppressOutput: true };
if (!theater) {
const { total, candidates } = pl.promotionCandidates(host);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-persona",
"version": "0.0.2",
"version": "0.0.3",
"description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills/"
}
+585
View File
@@ -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;
}
}
+5 -2
View File
@@ -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
View File
@@ -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}\`exclusivesession ${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)));
+71
View File
@@ -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);
+5 -1
View File
@@ -67,13 +67,17 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
--persona <slug> --session <PERSONA_SESSION> \
--romaji "<角色的官方英文名>" --session <PERSONA_SESSION> \
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
--emoji "<Emoji>" --avatar "<外觀描述>" \
--origin anime --work "<作品名>" \
--baseline "<依角色個性設定的十二情緒基線>"
```
`--romaji` 用**官方英文/羅馬拼音名**(維基或官方英文版的寫法,如 `Asuna``Yui``Rem`),
它會變成人格編號 `ASUNA-01` 與 Gitea 存取庫名稱。同一個角色要做第二份設定時,
索引會自動變成 `-02`
情緒基線抓法(0–100,總體別全部拉高):
| 角色類型 | 建議基線 |
+10 -1
View File
@@ -51,12 +51,21 @@ slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
--persona <slug> --session <PERSONA_SESSION> \
--romaji "<英文名>" --session <PERSONA_SESSION> \
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
--emoji "<Emoji>" --avatar "<Avatar>" \
--baseline "serenity=45,trust=35,joy=25,anxiety=6"
```
**`--romaji` 決定人格編號**(英文名全大寫+兩位索引,例如 `Asuna``ASUNA-01`)。
編號同時是本機目錄名與 Gitea 存取庫的名稱,所以:中文/日文名字要**先提議羅馬拼音、
拿給使用者確認拼法**再送出(`亞絲娜 → Asuna``沈宇 → Shen Yu`)。想先看號碼會長怎樣:
`code next --romaji "<英文名>" --session <PERSONA_SESSION>`
設好 `GITEA_HOST``GITEA_TOKEN` 時,`create` 會順手在 Gitea 開一個**私有**存取庫
(名稱=編號)並推第一版;沒設定或加 `--no-gitea` 就只在本機建立。細節見
`/jsc-persona:persona-sync`
`create` 會:建立 `IDENTITY.md``SOUL.md``AGENTS.md``USER.md`、十二情緒狀態、
記憶與心智圖骨架、人際關係圖,並**立刻取得這個人格的載入鎖並綁到本 session**。
+5 -1
View File
@@ -21,9 +21,13 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被
## 常用指令
```bash
# 全部人格 + 鎖狀態 + 記憶量
# 全部人格 + 編號 + 鎖狀態 + 記憶量(`⚠ 尚無編號` 表示還沒同步到 Gitea)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
# 這個人格的編號與 Gitea 存取庫
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" code show --session <PERSONA_SESSION>
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync status --session <PERSONA_SESSION>
# 單一人格的鎖細節(owner session/cwd/心跳、guest 名單、情緒)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --persona <slug>
+109
View File
@@ -0,0 +1,109 @@
---
name: persona-sync
description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大寫+索引,例如 ASUNA-01)、在 Gitea 為每個人格開一個以編號命名的私有存取庫、把高頻的活狀態同步到檔案區、把低頻的身分與長期記憶同步到 Wiki 區,以及把既有人格遷移上去。當使用者說要把人格存到 Gitea、備份或同步人格、在另一台機器接續同一個人格、問人格的編號是什麼、想看人格在 Gitea 上的存取庫、或同步出現衝突時觸發。不適用於:建立新人格(persona-create,它會自動開存取庫)、離線的單檔匯出匯入(persona-transfer)、記憶固化(persona-memory)。
---
# 🔗 persona-sync — 人格編號與 Gitea 儲存
**CLI**`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(一律帶 `--session <PERSONA_SESSION>`
---
## 人格編號
**編號 = 英文名全大寫 + 兩位索引**,同名才遞增:
```
亞絲娜(第一個) → ASUNA-01
結衣 → YUI-01
另一個亞絲娜 → ASUNA-02
```
- 編號**就是 Gitea 存取庫的名稱**,也是新人格的本機目錄名。
- 中文/日文名字要先轉**羅馬拼音**:由你提議拼法(`亞絲娜 → Asuna``沈宇 → Shen Yu`),
**拿給使用者確認再送出**——拼錯了會變成一個很難改的編號。
- 查下一個可用編號:`code next --romaji Asuna --session <id>`
## 資料放哪裡(依更新頻率切)
| 區 | 放什麼 | 什麼時候 push |
| --- | --- | --- |
| **檔案區**(主存取庫) | 高頻活狀態:`state/emotion.json``short-term.jsonl``inner.jsonl`(心裡話)、`said.jsonl``inbox/``mindmap/threads/``journal/` | 每輪對話後由 `Stop` hook 背景推送(有最小間隔) |
| **Wiki 區** | 低頻設定:`IDENTITY``SOUL``AGENTS``USER`、長期記憶、`INDEX`、心智圖、人際關係圖 | 記憶固化、改身分、改關係圖、`release` 時 |
Wiki 是給人讀的設定百科:Gitea 的 wiki 只有根目錄的 `.md` 會變成頁面,所以
`memory/long-term/xxx.md` 會攤平成 `Memory-xxx.md`(頁面顯示為「Memory xxx」),
原始路徑記在 `_paths.json`,拉回來時自動還原。Home 頁會自動列出所有長期記憶的連結。
**本機永遠是工作副本**:hook 每輪讀寫的是本機檔案,不經過網路。Gitea 掛掉、離線、沒設 token
人格照樣能聊天,只是不同步——**同步失敗永遠不阻斷對話**。
## 設定
```bash
export GITEA_HOST=https://gitea.example.com # 或 PERSONA_GITEA_HOST
export GITEA_TOKEN=<個人存取權杖> # 或 PERSONA_GITEA_TOKEN
export PERSONA_GITEA_OWNER=<帳號或組織> # 選填,預設是 token 本人的帳號
export PERSONA_GITEA=off # 需要時整個關掉
export PERSONA_SYNC_MIN_SECONDS=60 # 檔案區背景 push 的最小間隔
```
存取庫**預設私有**(人格裡有使用者的個人記憶)。要公開必須是使用者明講,才加 `--public`
## 常用流程
```bash
# 現在的同步狀態(編號、存取庫、兩區的最後 push/pull)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync status --session <PERSONA_SESSION>
# 手動推送(--area files|wiki|all
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync push --session <PERSONA_SESSION> --area all
# 從 Gitea 拉回(載入人格時會自動做一次)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session <PERSONA_SESSION>
```
### 把既有人格遷移上去
既有人格(還沒有編號的)要兩步:
```bash
# 1) 載入它(同步只能動本 session 載入的那個人格)
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" load --persona <舊 slug> --session <PERSONA_SESSION>
# 2) 指派編號 + 開存取庫 + 首次推送;--rename 連本機目錄名也改成編號
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" code assign \
--session <PERSONA_SESSION> --romaji "<英文名>" --rename
```
`--rename` 之後這個人格就用編號稱呼(`--persona ASUNA-01`)。不加 `--rename` 也可以,
目錄名維持原樣、只是多了一個編號與對應的存取庫。**遷移前先跟使用者確認**:
這會把他的個人記憶送上 Gitea(私有庫,但仍是上傳)。
### 在另一台機器接續同一個人格
在新機器上設好 `GITEA_HOST``GITEA_TOKEN`,然後:
```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync pull --session <PERSONA_SESSION>
```
(人格目錄還不存在的話,先用 `/jsc-persona:persona-transfer` 匯入一份,或手動 clone 存取庫。)
## 衝突
兩台機器都改過同一個檔案時,`load``sync pull` 會**停下來不覆蓋本機**,並列出衝突的檔案。
這時候:
1. 用一句話告訴使用者哪幾個檔案兩邊都動過。
2. 問他要哪一邊:
-**Gitea** 為準 → `sync pull --force`(本機那份會被覆蓋)
-**本機** 為準 → `sync push`(會蓋掉遠端)
3. **不要自己選**。記憶被蓋掉是不可逆的。
## 邊界
- 只能同步「本 session 目前載入的人格」——跨人格同步等於跨人格讀取,會被 hook 擋下。
- guest`persona-guest` sub agent)不能同步,它對人格檔案唯讀。
- `.sync/` 是兩區的 git clone 快取,**不要手動編輯**;砍掉它不會掉資料,下次 push 會重新 clone。
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。