feat: 人格編號、Gitea 儲存(檔案區/Wiki 區)與人格圖示 (v0.0.4) #3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills",
|
"skills": "./skills",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||||
"skills": "./skills"
|
"skills": "./skills"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
這三條在劇場模式一樣生效。
|
這三條在劇場模式一樣生效。
|
||||||
8. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」,
|
8. **人格可搬家**:`export` / `import`(單一 JSON bundle)。匯出只能匯出「本 session 載入的人格」,
|
||||||
其他人格一律 deny——匯出等於把記憶讀出來。
|
其他人格一律 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` 且全綠,並為新規則補一條測試。
|
`exportBundle`/`importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。
|
||||||
- 改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)後,要用 selftest ⑪ 的對照案例確認
|
- 改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)後,要用 selftest ⑪ 的對照案例確認
|
||||||
「換句話說同一件事」被擋、「只換關鍵詞」放行。
|
「換句話說同一件事」被擋、「只換關鍵詞」放行。
|
||||||
|
- 改動同步分區(`persona-gitea.mjs` 的 `AREAS`)後,selftest ⑬ 的「不重不漏」檢查必須維持全綠:
|
||||||
|
人格產生的每個檔案都要**恰好**屬於一區,否則同步會默默漏掉資料。
|
||||||
|
- selftest 自己會設 `PERSONA_GITEA=off`,**絕對不要**讓測試碰到真的 Gitea。
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九條硬規則
|
## 十一條硬規則
|
||||||
|
|
||||||
| 規則 | 怎麼做到 |
|
| 規則 | 怎麼做到 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -24,6 +24,8 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
|
|||||||
| **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 |
|
| **7. 短期記憶轉入長期記憶有成文條件** | `R1`–`R6` 六條規則寫在程式裡(`promotionCandidates`),`candidates` 子指令會列出達標的候選與依據,hook 在達標時提醒固化 |
|
||||||
| **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 |
|
| **8. 講話像人:推導藏起來、一到三句、不重複** | 推導寫進**心裡話** `think`(只回報「💭 心想 N 句」,永不回顯內容);說出口的話進 `said.jsonl`,下一輪注入「最近說過的話」提醒別重講;`room post` 直接**擋下**近似重複(字元 bigram+字集合相似度 ≥ 0.72)與超過三句的發言——劇場模式同樣適用 |
|
||||||
| **9. 人格可以匯出匯入** | `export` 把身分/情緒/記憶/心智圖/關係圖打包成單一 JSON bundle(可 `--gzip`、附 sha256),`import` 還原或換名複製;**不帶**載入鎖與 guest 租約,`journal/` 要明確 `--with-journal` 才帶走 |
|
| **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(受邀人格,唯讀)"]
|
subgraph G["Sub Agent(受邀人格,唯讀)"]
|
||||||
GA["persona-guest"]
|
GA["persona-guest"]
|
||||||
end
|
end
|
||||||
subgraph S["人格倉庫 ~/.claude/personas"]
|
subgraph S["人格倉庫 ~/.claude/personas(工作副本)"]
|
||||||
PA["alpha/|IDENTITY SOUL 記憶 情緒 心智圖 關係圖"]
|
PA["ASUNA-01/|IDENTITY SOUL 記憶 情緒 心智圖 關係圖"]
|
||||||
PB["beta/|…"]
|
PB["YUI-01/|…"]
|
||||||
RM[".rooms/room/transcript.jsonl"]
|
RM[".rooms/room/transcript.jsonl"]
|
||||||
end
|
end
|
||||||
|
subgraph GT["Gitea(存取庫名稱 = 人格編號)"]
|
||||||
|
GF["ASUNA-01 檔案區<br/>高頻活狀態"]
|
||||||
|
GW["ASUNA-01 Wiki<br/>低頻設定百科"]
|
||||||
|
end
|
||||||
P -->|"只能碰自己"| PA
|
P -->|"只能碰自己"| PA
|
||||||
GA -->|"只能碰自己"| PB
|
GA -->|"只能碰自己"| PB
|
||||||
P <-->|"唯一合法交流管道"| RM
|
P <-->|"唯一合法交流管道"| RM
|
||||||
GA <--> RM
|
GA <--> RM
|
||||||
|
PA -->|"每輪背景 push"| GF
|
||||||
|
PA -->|"固化/改身分/release"| GW
|
||||||
|
GF -.->|"載入時 pull"| PA
|
||||||
|
GW -.-> PA
|
||||||
```
|
```
|
||||||
|
|
||||||
## 人格倉庫(預設 `~/.claude/personas/<slug>/`,可用 `PERSONA_HOME` 覆寫)
|
## 人格倉庫(預設 `~/.claude/personas/<slug>/`,可用 `PERSONA_HOME` 覆寫)
|
||||||
@@ -68,7 +78,8 @@ flowchart TB
|
|||||||
│ ├── emotion.json # 十二情緒 levels / baseline / 半衰期
|
│ ├── emotion.json # 十二情緒 levels / baseline / 半衰期
|
||||||
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
|
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
|
||||||
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
|
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
|
||||||
│ └── config.json
|
│ ├── sync.json # Gitea 同步狀態(最後 push / pull)
|
||||||
|
│ └── config.json # 含人格編號 code
|
||||||
├── memory/
|
├── memory/
|
||||||
│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天)
|
│ ├── short-term.jsonl # 短期記憶(語意分析後;上限 240 筆 / 14 天)
|
||||||
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter)
|
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter)
|
||||||
@@ -81,6 +92,8 @@ flowchart TB
|
|||||||
│ ├── graph.json # 人際關係圖(親近度/信任度/連線)
|
│ ├── graph.json # 人際關係圖(親近度/信任度/連線)
|
||||||
│ └── graph.mmd # Mermaid 呈現(自動產生)
|
│ └── graph.mmd # Mermaid 呈現(自動產生)
|
||||||
└── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫)
|
└── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫)
|
||||||
|
|
||||||
|
(另有 `.sync/files/`、`.sync/wiki/`:兩個同步區的 git clone 快取,可安全刪除)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 十二情緒
|
## 十二情緒
|
||||||
@@ -129,6 +142,40 @@ AI 最容易露餡的三件事:把推理過程講出來、一次講一大段
|
|||||||
|
|
||||||
視窗預設 120 分鐘、少於 8 個字的短附和(「嗯」「好啊」)不算重複。
|
視窗預設 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
|
```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`
|
- **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`
|
### `persona-memory`
|
||||||
|
|
||||||
記憶固化:依 R1–R6 條件把短期記憶轉入長期(一則一檔)、淘汰雜訊、更新心智圖與思維導圖、消化 guest inbox、重建索引。
|
記憶固化:依 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 think --session <id> --text "<推導>" # 心裡話(只回報「心想 N 句」)
|
||||||
node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是不是又要說一次?
|
node scripts/persona.mjs said check --session <id> --text "<話>" # 這句是不是又要說一次?
|
||||||
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
|
node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話稿(劇場模式用)
|
||||||
node scripts/persona.mjs export --session <id> --out lumi.json # 人格搬家
|
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
|
||||||
node scripts/selftest.mjs # 102 項驗證:鎖、隔離、情緒、固化條件、說話節制、劇場模式、匯出匯入、hooks
|
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
|
||||||
|
node scripts/selftest.mjs # 116 項驗證:鎖、隔離、情緒、固化、說話節制、劇場模式、匯出匯入、編號與 Gitea 分區、hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`(CLI)、
|
檔案結構:`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`(自我測試)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
// Stop:情緒隨時間衰減、續租、記錄逐字,並在達到「短期→長期」條件時提醒固化。
|
// Stop:情緒隨時間衰減、續租、記錄逐字,並在達到「短期→長期」條件時提醒固化。
|
||||||
// 劇場模式進行中不發任何提醒(那會破壞「只顯示人格對話」)。
|
// 劇場模式進行中不發任何提醒(那會破壞「只顯示人格對話」)。
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { readEvent, respond } from "./_hook.mjs";
|
import { readEvent, respond } from "./_hook.mjs";
|
||||||
import * as pl from "../scripts/persona-lib.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();
|
const event = readEvent();
|
||||||
if (!event) process.exit(0);
|
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 };
|
const out = { suppressOutput: true };
|
||||||
if (!theater) {
|
if (!theater) {
|
||||||
const { total, candidates } = pl.promotionCandidates(host);
|
const { total, candidates } = pl.promotionCandidates(host);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-persona",
|
"name": "jsc-persona",
|
||||||
"version": "0.0.2",
|
"version": "0.0.3",
|
||||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||||
"skills": "./skills/"
|
"skills": "./skills/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 roomsDir = () => path.join(personaHome(), ROOMS_DIRNAME);
|
||||||
export const personaDir = (slug) => path.join(personaHome(), slug);
|
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, "", ".", ".."]);
|
const RESERVED_SLUGS = new Set([RUNTIME_DIRNAME, ROOMS_DIRNAME, "", ".", ".."]);
|
||||||
|
|
||||||
export function validSlug(slug) {
|
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-]*)/);
|
const sub = command.match(/persona\.(?:mjs|js|py)['"]?\s+([a-z][a-z0-9-]*)/);
|
||||||
if (sub) info.subcommand = sub[1];
|
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'"]+)/);
|
const sess = command.match(/--session[= ]+['"]?([^\s'"]+)/);
|
||||||
if (sess) info.session = sess[1];
|
if (sess) info.session = sess[1];
|
||||||
return info;
|
return info;
|
||||||
|
|||||||
+272
-13
@@ -14,9 +14,11 @@ import process from "node:process";
|
|||||||
import zlib from "node:zlib";
|
import zlib from "node:zlib";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as pl from "./persona-lib.mjs";
|
import * as pl from "./persona-lib.mjs";
|
||||||
|
import * as gt from "./persona-gitea.mjs";
|
||||||
|
|
||||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
|
const TEMPLATE_DIR = path.join(HERE, "..", "skills", "persona-create", "templates");
|
||||||
|
const SELF = path.join(HERE, "persona.mjs");
|
||||||
|
|
||||||
let QUIET = false;
|
let QUIET = false;
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ function emit(payload, asJson, lines) {
|
|||||||
const FLAGS = new Set([
|
const FLAGS = new Set([
|
||||||
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
|
"json", "quiet", "force", "takeover", "as-guest", "on", "off", "with-meta", "all",
|
||||||
"with-journal", "gzip", "record", "load", "allow-repeat",
|
"with-journal", "gzip", "record", "load", "allow-repeat",
|
||||||
|
"if-due", "no-gitea", "public", "rename",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
@@ -154,6 +157,15 @@ function requireMember(slug, sessionId, asGuest = false) {
|
|||||||
|
|
||||||
const hostOf = (flags, session) => str(flags.persona) || pl.loadSession(session).host;
|
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) {
|
function renderTemplate(name, mapping) {
|
||||||
let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8");
|
let text = fs.readFileSync(path.join(TEMPLATE_DIR, name), "utf8");
|
||||||
for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value));
|
for (const [key, value] of Object.entries(mapping)) text = text.replaceAll(`{{${key}}}`, String(value));
|
||||||
@@ -166,10 +178,25 @@ function renderTemplate(name, mapping) {
|
|||||||
|
|
||||||
const commands = {};
|
const commands = {};
|
||||||
|
|
||||||
commands.create = ({ flags }) => {
|
commands.create = async ({ flags }) => {
|
||||||
const session = requireSession(flags);
|
const session = requireSession(flags);
|
||||||
const slug = str(flags.persona);
|
// 人格編號 = 英文名全大寫 + 兩位索引(同名才遞增)。編號就是 Gitea 存取庫的名稱。
|
||||||
if (!pl.validSlug(slug)) die("slug 只能是小寫英數與連字號(最長 48 字),例如 `lumi`、`shen-yu`。");
|
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) {
|
if (pl.personaExists(slug) && !flags.force) {
|
||||||
die(`人格 \`${slug}\` 已存在(${pl.personaDir(slug)})。要覆寫請加 --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.emotionPath(slug), pl.defaultEmotionState(parseDeltas(flags.baseline)));
|
||||||
pl.writeJson(pl.configPath(slug), {
|
pl.writeJson(pl.configPath(slug), {
|
||||||
persona: slug,
|
persona: slug,
|
||||||
|
code,
|
||||||
|
romaji: gt.codePrefix(code),
|
||||||
display_name: mapping.NAME,
|
display_name: mapping.NAME,
|
||||||
created_at: pl.nowIso(),
|
created_at: pl.nowIso(),
|
||||||
created_by_session: session,
|
created_by_session: session,
|
||||||
origin: str(flags.origin) || "custom",
|
origin: str(flags.origin) || "custom",
|
||||||
source_work: str(flags.work),
|
source_work: str(flags.work),
|
||||||
schema: 1,
|
schema: 2,
|
||||||
});
|
});
|
||||||
pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] });
|
pl.writeJson(pl.relationsJson(slug), { nodes: [], edges: [] });
|
||||||
pl.writeText(
|
pl.writeText(
|
||||||
@@ -206,8 +235,28 @@ commands.create = ({ flags }) => {
|
|||||||
pl.rebuildIndex(slug);
|
pl.rebuildIndex(slug);
|
||||||
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
|
pl.acquireLock(slug, session, { cwd: str(flags.cwd) || null });
|
||||||
pl.bindHost(session, slug, { 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 開始對話。`);
|
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 }) => {
|
commands.list = ({ flags }) => {
|
||||||
@@ -221,6 +270,7 @@ commands.list = ({ flags }) => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
persona: slug,
|
persona: slug,
|
||||||
|
code: gt.personaCode(slug),
|
||||||
identity: pl.identityBrief(slug),
|
identity: pl.identityBrief(slug),
|
||||||
locked: status.locked,
|
locked: status.locked,
|
||||||
stale: status.stale,
|
stale: status.stale,
|
||||||
@@ -236,16 +286,17 @@ commands.list = ({ flags }) => {
|
|||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒";
|
const state = r.locked ? "🔒 已載入" : r.stale ? "⚠ 死鎖可接手" : "🔓 空閒";
|
||||||
lines.push(
|
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})` : "") +
|
(r.locked ? `(session ${r.owner_session}…, cwd ${r.owner_cwd})` : "") +
|
||||||
`|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
|
`|guest ${r.guests}|長期記憶 ${r.long_term}|短期 ${r.short_term}` +
|
||||||
|
(r.code ? "" : "|⚠ 尚無編號") +
|
||||||
(r.identity ? `|${r.identity}` : ""),
|
(r.identity ? `|${r.identity}` : ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
|
emit({ home: pl.personaHome(), personas: rows }, flags.json, lines);
|
||||||
};
|
};
|
||||||
|
|
||||||
commands.load = ({ flags }) => {
|
commands.load = async ({ flags }) => {
|
||||||
const session = requireSession(flags);
|
const session = requireSession(flags);
|
||||||
const slug = str(flags.persona);
|
const slug = str(flags.persona);
|
||||||
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
if (!pl.personaExists(slug)) die(`人格 \`${slug}\` 不存在。可用:${pl.listPersonas().join(", ") || "(無)"}`);
|
||||||
@@ -264,9 +315,32 @@ commands.load = ({ flags }) => {
|
|||||||
die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`);
|
die(`${err.message}\n 若確定那個程序已結束,可加 --takeover 接手。`);
|
||||||
}
|
}
|
||||||
pl.bindHost(session, slug, { cwd: str(flags.cwd) || null });
|
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.pruneShortTerm(slug);
|
||||||
pl.rebuildIndex(slug);
|
pl.rebuildIndex(slug);
|
||||||
const lines = [`✔ 已載入人格 \`${slug}\`(exclusive,session ${session.slice(0, 8)}…,租約 ${lock.lease_seconds}s)`];
|
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) {
|
if (lock.took_over_from) {
|
||||||
const prev = lock.took_over_from;
|
const prev = lock.took_over_from;
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -279,11 +353,23 @@ commands.load = ({ flags }) => {
|
|||||||
emit({ persona: slug, lock, context }, flags.json, lines);
|
emit({ persona: slug, lock, context }, flags.json, lines);
|
||||||
};
|
};
|
||||||
|
|
||||||
commands.release = ({ flags }) => {
|
commands.release = async ({ flags }) => {
|
||||||
const session = requireSession(flags);
|
const session = requireSession(flags);
|
||||||
const data = pl.loadSession(session);
|
const data = pl.loadSession(session);
|
||||||
const slug = str(flags.persona) || data.host;
|
const slug = str(flags.persona) || data.host;
|
||||||
if (!slug) die("本 session 沒有載入任何人格。");
|
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);
|
const released = pl.unbindSession(session);
|
||||||
ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest:${released.guests.join(", ")}` : "。"}`);
|
ok(`已釋放人格 \`${slug}\` 的載入鎖${released.guests.length ? `,並退出 guest:${released.guests.join(", ")}` : "。"}`);
|
||||||
};
|
};
|
||||||
@@ -557,6 +643,7 @@ commands.consolidate = ({ flags }) => {
|
|||||||
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`);
|
say(` 短期記憶已淘汰顯著度 < ${forget} 的項目,剩 ${keep.length} 筆。`);
|
||||||
}
|
}
|
||||||
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
|
ok(`長期記憶 \`${name}\` 已寫入(共 ${total} 則),INDEX.md 已重建。`);
|
||||||
|
pushWikiLater(slug, session, flags); // 固化=Wiki 區(低頻設定)該更新了
|
||||||
};
|
};
|
||||||
|
|
||||||
commands.prune = ({ flags }) => {
|
commands.prune = ({ flags }) => {
|
||||||
@@ -572,6 +659,7 @@ commands.reindex = ({ flags }) => {
|
|||||||
const slug = hostOf(flags, session);
|
const slug = hostOf(flags, session);
|
||||||
requireOwner(slug, session);
|
requireOwner(slug, session);
|
||||||
ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`);
|
ok(`INDEX.md 重建完成(${pl.rebuildIndex(slug)} 則長期記憶)。`);
|
||||||
|
pushWikiLater(slug, session, flags);
|
||||||
};
|
};
|
||||||
|
|
||||||
commands.emotion = ({ flags }) => {
|
commands.emotion = ({ flags }) => {
|
||||||
@@ -672,6 +760,7 @@ commands.relation = ({ flags, positional }) => {
|
|||||||
});
|
});
|
||||||
pl.renderRelations(slug);
|
pl.renderRelations(slug);
|
||||||
ok(`關係節點 \`${name}\` 已更新。`);
|
ok(`關係節點 \`${name}\` 已更新。`);
|
||||||
|
pushWikiLater(slug, session, flags);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "edge") {
|
if (action === "edge") {
|
||||||
@@ -685,6 +774,7 @@ commands.relation = ({ flags, positional }) => {
|
|||||||
});
|
});
|
||||||
pl.renderRelations(slug);
|
pl.renderRelations(slug);
|
||||||
ok(`關係連線 ${str(flags.from) || "self"} → ${to} 已更新。`);
|
ok(`關係連線 ${str(flags.from) || "self"} → ${to} 已更新。`);
|
||||||
|
pushWikiLater(slug, session, flags);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "render") {
|
if (action === "render") {
|
||||||
@@ -951,6 +1041,164 @@ commands.import = ({ flags }) => {
|
|||||||
emit({ ...result, source: bundle.persona, checksum_ok: checksumOk }, flags.json, lines);
|
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 }) => {
|
commands.gc = ({ flags }) => {
|
||||||
const removed = pl.gcRuntime();
|
const removed = pl.gcRuntime();
|
||||||
emit(removed, flags.json, [
|
emit(removed, flags.json, [
|
||||||
@@ -981,7 +1229,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
用法:node persona.mjs <subcommand> [options]
|
用法: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]
|
load --persona <slug> --session <id> [--takeover]
|
||||||
release --session <id> [--persona <slug>]
|
release --session <id> [--persona <slug>]
|
||||||
list 列出人格與鎖狀態
|
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]
|
room post|read|script|list|theater --session <id> [--room --as --text --text-file --emotion --limit --on --off --with-meta]
|
||||||
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force)
|
(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] 匯出目前載入的人格
|
export --session <id> [--out <檔案> --with-journal --gzip --force] 匯出目前載入的人格
|
||||||
import --session <id> --file <檔案> [--persona <新 slug> --force --load]
|
import --session <id> --file <檔案> [--persona <新 slug> --force --load]
|
||||||
|
|
||||||
@@ -1021,7 +1280,7 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
|
|||||||
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
|
全域旗標:--json(機器可讀)、--quiet(成功時不輸出;劇場模式必用)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function main(argv) {
|
async function main(argv) {
|
||||||
const sub = argv[0];
|
const sub = argv[0];
|
||||||
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
|
if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
|
||||||
process.stdout.write(HELP);
|
process.stdout.write(HELP);
|
||||||
@@ -1032,7 +1291,7 @@ function main(argv) {
|
|||||||
const parsed = parseArgs(argv.slice(1));
|
const parsed = parseArgs(argv.slice(1));
|
||||||
QUIET = Boolean(parsed.flags.quiet);
|
QUIET = Boolean(parsed.flags.quiet);
|
||||||
try {
|
try {
|
||||||
command({ flags: parsed.flags, positional: parsed._ });
|
await command({ flags: parsed.flags, positional: parsed._ });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof pl.LockError) die(err.message);
|
if (err instanceof pl.LockError) die(err.message);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -1040,4 +1299,4 @@ function main(argv) {
|
|||||||
return 0;
|
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 HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const STORE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-selftest-"));
|
const STORE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-selftest-"));
|
||||||
process.env.PERSONA_HOME = STORE;
|
process.env.PERSONA_HOME = STORE;
|
||||||
|
// 自我測試絕對不碰真的 Gitea:不建存取庫、不 push。同步邏輯只測不需要網路的部分。
|
||||||
|
process.env.PERSONA_GITEA = "off";
|
||||||
|
|
||||||
const pl = await import("./persona-lib.mjs");
|
const pl = await import("./persona-lib.mjs");
|
||||||
|
const gt = await import("./persona-gitea.mjs");
|
||||||
|
|
||||||
const CLI = path.join(HERE, "persona.mjs");
|
const CLI = path.join(HERE, "persona.mjs");
|
||||||
const HOOKS = path.join(HERE, "..", "hooks");
|
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");
|
tool_input: { command: `node persona.mjs import --persona gamma --session ${S_SPEAK} --file /tmp/b.json` } }) === "pass");
|
||||||
fs.rmSync(OUT, { recursive: true, force: true });
|
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(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||||
process.exit(failed ? 1 : 0);
|
process.exit(failed ? 1 : 0);
|
||||||
|
|||||||
@@ -67,13 +67,17 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
|
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
|
||||||
--persona <slug> --session <PERSONA_SESSION> \
|
--romaji "<角色的官方英文名>" --session <PERSONA_SESSION> \
|
||||||
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
|
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
|
||||||
--emoji "<Emoji>" --avatar "<外觀描述>" \
|
--emoji "<Emoji>" --avatar "<外觀描述>" \
|
||||||
--origin anime --work "<作品名>" \
|
--origin anime --work "<作品名>" \
|
||||||
--baseline "<依角色個性設定的十二情緒基線>"
|
--baseline "<依角色個性設定的十二情緒基線>"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--romaji` 用**官方英文/羅馬拼音名**(維基或官方英文版的寫法,如 `Asuna`、`Yui`、`Rem`),
|
||||||
|
它會變成人格編號 `ASUNA-01` 與 Gitea 存取庫名稱。同一個角色要做第二份設定時,
|
||||||
|
索引會自動變成 `-02`。
|
||||||
|
|
||||||
情緒基線抓法(0–100,總體別全部拉高):
|
情緒基線抓法(0–100,總體別全部拉高):
|
||||||
|
|
||||||
| 角色類型 | 建議基線 |
|
| 角色類型 | 建議基線 |
|
||||||
|
|||||||
@@ -51,12 +51,21 @@ slug = 小寫英數與連字號(例:`lumi`、`shen-yu`),是之後所有
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
|
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" create \
|
||||||
--persona <slug> --session <PERSONA_SESSION> \
|
--romaji "<英文名>" --session <PERSONA_SESSION> \
|
||||||
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
|
--name "<Name>" --creature "<Creature>" --vibe "<Vibe>" \
|
||||||
--emoji "<Emoji>" --avatar "<Avatar>" \
|
--emoji "<Emoji>" --avatar "<Avatar>" \
|
||||||
--baseline "serenity=45,trust=35,joy=25,anxiety=6"
|
--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`、十二情緒狀態、
|
`create` 會:建立 `IDENTITY.md`/`SOUL.md`/`AGENTS.md`/`USER.md`、十二情緒狀態、
|
||||||
記憶與心智圖骨架、人際關係圖,並**立刻取得這個人格的載入鎖並綁到本 session**。
|
記憶與心智圖骨架、人際關係圖,並**立刻取得這個人格的載入鎖並綁到本 session**。
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被
|
|||||||
## 常用指令
|
## 常用指令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 全部人格 + 鎖狀態 + 記憶量
|
# 全部人格 + 編號 + 鎖狀態 + 記憶量(`⚠ 尚無編號` 表示還沒同步到 Gitea)
|
||||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
|
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 名單、情緒)
|
# 單一人格的鎖細節(owner session/cwd/心跳、guest 名單、情緒)
|
||||||
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --persona <slug>
|
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" status --persona <slug>
|
||||||
|
|
||||||
|
|||||||
@@ -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 無亂碼。
|
||||||
Reference in New Issue
Block a user