feat(persona): 時區改為+08:00等值轉換、補機密遮蔽,並接上shared共用規範
依 todo.md 執行的規範治理專案: - persona-lib.mjs 的 iso()/nowIso() 改為輸出 +08:00 offset(等值瞬間轉換, 非加8小時),新增 nowDisplay()(人讀顯示格式)與 todayTaipei()(台北曆日 日期鍵),修正 11 處原本用 UTC 字串切片產生日期鍵的錯誤用法;新增 redactSecrets() 並套用到 persona-gitea.mjs 的 Gitea 錯誤路徑,避免機密 外洩。 - 新增一次性遷移腳本 scripts/migrate-tz.mjs(備份/dry-run/冪等/衝突偵測 中止),供之後有真實人格資料需要轉換時使用。 - persona 13 個 skill 與 AGENTS.md 接上 /jsc-shared 共用規範(spec-output、 spec-script-path、spec-subagent、spec-gitea、spec-git-safety、 spec-skill-invocation 等),不再各自重抄,且完全未變動人格行為本身的規範 (情緒模型、記憶衰減、劇場模式、鎖與隔離機制等逐字保留)。 - selftest 全數通過(788/788,含本次新增的時區邊界/冪等性測試)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "persona",
|
||||
"description": "JSC 跨 AI 助理共用 skills 的 Claude Code marketplace。",
|
||||
"description": "JSC 人格化記憶聊天 skills 的 Claude Code marketplace,提供人格建立與對話、十二情緒與短期/長期記憶管理、心智圖/思維導圖與人際關係圖維護等功能。",
|
||||
"owner": {
|
||||
"name": "JSC"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.2.1",
|
||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||
"version": "0.3.0",
|
||||
"description": "AI 人格化記憶聊天 plugin(Claude Code / Codex / Antigravity / OpenCode / GitHub Copilot CLI):以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話);於 Claude Code 以 /jsc-persona: 前綴呼叫。",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
"name": "JSC"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.2.1",
|
||||
"description": "AI 人格化記憶聊天 skills:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||
"version": "0.3.0",
|
||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||
"skills": "./skills"
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
## 給 AI 助理的指引
|
||||
|
||||
- 所有 skills 位於 `skills/<name>/SKILL.md`;處理任務前先比對使用者需求與各 `description`。
|
||||
- **呼叫慣例**:Claude Code 與 Antigravity 以 `/jsc-persona:<name>` 呼叫;Codex 用 `$<name>`;
|
||||
OpenCode 由模型依描述自動觸發。
|
||||
- 呼叫慣例依 /jsc-shared:spec-skill-invocation。
|
||||
- 完整清單見 `README.md` 的「Skills 目錄」。
|
||||
|
||||
## 這個 plugin 的運作前提(動手前一定要知道)
|
||||
@@ -140,7 +139,7 @@
|
||||
- 新增 skill 一律放在 `skills/<name>/`,`<name>` 使用小寫與連字號。
|
||||
- `description` 要寫清楚觸發條件(何時用、何時不用),這是跨助理自動載入的唯一依據。
|
||||
- 腳本一律 **Node.js(`.mjs`, ESM)**,只用內建模組(fs/path/os/crypto);hook 必須在任何環境都能跑,不得引入 npm 依賴。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無 BOM、不得出現亂碼。
|
||||
- 面向使用者的輸出規範依 /jsc-shared:spec-output。
|
||||
- 改動鎖或隔離邏輯(`scripts/persona-lib.mjs` 的 `guardDecide`/`acquireLock`/`promotionCandidates`/
|
||||
`exportBundle`/`importBundle`)後,**必須**跑 `node scripts/selftest.mjs` 且全綠,並為新規則補一條測試。
|
||||
- 改動重複判定門檻(`similarity`/`REPEAT_THRESHOLD`)後,要用 selftest ⑪ 的對照案例確認
|
||||
|
||||
@@ -919,129 +919,22 @@ node scripts/expression-check.mjs # G2/G3 表達驗收(人工看,不判
|
||||
|
||||
## 安裝 / 更新 / 移除(各助理)
|
||||
|
||||
> 指令中的 repo 網址換成你的:`https://gitea.jsc.idv.tw/plugins/persona.git`
|
||||
> 本 plugin 的 marketplace 名是 `persona`(= repo 名),plugin 名是 `jsc-persona`,安裝 token 為 `jsc-persona@persona`。
|
||||
>
|
||||
> **Claude / Codex 從 git URL 安裝(會 clone 遠端),請先把本 repo `push` 到 gitea。**
|
||||
> **Antigravity 的 `agy plugin install <url>` 目前只支援 github.com**;gitea 請改用「clone + 本地路徑」(見 Antigravity 節)。
|
||||
> 本機/離線:Claude 可用本地路徑加 marketplace;Antigravity 用本地路徑安裝。
|
||||
> **⚠ 若本機還留著更早的 `jsc-persona@jsc-plugins` 安裝**,那是舊版命名的殘影,請先移除舊鍵再重裝新 token。
|
||||
>
|
||||
> 本 plugin 的 marketplace 名是 **`persona`**(= repo 名),plugin 名是 **`jsc-persona`**,安裝 token 為 `jsc-persona@persona`。
|
||||
|
||||
> **⚠ 這版的正式 marketplace 名是 `persona`。** `jsc-persona@persona` 才是現在的安裝 token。
|
||||
> 如果你本機還留著更早的 `jsc-persona@jsc-plugins`,那只是舊安裝殘影,請先移除舊鍵再重裝新 token。
|
||||
> **人格資料不受影響**:人格倉庫在 `~/.claude/personas/`(或 `PERSONA_HOME`),不在 plugin 目錄裡,移除/重裝 plugin 不會動到情緒、記憶與關係圖。Antigravity/OpenCode 是本地路徑/目錄安裝,沒有 marketplace 名,不受此命名影響。
|
||||
>
|
||||
> **人格資料不受影響**:人格倉庫在 `~/.claude/personas/`(或 `PERSONA_HOME`),不在 plugin 目錄裡,
|
||||
> 移除/重裝 plugin 不會動到情緒、記憶與關係圖。Antigravity/OpenCode 是本地路徑/目錄安裝,沒有 marketplace 名,不受此命名影響。
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
# 安裝
|
||||
claude plugin marketplace add https://gitea.jsc.idv.tw/plugins/persona.git
|
||||
claude plugin install jsc-persona@persona
|
||||
|
||||
# 更新
|
||||
claude plugin marketplace update persona
|
||||
claude plugin update jsc-persona@persona
|
||||
|
||||
# 移除
|
||||
claude plugin uninstall jsc-persona@persona
|
||||
claude plugin marketplace remove persona
|
||||
```
|
||||
|
||||
- 工作階段內 slash 版(等價):把 `claude plugin` 換成 `/plugin`。
|
||||
- 本機開發(免 push):`claude plugin marketplace add /home/coder/plugins/persona`(本地路徑)後再 install。
|
||||
- 安裝後**重啟工作階段**讓 hooks 生效;用 `/hooks` 確認六個 hook 都在。
|
||||
- **呼叫**:`/jsc-persona:<name>`(例 `/jsc-persona:persona-chat`)。
|
||||
|
||||
### Codex
|
||||
|
||||
```bash
|
||||
# 安裝
|
||||
codex plugin marketplace add https://gitea.jsc.idv.tw/plugins/persona.git
|
||||
codex plugin add jsc-persona@persona
|
||||
|
||||
# 更新(重新抓取 marketplace 的 git 快照)
|
||||
codex plugin marketplace upgrade persona
|
||||
|
||||
# 移除
|
||||
codex plugin remove jsc-persona@persona
|
||||
codex plugin marketplace remove persona
|
||||
```
|
||||
|
||||
- 安裝 token `jsc-persona@persona` = plugin 名(`.codex-plugin/plugin.json` 的 `name`)@ marketplace 名(`.agents/plugins/marketplace.json` 的 `name`)。
|
||||
- 本 repo 的 Codex marketplace 以 `url` 來源指向自己,故 Codex **一律從 gitea 安裝**(需先 push);安裝後重啟 Codex。
|
||||
- **呼叫**:`$<name>`(例 `$persona-chat`),或用 `/skills` 選單。
|
||||
- Codex 沒有 hook:鎖與隔離只剩 CLI 層的自律檢查。
|
||||
|
||||
### Antigravity(`agy`)
|
||||
|
||||
> `agy plugin install <url>` 目前**只支援 github.com**;gitea 等自架 git 不支援 URL 安裝,請先 `git clone` 再用**本地路徑**安裝。
|
||||
|
||||
```bash
|
||||
# 安裝:clone 後用本地路徑
|
||||
git clone https://gitea.jsc.idv.tw/plugins/persona.git ~/plugins/persona
|
||||
agy plugin install ~/plugins/persona
|
||||
|
||||
# 更新(agy 無 update 子指令 → git pull 後重裝)
|
||||
git -C ~/plugins/persona pull
|
||||
agy plugin uninstall jsc-persona
|
||||
agy plugin install ~/plugins/persona
|
||||
|
||||
# 移除
|
||||
agy plugin uninstall jsc-persona
|
||||
```
|
||||
|
||||
- 若把 skills 放到 GitHub,則可直接 `agy plugin install https://github.com/<owner>/<repo>`。
|
||||
- 其他:`agy plugin list`、`agy plugin enable jsc-persona` / `disable jsc-persona`、`agy plugin validate <path>`。安裝後重啟工作階段。
|
||||
- **呼叫**:`/jsc-persona:<name>`(例 `/jsc-persona:persona-chat`)或依描述自動觸發。
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode 的「plugin」是 TypeScript/npm 套件,不適用於 skill 包;skills 改用**目錄安裝**。
|
||||
OpenCode 會讀 `~/.config/opencode/skills/`(也會讀 `~/.claude/skills/`、`~/.agents/skills/`)。
|
||||
|
||||
```bash
|
||||
# 安裝
|
||||
git clone https://gitea.jsc.idv.tw/plugins/persona.git ~/plugins/persona
|
||||
mkdir -p ~/.config/opencode/skills
|
||||
cp -r ~/plugins/persona/skills/* ~/.config/opencode/skills/
|
||||
|
||||
# 更新
|
||||
git -C ~/plugins/persona pull
|
||||
cp -r ~/plugins/persona/skills/* ~/.config/opencode/skills/
|
||||
|
||||
# 移除
|
||||
rm -rf ~/.config/opencode/skills/{persona-anime,persona-chat,persona-create,persona-icon,persona-invite,persona-memory,persona-relation,persona-sleep,persona-status,persona-story,persona-sync,persona-therapist,persona-transfer}
|
||||
```
|
||||
|
||||
> **Windows PowerShell**:`cp -r A B` → `Copy-Item A B -Recurse -Force`、`rm -rf X` → `Remove-Item X -Recurse -Force`、`~` → `$HOME`。
|
||||
|
||||
- 目錄安裝**不會帶入 `scripts/` 與 `hooks/`**;`persona.mjs` 不在,等於整套狀態操作都不可用。要在 OpenCode 用本 plugin,請另外 clone 本 repo 並自行呼叫 `scripts/persona.mjs`。
|
||||
- **呼叫**:直接描述需求,模型會依 skill 描述自動透過 skill 工具呼叫。
|
||||
|
||||
### GitHub Copilot CLI
|
||||
|
||||
Copilot CLI 支援與 Claude Code 類似的原生 plugin / marketplace 指令,可直接從 marketplace 安裝、更新與移除本 plugin。
|
||||
|
||||
```bash
|
||||
# 安裝
|
||||
copilot plugin marketplace add https://gitea.jsc.idv.tw/plugins/persona.git
|
||||
copilot plugin install jsc-persona@persona
|
||||
|
||||
# 更新
|
||||
copilot plugin marketplace update persona
|
||||
copilot plugin update jsc-persona@persona
|
||||
|
||||
# 移除
|
||||
copilot plugin uninstall jsc-persona@persona
|
||||
copilot plugin marketplace remove persona
|
||||
```
|
||||
|
||||
- 安裝 token `jsc-persona@persona` = plugin 名(plugin manifest 的 `name`)@ marketplace 名。
|
||||
- `copilot plugin marketplace add` 支援 GitHub `owner/repo`、git URL 與本地路徑;Gitea repo 可用上方 HTTPS URL。
|
||||
- Copilot CLI 沒有 hook:鎖與隔離同樣只剩 CLI 層的自律檢查。
|
||||
- **呼叫**:在 Copilot CLI 中用自然語言描述需求,例如 `copilot -i "用 lumi 這個人格跟我聊聊"`。
|
||||
> 完整的安裝/更新/移除指令(Claude Code、Codex、Antigravity、OpenCode、GitHub Copilot CLI 五種助理),一律以 [`/jsc-shared:spec-plugin-cli`](https://gitea.jsc.idv.tw/plugins/shared/src/branch/master/skills/spec-plugin-cli/SKILL.md) 為唯一權威版本,套用時代入下列佔位符:
|
||||
>
|
||||
> | 佔位符 | 值 |
|
||||
> | --- | --- |
|
||||
> | `<host>` | `gitea.jsc.idv.tw` |
|
||||
> | `<name>` | `persona` |
|
||||
> | `<plugin>` | `jsc-persona` |
|
||||
> | `<marketplace>` | `persona` |
|
||||
> | `<token>`(= `<plugin>@<marketplace>`) | `jsc-persona@persona` |
|
||||
> | `<url>` | `https://gitea.jsc.idv.tw/plugins/persona.git` |
|
||||
|
||||
---
|
||||
|
||||
@@ -1085,3 +978,4 @@ copilot plugin marketplace remove persona
|
||||
3. 動到隔離規則 → 一定要在 `selftest.mjs` 的第 ③(隔離)/⑧(guest 與劇場模式)區加對應案例,跑到全綠。
|
||||
4. 把 skill 補進上方「Skills 目錄」區塊。
|
||||
5. bump `.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`plugin.json` 三處 `version`,commit 後 push。
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"version": "0.2.1",
|
||||
"description": "AI 人格化記憶聊天 plugin:OpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||
"version": "0.3.0",
|
||||
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話);於 Antigravity 以 /jsc-persona: 前綴呼叫。",
|
||||
"skills": "./skills"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "jsc-persona",
|
||||
"shortName": "persona",
|
||||
"version": "0.3.0",
|
||||
"descriptionCore": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。",
|
||||
"assistants": ["Claude Code", "Codex", "Antigravity", "OpenCode", "GitHub Copilot CLI"],
|
||||
"cliPrefix": "/jsc-persona:",
|
||||
"callPrefixAssistants": {
|
||||
"root": "Antigravity",
|
||||
"claudePlugin": "Claude Code"
|
||||
},
|
||||
"codexNote": "(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
|
||||
"skillsPath": "./skills",
|
||||
"author": { "name": "JSC" },
|
||||
"homepage": "https://gitea.jsc.idv.tw/plugins/persona",
|
||||
"repository": "https://gitea.jsc.idv.tw/plugins/persona.git",
|
||||
"keywords": [
|
||||
"persona",
|
||||
"memory",
|
||||
"emotion",
|
||||
"mindmap",
|
||||
"relationship",
|
||||
"openclaw",
|
||||
"anime",
|
||||
"roleplay",
|
||||
"jsc"
|
||||
],
|
||||
"marketplace": {
|
||||
"repoDescription": "JSC 人格化記憶聊天 skills 的 Claude Code marketplace,提供人格建立與對話、十二情緒與短期/長期記憶管理、心智圖/思維導圖與人際關係圖維護等功能。",
|
||||
"pluginSummary": "AI 人格化記憶聊天(人格 / 情緒 / 記憶 / 心智圖 / 關係圖)"
|
||||
},
|
||||
"readmeHeadings": {
|
||||
"addSkill": "新增/修改 skill"
|
||||
},
|
||||
"readmeInstallNotes": [
|
||||
"本 plugin 的 marketplace 名是 `persona`(= repo 名),plugin 名是 `jsc-persona`,安裝 token 為 `jsc-persona@persona`。",
|
||||
"**⚠ 若本機還留著更早的 `jsc-persona@jsc-plugins` 安裝**,那是舊版命名的殘影,請先移除舊鍵再重裝新 token。",
|
||||
"**人格資料不受影響**:人格倉庫在 `~/.claude/personas/`(或 `PERSONA_HOME`),不在 plugin 目錄裡,移除/重裝 plugin 不會動到情緒、記憶與關係圖。Antigravity/OpenCode 是本地路徑/目錄安裝,沒有 marketplace 名,不受此命名影響。"
|
||||
],
|
||||
"_driftDecisions": [
|
||||
"決議1:skills 欄位統一為不帶尾斜線的 './skills'。現況:persona 三份 plugin.json(root/.claude-plugin/.codex-plugin)皆已是 './skills',唯一完全符合決議、不需調整的 repo。",
|
||||
"決議2:root plugin.json 已有「於 Antigravity 以 /jsc-persona: 前綴呼叫」句尾,符合決議,不需調整。",
|
||||
"決議3:.claude-plugin/plugin.json 現況完全沒有助理括號清單(四個 repo 中唯一缺漏,非順序/命名問題而是整段缺失)。已依統一決議補上「(Claude Code / Codex / Antigravity / OpenCode / GitHub Copilot CLI)」。",
|
||||
"決議4對應:.codex-plugin/plugin.json 專屬補充資訊「(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)」為真實資訊差異,已保留於 codexNote,不可被格式統一誤刪。",
|
||||
"額外發現1(重要,非既知四點):root 與 .codex-plugin 的描述明顯比 .claude-plugin 簡短,完全沒提到「思維導圖」「可用 sub agent 邀請其他人格同場對話(劇場模式)」「以 IDENTITY/SOUL 建立人格、可用動漫作品+角色名上網蒐集設定」等內容——這是實質內容缺漏,不是格式差異。descriptionCore 已採用 .claude-plugin 的完整版本為準,下階段產生器需把完整內容回補到 root 與 .codex-plugin。",
|
||||
"額外發現2(重要,非既知四點):.claude-plugin/marketplace.json 目前的 repo 層 description 為「JSC 跨 AI 助理共用 skills 的 Claude Code marketplace。」——這段文字幾乎是照抄 shared 的樣板句(少了「規範」二字),完全沒提到人格/情緒/記憶/心智圖等 persona 專屬內容,判斷為複製貼上殘留、忘記客製化,而非合理的精簡描述。marketplace.repoDescription 已在此 meta 更正為 persona 專屬描述,下階段產生器需一併修正該 marketplace.json(plugins[].description 本身沒問題,維持原樣)。",
|
||||
"額外發現3(次要):root 描述用詞為「AI 人格化記憶聊天 plugin」,.codex-plugin 用詞為「AI 人格化記憶聊天 skills」,.claude-plugin 用「plugin」——plugin/skills 用詞不一致,屬低優先級格式細節。"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
#!/usr/bin/env node
|
||||
// migrate-tz.mjs — 一次性遷移:修正既有人格資料裡的時區資料。
|
||||
//
|
||||
// 背景
|
||||
// ----
|
||||
// `persona-lib.mjs` 的 `iso()`/`nowIso()`(約第 140 行)現在會把時刻位移到台北時區
|
||||
// 再輸出 `+08:00` 字尾;`todayTaipei()`(約第 168 行)也是同一套位移邏輯,取代了
|
||||
// 「錯誤的 `nowIso().slice(0, 10)`」(那個註解本身就是在講:舊版 `iso()` 曾經機械地
|
||||
// 回傳 UTC `Z` 時間戳,沒有位移,直接切前 10 個字元拿到的是 UTC 當天,不是台北當天)。
|
||||
//
|
||||
// 這兩個函式修好之後**不會回頭改舊資料**:在 UTC 16:00~23:59(台北已經跨到下一天)
|
||||
// 這八小時窗口內寫下的舊紀錄,時間戳本身是 `...Z` 格式(可以無損換算),但由它
|
||||
// 切出來的日期鍵(例如「今天」的日記日期)算出來的是 UTC 當天,比實際的台北曆日
|
||||
// 少一天。這支腳本就是把這兩類舊資料換算成正確答案:
|
||||
//
|
||||
// (1) 任何 `Z` 結尾的 ISO 時間戳字串 → 等值換算成 `+08:00` 格式(換算成同一個
|
||||
// 瞬間在台北時區的牆上時間,不是機械加 8 小時:兩者在大多數時刻結果一樣,
|
||||
// 但概念上與程式碼上都要走「取 epoch → 用 iso() 同一套位移公式重新格式化」
|
||||
// 這條路,才經得起夏令時間/閏秒之類未來變動)。
|
||||
// (2) 任何「純日期」欄位(`yyyy-MM-dd`,沒有時間部分)→ 如果它所在的**同一個
|
||||
// JSON 物件**裡,找得到剛好一個(不模糊)完整時間戳可以佐證「這筆記錄實際
|
||||
// 發生在哪個台北曆日」,就用那個時間戳重算;同一物件裡的時間戳互相矛盾,
|
||||
// 或根本沒有時間戳可用,就不動它,只記到報告的「無法確定」清單。
|
||||
//
|
||||
// 資料模型調查結論(見 persona-lib.mjs)
|
||||
// --------------------------------------
|
||||
// * `state/mood.json`(`loadDayMood`/`updateDayMood`,約第 716~752 行):
|
||||
// `{ date, valence, arousal, samples, updated_at }` —— `date` 與 `updated_at`
|
||||
// 是同一個物件的兩個欄位,`updated_at` 就是這筆「當日心情底色」最後一次被寫入
|
||||
// 的完整時間戳,可以直接拿來重算 `date` 應該是哪個台北曆日。這是本腳本能夠
|
||||
// 自動判定重算的典型案例(也是題目說的「日記的單日紀錄」)。
|
||||
// * `relations/graph.json` 的 `nodes[].rifts[]`(`openRifts`/`riftBrief`,
|
||||
// 約第 4854~4886 行;寫入邏輯在 persona.mjs 的 `relation rift`):
|
||||
// 每筆 `{ at, about, memory?, healed_at? }` 都是純日期鍵,但**同一筆 rift 物件
|
||||
// 裡沒有伴隨的完整時間戳**——唯一的時間戳在它的上層節點(`node.updated_at`),
|
||||
// 而 `node.updated_at` 是整個節點共用、會被之後任何一次更新(改親近度、加另一件
|
||||
// 還沒和好的事…)覆寫掉的欄位,不能拿來當「這一筆 rift 當時是幾點」的證據。
|
||||
// 這一類欄位落進「無法確定,需人工檢視」清單,不強行用不可靠的上層時間戳去猜。
|
||||
// * 自傳章節(`memory/chapters/*.md`)與長期記憶(`memory/long-term/*.md`)的
|
||||
// `from`/`to`/`first_seen`/`last_seen` 等日期鍵存在 Markdown front matter 裡,
|
||||
// 不是 `.json` 檔——依題目指示本腳本只走 `.json`,這兩處天生不在掃描範圍內,
|
||||
// 如果之後要修,需要另一支專門處理 Markdown front matter 的腳本。
|
||||
// * 其餘找到的日期鍵/時間戳欄位(`state/emotion.json` 的 `updated_at`/
|
||||
// `streak.at`/`last_trigger.at`、`state/config.json` 的 `created_at`、
|
||||
// `state/lock.json`/`guests.json`/`sleepers.json` 的 `*_at`、
|
||||
// `state/loops.json` 的 `opened_at`/`touched_at`、
|
||||
// `memory/import/<work>/work.json`/`names.json` 的 `created_at`/`updated_at`)
|
||||
// 全部都是完整時間戳,沒有「純日期」欄位,直接吃規則 (1) 就好。
|
||||
//
|
||||
// 掃描範圍
|
||||
// --------
|
||||
// 只遞迴走訪 `PERSONA_HOME` 底下、排除 `.runtime`/`.rooms`(這兩個是跨人格的
|
||||
// 執行期目錄,不是人格目錄)的每一個子目錄裡的每一個 `.json` 檔案;不動
|
||||
// `.jsonl`(append-only 日誌,語意上是「歷史事件序列」,不是「這一筆的日期鍵」,
|
||||
// 且題目本身就是說 `.json`)。
|
||||
//
|
||||
// 用法
|
||||
// ----
|
||||
// node migrate-tz.mjs [--dry-run] [--home <path>]
|
||||
//
|
||||
// 不帶 `--home` 時依序退回 `PERSONA_HOME` 環境變數、`~/.claude/personas`。
|
||||
//
|
||||
// 安全機制
|
||||
// --------
|
||||
// a. 執行前一定先檢查是否有人格處於載入狀態(exclusive lock 或存活的 guest
|
||||
// 租約)——不管是不是 `--dry-run`。有就中止、不強行執行。
|
||||
// b. 正式執行(非 `--dry-run`)一定先把整個 `PERSONA_HOME` 完整複製備份到
|
||||
// `<上一層目錄>/personas.bak-<yyyyMMddHHmmss>/`(時間戳是台北時區)。
|
||||
// `--dry-run` 不寫入任何檔案,連備份都不做。
|
||||
// c. 先掃描「整個」`PERSONA_HOME`、算出全部異動計畫,**掃描階段完全不寫檔**;
|
||||
// 只有掃描完全結束、且沒有偵測到合併衝突,才會真正落筆——這樣「合併衝突
|
||||
// 讓整批中止」才能保證「不寫入任何檔案」,不會有些檔案改了、有些沒改。
|
||||
// d. 合併衝突:同一個陣列裡,兩筆以上物件成員共用同一個欄位名稱、原始值不同,
|
||||
// 重算後卻落在同一個台北日──這種情況不自動選一筆,整批中止、報告列出
|
||||
// 衝突的原始內容,等人工決定怎麼合併再重跑。
|
||||
// e. 執行完(非 dry-run)後自動驗證:全樹已經沒有 `Z` 結尾的時間戳字串;
|
||||
// 每一個被轉換過的時間戳欄位,轉換前後的 epoch 完全相等。任何一項不成立
|
||||
// 都算嚴重錯誤(exit code 4),需要人工介入。
|
||||
//
|
||||
// exit code:0 成功(含「沒有東西可遷移」)/1 一般錯誤/2 有人格正在載入而中止/
|
||||
// 3 偵測到合併衝突而中止/4 執行後驗證失敗(嚴重錯誤)。
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 路徑與時間工具(刻意仿照 persona-lib.mjs 的 iso()/todayTaipei(),見檔案開頭
|
||||
// 註解;不 import persona-lib.mjs,讓這支一次性腳本不依賴那個模組今後怎麼演化)
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const RUNTIME_DIRNAME = ".runtime";
|
||||
const ROOMS_DIRNAME = ".rooms";
|
||||
|
||||
function expandUser(p) {
|
||||
if (p === "~") return os.homedir();
|
||||
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
||||
return p;
|
||||
}
|
||||
|
||||
function resolveHome(homeArg) {
|
||||
const raw = homeArg || process.env.PERSONA_HOME || "~/.claude/personas";
|
||||
return path.resolve(expandUser(raw));
|
||||
}
|
||||
|
||||
/** 等同 persona-lib.mjs 的 iso():取 epoch,位移 8 小時,再格式化——不是機械加 8 小時。 */
|
||||
function taipeiIsoFromEpoch(ms) {
|
||||
const truncated = Math.floor(ms / 1000) * 1000;
|
||||
const shifted = new Date(truncated + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}` +
|
||||
`T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}+08:00`
|
||||
);
|
||||
}
|
||||
|
||||
/** 等同 persona-lib.mjs 的 todayTaipei():同一個瞬間換算成台北曆日。 */
|
||||
function taipeiDateFromEpoch(ms) {
|
||||
const shifted = new Date(Math.floor(ms / 1000) * 1000 + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
|
||||
}
|
||||
|
||||
/** 備份檔名用的緊湊時間戳(台北時區):yyyyMMddHHmmss。 */
|
||||
function taipeiCompactStamp(date = new Date()) {
|
||||
const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${shifted.getUTCFullYear()}${pad(shifted.getUTCMonth() + 1)}${pad(shifted.getUTCDate())}` +
|
||||
`${pad(shifted.getUTCHours())}${pad(shifted.getUTCMinutes())}${pad(shifted.getUTCSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 值的形狀判斷(全部靠值的形狀,不靠欄位名稱白名單)
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
const Z_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
||||
const TW_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$/;
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** 形狀對了還要日曆上真的存在(擋 2026-13-40 之類假日期),做法跟 persona-lib.mjs 的 isYmd 一樣。 */
|
||||
function isValidCalendarDate(value) {
|
||||
if (!DATE_RE.test(value)) return false;
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
/** 一個物件自己身上(不含巢狀子物件)所有完整時間戳,換算後的台北曆日集合。 */
|
||||
function siblingTaipeiDates(obj) {
|
||||
const dates = new Set();
|
||||
for (const value of Object.values(obj)) {
|
||||
if (typeof value !== "string") continue;
|
||||
if (Z_RE.test(value) || TW_RE.test(value)) {
|
||||
const t = new Date(value).getTime();
|
||||
if (!Number.isNaN(t)) dates.add(taipeiDateFromEpoch(t));
|
||||
}
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 樹狀掃描:只算出「該怎麼改」,完全不動傳進來的物件、也不寫檔
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
/**
|
||||
* 掃一個檔案已經 parse 好的 JSON 樹。
|
||||
* 回報三種結果:
|
||||
* changes 要改的欄位(timestamp:Z→+08:00;datekey:日期鍵重算)
|
||||
* undetermined 純日期欄位但推算不出正確台北日,維持原值
|
||||
* conflicts 同一個陣列裡兩筆以上成員重算後撞在同一個台北日
|
||||
*/
|
||||
function scanTree(root, fileLabel) {
|
||||
const changes = [];
|
||||
const undetermined = [];
|
||||
const conflicts = [];
|
||||
|
||||
function visitObject(obj, pathArr) {
|
||||
const resolvable = siblingTaipeiDates(obj);
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === "string") {
|
||||
if (Z_RE.test(value)) {
|
||||
const epochOld = new Date(value).getTime();
|
||||
const newValue = taipeiIsoFromEpoch(epochOld);
|
||||
changes.push({
|
||||
file: fileLabel,
|
||||
path: [...pathArr, key],
|
||||
kind: "timestamp",
|
||||
oldValue: value,
|
||||
newValue,
|
||||
epochOld,
|
||||
epochNew: new Date(newValue).getTime(),
|
||||
});
|
||||
} else if (DATE_RE.test(value) && isValidCalendarDate(value)) {
|
||||
if (resolvable.size === 1) {
|
||||
const [only] = resolvable;
|
||||
if (only !== value) {
|
||||
changes.push({ file: fileLabel, path: [...pathArr, key], kind: "datekey", oldValue: value, newValue: only });
|
||||
}
|
||||
} else if (resolvable.size === 0) {
|
||||
undetermined.push({
|
||||
file: fileLabel,
|
||||
path: [...pathArr, key],
|
||||
value,
|
||||
reason: "同一物件裡找不到可用的完整時間戳",
|
||||
});
|
||||
} else {
|
||||
undetermined.push({
|
||||
file: fileLabel,
|
||||
path: [...pathArr, key],
|
||||
value,
|
||||
reason: `同一物件裡有 ${resolvable.size} 個互相矛盾的時間戳(${[...resolvable].join(" / ")}),無法判斷歸屬哪個台北日`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (value && typeof value === "object") {
|
||||
visit(value, [...pathArr, key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一個陣列裡,兩筆以上物件成員共用同一個欄位名稱、原始值不同,卻重算成同一個台北日。 */
|
||||
function detectArrayConflicts(arr, pathArr) {
|
||||
const objItems = arr
|
||||
.map((item, i) => ({ item, i }))
|
||||
.filter(({ item }) => item && typeof item === "object" && !Array.isArray(item));
|
||||
if (objItems.length < 2) return;
|
||||
const fieldNames = new Set();
|
||||
for (const { item } of objItems) {
|
||||
for (const [k, v] of Object.entries(item)) {
|
||||
if (typeof v === "string" && DATE_RE.test(v) && isValidCalendarDate(v)) fieldNames.add(k);
|
||||
}
|
||||
}
|
||||
for (const field of fieldNames) {
|
||||
const groups = new Map(); // 新值 → [{index, original, item}]
|
||||
for (const { item, i } of objItems) {
|
||||
const raw = item[field];
|
||||
if (typeof raw !== "string" || !DATE_RE.test(raw) || !isValidCalendarDate(raw)) continue;
|
||||
const dates = siblingTaipeiDates(item);
|
||||
if (dates.size !== 1) continue; // 這筆自己就推算不出來,不參與衝突判斷(會落進 undetermined)
|
||||
const [resolved] = dates;
|
||||
if (!groups.has(resolved)) groups.set(resolved, []);
|
||||
groups.get(resolved).push({ index: i, original: raw, item });
|
||||
}
|
||||
for (const [newValue, group] of groups) {
|
||||
if (group.length < 2) continue;
|
||||
const distinctOriginals = new Set(group.map((g) => g.original));
|
||||
if (distinctOriginals.size > 1) {
|
||||
conflicts.push({
|
||||
file: fileLabel,
|
||||
path: [...pathArr],
|
||||
field,
|
||||
newValue,
|
||||
entries: group.map((g) => ({
|
||||
index: g.index,
|
||||
path: [...pathArr, g.index, field],
|
||||
original: g.original,
|
||||
record: g.item,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function visit(node, pathArr) {
|
||||
if (Array.isArray(node)) {
|
||||
detectArrayConflicts(node, pathArr);
|
||||
node.forEach((item, i) => {
|
||||
if (item && typeof item === "object") visit(item, [...pathArr, i]);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node && typeof node === "object") {
|
||||
visitObject(node, pathArr);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(root) || (root && typeof root === "object")) {
|
||||
visit(root, []);
|
||||
} else if (typeof root === "string") {
|
||||
// 罕見邊界:整個檔案的內容就是一個字串(沒有其他欄位可以當 sibling)。
|
||||
if (Z_RE.test(root)) {
|
||||
const epochOld = new Date(root).getTime();
|
||||
const newValue = taipeiIsoFromEpoch(epochOld);
|
||||
changes.push({ file: fileLabel, path: [], kind: "timestamp", oldValue: root, newValue, epochOld, epochNew: new Date(newValue).getTime() });
|
||||
} else if (DATE_RE.test(root) && isValidCalendarDate(root)) {
|
||||
undetermined.push({ file: fileLabel, path: [], value: root, reason: "檔案頂層就是日期字串,沒有其他欄位可用來推算" });
|
||||
}
|
||||
}
|
||||
|
||||
return { changes, undetermined, conflicts };
|
||||
}
|
||||
|
||||
function applyChanges(root, changes) {
|
||||
let newRoot = root;
|
||||
for (const change of changes) {
|
||||
if (change.path.length === 0) {
|
||||
newRoot = change.newValue;
|
||||
continue;
|
||||
}
|
||||
let cur = newRoot;
|
||||
for (let i = 0; i < change.path.length - 1; i++) cur = cur[change.path[i]];
|
||||
cur[change.path[change.path.length - 1]] = change.newValue;
|
||||
}
|
||||
return newRoot;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 檔案/目錄走訪
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
function listPersonaDirs(home) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(home, { withFileTypes: true });
|
||||
} catch {
|
||||
return null; // home 不存在
|
||||
}
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && e.name !== RUNTIME_DIRNAME && e.name !== ROOMS_DIRNAME && !e.name.startsWith("."))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function collectJsonFiles(dir) {
|
||||
const out = [];
|
||||
const stack = [dir];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(cur, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = path.join(cur, e.name);
|
||||
if (e.isDirectory()) stack.push(full);
|
||||
else if (e.isFile() && e.name.endsWith(".json")) out.push(full);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 前置檢查:有沒有人格正在被載入
|
||||
//
|
||||
// 邏輯仿照 persona-lib.mjs 的 lockIsDead()/liveGuests()(約第 1607~1617 行、
|
||||
// LEASE_SECONDS=900/GUEST_LEASE_SECONDS=1800,約第 23~24 行):只看心跳租約
|
||||
// 有沒有過期,不靠 pid(CLI process 跑完就結束,不能拿 pid 存活判斷)。
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
const LEASE_SECONDS = 900;
|
||||
const GUEST_LEASE_SECONDS = 1800;
|
||||
|
||||
function readJsonSafe(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkActiveLocks(home, personaDirs) {
|
||||
const active = [];
|
||||
for (const slug of personaDirs) {
|
||||
const lock = readJsonSafe(path.join(home, slug, "state", "lock.json"));
|
||||
if (lock && typeof lock === "object" && lock.heartbeat_at) {
|
||||
const hbAge = (Date.now() - new Date(lock.heartbeat_at).getTime()) / 1000;
|
||||
const lease = Number(lock.lease_seconds) || LEASE_SECONDS;
|
||||
if (Number.isFinite(hbAge) && hbAge <= lease) {
|
||||
active.push({ slug, kind: "lock", session_id: lock.session_id, cwd: lock.cwd, heartbeat_at: lock.heartbeat_at });
|
||||
}
|
||||
}
|
||||
const guestsData = readJsonSafe(path.join(home, slug, "state", "guests.json"));
|
||||
const guests = Array.isArray(guestsData?.guests) ? guestsData.guests : [];
|
||||
for (const g of guests) {
|
||||
if (!g || !g.heartbeat_at) continue;
|
||||
const hbAge = (Date.now() - new Date(g.heartbeat_at).getTime()) / 1000;
|
||||
if (Number.isFinite(hbAge) && hbAge <= GUEST_LEASE_SECONDS) {
|
||||
active.push({ slug, kind: "guest", session_id: g.session_id, room: g.room, heartbeat_at: g.heartbeat_at });
|
||||
}
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 備份
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
function backupHome(home) {
|
||||
const parent = path.dirname(home);
|
||||
const dest = path.join(parent, `personas.bak-${taipeiCompactStamp(new Date())}`);
|
||||
fs.cpSync(home, dest, { recursive: true });
|
||||
return dest;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 執行後驗證
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
function verifyNoLeftoverZ(home, personaDirs) {
|
||||
const offenders = [];
|
||||
for (const slug of personaDirs) {
|
||||
for (const file of collectJsonFiles(path.join(home, slug))) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
// 粗略但夠用:找形似 ISO 時間戳、以 Z 結尾的片段(跟 Z_RE 同一個形狀,
|
||||
// 只是不要求整個字串就是它——用來抓「文件裡還殘留 Z 時間戳」這個粗粒度事實)。
|
||||
const re = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/g;
|
||||
let m;
|
||||
while ((m = re.exec(text))) offenders.push({ file, match: m[0] });
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// CLI
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { dryRun: false, home: null, help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--dry-run") out.dryRun = true;
|
||||
else if (a === "--home") out.home = argv[++i];
|
||||
else if (a === "--help" || a === "-h") out.help = true;
|
||||
else {
|
||||
process.stderr.write(`未知的參數:${a}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(
|
||||
"用法:node migrate-tz.mjs [--dry-run] [--home <path>]\n" +
|
||||
" --dry-run 只掃描並印出報告,不寫入任何檔案(連備份都不做)\n" +
|
||||
" --home 指定 PERSONA_HOME;不帶時用環境變數 PERSONA_HOME,再退回 ~/.claude/personas\n",
|
||||
);
|
||||
}
|
||||
|
||||
function fmtPath(p) {
|
||||
return p.length ? `/${p.join("/")}` : "(root)";
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
const home = resolveHome(args.home);
|
||||
console.log(`[migrate-tz] PERSONA_HOME = ${home}${args.dryRun ? "(--dry-run,不會寫入任何檔案)" : ""}`);
|
||||
|
||||
const personaDirs = listPersonaDirs(home);
|
||||
if (personaDirs === null) {
|
||||
console.log(`[migrate-tz] 目錄不存在,沒有東西可遷移:${home}`);
|
||||
return 0;
|
||||
}
|
||||
if (personaDirs.length === 0) {
|
||||
console.log(`[migrate-tz] 目錄裡沒有人格子目錄,沒有東西可遷移:${home}`);
|
||||
return 0;
|
||||
}
|
||||
console.log(`[migrate-tz] 找到 ${personaDirs.length} 個人格目錄:${personaDirs.join(", ")}`);
|
||||
|
||||
// a. 前置檢查:有沒有人格正在載入
|
||||
const activeLocks = checkActiveLocks(home, personaDirs);
|
||||
if (activeLocks.length) {
|
||||
console.error("[migrate-tz] 中止:以下人格目前處於載入狀態,不強行執行:");
|
||||
for (const a of activeLocks) {
|
||||
if (a.kind === "lock") {
|
||||
console.error(` - ${a.slug}:exclusive lock(session ${a.session_id},cwd ${a.cwd},最後心跳 ${a.heartbeat_at})`);
|
||||
} else {
|
||||
console.error(` - ${a.slug}:guest 租約(session ${a.session_id},room ${a.room},最後心跳 ${a.heartbeat_at})`);
|
||||
}
|
||||
}
|
||||
console.error("請先讓所有人格釋放鎖(結束對話 / persona status --release)再重跑這支腳本。");
|
||||
return 2;
|
||||
}
|
||||
console.log("[migrate-tz] 前置檢查通過:沒有人格處於載入狀態。");
|
||||
|
||||
// b. 備份(dry-run 完全不寫檔,連備份都不做)
|
||||
let backupDir = null;
|
||||
if (!args.dryRun) {
|
||||
backupDir = backupHome(home);
|
||||
console.log(`[migrate-tz] 已備份到:${backupDir}`);
|
||||
}
|
||||
|
||||
// c/d/e. 掃描整個 PERSONA_HOME,算出全部異動計畫;掃描階段完全不寫檔
|
||||
const perFile = []; // { file, label, root, changes }
|
||||
const allChanges = [];
|
||||
const allUndetermined = [];
|
||||
const allConflicts = [];
|
||||
|
||||
for (const slug of personaDirs) {
|
||||
for (const file of collectJsonFiles(path.join(home, slug))) {
|
||||
const label = path.relative(home, file);
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
let root;
|
||||
try {
|
||||
root = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`[migrate-tz] 略過(JSON 解析失敗,不會被計入任何統計):${label} — ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
const { changes, undetermined, conflicts } = scanTree(root, label);
|
||||
if (changes.length) perFile.push({ file, label, root, changes });
|
||||
allChanges.push(...changes);
|
||||
allUndetermined.push(...undetermined);
|
||||
allConflicts.push(...conflicts);
|
||||
}
|
||||
}
|
||||
|
||||
const tsChanges = allChanges.filter((c) => c.kind === "timestamp");
|
||||
const dateChanges = allChanges.filter((c) => c.kind === "datekey");
|
||||
const filesChanged = new Set(perFile.map((f) => f.label));
|
||||
|
||||
console.log("");
|
||||
console.log("========== 掃描報告 ==========");
|
||||
console.log(`將改動的檔案數:${filesChanged.size}`);
|
||||
console.log(`時間戳欄位(Z → +08:00):${tsChanges.length}`);
|
||||
console.log(`日期鍵欄位(重算台北曆日):${dateChanges.length}`);
|
||||
console.log(`無法確定(維持原值,需人工檢視):${allUndetermined.length}`);
|
||||
console.log(`合併衝突:${allConflicts.length}`);
|
||||
|
||||
if (allUndetermined.length) {
|
||||
console.log("");
|
||||
console.log("---- 無法確定,需人工檢視 ----");
|
||||
for (const u of allUndetermined) {
|
||||
console.log(` ${u.file}${fmtPath(u.path)} = "${u.value}" — ${u.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (allConflicts.length) {
|
||||
console.log("");
|
||||
console.log("---- 合併衝突(未寫入任何檔案,需人工決定合併方式後重跑) ----");
|
||||
for (const c of allConflicts) {
|
||||
console.log(` 檔案 ${c.file},陣列 ${fmtPath(c.path)},欄位 \`${c.field}\`,重算後都落在 ${c.newValue}:`);
|
||||
for (const e of c.entries) {
|
||||
console.log(` - [${e.index}] 原始值 ${e.original}:${JSON.stringify(e.record)}`);
|
||||
}
|
||||
}
|
||||
console.error("");
|
||||
console.error(`[migrate-tz] 中止:偵測到 ${allConflicts.length} 組合併衝突,不寫入任何檔案。`);
|
||||
console.error("請先人工決定怎麼合併上面列出的紀錄,再重新執行這支腳本。");
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log("");
|
||||
console.log("[migrate-tz] --dry-run:以上為預覽,沒有寫入任何檔案。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (filesChanged.size === 0) {
|
||||
console.log("");
|
||||
console.log("[migrate-tz] 沒有需要修改的欄位(可能已經是 +08:00 格式,或本來就沒有需要換算的資料)。");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 落筆:只有掃描完全結束、確定沒有合併衝突,才寫入
|
||||
for (const { file, root, changes } of perFile) {
|
||||
const newRoot = applyChanges(root, changes);
|
||||
const tmp = `${file}.tmp${process.pid}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(newRoot, null, 2) + "\n", "utf8");
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
console.log("");
|
||||
console.log(`[migrate-tz] 已寫入 ${filesChanged.size} 個檔案:`);
|
||||
for (const label of filesChanged) console.log(` - ${label}`);
|
||||
|
||||
// e. 執行後自動驗證
|
||||
console.log("");
|
||||
console.log("========== 執行後驗證 ==========");
|
||||
const leftover = verifyNoLeftoverZ(home, personaDirs);
|
||||
if (leftover.length) {
|
||||
console.error(`[migrate-tz] 嚴重錯誤:全樹掃描後仍找到 ${leftover.length} 個 Z 結尾的時間戳,遷移不完整:`);
|
||||
for (const o of leftover) console.error(` - ${o.file}: ${o.match}`);
|
||||
return 4;
|
||||
}
|
||||
console.log("(i) 全樹已無殘留的 Z 結尾時間戳:通過。");
|
||||
|
||||
const epochMismatches = tsChanges.filter((c) => c.epochOld !== c.epochNew);
|
||||
if (epochMismatches.length) {
|
||||
console.error(`[migrate-tz] 嚴重錯誤:${epochMismatches.length} 個時間戳欄位轉換前後 epoch 不相等:`);
|
||||
for (const m of epochMismatches) {
|
||||
console.error(` - ${m.file}${fmtPath(m.path)}:${m.oldValue}(${m.epochOld}) → ${m.newValue}(${m.epochNew})`);
|
||||
}
|
||||
return 4;
|
||||
}
|
||||
console.log(`(ii) ${tsChanges.length} 個時間戳欄位逐一驗證轉換前後 epoch 相等:通過。`);
|
||||
|
||||
console.log("");
|
||||
console.log(`[migrate-tz] 完成。備份保留於:${backupDir}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
process.exit(main());
|
||||
+19
-11
@@ -77,7 +77,7 @@ export async function nextCodeAcrossMachines(romaji, { owner = null } = {}) {
|
||||
const taken = remote.personas.map((p) => p.code);
|
||||
return { code: nextCode(romaji, { taken }), checked_remote: true, taken };
|
||||
} catch (err) {
|
||||
return { code: local, checked_remote: false, reason: String(err.message || err) };
|
||||
return { code: local, checked_remote: false, reason: pl.redactSecrets(String(err.message || err)) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,8 @@ export function updateSyncState(slug, mutate) {
|
||||
// 環境與 API
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
// giteaEnv()/giteaProblem() 實作 /jsc-shared:spec-gitea 的『token 解析優先序』章節:
|
||||
// 專用變數(PERSONA_GITEA_TOKEN)插在最前面,其次才是 GITEA_TOKEN,順序不可打亂。
|
||||
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 || "");
|
||||
@@ -194,6 +196,8 @@ export function giteaProblem() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// api() 實作 /jsc-shared:spec-gitea 的『API 呼叫慣例』章節:
|
||||
// base 為 `${host}/api/v1`,標頭帶 `Authorization: token <token>`,body 一律 UTF-8 JSON。
|
||||
async function api(method, route, body = null) {
|
||||
const { host, token } = giteaEnv();
|
||||
const res = await fetch(`${host}/api/v1${route}`, {
|
||||
@@ -223,7 +227,7 @@ export async function giteaLogin() {
|
||||
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)}`);
|
||||
if (!res.ok || !res.json?.login) throw new Error(`取不到 Gitea 帳號(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 120)}`);
|
||||
pl.writeJson(ownerCachePath(), { host: env.host, login: res.json.login, cached_at: pl.nowIso() });
|
||||
return res.json.login;
|
||||
}
|
||||
@@ -241,7 +245,7 @@ async function listRepos(owner, me) {
|
||||
const out = [];
|
||||
for (let page = 1; page <= 40; page += 1) {
|
||||
const res = await api("GET", `${route}?page=${page}&limit=50`);
|
||||
if (!res.ok) throw new Error(`列出 ${owner} 的存取庫失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
|
||||
if (!res.ok) throw new Error(`列出 ${owner} 的存取庫失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`);
|
||||
const batch = Array.isArray(res.json) ? res.json : [];
|
||||
out.push(...batch);
|
||||
if (batch.length < 50) break;
|
||||
@@ -304,7 +308,7 @@ export async function ensureRepo(owner, code, { description = "", private_ = tru
|
||||
description: description || `jsc-persona 人格 ${code}`,
|
||||
auto_init: false,
|
||||
});
|
||||
if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${res.text.slice(0, 160)}`);
|
||||
if (!res.ok) throw new Error(`建立存取庫 ${owner}/${code} 失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`);
|
||||
void env;
|
||||
return { repo: res.json, created: true };
|
||||
}
|
||||
@@ -318,7 +322,7 @@ export async function ensureWikiHome(owner, code, content) {
|
||||
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)}`);
|
||||
if (!res.ok) throw new Error(`建立 Wiki 首頁失敗(HTTP ${res.status}):${pl.redactSecrets(res.text).slice(0, 160)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -367,7 +371,7 @@ export function git(args, cwd = null) {
|
||||
|
||||
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}`);
|
||||
if (!res.ok) throw new Error(`${what} 失敗:git ${args.join(" ")}\n ${pl.redactSecrets(res.stderr || res.stdout)}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -478,6 +482,10 @@ const WIKI_PREFIX = { memory: "Memory", mindmap: "Mindmap", relations: "Relation
|
||||
* Gitea 的 wiki **只有根目錄的 .md 會變成頁面**(1.27 實測:子目錄頁面連結 404),
|
||||
* 所以低頻區的檔案要攤平成根層檔名(`memory/long-term/x.md` → `Memory-x.md`,
|
||||
* 網頁上顯示為「Memory x」),再用 `_paths.json` 記住原本的路徑,pull 時才還原得回去。
|
||||
*
|
||||
* 對應 /jsc-shared:spec-gitea 的『Wiki 頁名轉義規則』第 3 點:本檔走 git clone/push
|
||||
* 直接寫檔名,不反推 Gitea 的 title↔sub_url 內部轉義,改用 `_paths.json` manifest
|
||||
* 自己記住「工作路徑 → 儲存檔名」的對應。
|
||||
*/
|
||||
export function wikiName(rel) {
|
||||
if (!rel.includes("/")) return rel;
|
||||
@@ -718,7 +726,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
|
||||
});
|
||||
return { ok: true, changed: false, files: staged.length, area, code: theCode };
|
||||
}
|
||||
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowIso()}`], dir, "git commit");
|
||||
gitOrThrow(["commit", "-q", "-m", message || `sync(${area}): ${pl.nowDisplay()}`], dir, "git commit");
|
||||
let pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
|
||||
let overwrote = null;
|
||||
if (!pushed.ok) {
|
||||
@@ -754,7 +762,7 @@ export async function pushArea(slug, area, { message = "", code = null, owner =
|
||||
}
|
||||
pushed = git(["push", "-q", "-u", "origin", "HEAD"], dir);
|
||||
}
|
||||
if (!pushed.ok) return { ok: false, area, code: theCode, reason: pushed.stderr || pushed.stdout };
|
||||
if (!pushed.ok) return { ok: false, area, code: theCode, reason: pl.redactSecrets(pushed.stderr || pushed.stdout) };
|
||||
updateSyncState(slug, (state) => {
|
||||
state.code = theCode;
|
||||
state.owner = theOwner;
|
||||
@@ -802,7 +810,7 @@ export async function pullArea(slug, area, { code = null, owner = null, force =
|
||||
clearStaleIndexLock(dir);
|
||||
const localChanged = new Set(statusPaths(dir));
|
||||
const fetched = git(["fetch", "--quiet", "origin"], dir);
|
||||
if (!fetched.ok) return { ok: false, area, reason: fetched.stderr || "fetch 失敗" };
|
||||
if (!fetched.ok) return { ok: false, area, reason: pl.redactSecrets(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);
|
||||
@@ -815,7 +823,7 @@ export async function pullArea(slug, area, { code = null, owner = null, force =
|
||||
}
|
||||
git(["checkout", "--", "."], dir);
|
||||
const reset = git(["reset", "--hard", "--quiet", remoteRef], dir);
|
||||
if (!reset.ok) return { ok: false, area, reason: reset.stderr };
|
||||
if (!reset.ok) return { ok: false, area, reason: pl.redactSecrets(reset.stderr) };
|
||||
// 只寫回「遠端真的改過的」與「本機缺少的」。
|
||||
// 不能無差別覆蓋:本機有較新但還沒 push 的內容時,那會把它蓋掉。
|
||||
const root = pl.personaDir(slug);
|
||||
@@ -920,7 +928,7 @@ export async function importFromRemote(code, { owner = null, slug = null, force
|
||||
// 本機是空的,衝突判定沒有意義;覆蓋既有人格時使用者已經明講了 --force
|
||||
results[area] = await pullArea(target, area, { code, owner: theOwner, force: true });
|
||||
} catch (err) {
|
||||
results[area] = { ok: false, area, reason: String(err.message || err) };
|
||||
results[area] = { ok: false, area, reason: pl.redactSecrets(String(err.message || err)) };
|
||||
}
|
||||
}
|
||||
if (!pl.personaExists(target)) {
|
||||
|
||||
+68
-9
@@ -129,7 +129,20 @@ export function setDefaultPersona(slug) {
|
||||
// 時間與檔案 IO
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
export const iso = (date = new Date()) => new Date(Math.floor(date.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z");
|
||||
const TAIPEI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 產生帶 `+08:00`(Asia/Taipei)offset 的 ISO 字串,例如 `2026-08-11T10:10:13+08:00`。
|
||||
* 這是等值時區轉換:輸出代表跟輸入 `date` 完全相同的瞬間(epoch 不變),
|
||||
* 只是把「顯示的牆上時間」換成台北時區,而不是原本 UTC 的 `Z`。
|
||||
* `parseIso()`/`new Date()` 都能正確解析回相同的 epoch,既有時間差運算不受影響。
|
||||
*/
|
||||
export const iso = (date = new Date()) => {
|
||||
const truncated = new Date(Math.floor(date.getTime() / 1000) * 1000);
|
||||
const shifted = new Date(truncated.getTime() + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}+08:00`;
|
||||
};
|
||||
export const nowIso = () => iso(new Date());
|
||||
|
||||
export function parseIso(value) {
|
||||
@@ -138,6 +151,26 @@ export function parseIso(value) {
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 給人看的顯示字串(不可被 `new Date()` 解析):Asia/Taipei 時區、`yyyy/MM/dd HH:mm:ss`。
|
||||
* 用途:任何要印給使用者看或寫進 log 訊息的地方,依 /jsc-shared:spec-time-log 規定的格式。
|
||||
*/
|
||||
export function nowDisplay(date = new Date()) {
|
||||
const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${shifted.getUTCFullYear()}/${pad(shifted.getUTCMonth() + 1)}/${pad(shifted.getUTCDate())} ${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 該時刻依台北曆日計算的 `yyyy-MM-dd` 日期字串,取代錯誤的 `nowIso().slice(0, 10)`(那是 UTC 當天)。
|
||||
* 例如 UTC `2026-08-10T16:30:00Z`(台北 `2026-08-11 00:30`)要回傳 `2026-08-11`。
|
||||
*/
|
||||
export function todayTaipei(date = new Date()) {
|
||||
const shifted = new Date(Math.floor(date.getTime() / 1000) * 1000 + TAIPEI_OFFSET_MS);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
|
||||
}
|
||||
|
||||
export function ageSeconds(value) {
|
||||
const dt = parseIso(value);
|
||||
if (!dt) return Infinity;
|
||||
@@ -686,7 +719,7 @@ export function loadDayMood(slug) {
|
||||
const data = readJson(dayMoodPath(slug), {}) ?? {};
|
||||
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
||||
return {
|
||||
date: typeof data.date === "string" ? data.date : nowIso().slice(0, 10),
|
||||
date: typeof data.date === "string" ? data.date : todayTaipei(),
|
||||
valence: clamp(num(data.valence), -100, 100),
|
||||
arousal: clamp(num(data.arousal), 0, 100),
|
||||
samples: Math.max(0, Math.floor(num(data.samples))),
|
||||
@@ -701,7 +734,7 @@ export function loadDayMood(slug) {
|
||||
export function updateDayMood(slug, state = null) {
|
||||
return withFileLock(dayMoodPath(slug), () => {
|
||||
const day = loadDayMood(slug);
|
||||
const today = nowIso().slice(0, 10);
|
||||
const today = todayTaipei();
|
||||
if (day.date !== today) {
|
||||
day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10;
|
||||
day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10;
|
||||
@@ -724,7 +757,7 @@ export function sleepDayMood(slug) {
|
||||
const day = loadDayMood(slug);
|
||||
day.valence = Math.round(day.valence * DAY_MOOD_CARRY * 10) / 10;
|
||||
day.arousal = Math.round(day.arousal * DAY_MOOD_CARRY * 10) / 10;
|
||||
day.date = nowIso().slice(0, 10);
|
||||
day.date = todayTaipei();
|
||||
day.samples = 0;
|
||||
day.updated_at = nowIso();
|
||||
writeJson(dayMoodPath(slug), day);
|
||||
@@ -2759,7 +2792,7 @@ export function writeLongTermMemory(slug, {
|
||||
const file = path.join(longTermDir(slug), `${name}.md`);
|
||||
let existing = {};
|
||||
if (fs.existsSync(file)) [existing] = parseFrontMatter(fs.readFileSync(file, "utf8"));
|
||||
const today = nowIso().slice(0, 10);
|
||||
const today = todayTaipei();
|
||||
const stamp = contextStamp(slug);
|
||||
// `gist` 明寫時,`detail` 沒給就是**沒有細節**——退回整份 body 會讓主旨之外
|
||||
// 再抄一次全文,那是把「衰減先吃細節」這件事整個抵銷掉。
|
||||
@@ -2844,7 +2877,7 @@ export function writeChapter(slug, { name, title = "", from = "", to = "", body
|
||||
"---",
|
||||
`name: ${key}`,
|
||||
`title: ${fm(title || existing.title || name).slice(0, 120)}`,
|
||||
`from: ${fm(from) || existing.from || nowIso().slice(0, 10)}`,
|
||||
`from: ${fm(from) || existing.from || todayTaipei()}`,
|
||||
// `to` 空著=還在這一段裡。所以沒有值就**不寫這一行**,不要寫成 `to: `。
|
||||
...((fm(to) || existing.to) ? [`to: ${fm(to) || existing.to}`] : []),
|
||||
`updated_at: ${nowIso()}`,
|
||||
@@ -2900,7 +2933,7 @@ export function currentChapter(slug) {
|
||||
}
|
||||
|
||||
/** 收掉一段(寫上 `to`)。找不到那一段回 null。 */
|
||||
export function closeChapter(slug, name, at = nowIso().slice(0, 10)) {
|
||||
export function closeChapter(slug, name, at = todayTaipei()) {
|
||||
const key = slugify(name);
|
||||
if (!fs.existsSync(chapterPath(slug, key))) return null;
|
||||
return writeChapter(slug, { name: key, to: at });
|
||||
@@ -2927,7 +2960,7 @@ export function rebuildIndex(slug) {
|
||||
const lines = [
|
||||
"# 長期記憶索引",
|
||||
"",
|
||||
`<!-- 由 persona.mjs 自動產生,最後更新 ${nowIso()};一則記憶一行 -->`,
|
||||
`<!-- 由 persona.mjs 自動產生,最後更新 ${nowDisplay()};一則記憶一行 -->`,
|
||||
"",
|
||||
];
|
||||
const sorted = [...entries].sort((a, b) => Number(b.salience || 0) - Number(a.salience || 0));
|
||||
@@ -3244,7 +3277,7 @@ export function markShortTermReviewed(slug, { indexes = null, until = null, prom
|
||||
*/
|
||||
export function touchRecall(slug, names) {
|
||||
const wanted = new Set(names);
|
||||
const today = nowIso().slice(0, 10);
|
||||
const today = todayTaipei();
|
||||
for (const meta of longTermEntries(slug)) {
|
||||
if (!wanted.has(meta._name)) continue;
|
||||
let text;
|
||||
@@ -6104,6 +6137,32 @@ export function injectSafeLine(text, limit = 0) {
|
||||
return limit > 0 ? one.slice(0, limit) : one;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 機密遮蔽(port 自 doc/scripts/worklog/transcript.py 的 REDACT_PATTERNS,
|
||||
// 為 /jsc-shared:spec-gitea『機密遮蔽實作』章節的來源依據;本檔僅共用定義,
|
||||
// 套用地點見 A0-4:persona 側 Gitea 錯誤遮蔽)
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
const REDACT_PATTERNS = [
|
||||
[/[A-Za-z0-9_-]*:[A-Za-z0-9_-]{16,}@/g, "***@"], // URL 內嵌憑證 user:token@
|
||||
[/\b[0-9a-f]{40}\b/g, "***"], // Gitea 40 字元 token
|
||||
[/\bgh[pousr]_[A-Za-z0-9_]{16,}\b/g, "***"], // GitHub token
|
||||
[/\bsk-[A-Za-z0-9\-_]{16,}\b/g, "***"], // API key
|
||||
[/\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+/gi, "$1=***"],
|
||||
[/Authorization:\s*(token|bearer)\s+\S+/gi, "Authorization: $1 ***"],
|
||||
[/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, "***"], // Email
|
||||
[/\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b/g, "***"], // 台灣手機
|
||||
[/\b[A-Z][12]\d{8}\b/g, "***"], // 身分證字號
|
||||
];
|
||||
|
||||
/** 對文字依序套用全部機密遮蔽規則,回傳遮蔽後的結果。 */
|
||||
// 本檔遮蔽規則對應 shared/scripts/lib/redact-patterns.json(經 /jsc-shared:spec-gitea 收斂)
|
||||
export function redactSecrets(text) {
|
||||
let out = String(text ?? "");
|
||||
for (const [pattern, replacement] of REDACT_PATTERNS) out = out.replace(pattern, replacement);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function identityFields(slug) {
|
||||
const fields = {};
|
||||
let text;
|
||||
|
||||
+7
-7
@@ -479,7 +479,7 @@ commands.release = async ({ flags }) => {
|
||||
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()}` });
|
||||
const res = await gt.pushArea(slug, area, { message: `release: 對話結束 ${pl.nowDisplay()}` });
|
||||
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) {
|
||||
@@ -594,7 +594,7 @@ commands.sleep = async ({ flags }) => {
|
||||
} else {
|
||||
for (const area of gt.AREA_KEYS) {
|
||||
try {
|
||||
const res = await gt.pushArea(slug, area, { message: `sleep: 收尾 ${pl.nowIso()}` });
|
||||
const res = await gt.pushArea(slug, area, { message: `sleep: 收尾 ${pl.nowDisplay()}` });
|
||||
sync[area] = res.ok ? (res.changed ? "pushed" : "no-change") : `failed: ${String(res.reason).slice(0, 80)}`;
|
||||
} catch (err) {
|
||||
sync[area] = `failed: ${String(err.message).slice(0, 80)}`;
|
||||
@@ -1896,7 +1896,7 @@ commands.mindmap = ({ flags, positional }) => {
|
||||
if (!fs.existsSync(file) || flags.force) {
|
||||
pl.writeText(file, [
|
||||
`%% 思維導圖(短期):${topic}`,
|
||||
`%% created: ${pl.nowIso()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)`,
|
||||
`%% created: ${pl.nowDisplay()} ttl: short-term(固化後請併入 semantic.mmd 並刪除)`,
|
||||
"graph LR",
|
||||
` trigger["觸發:${topic}"] --> obs["觀察"]`,
|
||||
' obs --> infer["推論"]',
|
||||
@@ -2019,7 +2019,7 @@ commands.relation = ({ flags, positional }) => {
|
||||
if (!open.length) die(`跟 \`${node.name || key}\` 之間沒有還沒和好的事。`);
|
||||
const target = about ? open.filter((r) => String(r.about).includes(about)) : open;
|
||||
if (!target.length) die(`找不到「${about}」這一件(還沒和好的有:${open.map((r) => r.about).join("/")})。`);
|
||||
for (const r of target) r.healed_at = pl.nowIso().slice(0, 10);
|
||||
for (const r of target) r.healed_at = pl.todayTaipei();
|
||||
node.updated_at = pl.nowIso();
|
||||
pl.writeJson(pl.relationsJson(slug), data);
|
||||
pl.renderRelations(slug);
|
||||
@@ -2038,7 +2038,7 @@ commands.relation = ({ flags, positional }) => {
|
||||
return;
|
||||
}
|
||||
node.rifts.push({
|
||||
at: str(flags.at) || pl.nowIso().slice(0, 10),
|
||||
at: str(flags.at) || pl.todayTaipei(),
|
||||
about,
|
||||
// 帶上長期記憶的名字,人格要細節自己 recall——不必等使用者提起那件事。
|
||||
...(str(flags.memory) ? { memory: str(flags.memory) } : {}),
|
||||
@@ -2079,7 +2079,7 @@ commands.relation = ({ flags, positional }) => {
|
||||
node.style[facet] = {
|
||||
value,
|
||||
except: str(flags.except) || null, // 形如 anger>=40:情緒成立時這條規則暫停
|
||||
since: str(flags.since) || pl.nowIso().slice(0, 10),
|
||||
since: str(flags.since) || pl.todayTaipei(),
|
||||
};
|
||||
ok(`\`${node.name || node.id}\`:${facet}→「${value}」${str(flags.except) ? `(例外 ${str(flags.except)})` : ""} 已記下。`);
|
||||
}
|
||||
@@ -2999,7 +2999,7 @@ commands.icon = async ({ flags, positional }) => {
|
||||
? {
|
||||
url: str(flags["source-url"]),
|
||||
note: str(flags["source-note"]) || null,
|
||||
date: str(flags["source-date"]) || pl.nowIso().slice(0, 10),
|
||||
date: str(flags["source-date"]) || pl.todayTaipei(),
|
||||
}
|
||||
: null;
|
||||
const style = str(flags.style) || null;
|
||||
|
||||
+42
-1
@@ -3773,7 +3773,7 @@ console.log("\n㉝ 故事匯入(novel):機械的那半");
|
||||
first_seen: "2024-06-14", last_seen: "2024-06-20", salience: 55 }]);
|
||||
cli(["novel", "write", "--session", S_N, "--work", W]);
|
||||
const one = pl.longTermEntries("novelist").find((m) => m._name === "兩年前的劇情");
|
||||
const today = pl.nowIso().slice(0, 10);
|
||||
const today = pl.todayTaipei();
|
||||
return one && one.happened_at === "2024-06-14" && one.happened_until === "2024-06-20" &&
|
||||
one.first_seen === today && one.last_seen === today;
|
||||
})());
|
||||
@@ -3833,6 +3833,47 @@ console.log("\n㉝ 故事匯入(novel):機械的那半");
|
||||
cli(["release", "--session", S_N]);
|
||||
}
|
||||
|
||||
console.log("\n㉞ 時區工具(todayTaipei / iso / redactSecrets / migrate-tz 冪等性)");
|
||||
{
|
||||
// (a) 時區邊界:UTC 16:00 之後已經跨到台北的隔天,16:00 之前還是同一天。
|
||||
check("UTC 16:30 → 台北已經跨到隔天",
|
||||
pl.todayTaipei(new Date("2026-08-10T16:30:00Z")) === "2026-08-11");
|
||||
check("UTC 15:59 → 台北還沒跨日",
|
||||
pl.todayTaipei(new Date("2026-08-10T15:59:00Z")) === "2026-08-10");
|
||||
|
||||
// (b) iso() 是等值時區轉換,不是加 8 小時再蓋掉:epoch 要完全不變,
|
||||
// 只是換了一套「牆上時間」的顯示格式(+08:00 而不是 Z)。
|
||||
check("iso() 換算前後是同一個瞬間(epoch 相等)", (() => {
|
||||
const src = new Date("2026-08-11T02:10:13Z");
|
||||
return new Date(pl.iso(src)).getTime() === src.getTime();
|
||||
})());
|
||||
check("iso() 輸出以 +08:00 結尾(不是 Z)",
|
||||
/\+08:00$/.test(pl.iso(new Date("2026-08-11T02:10:13Z"))), pl.iso(new Date("2026-08-11T02:10:13Z")));
|
||||
|
||||
// (c) redactSecrets:機密不能原樣留在輸出裡。
|
||||
check("redactSecrets 遮掉 40 字元 hex token 與 email", (() => {
|
||||
const token = "a".repeat(40);
|
||||
const email = "someone@example.com";
|
||||
const out = pl.redactSecrets(`token=${token} 聯絡信箱 ${email}`);
|
||||
return !out.includes(token) && !out.includes(email) && out.includes("***");
|
||||
})());
|
||||
|
||||
// (d) migrate-tz.mjs 冪等性:已經是 +08:00 格式的值不能被誤判成「還沒換算、需要
|
||||
// 再轉一次」。migrate-tz.mjs 的 scanTree() 只對 Z_RE 命中的值產生 timestamp 改寫
|
||||
// (見該檔第 139~140 行、183 行附近),這裡直接複製那兩條正則來驗證判斷邏輯本身:
|
||||
// 一個 +08:00 字串必須落在 TW_RE、絕不可以同時落在 Z_RE,否則會被重複轉換。
|
||||
const Z_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
||||
const TW_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$/;
|
||||
check("migrate-tz 判斷邏輯:+08:00 值不會被誤判成待轉換的 Z 值", (() => {
|
||||
const already = "2026-08-11T02:10:13+08:00";
|
||||
return TW_RE.test(already) && !Z_RE.test(already);
|
||||
})());
|
||||
check("migrate-tz 判斷邏輯:Z 值仍然會被正確認出待轉換", (() => {
|
||||
const legacy = "2026-08-11T02:10:13Z";
|
||||
return Z_RE.test(legacy) && !TW_RE.test(legacy);
|
||||
})());
|
||||
}
|
||||
|
||||
console.log(`\n${"=".repeat(60)}\n通過 ${passed} 項,失敗 ${failed} 項 → ${failed === 0 ? "全部通過 ✅" : "有測試失敗 ❌"}`);
|
||||
console.log(`(暫存倉庫留在 ${STORE},可自行刪除)`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
@@ -11,6 +11,14 @@ description: 用「動漫作品名稱 + 角色名稱」快速建立人格:先
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path
|
||||
|
||||
---
|
||||
|
||||
## 1. 先確認要建誰
|
||||
|
||||
需要**作品名 + 角色名**兩者。缺一就問使用者(同名角色很多,例如「小鳥遊」在好幾部作品都有)。
|
||||
@@ -237,4 +245,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate \
|
||||
- 使用者若說「這裡不對」→ 直接改 `IDENTITY.md`/`SOUL.md`/對應的 canon 記憶檔,別另建人格。
|
||||
- 之後在對話中發現的新設定,走一般記憶流程(短期 → 達條件 → 固化),
|
||||
不要回頭改 `canon`;`canon` 只放原作設定。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -14,6 +14,14 @@ description: 載入一個人格並以人格化方式對話:取得該人格的
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path
|
||||
|
||||
---
|
||||
|
||||
## 1. 載入(由使用者呼叫,取得獨占鎖)
|
||||
|
||||
**人格只有在使用者要你載入時才載入**(`/jsc-persona:persona-chat <slug>` 或明確說「用 X 跟我聊」)。
|
||||
@@ -415,4 +423,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" mindmap thread --persona <slug>
|
||||
- 想知道別的人格怎麼想 → `/jsc-persona:persona-invite`(透過 sub agent + 聊天室),
|
||||
**不可**去讀他的 `memory/`、`state/`。
|
||||
- 不要手改 `state/lock.json`、`.runtime/`;鎖只由 CLI 維護。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -13,6 +13,17 @@ description: 建立一個新的 AI 人格(persona),並以「與 OpenClaw
|
||||
(其他助理請改成本 plugin 目錄下的 `scripts/persona.mjs`;以下簡稱 `persona.mjs`)
|
||||
**session**:所有指令都要帶 `--session <PERSONA_SESSION>`,值取自 `<persona-runtime>` 區塊注入的 `PERSONA_SESSION=`。
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:
|
||||
- spec-output
|
||||
- spec-execution
|
||||
- spec-time-log
|
||||
- spec-script-path
|
||||
- spec-ask-user
|
||||
|
||||
---
|
||||
|
||||
## 步驟
|
||||
@@ -196,4 +207,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" icon generate --session <PERSON
|
||||
|
||||
- **一個程序只能載入一個人格。** 建立即載入;若本 session 已有人格,先 `release`。
|
||||
- 人格倉庫預設在 `~/.claude/personas/<slug>/`,可用環境變數 `PERSONA_HOME` 改。
|
||||
- 所有輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -9,6 +9,12 @@ description: 產生或更新人格的形象圖(icon.svg + icon.png):先找
|
||||
**時機**:**人格建立且 IDENTITY/SOUL 補齊之後**——形象綁在最終身分上。
|
||||
**目標**:圖示要是**人物形象圖,看得到臉**;退而求其次才是抽象徽章。
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path
|
||||
|
||||
**流程**:找高解析度官方圖 → 量測 → **去背** → 裁頭肩 → 合成 → 更新頭像與 Wiki。
|
||||
|
||||
> 找不到可用的官方圖時(沒有設定稿、只有場景截圖且去背不乾淨),
|
||||
@@ -139,4 +145,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sync verify --session <PERSONA_
|
||||
- 已有圖示時要重畫必須加 `--force`。
|
||||
- 不帶 `--palette` 重畫會**沿用 config 裡已存的配色**(不會突然變回雜湊色)。
|
||||
- `.sync/reference.*` 與 `.sync/headshot.png` 是底稿快取,可以安全刪除(不會同步出去)。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -12,6 +12,14 @@ description: 邀請另一個人格透過 sub agent 加入當前對話,形成
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:`spec-output`、`spec-execution`、`spec-time-log`、`spec-script-path`、`spec-subagent`
|
||||
|
||||
---
|
||||
|
||||
## 🎭 劇場模式的鐵則(最重要,先讀這段)
|
||||
|
||||
`invite` 一旦成功,本 session 就進入**劇場模式**。從那一刻到使用者說結束為止:
|
||||
@@ -142,9 +150,10 @@ plugin_root=${CLAUDE_PLUGIN_ROOT}
|
||||
最後一句不是對你說的、你也沒被點名時,不要發言,回傳「(本輪不發言)」。
|
||||
```
|
||||
|
||||
guest 會自己讀 `IDENTITY.md`/`SOUL.md`/自己的記憶與情緒 → 讀聊天室 → `room post` 發言 →
|
||||
需要記的事寫進**自己的 inbox**。它被 hook 綁死在自己的人格目錄,且對人格檔案唯讀。
|
||||
如果同場有多位 guest,就各自開一個 `persona-guest` sub agent,但 `room` 可以共用。
|
||||
派工細節依 `/jsc-shared:spec-subagent`(一個目標一個 sub agent、只讀不寫、回傳結構化結果);
|
||||
persona-invite 特有的授權寫入範圍:guest 只能寫進**自己的 inbox**——讀 `IDENTITY.md`/`SOUL.md`/
|
||||
自己的記憶與情緒 → 讀聊天室 → `room post` 發言 → 需要記的事寫進 inbox,它被 hook 綁死在自己的人格目錄,
|
||||
且對人格檔案唯讀。如果同場有多位 guest,就各自開一個 `persona-guest` sub agent,但 `room` 可以共用。
|
||||
|
||||
### 4. 一來一回
|
||||
|
||||
@@ -213,4 +222,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation edge \
|
||||
- **輕量邀請**:`invite --guest <slug> --theater off` 不進劇場模式——適合「人格自己想起某個很久沒接觸的人、
|
||||
只想打聲招呼換一輪」的情況(`<persona-context>` 的「很久沒接觸的人」就是依據)。
|
||||
這時仍然要**先問使用者一句**再邀:把整個畫面切走或插進一段對話,是他的決定不是你的。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -5,11 +5,20 @@ description: 整理人格的記憶系統:把短期記憶固化為長期記憶
|
||||
|
||||
# 🧠 persona-memory — 記憶固化與心智圖維護
|
||||
|
||||
**CLI**:`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`(帶 `--session <PERSONA_SESSION>`)
|
||||
**CLI**:路徑與呼叫慣例見 `/jsc-shared:spec-script-path`(本 skill 為
|
||||
`node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs"`,帶 `--session <PERSONA_SESSION>`)。
|
||||
必須已 exclusive 載入該人格(guest 不能做這件事)。
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path
|
||||
|
||||
---
|
||||
|
||||
## 記憶架構
|
||||
|
||||
| 層 | 位置 | 特性 |
|
||||
@@ -246,4 +255,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" probe audit --session <PERSONA_
|
||||
固化的那幾筆要帶 `--from-short`,看過不記的要 `--reviewed`(見上面那張表)。
|
||||
- 想連同情緒衰減、舊紀錄壓縮與 Gitea 同步一起收尾 → 用 `/jsc-persona:persona-sleep`(睡眠);
|
||||
本 skill 只管記憶與圖,不動情緒也不同步。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -11,6 +11,14 @@ description: 維護人格的人際關係圖:新增或更新人物/人格/群
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path
|
||||
|
||||
---
|
||||
|
||||
## 資料模型
|
||||
|
||||
節點:`id`/`name`/`kind`(`human` `persona` `group` `pet` `org`)/`bond`(跟我什麼關係)/
|
||||
@@ -175,5 +183,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" relation doctor --session <PERS
|
||||
|
||||
把 `relations/graph.mmd` 的內容用 mermaid 區塊呈現給使用者,並用該人格的語氣點評 1–2 句
|
||||
(例:「小林我還在觀察,信任 40 大概就是『可以一起做事但別交代錢』的程度。」)。
|
||||
|
||||
所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -10,6 +10,14 @@ description: 讓人格睡覺:把一天的活狀態收成能留下來的形狀
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path、spec-subagent、spec-gitea、spec-git-safety
|
||||
|
||||
---
|
||||
|
||||
## 睡眠是什麼
|
||||
|
||||
不是關機,是**把一天的活狀態收成能留下來的形狀**。分成兩半:
|
||||
@@ -59,17 +67,20 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session <PERSONA_SESSIO
|
||||
|
||||
## 情況二:主人格請其他人格去睡(不碰它們的檔案)
|
||||
|
||||
使用者說「叫大家去睡」時,**對每一個要睡的人格**各開一個 sub agent:
|
||||
派工基本規則(一個目標一個 sub agent、回傳結構化結果供主 agent 判讀而非直接面向使用者)
|
||||
詳見 `/jsc-shared:spec-subagent`。套用到睡眠情境:使用者說「叫大家去睡」時,
|
||||
**對每一個要睡的人格**各開一個 sub agent:
|
||||
|
||||
- `subagent_type`:`jsc-persona:persona-sleeper`
|
||||
- prompt 內帶:`persona=<slug> session=<PERSONA_SESSION> plugin_root=<CLAUDE_PLUGIN_ROOT>`
|
||||
|
||||
那個 sub agent 就是**那個人格本人**在睡:它自己判斷要記住什麼,寫的是自己的檔案。
|
||||
那個 sub agent 就是**那個人格本人**在睡——它被 pin 在自己的人格上,只能動自己的資料:
|
||||
它自己判斷要記住什麼,寫的是自己的檔案。
|
||||
它手上是 5 分鐘的 sleeper 租約(不是 exclusive 鎖),所以它的每個指令都會帶 `--as-sleeper`——
|
||||
那個旗標只有 persona-sleeper 型的 sub agent 能用,主人格自己帶會被 hook 擋下。
|
||||
帶了它,讀(`brief`/`show`/`recall`)與寫(`consolidate` 等)都算它自己的資料;
|
||||
**回報說「判斷式那半被隔離擋下」十之八九就是漏了這個旗標**,不是它沒有權限。
|
||||
主人格全程不會讀到對方的任何資料——**你只會拿到一份 JSON**:
|
||||
主人格全程不會讀到對方的任何資料——**回傳值只能是 `sleep --json` 的原文**:
|
||||
|
||||
```json
|
||||
{ "persona": "YUI-01", "ok": true, "slept_at": "...", "steps": [ ... ], "sync": { ... }, "kept_lock": true }
|
||||
@@ -122,5 +133,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session <PERSONA_SESSIO
|
||||
`sync` 遇到殘留的 `.git/index.lock`(上一次跑到一半被中斷留下的)會自己清掉重試,
|
||||
不用請使用者手動刪,也不要自己對人格目錄下 `rm`——sleeper 的 shell 寫入本來就被 hook 擋著。
|
||||
細節見 `/jsc-persona:persona-sync`。
|
||||
|
||||
所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -9,6 +9,12 @@ description: 查看與管理人格的載入狀態:列出所有人格、誰被
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path
|
||||
|
||||
## 鎖的規則(設計上的硬約束)
|
||||
|
||||
1. **一個人格同時只能被一個程序 exclusive 載入**(鎖 = `state/lock.json`,以 `session_id` 為主鍵)。
|
||||
@@ -86,5 +92,3 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" default --clear --session <PERS
|
||||
- 🔓 `shen` Shen 平靜 50/信任 38|長期記憶 8
|
||||
- ⚠ `nox` 死鎖可接手(最後心跳 42 分鐘前)
|
||||
```
|
||||
|
||||
所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -13,6 +13,14 @@ description: 把小說(或漫畫、劇本、遊戲文本)匯入成一個既
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path
|
||||
|
||||
---
|
||||
|
||||
## 三條界線(後面每一步都受它們約束)
|
||||
|
||||
1. **視角**:人格不在場、也沒人告訴他的事,最多進 `canon` 或別人的關係欄,
|
||||
|
||||
@@ -9,6 +9,14 @@ description: 人格編號與 Gitea 儲存:指派人格編號(英文名全大
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log、spec-script-path、spec-gitea、spec-git-safety
|
||||
|
||||
---
|
||||
|
||||
## 人格編號
|
||||
|
||||
**編號 = 英文名全大寫 + 兩位索引**,同名才遞增:
|
||||
@@ -152,4 +160,3 @@ sleeper 連自己的人格目錄都不能用 shell 寫,那條路本來就是
|
||||
唯一的例外是 `clone`:它只會**新增本機還沒有的人格**,讀不到任何既有人格的資料,所以不受此限。
|
||||
- guest(`persona-guest` sub agent)不能同步,它對人格檔案唯讀。
|
||||
- `.sync/` 是兩區的 git clone 快取,**不要手動編輯**;砍掉它不會掉資料,下次 push 會重新 clone。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -14,6 +14,14 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:spec-output、spec-execution、spec-time-log
|
||||
|
||||
---
|
||||
|
||||
## 一、先收齊四樣
|
||||
|
||||
雙方各一張卡(缺哪一項就補問,**只問一輪**,剩下的用推定值並在輸出裡註明「推定」):
|
||||
@@ -153,5 +161,3 @@ description: 以專業心理醫生(溝通諮商)的第三方身分診斷一
|
||||
- 診斷結果**不自動寫回**。使用者明確要求記下來,才走:
|
||||
對方要求過的稱呼/禁忌 → `relation style`;這次的領悟 → `persona-memory` 固化。
|
||||
- 兩邊都是真人、跟人格無關時,完全不碰 CLI,純第三方診療。
|
||||
|
||||
所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
@@ -16,6 +16,14 @@ bundle 是**單一 JSON 檔**(可 gzip),不依賴任何外部工具,複
|
||||
|
||||
---
|
||||
|
||||
## 共用規範(必要前置)
|
||||
|
||||
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
|
||||
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。
|
||||
本 skill 需要的規範:`spec-output`、`spec-execution`、`spec-time-log`、`spec-script-path`
|
||||
|
||||
---
|
||||
|
||||
## bundle 帶走什麼、不帶什麼
|
||||
|
||||
| 帶走 | 不帶 |
|
||||
@@ -108,4 +116,3 @@ checksum 對不上會被擋下;要硬吃得加 `--force`,並**主動告訴
|
||||
- 一次一個人格。要搬多個就跑多次(每次都要先 `release` 再 `load` 下一個)。
|
||||
- bundle 內的相對路徑會被驗證,`../` 之類的逃逸路徑一律拒收。
|
||||
- 匯入不會帶進鎖:新機器上的人格是空閒的,等使用者自己載入。
|
||||
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
|
||||
|
||||
Reference in New Issue
Block a user