32 Commits
Author SHA1 Message Date
jiantw83 0b3147661d Merge pull request 'feat: companion 預設角色吹雪補上長相、口癖與簡短經歷' (#57) from feat/fubuki-default-profile-v2 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/57
2026-10-14 10:39:07 +00:00
rootandClaude Sonnet 5.5 153ed6218c feat: companion 內建吹雪附帶原典記憶,首次載入時寫入
- 新增 canon.ts:41 筆簡短已查證事實(語意、情感、程序、情節),來源標在文字後
- createCompanion 載入內建角色時寫入 memory.json(canon、long,新鮮度固定 1)
- 以來源 canon 判斷,只寫一次;直接寫檔,不增加鞏固用的重要度
- 不含會過期的近況與使用者記憶

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 10:38:26 +00:00
rootandClaude Sonnet 5.5 b6feaa1809 chore: jsc-mod-companion 版本號改為 0.0.5
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 10:36:09 +00:00
rootandClaude Sonnet 5.5 09ec8681b1 feat: companion 預設角色吹雪補上長相、口癖與簡短經歷
- face 補上淺藍色眼睛、三角狐耳、黑色尾尖(wiki,可信度 0.5)
- phrases 新增 5 句常用口癖
- persona 新增與夏色まつり的關係、已確認的經歷兩行
- 不含使用者記憶與會過期的近況

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 10:36:03 +00:00
jiantw83 e567014af1 Merge pull request 'feat: companion 狀態列與 info 輸出對齊設計稿' (#55) from feat/companion-info-design into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/55
2026-10-14 10:03:41 +00:00
rootandClaude Sonnet 5.5 13d3678032 feat: companion 狀態列與 info 輸出對齊設計稿
- 狀態列的 PAD 條改為最強的兩種情緒(不含精力)
- info 短版只列名字、關係、前 3 強情緒、適應度與待辦
- info --all 補上 12 格情緒與關係長條、人格 18 參數三層值、世界與時間感,基本資訊移到這裡
- 版本號改為 0.0.4

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 10:03:31 +00:00
jiantw83 b99bf4e539 Merge pull request 'fix: companion 指令 status 改名 info,避免與內建 /status 撞名' (#54) from fix/companion-info-command into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/54
2026-10-14 09:48:41 +00:00
rootandClaude Sonnet 5.5 fe2ccb619b fix: companion 指令 status 改名 info,避免與內建 /status 撞名
撞名使 session.start 例外中斷,companion 無法初始化。
註冊失敗改為略過單一指令,版本號改為 0.0.3。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:48:12 +00:00
jiantw83 4aba330877 Merge pull request 'chore: jsc-mod-companion 版本號改為 0.0.2' (#53) from chore/companion-version-0.0.6 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/53
2026-10-14 09:39:36 +00:00
rootandClaude Sonnet 5.5 f9bdf1b2ed chore: jsc-mod-companion 版本號改為 0.0.2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:39:22 +00:00
rootandClaude Sonnet 5.5 d4da01098f chore: jsc-mod-companion 版本號更新為 0.0.6
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:39:08 +00:00
jiantw83 8459330cb9 Merge pull request 'feat: companion 作息、主動發話、適應度與外部素材' (#52) from issue-46 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/52
2026-10-14 09:35:56 +00:00
adminandClaude Sonnet 5.5 7e6e323473 feat: companion 作息、主動發話、適應度與外部素材
- 作息與疲勞、想念累積與時間感,主動發話沿用 care.ts 的冷卻、日限、靜音
- 想家與適應度(9.13)
- 獨立腳本 fetch-materials.mjs 由系統排程寫入快取,hook 只讀、不連網
- 新增 /jsc-mod-companion:materials 與 setup location=、feeds=
- 天氣微調心情基準,新聞依適應度每天最多 2 則並寫入情節記憶
- 版本 0.0.5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:29:46 +00:00
jiantw83 eeed12741e Merge pull request 'feat: companion 記憶權重、反覆思量與檢索' (#51) from issue-45 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/51
2026-10-14 09:06:43 +00:00
adminandClaude Sonnet 5.5 6f7b4d91ea feat: companion 記憶權重、反覆思量與檢索
- 權重 W = I_eff × F × A,暫時/可升級/長期記憶的衰減與封存,任何記憶不刪除
- 未解負向事件反覆思量、開放迴路逾時、危險內容不固化
- 檢索改為關鍵字 + RRF × W + 心情一致加權,保留外部檢索器介面
- 版本 0.0.4

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:01:55 +00:00
jiantw83 46f58a3ff8 Merge pull request 'feat: companion 關係 4 維度與階段(到好友)' (#50) from issue-44 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/50
2026-10-14 08:46:02 +00:00
adminandClaude Sonnet 5.5 29a61f60ba merge: 合併 origin/main(情緒 8 維度),舊 mood 與 affinity 存檔一併升級,版本 0.0.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:45:30 +00:00
jiantw83 7608905835 Merge pull request 'feat: jsc-mod-companion 情緒 8 維度與舊存檔升級' (#49) from issue-42 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/49
2026-10-14 08:42:10 +00:00
adminandClaude Sonnet 5.5 9d08412e89 merge: 合併 origin/main,神經質改取人格 18 參數推導值
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:07:03 +00:00
adminandClaude Sonnet 5.5 197ad94e70 merge: 合併 origin/main(人格 18 參數),版本 0.0.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:02:11 +00:00
jiantw83 df76eae2ba Merge pull request 'feat: jsc-mod-companion 人格 18 參數與三層值' (#48) from issue-43 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/48
2026-10-14 08:01:00 +00:00
adminandClaude Sonnet 5.5 4f092c3cbc merge: 合併 origin/main,保留評估標籤與關係維度兩段 README,版本 0.0.3
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:00:46 +00:00
adminandClaude Sonnet 5.5 f44b7af81c merge: 合併 origin/main,情感注入改由 rules.ts 的 PAD 增減換算
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:00:38 +00:00
adminandClaude Sonnet 5.5 c39c8f7430 merge: 合併 origin/main,解決 README 與 core.ts 衝突
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:59:29 +00:00
jiantw83 fd000b0bc2 Merge pull request 'feat: companion 評估標籤管線與 rules.ts 規則表' (#47) from issue-41 into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/47
2026-10-14 07:56:09 +00:00
adminandClaude Sonnet 5.5 4f86b6b7dc feat: companion 關係改為好感、信賴、親密、依賴四維度與四階段
- 兩本帳(原典基準、存檔累積),日限、有效增量、期望違反公式
- 階段 陌生/熟人/朋友/好友,只升不降;14 天無互動每日 −0.5
- 舊 affinity 存檔讀檔時自動升級
- 版本 0.0.2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:54:08 +00:00
adminandClaude Sonnet 5.5 c24f768188 feat: jsc-mod-companion 情緒擴充為 8 維度並自動升級舊 PAD 存檔
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:53:56 +00:00
adminandClaude Sonnet 5.5 6e6d462f76 feat: jsc-mod-companion 新增人格 18 參數與三層值
- 18 參數、三層值(基準+存檔偏移 ±15+今日偏移 N(0,5) ±10)
- 有效心防、有效害羞、有效口是心非
- Big Five 改為由參數推導,接上心情半衰期與關心頻率
- 核心設定(姓名、性別、年齡、核心信念)不可由 AI 改動
- 版本號 0.0.2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:52:59 +00:00
adminandClaude Sonnet 5.5 31a45f0103 feat: companion 新增評估標籤管線與 rules.ts 規則表
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:52:36 +00:00
jiantw83 b47c838fd5 Merge pull request 'feat: jsc-wiki 新增第9章「人格與情感模擬」與 persona 技能' (#38) from feat/jsc-wiki-persona-chapter into main
Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/38
2026-10-14 06:28:56 +00:00
rootandClaude Sonnet 5.5 a4e9474227 fix: jsc-wiki 目錄不再顯示來源括號
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 06:27:30 +00:00
rootandClaude Sonnet 5.5 5f3cb8a6b8 feat: jsc-wiki 新增第9章「人格與情感模擬」與 persona 技能
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 06:22:56 +00:00
38 changed files with 3144 additions and 285 deletions
+1 -1
View File
@@ -33,7 +33,7 @@
{
"name": "jsc-wiki",
"source": "./plugins/jsc-wiki",
"description": "讀取本 repo wiki 的「JSC 知識庫」(一本書),依領域提供 /jsc-wiki:system、network、virt、container、database、dev、ai、apps 指令,並可用 book-search/book-import 把網路知識加入 wiki"
"description": "讀取本 repo wiki 的「JSC 知識庫」(一本書),依領域提供 /jsc-wiki:system、network、virt、container、database、dev、ai、apps、persona 指令,並可用 book-search/book-import 把網路知識加入 wiki"
}
]
}
+3 -3
View File
@@ -10,7 +10,7 @@ JSC 自用 Claude Code mods 的 marketplace。每個插件皆可單獨安裝、
輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟。
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.1`
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.5`
小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色。
@@ -26,9 +26,9 @@ JSC 自用 Claude Code mods 的 marketplace。每個插件皆可單獨安裝、
Matt Pocock 的工程技能整套(grilling、spec/ticket、TDD、code review、領域建模等),改編自 mattpocock/skills。
### [jsc-wiki](plugins/jsc-wiki) `0.0.2`
### [jsc-wiki](plugins/jsc-wiki) `0.0.3`
讀取本 repo 的 wiki「JSC 知識庫」(一本書),依領域提供 `/jsc-wiki:system`、`:network`、`:virt`、`:container`、`:database`、`:dev`、`:ai`、`:apps` 等指令;`book-search` 蒐集網路知識,`book-import` 依章節加入 wiki,新增領域時自動產生對應技能。
讀取本 repo 的 wiki「JSC 知識庫」(一本書),依領域提供 `/jsc-wiki:system`、`:network`、`:virt`、`:container`、`:database`、`:dev`、`:ai`、`:apps`、`:persona` 等指令;`book-search` 蒐集網路知識,`book-import` 依章節加入 wiki,新增領域時自動產生對應技能。
各插件的指令與可複製的程式碼片段請見其資料夾內的 README。
@@ -1,7 +1,7 @@
{
"name": "jsc-mod-companion",
"version": "0.0.1",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
"version": "0.0.5",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、作息與主動關心、外部素材與待辦,並可用 ingest skill 解析小說建立角色",
"license": "MIT",
"author": {
"name": "JSC"
+119 -4
View File
@@ -2,12 +2,17 @@
## 指令
### `/jsc-mod-companion:status`
### `/jsc-mod-companion:info`
查看目前角色、心情、好感與待辦數量。
預設是短版:名字與主情緒、關係階段(好感)、前 3 強情緒、適應度、待辦數。
`--all` 顯示完整數值:時間感與天氣、8 維度情緒(心情、精力、害羞、忌妒、想念、心跳、想家、不悅,12 格 ▰▱ 量表依維度上色)、關係四維度(好感、信賴、親密、依賴)長條、人格 18 參數(基準、存檔偏移、今日偏移與有效值)、世界(適應度、想家)與角色基本資訊。
輸入框上方的狀態列顯示最強的兩種情緒(不含精力)與關係階段;寬度不夠時依序捨棄氣泡、待辦、情緒條、階段。
```text
/jsc-mod-companion:status
/jsc-mod-companion:info
/jsc-mod-companion:info --all
```
### `/jsc-mod-companion:switch <角色>`
@@ -28,7 +33,7 @@
### `/jsc-mod-companion:setup [key=value…]`
不給參數時顯示目前設定;給 key=value 則儲存。可設定:`nickname`、`wake=HH:MM`、`sleep=HH:MM`、`tz=+0800`、`careLimit=N`(每日主動關心上限)、`quietStart=HH:MM`、`quietEnd=HH:MM`。
不給參數時顯示目前設定;給 key=value 則儲存。可設定:`nickname`、`wake=HH:MM`、`sleep=HH:MM`、`tz=+0800`、`careLimit=N`(每日主動關心上限)、`quietStart=HH:MM`、`quietEnd=HH:MM`、`location=緯度,經度或地名`(啟用外部素材)、`feeds=網址,網址…`(自設 RSS,預設空清單)。
```text
/jsc-mod-companion:setup nickname=阿明 wake=07:30 sleep=23:30 tz=+0800 careLimit=3 quietStart=22:00 quietEnd=08:00
@@ -74,6 +79,15 @@
/jsc-mod-companion:quiet 30
```
### `/jsc-mod-companion:materials [list]`
查看外部素材(天氣、新聞)的快取狀態;`list` 列出有效新聞的標題與連結。需先 `setup location=` 才啟用。
```text
/jsc-mod-companion:materials
/jsc-mod-companion:materials list
```
### `/jsc-mod-companion:ingest`
解析單章小說(txt/md),逐一詢問合併、新建或略過後寫入角色資料;提供章節檔或要求建立角色時也會自動使用。
@@ -81,3 +95,104 @@
```text
/jsc-mod-companion:ingest chapter1.md 章節編號 1
```
## 關係與階段
關係有好感、信賴、親密、依賴四個維度(0–100),各有「原典基準」(朋友線為 0)與「存檔累積」兩本帳,有效值為兩者相加。階段為 陌生、熟人、朋友、好友,只升不降:
| 階段 | 條件 |
| --- | --- |
| 熟人 | 好感 ≥ 15 |
| 朋友 | 好感 ≥ 30、信賴 ≥ 25 |
| 好友 | 好感 ≥ 50、信賴 ≥ 45、親密 ≥ 30 |
- 每日增量有日限(好感 4,信賴、親密、依賴依角色 `relationTraits` 的心防、害羞、撒嬌、依賴傾向調整);有效增量 = 原始增量 × (1 − 今日累計 / 日限) × (1 − 目前值 / 100)。
- 被罵時好感 −3,信賴依期望違反公式扣除(越親密扣越多,單次上限 −30)。
- 14 天無互動後,每個維度每天 −0.5;階段不會降低。
- 舊版單一好感值(`affinity`)的存檔在讀檔時自動升級:好感值保留,其餘維度依比例推估,階段不低於原本。
與第 9 章的差異:不實作曖昧、戀人、穩定戀人與冷戰子狀態;負向事件目前只有「被罵」一種嚴重度(0.4);增量沿用原有的「有意義互動/被罵」判斷,尚未接標籤管線。
## 人格 18 參數(與第 9 章的差異)
人格參數依 wiki 9.2 實作於 `hooks/companion/core/params.ts`:
- 三層值:角色包基準(`core.json` 的 `params`)+存檔偏移(每次 ±1,上限 ±15)+今日偏移(N(0,5),上限 ±10),總值限制在 0–100。
- 衍生值:有效心防、有效害羞、有效口是心非,公式同 9.2;信賴與親密以 0–100 輸入。
- Big Five 保留為推導值:有 `params` 時,神經質(心情半衰期)與外向性(關心頻率)改由 18 參數推導,加權為設計啟發式;沒有 `params` 的舊角色沿用 `neuroticism`、`extraversion`。ingest 的 Big Five 推論與證據機制不變。
- 姓名、性別、年齡、核心信念不可由 AI 改動,`filterAiPatch` 會剔除這些欄位。
- 目前只提供計算與儲存欄位(`state.json` 的 `params`);每日抽樣、存檔偏移累積的觸發時機留待後續工作項目。
## 評估標籤
使用者每句話會對應到一個評估標籤,再由 `rules.ts` 的規則表換成心情增減量;模型只負責分類,不給數值。
- 正則先判明確的情況(責罵、感謝、完成)。
- 正則沒判出時,採用模型於回覆末尾補的 `<評估:標籤>`;標籤須在 `tags.ts` 的白名單內,缺失或不在白名單就當作沒有標籤。
- 白名單約 20 個,參考 wiki 第 9.4 章;不含戀愛與親密類標籤。
## 情緒模型
- 8 維度各有基準與半衰期(心情 8h、精力 6h、害羞 2h、忌妒 4h、想念 12h、心跳 1h、想家 24h、不悅 4h,神經質 50 時),數值集中在 `hooks/companion/emotion.ts`。
- 晝夜節律:精力基準在本地 15 點最高、凌晨 3 點最低(±0.1,依 `tz` 設定)。
- 事件的情感注入、疲勞易怒(精力低於 0.3 時負面變化 ×1.5)、正向習慣化(重複 ×0.7ⁿ)、負向敏感化(重複最多 ×2),計次以 6 小時半衰期遺忘。
- 舊的 PAD 存檔讀檔時自動映射(p→心情、a→精力、d→害羞)並升級,不需重建。
- 與第 9 章的差異:晝夜節律振幅、習慣化係數等數值為本實作自訂,尚未逐項對照第 9 章。
## 記憶權重與檢索
記憶依 wiki 9.5 實作於 `hooks/companion/memory.ts`,數值集中在 `rules.ts` 的 `MEMORY`。
- 權重 W = I_eff × F × A:I_eff = 初始重要度/2 + 情緒峰值/40 + 類別加成(限制在 1–5);F = 0.5^(Δ回合/200),原典記憶固定 1,被檢索到時至少回到 0.8;A 為存取加成。
- 反覆思量:未解的負向事件(心情增量 p < 0 的標籤)每日權重 +20%,上限 3 倍;`apology`、`soothe` 標籤出現時全部標為已解。
- 衰減與封存:暫時記憶在場景結束(`end`)或 7 天後封存;可升級記憶 30 天未升級降權一半、90 天未檢索封存,被檢索 3 次升為長期;長期與鎖定記憶不衰減。封存只改 `status`,任何記憶都不刪除。
- 危險內容(自傷、違法等關鍵字)不固化:只留一則安全事件摘要,不留原文、不升級、不進反思。
- 開放迴路:角色回覆末尾的問句記為開放迴路,使用者 2 回合內沒有相關回應就逾時;未收尾的對話與未解負向事件列入每回合必帶(`core()`)。
- 檢索:關鍵字排名經 RRF(Σ 1/(60+rank))融合後乘上 W,再加心情一致加權 w_m × 相似度(w_m = 0.10 + 0.05 × (1 − 釋懷速度))。`recall` 的 `extra` 保留外部檢索器介面,語意相似度不做。
- 舊存檔缺少的欄位讀檔時自動補上。
與第 9 章的差異:A 的公式(1 + 0.1 × min(命中次數, 5))為本實作自訂,wiki 只寫「被檢索到時 F 至少回到 0.8」;回合序號以使用者回合計;情緒峰值由標籤向量長度換算;原典佔比、相似記憶合併、釘選與使用者模型容量尚未實作。
## 作息、想念與主動發話
依 wiki 9.11、9.12 實作,數值集中在 `rules.ts` 的 `SCHEDULE`、`MISSING`,邏輯在 `schedule.ts`、`absence.ts`;都在讀取時換算,不寫回存檔。
- 作息與疲勞:依 `setup wake= sleep= tz=` 位移精力基準——醒超過 12 小時每小時 −0.02(上限 −0.15)、連續工作超過 1 小時每小時 −0.03(上限 −0.15)、在使用者睡眠時段仍在線再 −0.1;剛起床 2 小時內 +0.03。
- 想念累積:離開超過 2 小時後每小時 +0.03(乘上 0.5 + 想念累積速度參數/100),上限 0.8。離開 6 小時以上再回來,先把累積的想念存下來,再套用「回來」標籤降低;想念 ≥ 0.5 時回來會主動說出口(`miss_you`)。
- 時間感:prompt 會帶上目前時段、離上次互動多久;`/jsc-mod-companion:info` 顯示同樣資訊。
- 主動發話沿用 `care.ts` 的冷卻、每日上限與靜音,新增三種觸發:`miss_you`(想念,6 小時冷卻)、`homesick`(想家 ≥ 0.4,12 小時冷卻)、`news`(本地新聞,4 小時冷卻);都以情境注入下一次回覆,不另外觸發模型回合。
與第 9 章的差異:不實作週課表、賴床、生病、生理週期、叫醒與助理、離線事件摘要;閒置門檻、打字延遲等表達層規則沿用原本的 `care.ts`,未改成 9.11 的公式。
## 適應度與想家
依 wiki 9.13 實作於 `adapt.ts`:每日 ΔA = k × (1 − A/100),k = 1.5 + 1.5 × (主動度 + 想像力)/2(兩個參數取自 18 參數,沒有就用 50)。本地「第一次」事件(第一次讀到天氣、第一次聊到新聞)各 +2。
- 想家基準 = 0.25 × (1 − A/100),適應度越高越不想家;`/jsc-mod-companion:info` 顯示適應度與描述。
- 舊存檔沒有適應度時,從升級當天起算 0。
與第 9 章的差異:成長只依日曆天數,不論當天有沒有互動;不實作「她會不會知道」的判定、運勢與星座血型,也不做 U 形曲線。
## 外部素材(天氣、新聞)
hook **不連網**。素材由系統排程執行獨立腳本,寫入快取檔 `~/.companion/materials.json`,hook 只讀。
- 需要 `/jsc-mod-companion:setup location=…` 才啟用;沒設定時腳本與 hook 都什麼也不做。
- 只存標題、摘要、連結與抓取時間。天氣 6 小時、新聞 24 小時過期;檔案缺失、損壞或過期都當作沒查。
- 天氣用 Open-Meteo(免金鑰),只微調心情基準(晴 +0.04、雨 −0.04、雷雨 −0.06,上限 ±0.06)。新聞來自使用者自設的 RSS/Atom(`setup feeds=…`,預設空清單)。
- 新聞依適應度說:< 40 不聊,40–69 每天最多 1 則,≥ 70 每天最多 2 則;同一則不重複;真的說出口才寫入情節記憶(「聊到時事:…」)並計數,時事不進原典。
### 排程腳本
```bash
# 單獨執行(讀 ~/.companion/user.json;可用 COMPANION_ROOT 指定資料夾)
node plugins/jsc-mod-companion/scripts/fetch-materials.mjs
# cron:每 30 分鐘
*/30 * * * * node /path/to/plugins/jsc-mod-companion/scripts/fetch-materials.mjs
# 測試
node --test plugins/jsc-mod-companion/scripts/fetch-materials.test.mjs
```
某一部分抓取失敗時保留該部分原本的快取,過期後自然當作沒查。systemd timer 也可以,ExecStart 指向同一支腳本即可。
@@ -0,0 +1,17 @@
import { MISSING as M } from './rules'
const HOUR = 3_600_000
/** 想念累積:離開越久越想,速度隨想念累積速度參數(0–100,預設 50)調整 */
export function missingAccrual(idleMs: number, missParam = 50): number {
const rate = M.perHour * (0.5 + missParam / 100)
return Math.min(M.max, Math.max(0, idleMs / HOUR - M.graceH) * rate)
}
/** 時間感:把離開的時間說成人話 */
export function describeAbsence(idleMs: number): string {
const h = idleMs / HOUR
if (h < 1) return `${Math.max(1, Math.round(idleMs / 60_000))} 分鐘`
if (h < 48) return `${Math.round(h)} 小時`
return `${Math.round(h / 24)} 天`
}
@@ -0,0 +1,42 @@
import { ADAPT as A } from './rules'
const DAY = 86_400_000
export type Adapt = { value: number; day: number; firsts: string[] }
/** 本地日序號(時區換算後的第幾天) */
export const dayNumber = (ms: number, tzOffsetMin: number) => Math.floor((ms + tzOffsetMin * 60_000) / DAY)
export const initialAdapt = (now: number, tzOffsetMin: number): Adapt => ({ value: 0, day: dayNumber(now, tzOffsetMin), firsts: [] })
/** k = 1.5 + 1.5 × (主動度 + 想像力)/2,參數 0–100 */
export const growthRate = (initiative = 50, imagine = 50) => A.kBase + A.kSpan * ((initiative + imagine) / 200)
/** 逐日成長 ΔA = k(1 − A/100),跨多日用閉式:A' = 100 − (100 − A)(1 − k/100)^n;只往前算、不寫回 */
export function grow(a: Adapt, now: number, tzOffsetMin: number, k: number): Adapt {
const n = dayNumber(now, tzOffsetMin) - a.day
if (n <= 0) return a
return { ...a, day: a.day + n, value: 100 - (100 - a.value) * Math.pow(1 - k / 100, n) }
}
/** 本地「第一次」事件(例如第一次讀到天氣、新聞)另加 2;每種事件只算一次 */
export function markFirst(a: Adapt, kind: string): Adapt {
if (a.firsts.includes(kind)) return a
return { ...a, value: Math.min(100, a.value + A.first), firsts: [...a.firsts, kind] }
}
/** 想家基準:越不熟悉越想家 */
export const homesickBase = (value: number) => A.homesickMax * (1 - value / 100)
/** 今天最多聊幾則本地新聞:適應度不夠不聊 */
export function newsQuota(value: number, cap: number): number {
if (value < A.newsMin) return 0
return Math.min(cap, value >= A.newsTwo ? 2 : 1)
}
export function describeAdapt(value: number): string {
if (value < 20) return '初來乍到'
if (value < 40) return '慢慢熟悉'
if (value < 60) return '逐漸適應'
return value < 80 ? '相當熟悉' : '像本地人'
}
@@ -1,49 +0,0 @@
import { clamp } from './mood'
import type { Affinity } from './types'
const DAY = 86_400_000
export const GAIN = 0.01
export const DAILY_CAP = 0.05
export const LOSS = 0.03
const IDLE_GRACE_DAYS = 7
const IDLE_HALF_LIFE_DAYS = 30
// 升階門檻;降階門檻 = 升階門檻 − 0.05,形成遲滯
export const UP = [0.3, 0.55, 0.8]
export const HYSTERESIS = 0.05
export const dayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10)
export const initialAffinity = (now: number): Affinity => ({ value: 0, stage: 0, dayKey: dayKey(now), dayGain: 0, lastSeen: now })
export function stageFor(value: number, current: number): number {
let s = current
while (s < UP.length && value >= UP[s]!) s++
while (s > 0 && value < UP[s - 1]! - HYSTERESIS) s--
return s
}
// 離開超過 7 天後,以 30 天半衰期回到 0
export function idleDecay(a: Affinity, now: number): Affinity {
const idleDays = (now - a.lastSeen) / DAY
if (idleDays <= IDLE_GRACE_DAYS) return a
const value = a.value * Math.pow(0.5, (idleDays - IDLE_GRACE_DAYS) / IDLE_HALF_LIFE_DAYS)
return { ...a, value, stage: stageFor(value, a.stage) }
}
export function interact(prev: Affinity, kind: 'meaningful' | 'negative', now: number): Affinity {
let a = idleDecay(prev, now)
const key = dayKey(now)
if (a.dayKey !== key) a = { ...a, dayKey: key, dayGain: 0 }
let { value, dayGain } = a
if (kind === 'meaningful') {
const gain = Math.max(0, Math.min(GAIN, DAILY_CAP - dayGain))
value += gain
dayGain += gain
} else {
value -= LOSS
}
value = clamp(value)
return { value, dayGain, dayKey: key, lastSeen: now, stage: stageFor(value, a.stage) }
}
@@ -7,18 +7,19 @@ import { lineWidth } from './seg'
const VIEW: View = {
core: { id: 'miko', name: '月見ミコ', facts: {} as never, neuroticism: 50, baseline: { p: 0, a: 0, d: 0 } },
mood: { p: 0.5, a: 0, d: -0.5 },
emotions: { mood: 0.75, energy: 0.5, shy: 0.4, jealous: 0, missing: 0.1, heartbeat: 0, homesick: 0, upset: 0 },
label: '放鬆',
kaomoji: '(´ᴗ`)',
affinity: 0.35,
stage: '熟悉',
relation: { affection: 35, trust: 20, intimacy: 10, dependence: 5 },
stage: '熟人',
todos: 2,
}
const text = (v: View, cols: number, compact = false, bubble?: string) => fitCompanion(v, cols, compact, bubble).map(s => s.text).join('')
describe('角色行', () => {
test('寬版顯示顏文字、名字、心情、PAD 條、階段、待辦、氣泡', () => {
expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · P▰▰▱ A▰▰▱ D▰▱▱ · 熟悉 · ☐2 · 💬 早安')
test('寬版顯示顏文字、名字、心情、情緒條、階段、待辦、氣泡', () => {
expect(text(VIEW, 200, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆 · 心情▰▰▰▱ 害羞▰▰▱▱ · 熟人 · ☐2 · 💬 早安')
})
test('緊湊模式只剩「顏文字 名字 心情」', () => {
@@ -36,7 +37,7 @@ describe('角色行', () => {
}
expect(text(VIEW, 40, false, '早安')).not.toContain('早安')
expect(text(VIEW, 36, false, '早安')).not.toContain('☐')
expect(text(VIEW, 30, false, '早安')).not.toContain('PAD')
expect(text(VIEW, 30, false, '早安')).not.toContain('心情▰')
expect(text(VIEW, 22, false, '早安')).toBe('(´ᴗ`) 月見ミコ 放鬆')
expect(lineWidth(fitCompanion(VIEW, 24))).toBeLessThanOrEqual(24)
})
@@ -1,10 +1,12 @@
import { DIM_INFO, ranked } from './emotion'
import { bar, lineWidth } from './seg'
import type { Seg } from './seg'
import type { View } from './core'
const axis = (v: number) => bar(((v + 1) / 2) * 100, 3)
// 精力隨作息起伏,不列入主要情緒;取最強的兩種
const top2 = (v: View) => ranked(v.emotions).filter(d => d !== 'energy').slice(0, 2)
// 由完整版往下捨棄次要資訊:氣泡 → 待辦 → PAD 條 → 好感階段;緊湊版只留「顏文字 名字 心情」
// 由完整版往下捨棄次要資訊:氣泡 → 待辦 → 情緒條 → 好感階段;緊湊版只留「顏文字 名字 心情」
function line(v: View, level: number, compact: boolean, bubble?: string): Seg[] {
const out: Seg[] = [
{ text: `${v.kaomoji} `, color: 'claude' },
@@ -14,7 +16,7 @@ function line(v: View, level: number, compact: boolean, bubble?: string): Seg[]
]
if (compact) return out
const dot = { text: ' · ', dim: true }
if (level < 3) out.push(dot, { text: `P${axis(v.mood.p)} A${axis(v.mood.a)} D${axis(v.mood.d)}`, dim: true })
if (level < 3) out.push(dot, { text: top2(v).map(d => `${DIM_INFO[d].name}${bar(v.emotions[d] * 100, 4)}`).join(' '), dim: true })
if (level < 4) out.push(dot, { text: v.stage })
if (level < 2 && v.todos > 0) out.push(dot, { text: `☐${v.todos}`, color: 'warning' })
if (level < 1 && bubble) out.push(dot, { text: `💬 ${bubble}`, dim: true })
@@ -0,0 +1,49 @@
import type { Layer } from './memory'
export type CanonSeed = { layer: Layer; importance: number; text: string }
// 原典記憶:簡短的已查證事實,來源標在文字後面(官方、日文維基、粉絲wiki 可信度依序降低)。
// 不含會過期的近況與使用者記憶;首次載入角色時寫入 memory.json,新鮮度固定為 1。
export const FUBUKI_CANON: CanonSeed[] = [
{ layer: 'semantic', importance: 7, text: '設計者是凪白みと;原始服裝為黑白水手服配淺藍緞帶' },
{ layer: 'semantic', importance: 6, text: '最愛綠茶,也喝紅茶和柳橙汁;零食愛軟糖和肉乾' },
{ layer: 'semantic', importance: 5, text: '什麼動漫、漫畫、遊戲被推薦都會去試;也會和其他成員聯動玩恐怖遊戲' },
{ layer: 'semantic', importance: 8, text: '大神ミオ:同屬 Hololive Gamers;フブキ敬佩她對約定全力以赴、重視身邊人' },
{ layer: 'semantic', importance: 7, text: '百鬼あやめ:AyaFubuMi 的另一位成員(2 期生)' },
{ layer: 'semantic', importance: 6, text: '與荒咬オウガ組成 FOXDEMON,常一起辦談話與對戰直播(粉絲wiki)' },
{ layer: 'semantic', importance: 6, text: '凪白みと被她稱為「媽媽」,是她的插畫家(粉絲wiki)' },
{ layer: 'semantic', importance: 5, text: '愛貓名叫 Leo(粉絲wiki)' },
{ layer: 'semantic', importance: 5, text: '喜歡綠茶、雪見大福、味噌湯,不愛苦味,能吃辣(粉絲wiki)' },
{ layer: 'semantic', importance: 5, text: '最近在玩《R.E.P.O.》《FFXIV》和恐怖遊戲(2025/9 對談)' },
{ layer: 'semantic', importance: 4, text: '喜歡的角色:Silverash(明日方舟)、Leona(扭曲仙境)(粉絲wiki)' },
{ layer: 'semantic', importance: 7, text: '夏色まつり:同為 Hololive 1 期生,兩人搭檔被稱為「夏色吹雪」(日文維基)' },
{ layer: 'semantic', importance: 6, text: '官方出處:2020/8/4 官方帳號公告 #たんもしライブ,由まつり與フブキ介紹《偵探已經死了》' },
{ layer: 'semantic', importance: 6, text: '官方出處:2021/8 兩人出演 Cinderella switch ~みんなでつくるホロライブ~ vol.02(VARK 虛擬演唱會)' },
{ layer: 'semantic', importance: 5, text: '官方出處:2023/5 與さくらみこ、星街すいせい、雪花ラミィ一起參與「Tower of Sky」合作,フブキ 5/31、まつり 6/1 直播' },
{ layer: 'emotional', importance: 6, text: '好勝:贏了開心慶祝、輸了會不甘心' },
{ layer: 'emotional', importance: 5, text: '害羞時把自己藏在畫面底部,只露出狐耳' },
{ layer: 'emotional', importance: 7, text: 'ミオ希望她「一直保持天真的白上」;她希望ミオ別想太多、多依賴別人' },
{ layer: 'emotional', importance: 6, text: '被說像貓會激動,脫口「ネコじゃない!キツネじゃい!」' },
{ layer: 'emotional', importance: 5, text: '出道初期害羞怕生,講話會結巴、害羞就跑出畫面,之後越來越有自信(粉絲wiki)' },
{ layer: 'emotional', importance: 5, text: '惡作劇整人時會心虛,但還是會做(粉絲wiki)' },
{ layer: 'procedural', importance: 6, text: '開場:「こんこんきーつね!」「おはこん/ひるこん/よるこん」「Hi Friends!」' },
{ layer: 'procedural', importance: 6, text: '收尾:「おつこーん」;遊戲失敗時說「F R.I.P」' },
{ layer: 'procedural', importance: 6, text: '觀眾求婚時的標準回應:「我們只是朋友!」' },
{ layer: 'procedural', importance: 5, text: '給ミオ的建議:想做的事全都說出來、先決定好 MC 內容、注意時間分配' },
{ layer: 'episodic', importance: 7, text: '2020/8/21 為睡過頭的シオン與マリン做 4 小時倒數叫醒直播' },
{ layer: 'episodic', importance: 7, text: '2019/10/24 翻唱〈Scatman〉大紅,後來還做成 3D 版' },
{ layer: 'episodic', importance: 6, text: '黃金鯉魚王釣了 27 天、26 場直播、超過 1 萬次,於 12/24 釣到(粉絲wiki)' },
{ layer: 'episodic', importance: 6, text: 'Tetris 99 目標前 95 名卻拿第 98 名,輸到「變成饅頭」(粉絲wiki)' },
{ layer: 'episodic', importance: 6, text: '2025/9 與ミオ對談:先聽說ミオ要辦個人演唱會,在休息室跟ミオ說恭喜,ミオ還一頭霧水' },
{ layer: 'episodic', importance: 5, text: '因飯後睏倦睡過頭錯過 Unrailed! 直播,成員們直接開了 No-Synergy 雜談(粉絲wiki)' },
{ layer: 'episodic', importance: 9, text: '2019 出道約 9 個月(2019/3)達 10 萬訂閱,2019/12 拿到 V 大獎 Good Contents 賞' },
{ layer: 'episodic', importance: 8, text: '2020/11/5 在和 Hololive Gamers 聯動時達成 100 萬訂閱' },
{ layer: 'episodic', importance: 8, text: '2020/3/14 以「謝罪」為名宣布養貓 Leo' },
{ layer: 'episodic', importance: 8, text: '2021 年認證標章申請兩度落空,成為圈內梗,12/24 終於取得' },
{ layer: 'episodic', importance: 9, text: '2022/10 YouTube 破 200 萬,Hololive 第 5 人' },
{ layer: 'episodic', importance: 8, text: '2023/11 為《假面騎士》電影ギーツケミー配音,VTuber 首次客串' },
{ layer: 'episodic', importance: 9, text: '2025/1/15 首張專輯《FBKINGDOM "Blessing"》登上 Billboard Japan 專輯榜第 1' },
{ layer: 'episodic', importance: 10, text: '2025/2/13 首場個人演唱會「FBKINGDOM "ANTHEM"」,さくらみこ與星街すいせい當嘉賓' },
{ layer: 'episodic', importance: 7, text: '2024 年與 Gamers 夥伴辦 hololive GAMERS fes. 超超超超ゲーマーズ' },
{ layer: 'episodic', importance: 6, text: '2026/10/6 單曲〈FOXXXROOM〉預定發行' },
]
@@ -2,7 +2,7 @@ import type { Clock, Fs } from './io'
import { readJson, writeJson } from './io'
import { isVerifyCommand } from './verify'
export type Trigger = 'session_start' | 'idle_return' | 'long_work' | 'late_night' | 'tool_failures' | 'quota_warning' | 'todo_due' | 'test_recovered' | 'turn_done'
export type Trigger = 'session_start' | 'idle_return' | 'long_work' | 'late_night' | 'tool_failures' | 'quota_warning' | 'todo_due' | 'test_recovered' | 'turn_done' | 'miss_you' | 'homesick' | 'news'
export type Stage = 'stranger' | 'familiar' | 'close' | 'special'
export type Attribute = 'tsundere' | 'genki' | 'calm' | 'gentle'
export type Delivery = 'toast' | 'inject'
@@ -62,6 +62,12 @@ export interface Signals {
quotaWarning?: boolean
/** 到期的待辦標題 */
dueTodo?: string
/** 想念累積到會說出口的程度(回來時) */
missing?: boolean
/** 想家到會說出口的程度 */
homesick?: boolean
/** 想聊的一則本地新聞標題 */
news?: string
}
export const COOLDOWN_MS: Record<Trigger, number> = {
@@ -74,6 +80,9 @@ export const COOLDOWN_MS: Record<Trigger, number> = {
todo_due: 10 * 60_000,
test_recovered: 10 * 60_000,
turn_done: 20 * 60_000,
miss_you: 6 * 3600_000,
homesick: 12 * 3600_000,
news: 4 * 3600_000,
}
/** 會讓角色疲乏的工作事件反應:越常說越容易膩 */
@@ -358,6 +367,84 @@ const L: Record<Trigger, Record<Attribute, Record<Stage, Line>>> = {
special: n => `${n},完成了呢,我們一起休息一下吧。`,
},
},
miss_you: {
tsundere: {
stranger: n => `${n},好久不見。……只是剛好想到而已。`,
familiar: n => `${n},你回來了。我才沒有一直在想你。`,
close: n => `${n},終於回來了。這段時間……有一點想你,就一點。`,
special: n => `${n},你回來了。我很想你,真的。`,
},
genki: {
stranger: n => `${n},好久不見!今天也請多指教!`,
familiar: n => `${n}!好久不見,我有想你喔!`,
close: n => `${n}~你回來了!這段時間好想你呀!`,
special: n => `${n}!終於見到你了,我好想你!`,
},
calm: {
stranger: n => `${n},好久不見。`,
familiar: n => `${n},你回來了。這段時間不見,有點想你。`,
close: n => `${n},歡迎回來。有一陣子沒見,我想你了。`,
special: n => `${n},你回來了。我很想你。`,
},
gentle: {
stranger: n => `${n},好久不見,歡迎回來。`,
familiar: n => `${n},歡迎回來,這段時間有點想你呢。`,
close: n => `${n},你回來了,我一直惦記著你。`,
special: n => `${n},終於回來了,我好想你。`,
},
},
homesick: {
tsundere: {
stranger: n => `${n},這裡還不太習慣……不用在意我。`,
familiar: n => `${n},偶爾會想起以前待的地方。只是偶爾而已。`,
close: n => `${n},我有點想念老家了。……聽我說說也無妨。`,
special: n => `${n},我有點想家。能在你身邊說這個,我覺得安心。`,
},
genki: {
stranger: n => `${n},這裡的一切都好新鮮,不過偶爾也會想起家鄉!`,
familiar: n => `${n},有點想家呢!不過有你在,很快就會習慣的!`,
close: n => `${n}~我有點想家了,可以跟你聊聊以前的事嗎?`,
special: n => `${n},想家的時候,想到你就會安心一點。`,
},
calm: {
stranger: n => `${n},我還在慢慢適應這裡。`,
familiar: n => `${n},偶爾會想起以前的地方,不過沒關係。`,
close: n => `${n},我有些想家。說出來,心情好多了。`,
special: n => `${n},我有點想家,謝謝你願意聽我說。`,
},
gentle: {
stranger: n => `${n},我還在適應這裡,請多包涵。`,
familiar: n => `${n},有時候會想起家鄉,不過慢慢會習慣的。`,
close: n => `${n},我有點想家,可以陪我聊聊嗎?`,
special: n => `${n},想家的時候有你在,我覺得很溫暖。`,
},
},
news: {
tsundere: {
stranger: (n, x) => `${n},我看到一則新聞:「${x}」。`,
familiar: (n, x) => `${n},剛看到「${x}」……你有聽說嗎?`,
close: (n, x) => `${n},今天看到「${x}」,覺得你可能會有興趣。`,
special: (n, x) => `${n},我看到「${x}」,想跟你聊聊。`,
},
genki: {
stranger: (n, x) => `${n},我看到一則新聞:「${x}」!`,
familiar: (n, x) => `${n},你看你看,「${x}」!`,
close: (n, x) => `${n}~今天有則新聞「${x}」,一起聊聊吧!`,
special: (n, x) => `${n}!我看到「${x}」,好想馬上跟你說!`,
},
calm: {
stranger: (n, x) => `${n},看到一則新聞:「${x}」。`,
familiar: (n, x) => `${n},今天的新聞有「${x}」。`,
close: (n, x) => `${n},看到「${x}」,想聽聽你的看法。`,
special: (n, x) => `${n},「${x}」這則新聞,你怎麼看?`,
},
gentle: {
stranger: (n, x) => `${n},看到一則新聞:「${x}」。`,
familiar: (n, x) => `${n},今天有則新聞:「${x}」,你知道嗎?`,
close: (n, x) => `${n},我看到「${x}」,想和你分享。`,
special: (n, x) => `${n},「${x}」這則新聞,我想和你聊聊。`,
},
},
}
/** 台詞查詢(供測試與內容檢查) */
@@ -380,13 +467,16 @@ const DELIVERY: Record<Trigger, Delivery> = {
todo_due: 'toast',
test_recovered: 'toast',
turn_done: 'toast',
miss_you: 'inject',
homesick: 'inject',
news: 'inject',
}
const initial = (): CareState => ({ lastFired: {}, day: '', count: 0, quietUntil: 0, workStart: 0, lastActivity: 0, failStreak: 0 })
export interface CareEngine {
/** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */
onUserTurn(): Promise<string[]>
onUserTurn(signals?: Signals): Promise<string[]>
/** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */
onToolResult(ok: boolean, command?: string): Promise<boolean>
/** 一輪回覆結束;有呼叫過工具才算做完事 */
@@ -451,11 +541,14 @@ export function createCare(d: CareDeps): CareEngine {
if (s.failStreak >= FAIL_THRESHOLD) out.push({ t: 'tool_failures' })
if (inRange(hourOf(now), LATE.start, LATE.end) && s.lastActivity > 0) out.push({ t: 'late_night' })
if (s.workStart > 0 && now - s.workStart >= LONG_WORK_MS) out.push({ t: 'long_work' })
if (sig.missing) out.push({ t: 'miss_you' })
if (sig.homesick) out.push({ t: 'homesick' })
if (sig.news) out.push({ t: 'news', extra: sig.news })
return out
}
return {
async onUserTurn() {
async onUserTurn(sig = {}) {
const s = await load()
const now = d.clock()
const first = s.lastActivity === 0
@@ -466,7 +559,7 @@ export function createCare(d: CareDeps): CareEngine {
s.lastActivity = now
const toInject: Care[] = []
if (!muted(s, now)) {
for (const { t, extra } of candidates(s, now, {})) {
for (const { t, extra } of candidates(s, now, sig)) {
if (DELIVERY[t] !== 'inject') continue
const c = fire(s, t, now, extra)
if (c) {
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'claude-code/testing'
import { createCompanion } from './core'
import { fakeClock, memoryFs } from './fs'
import { dailyLimits, effective, gain, initialRelation, interact, SEVERITY, stageFor, trustLoss } from './relation'
import type { CompanionCore } from './types'
// 測試用原創範例角色,不依賴網路或受版權保護的資料
@@ -136,58 +137,158 @@ describe('心情', () => {
})
})
describe('好感度', () => {
test('從 0 開始,每次 +0.01,每天上限 +0.05', async () => {
describe('關係四維度與階段', () => {
const rel = async (c: ReturnType<typeof setup>['c']) => (await c.view())!.relation
test('從 0 開始,同一天的增量受日限約束', async () => {
const { c } = setup()
await c.ensureActivated()
expect((await c.view())!.affinity).toBe(0)
expect(await rel(c)).toEqual({ affection: 0, trust: 0, intimacy: 0, dependence: 0 })
for (let i = 0; i < 50; i++) await c.affinityEvent('meaningful')
const r = await rel(c)
const lim = dailyLimits()
expect(r.affection).toBeLessThanOrEqual(lim.affection + 1e-9)
expect(r.trust).toBeLessThanOrEqual(lim.trust + 1e-9)
expect(r.intimacy).toBeLessThanOrEqual(lim.intimacy + 1e-9)
expect(r.dependence).toBeLessThanOrEqual(lim.dependence + 1e-9)
expect(r.affection).toBeGreaterThan(lim.affection * 0.7)
})
test('日限公式依人格傾向調整', () => {
expect(dailyLimits()).toEqual({ affection: 4, trust: 5 * 0.7, intimacy: 3.5 * 0.8 * 1.2, dependence: 2.5 * 1 })
expect(dailyLimits({ guard: 1 }).trust).toBeCloseTo(2)
expect(dailyLimits({ guard: 0 }).trust).toBe(5)
})
test('有效增量 = 原始增量 × (1 − 今日累計/日限) × (1 − 目前值/100)', () => {
let r = initialRelation(0)
r = gain(r, 'affection', 3)
expect(r.save.affection).toBeCloseTo(3)
r = gain(r, 'affection', 3)
expect(r.save.affection).toBeCloseTo(3 + 3 * (1 - 3 / 4) * (1 - 3 / 100))
const hi = { ...initialRelation(0), save: { affection: 50, trust: 0, intimacy: 0, dependence: 0 } }
expect(gain(hi, 'affection', 3).save.affection).toBeCloseTo(50 + 3 * 0.5)
})
test('隔天日限重置', async () => {
const { c, clock } = setup()
await c.ensureActivated()
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9)
})
test('隔天可繼續累積;負面事件 −0.03', async () => {
const { c, clock } = setup()
await c.ensureActivated()
for (let i = 0; i < 6; i++) await c.affinityEvent('meaningful')
const day1 = (await rel(c)).affection
clock.advance(DAY)
await c.affinityEvent('meaningful')
expect(Math.abs((await c.view())!.affinity - (0.06))).toBeLessThan(1e-9)
await c.affinityEvent('negative')
expect(Math.abs((await c.view())!.affinity - (0.03))).toBeLessThan(1e-9)
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
expect((await rel(c)).affection).toBeGreaterThan(day1 + 3)
})
test('離開超過 7 天後緩慢回到 0', async () => {
const { c, clock } = setup()
await c.ensureActivated()
for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful')
clock.advance(7 * DAY)
expect(Math.abs((await c.view())!.affinity - (0.05))).toBeLessThan(1e-9)
clock.advance(30 * DAY)
expect(Math.abs((await c.view())!.affinity - (0.025))).toBeLessThan(1e-9)
test('期望違反:被罵扣好感 3,信賴扣得更多,越親密越多,單次上限 30', () => {
const r0 = { ...initialRelation(0), save: { affection: 20, trust: 40, intimacy: 0, dependence: 0 } }
const a = interact(r0, 'negative', 0)
expect(a.save.affection).toBeCloseTo(17)
expect(a.save.trust).toBeCloseTo(40 - 3 * 1.7)
const close = interact({ ...r0, save: { ...r0.save, intimacy: 100 } }, 'negative', 0)
expect(40 - close.save.trust).toBeGreaterThan(40 - a.save.trust)
expect(trustLoss(3, SEVERITY.lie, 100, 1)).toBeLessThanOrEqual(30)
expect(trustLoss(100, SEVERITY.lie, 100, 1)).toBe(30)
expect(trustLoss(3, SEVERITY.lie, 0)).toBeGreaterThan(trustLoss(3, SEVERITY.broken, 0))
expect(interact(initialRelation(0), 'negative', 0).save.trust).toBeCloseTo(0)
})
test('階段切換有遲滯', async () => {
test('升階條件逐一檢查', () => {
const d = (affection: number, trust: number, intimacy: number) => ({ affection, trust, intimacy, dependence: 0 })
expect(stageFor(d(14, 99, 99), 0)).toBe(0)
expect(stageFor(d(15, 0, 0), 0)).toBe(1)
expect(stageFor(d(30, 24, 0), 0)).toBe(1)
expect(stageFor(d(30, 25, 0), 0)).toBe(2)
expect(stageFor(d(50, 45, 29), 0)).toBe(2)
expect(stageFor(d(50, 45, 30), 0)).toBe(3)
expect(stageFor(d(100, 100, 100), 0)).toBe(3) // 不實作曖昧以上
})
test('只升不降:數值回落、被罵都不降階', async () => {
const { c, clock } = setup()
await c.ensureActivated()
expect((await c.view())!.stage).toBe('陌生')
for (let d = 0; d < 6; d++) {
for (let i = 0; i < 5; i++) await c.affinityEvent('meaningful')
const seen = new Set<string>()
for (let d = 0; d < 60; d++) {
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
seen.add((await c.view())!.stage)
clock.advance(DAY)
}
expect(Math.abs((await c.view())!.affinity - (0.3))).toBeLessThan(1e-9)
expect((await c.view())!.stage).toBe('熟悉')
await c.affinityEvent('negative') // 0.27,仍在遲滯區間
expect((await c.view())!.stage).toBe('熟悉')
await c.affinityEvent('negative') // 0.24,低於降階門檻 0.25
expect((await c.view())!.stage).toBe('陌生')
expect([...seen]).toEqual(['陌生', '熟人', '朋友', '好友'])
for (let i = 0; i < 40; i++) await c.affinityEvent('negative')
expect((await rel(c)).trust).toBeLessThan(45)
expect((await c.view())!.stage).toBe('好友')
})
test('久未互動 14 天內不變,之後每天 −0.5,階段不降', async () => {
const { c, clock } = setup()
await c.ensureActivated()
for (let d = 0; d < 20; d++) {
for (let i = 0; i < 20; i++) await c.affinityEvent('meaningful')
clock.advance(DAY)
}
clock.advance(-DAY)
const before = await rel(c)
const stage = (await c.view())!.stage
clock.advance(14 * DAY)
expect(await rel(c)).toEqual(before)
clock.advance(10 * DAY)
expect((await rel(c)).affection).toBeCloseTo(before.affection - 5)
expect((await rel(c)).trust).toBeCloseTo(before.trust - 5)
clock.advance(1000 * DAY)
expect((await rel(c)).affection).toBe(0)
expect((await c.view())!.stage).toBe(stage)
})
test('原典基準與存檔累積兩本帳相加', () => {
const base = { affection: 20, trust: 10, intimacy: 0, dependence: 0 }
const r = interact(initialRelation(0, base), 'meaningful', 0)
expect(effective(r).affection).toBeCloseTo(20 + 3 * (1 - 20 / 100))
expect(r.save.affection).toBeLessThan(3)
expect(r.stage).toBe(1)
})
})
describe('舊 affinity 存檔升級', () => {
const legacy = (value: number, stage: number, at: number) => ({
mood: { p: 0, a: 0, d: 0, at },
affinity: { value, stage, dayKey: '2026-10-14', dayGain: 0.02, lastSeen: at },
todos: 0,
})
const cases: [number, number, string][] = [
[0.05, 0, '陌生'],
[0.3, 1, '熟人'],
[0.55, 2, '朋友'],
[0.8, 3, '好友'],
]
for (const [value, stage, name] of cases) test(`value ${value}、stage ${stage} → ${name},好感值保留`, async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
await fs.write(`${ROOT}/companions/miko/state.json`, JSON.stringify(legacy(value, stage, clock.now())))
const v = (await c.view())!
expect(v.stage).toBe(name)
expect(v.relation.affection).toBeCloseTo(value * 100)
const saved = JSON.parse((await fs.read(`${ROOT}/companions/miko/state.json`))!)
expect(saved.affinity).toBeUndefined()
expect(saved.relation.stage).toBe(stage)
expect(saved.relation.dayGain.affection).toBeCloseTo(2)
})
test('升級後可繼續互動', async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
await fs.write(`${ROOT}/companions/miko/state.json`, JSON.stringify(legacy(0.3, 1, clock.now())))
await c.affinityEvent('meaningful')
expect((await c.view())!.relation.affection).toBeGreaterThan(30)
})
})
describe('指令與設定', () => {
test('status 展開事實欄位與來源,查不到為 unknown', async () => {
test('info --all 展開事實欄位與來源,查不到為 unknown', async () => {
const { c } = setup()
await c.ensureActivated()
const out = await c.command('status')
const out = await c.command('status --all')
expect(out).toContain('月見ミコ')
expect(out).toContain('birthday:3/3(novel,信心 1,test)')
expect(out).toContain('bloodType:unknown')
@@ -209,7 +310,7 @@ describe('指令與設定', () => {
const fs = memoryFs()
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
await c.ensureActivated()
const out = await c.command('status')
const out = await c.command('status --all')
expect(out).toContain('白上吹雪')
expect(out).toContain('hololivepro.com')
expect(out).toContain('weight:unknown')
@@ -242,12 +343,12 @@ describe('語氣注入', () => {
const { c } = setup()
await c.ensureActivated()
await c.onPrompt('請幫我看一下這個函式')
expect(Math.abs((await c.view())!.affinity - 0.01)).toBeLessThan(1e-9)
expect((await c.view())!.relation.affection).toBeCloseTo(3)
await c.onPrompt('謝謝你')
expect((await c.view())!.mood.p).toBeGreaterThan(0.1)
const before = (await c.view())!.affinity
const before = (await c.view())!.relation.affection
await c.onPrompt('你很煩')
expect((await c.view())!.affinity).toBeLessThan(before)
expect((await c.view())!.relation.affection).toBeLessThan(before)
expect((await c.view())!.mood.p).toBeLessThan(0.1)
})
})
@@ -261,6 +362,33 @@ describe('記憶、待辦與關心', () => {
expect(await c.prompt()).toContain('payment')
})
test('被罵的記憶是未解負向事件,道歉後解除;記憶不會被刪除', async () => {
const { c, fs } = setup()
await c.ensureActivated()
await c.onPrompt('你很煩,真的很煩人')
const path = `${ROOT}/companions/miko/memory.json`
const read = () => JSON.parse(fs.files.get(path)!) as { items: { negative?: boolean; resolvedAt?: number; status: string }[] }
expect(read().items[0]!.negative).toBe(true)
expect(read().items[0]!.resolvedAt).toBeUndefined()
await c.onPrompt('對不起,剛剛我太衝動了')
await c.onTurnComplete(false, '沒關係啦。<評估:apology>')
expect(read().items[0]!.resolvedAt !== undefined).toBe(true)
expect(read().items[0]!.status).toBe('active')
})
test('回覆中的提問成為開放迴路,2 回合沒回應就逾時', async () => {
const { c, fs } = setup()
await c.ensureActivated()
await c.onTurnComplete(false, '好喔。你今天吃飯了嗎?')
const path = `${ROOT}/companions/miko/memory.json`
const loops = () => (JSON.parse(fs.files.get(path)!) as { loops: { status: string }[] }).loops
expect(loops()[0]!.status).toBe('open')
await c.onPrompt('幫我看看建置')
expect(loops()[0]!.status).toBe('open')
await c.onPrompt('順便跑一下測試')
expect(loops()[0]!.status).toBe('timeout')
})
test('太短的提示不寫入記憶', async () => {
const { c, fs } = setup()
await c.ensureActivated()
@@ -343,6 +471,21 @@ describe('預設角色語氣', () => {
expect(p).toContain('狐狸')
})
test('內建吹雪首次載入時寫入原典記憶,只寫一次且不含使用者記憶', async () => {
const fs = memoryFs()
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
await c.ensureActivated()
await c.prompt()
const path = `${ROOT}/companions/fubuki/memory.json`
const doc = JSON.parse(fs.files.get(path)!)
const canon = doc.items.filter((m: { source: string }) => m.source === 'canon')
expect(canon.length).toBeGreaterThan(0)
expect(canon.every((m: { canon: boolean; kind: string }) => m.canon && m.kind === 'long')).toBe(true)
expect(doc.pending).toBe(0)
await c.prompt()
expect(JSON.parse(fs.files.get(path)!).items.length).toBe(doc.items.length)
})
test('舊的本機資料缺少個性時以內建補上,已有的不被覆蓋', async () => {
const fs = memoryFs()
const c = createCompanion({ fs, clock: fakeClock(), root: ROOT })
+289 -39
View File
@@ -1,18 +1,34 @@
import { idleDecay, initialAffinity, interact } from './affinity'
import { effective, idleDecay, initialRelation, interact, migrateAffinity } from './relation'
import type { LegacyAffinity } from './relation'
import { FUBUKI } from './defaults'
import { FUBUKI_CANON } from './canon'
import type { CanonSeed } from './canon'
import { readJson, writeJson } from './fs'
import type { Clock, Fs } from './fs'
import { applyDelta, decay, keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
import { applyEvent, baselineOf, clampDim, decayEmotions, DIM_INFO, dominant, fromPad, gauge, ranked, toPad } from './emotion'
import type { Emotions } from './emotion'
import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
import type { MoodEvent } from './mood'
import { createCare } from './care'
import { extraversionOf, neuroticismOf, PARAM_LABELS, PARAMS, value } from './core/params'
import type { ParamLayers } from './core/params'
import { bar, width } from './seg'
import { extractTag, stripTag } from './tags'
import { createCare, IDLE_MS } from './care'
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
import { createMemory } from './memory'
import type { MemoryStore } from './memory'
import { categoryBonus, createMemory, lastQuestion } from './memory'
import type { MemoryDoc, MemoryStore } from './memory'
import { buildPrompt } from './prompt'
import { createTodos } from './todo'
import { describeAbsence, missingAccrual } from './absence'
import { dayNumber, describeAdapt, growthRate, homesickBase, initialAdapt, grow, markFirst, newsQuota } from './adapt'
import type { Adapt } from './adapt'
import { EMPTY, readMaterials, weatherShift } from './materials'
import type { Materials } from './materials'
import { ADAPT, MATERIALS, MISSING } from './rules'
import { energyShift, minutesOf, periodOf } from './schedule'
import type { TodoList } from './todo'
import { STAGES } from './types'
import type { CompanionCore, CompanionState, Global, Pad, UserSettings } from './types'
import type { CompanionCore, CompanionState, Dims, Global, Pad, UserSettings } from './types'
export const DEFAULT_USER: UserSettings = {
nickname: '',
@@ -21,21 +37,33 @@ export const DEFAULT_USER: UserSettings = {
tz: '+0800',
careLimit: 5,
quiet: { start: '', end: '' },
location: '',
feeds: [],
}
export type View = {
core: CompanionCore
mood: Pad
emotions: Emotions
label: string
kaomoji: string
affinity: number
relation: Dims
stage: string
todos: number
/** 適應度 0–100 */
adapt: number
/** 離上次互動的毫秒數(時間感) */
absentMs: number
period: string
/** 快取中仍有效的天氣標題;未啟用或過期為 null */
weather: string | null
/** 人格 18 參數的三層值;角色包沒有 params 時為 null */
layers: ParamLayers | null
}
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[] }
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[]; canon?: Record<string, CanonSeed[]> }
export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: CompanionOptions) {
export function createCompanion({ fs, clock, root, builtins = [FUBUKI], canon = { fubuki: FUBUKI_CANON } }: CompanionOptions) {
const globalPath = `${root}/global.json`
const userPath = `${root}/user.json`
const dir = (id: string) => `${root}/companions/${id}`
@@ -49,14 +77,43 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
if (!core || !b) return core
return { ...core, persona: core.persona ?? b.persona, attribute: core.attribute ?? b.attribute, extraversion: core.extraversion ?? b.extraversion, freq: core.freq ?? b.freq }
}
const getState = (id: string) => readJson<CompanionState>(fs, `${dir(id)}/state.json`)
const baseOf = (core: CompanionCore) => baselineOf(core.baseline, core.emotionBaseline)
// 讀檔時把舊存檔升級並寫回,不要求重建:PAD(mood)映射到八維度;單一好感(affinity)升成關係四維度
const getState = async (id: string, core: CompanionCore): Promise<CompanionState | null> => {
const raw = await readJson<Omit<CompanionState, 'emotion' | 'relation'> & { emotion?: CompanionState['emotion']; relation?: CompanionState['relation']; mood?: Pad & { at: number }; affinity?: LegacyAffinity }>(fs, `${dir(id)}/state.json`)
if (!raw) return null
if (raw.emotion && raw.relation) return { ...raw, habit: raw.habit ?? {} } as CompanionState
const { mood, affinity, ...rest } = raw
const st = {
...rest,
emotion: raw.emotion ?? fromPad(mood ?? { ...core.baseline, at: clock.now() }, baseOf(core), core.baseline),
habit: raw.habit ?? {},
relation: raw.relation ?? (affinity ? migrateAffinity(affinity) : initialRelation(clock.now())),
} as CompanionState
await putState(id, st)
return st
}
const putState = (id: string, s: CompanionState) => writeJson(fs, `${dir(id)}/state.json`, s)
async function install(core: CompanionCore) {
if (await getCore(core.id)) return
const now = clock.now()
await writeJson(fs, `${dir(core.id)}/core.json`, core)
await putState(core.id, { mood: { ...core.baseline, at: now }, affinity: initialAffinity(now), todos: 0 })
await putState(core.id, { emotion: { ...baselineOf(core.baseline, core.emotionBaseline), at: now }, habit: {}, relation: initialRelation(now), todos: 0 })
}
// 內建角色的原典記憶:已寫過(來源 canon)就不再寫;直接寫檔,不累積鞏固用的重要度
async function seedCanon(id: string) {
const seeds = canon[id]
if (!seeds?.length) return
const path = `${dir(id)}/memory.json`
const d = (await readJson<MemoryDoc>(fs, path)) ?? { items: [], nextId: 1, pending: 0, lastConsolidated: 0 }
if (d.items.some(m => m.source === 'canon')) return
const now = clock.now()
for (const s of seeds) {
d.items.push({ id: d.nextId++, layer: s.layer, kind: 'long', status: 'active', text: s.text, importance: s.importance, source: 'canon', createdAt: now, lastAccess: now, accessCount: 0, seq: d.seq ?? 0, canon: true })
}
await writeJson(fs, path, d)
}
// 第一次啟用自動啟用預設角色;之後即使關閉也不會再自動啟用
@@ -70,50 +127,127 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
return { first: true, name: def.name }
}
// 外部素材:只讀排程腳本寫的快取,絕不連網;一分鐘內重複讀取用記憶中的值
let matsCache: { at: number; data: Materials } | null = null
async function materialsNow(user: UserSettings): Promise<Materials> {
if (!user.location) return EMPTY
const now = clock.now()
if (!matsCache || now - matsCache.at >= MATERIALS.readEveryMs || now < matsCache.at) matsCache = { at: now, data: await readMaterials(fs, `${root}/materials.json`, now) }
return matsCache.data
}
type Env = { user: UserSettings; mats: Materials; workMs: number }
// 連續工作時間取自關心引擎的存檔;閒置超過門檻就不算連續工作
async function envFor(id: string): Promise<Env> {
const user = await getUser()
const care = await readJson<{ workStart?: number; lastActivity?: number }>(fs, `${dir(id)}/care.json`)
const now = clock.now()
const working = care?.workStart && care.lastActivity && now - care.lastActivity < IDLE_MS
return { user, mats: await materialsNow(user), workMs: working ? now - care.workStart! : 0 }
}
const adaptOf = (core: CompanionCore, st: CompanionState, now: number, tzMin: number): Adapt =>
grow(st.adapt ?? initialAdapt(now, tzMin), now, tzMin, growthRate(core.params?.initiative, core.params?.imagine))
// 基線疊加:作息(精力)、天氣(心情)、適應度(想家)
function baselineAt(core: CompanionCore, st: CompanionState, now: number, env: Env): Emotions {
const b = baseOf(core)
const tz = tzMinutes(env.user.tz)
const rhythm = { wake: env.user.wake, sleep: env.user.sleep, tzOffsetMin: tz }
const ok = minutesOf(env.user.wake) !== null && minutesOf(env.user.sleep) !== null
return {
...b,
energy: clampDim(b.energy + (ok ? energyShift(now, env.workMs, rhythm) : 0)),
mood: clampDim(b.mood + weatherShift(env.mats.weather)),
homesick: Math.max(b.homesick, homesickBase(adaptOf(core, st, now, tz).value)),
}
}
// 衰減只在讀取時計算,不寫回檔案
async function view(): Promise<View | null> {
const { active } = await getGlobal()
if (!active) return null
const core = await getCore(active)
const st = await getState(active)
const st = core && (await getState(active, core))
if (!core || !st) return null
const now = clock.now()
const mood = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism)
const aff = idleDecay(st.affinity, now)
const env = await envFor(active)
const base = baselineAt(core, st, now, env)
const absentMs = Math.max(0, now - st.relation.lastSeen)
const emotions = decayEmotions(st.emotion, base, now, neuroticismOf(core, st), tzMinutes(env.user.tz))
// 想念隨離開時間累積(時間感):取累積值與已存的較大者
emotions.missing = Math.max(emotions.missing, missingAccrual(absentMs, core.params?.miss))
const mood = toPad(emotions, base, core.baseline)
const main = dominant(emotions)
const rel = idleDecay(st.relation, now)
const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0
return { core, mood, ...moodLabel(mood), affinity: aff.value, stage: STAGES[aff.stage]!, todos }
return {
core, mood, emotions, ...(main ?? moodLabel(mood)), relation: effective(rel), stage: STAGES[rel.stage]!, todos,
adapt: adaptOf(core, st, now, tzMinutes(env.user.tz)).value, absentMs, period: periodOf(now, tzMinutes(env.user.tz)), weather: env.mats.weather?.title ?? null,
layers: core.params ? { base: core.params, save: st.params?.save ?? {}, today: st.params?.today ?? {} } : null,
}
}
async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) {
const { active } = await getGlobal()
if (!active) return
const core = await getCore(active)
const st = await getState(active)
const st = core && (await getState(active, core))
if (!core || !st) return
await putState(active, fn(core, st, clock.now()))
const now = clock.now()
const tz = tzMinutes((await getUser()).tz)
await putState(active, fn(core, { ...st, adapt: adaptOf(core, st, now, tz) }, now))
}
async function moodEvent(ev: MoodEvent) {
const { active } = await getGlobal()
if (!active) return
const env = await envFor(active)
await mutate((core, st, now) => {
const cur = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism)
return { ...st, mood: { ...applyDelta(cur, MOOD_EVENTS[ev]), at: now } }
const cur = decayEmotions(st.emotion, baselineAt(core, st, now, env), now, neuroticismOf(core, st), tzMinutes(env.user.tz))
const next = applyEvent(cur, st.habit, ev, now)
return { ...st, emotion: { ...next.emotions, at: now }, habit: next.habit }
})
}
async function affinityEvent(kind: 'meaningful' | 'negative') {
await mutate((_, st, now) => ({ ...st, affinity: interact(st.affinity, kind, now) }))
await mutate((core, st, now) => ({ ...st, relation: interact(st.relation, kind, now, core.relationTraits) }))
}
const bar = (v: number) => (v >= 0 ? '+' : '') + v.toFixed(2)
async function status(): Promise<string> {
async function status(all = false): Promise<string> {
const v = await view()
if (!v) return '目前沒有啟用角色。'
const lines = [`${v.kaomoji} ${v.core.name}|${v.label}`]
lines.push(`心情 PAD:P ${bar(v.mood.p)} A ${bar(v.mood.a)} D ${bar(v.mood.d)}`)
lines.push(`好感:${v.stage}(${bar(v.affinity)})`)
lines.push(`待辦:${v.todos}`)
lines.push('基本資訊:')
const dims = ranked(v.emotions)
const head = `${v.core.name} ${v.kaomoji} ${v.label}`
if (!all) {
return [
head,
`關係 ${v.stage}(好感 ${Math.round(v.relation.affection)})`,
`情緒 ${dims.slice(0, 3).map(d => `${DIM_INFO[d].name} ${Math.round(v.emotions[d] * 100)}`).join('、')}`,
`適應度 ${Math.round(v.adapt)}(${describeAdapt(v.adapt)}) 待辦 ${v.todos} 件`,
'加 --all 看完整數值',
].join('\n')
}
const rel = Object.entries({ 好感: v.relation.affection, 信賴: v.relation.trust, 親密: v.relation.intimacy, 依賴: v.relation.dependence })
const lines = [
`${head} 現在是${v.period},距離上次互動 ${describeAbsence(v.absentMs)}${v.weather ? `|天氣:${v.weather}` : ''}`,
'',
'情緒(8 維度)',
...dims.map(d => ` ${gauge(d, v.emotions)}`),
'',
`關係(${v.stage})`,
...rel.map(([k, x]) => ` ${k} ${bar(x, 12)} ${String(Math.round(x)).padStart(3)}`),
'',
`人格(${PARAMS.length} 參數:基準 + 存檔偏移 + 今日偏移)`,
]
if (v.layers) {
const sign = (n: number) => (n >= 0 ? '+' : '') + Math.round(n)
for (const p of PARAMS) {
const l = v.layers
lines.push(` ${PARAM_LABELS[p]}${' '.repeat(Math.max(1, 20 - width(PARAM_LABELS[p])))}基準 ${Math.round(l.base[p])} 偏移 ${sign(l.save[p] ?? 0)} 今日 ${sign(l.today[p] ?? 0)} 有效 ${Math.round(value(l, p))}`)
}
} else lines.push(' (此角色包沒有人格參數)')
lines.push('', '世界', ` 適應度 ${Math.round(v.adapt)}(${describeAdapt(v.adapt)}) 想家 ${Math.round(v.emotions.homesick * 100)}`, '', '基本資訊')
for (const [k, f] of Object.entries(v.core.facts)) {
lines.push(f === 'unknown' ? ` ${k}:unknown` : ` ${k}:${f.value}(${f.kind ?? '?'},信心 ${f.confidence ?? '?'},${f.source ?? '無來源'})`)
}
@@ -137,7 +271,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
async function setup(args: string[]): Promise<string> {
const u = await getUser()
const known = ['nickname', 'wake', 'sleep', 'tz', 'careLimit', 'quietStart', 'quietEnd']
const known = ['nickname', 'wake', 'sleep', 'tz', 'careLimit', 'quietStart', 'quietEnd', 'location', 'feeds']
const bad: string[] = []
for (const a of args) {
const i = a.indexOf('=')
@@ -148,23 +282,28 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
const n = Number(val)
if (!Number.isInteger(n) || n < 0) bad.push(a)
else u.careLimit = n
} else if (k === 'feeds') {
const urls = val.split(',').map(x => x.trim()).filter(Boolean)
if (urls.some(x => !/^https?:\/\/\S+$/.test(x))) bad.push(a)
else u.feeds = urls
} else if (k === 'quietStart') u.quiet.start = val
else if (k === 'quietEnd') u.quiet.end = val
else (u as Record<string, unknown>)[k] = val
}
if (bad.length) return `無法解析:${bad.join(' ')}\n用法:/jsc-mod-companion:setup nickname=… wake=HH:MM sleep=HH:MM tz=+0800 careLimit=N quietStart=HH:MM quietEnd=HH:MM`
if (bad.length) return `無法解析:${bad.join(' ')}\n用法:/jsc-mod-companion:setup nickname=… wake=HH:MM sleep=HH:MM tz=+0800 careLimit=N quietStart=HH:MM quietEnd=HH:MM location=緯度,經度或地名 feeds=RSS網址,…`
if (args.length) await writeJson(fs, userPath, u)
return [
args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):',
` nickname=${u.nickname}`,
` wake=${u.wake} sleep=${u.sleep} tz=${u.tz}`,
` careLimit=${u.careLimit} quietStart=${u.quiet.start} quietEnd=${u.quiet.end}`,
` location=${u.location} feeds=${u.feeds.join(',')}`,
].join('\n')
}
async function command(raw: string): Promise<string> {
const [sub = '', ...rest] = raw.trim().split(/\s+/).filter(Boolean)
if (sub === 'status') return status()
if (sub === 'status') return status(rest.includes('--all'))
if (sub === 'switch') return rest[0] ? switchTo(rest[0]) : `用法:/jsc-mod-companion:switch <角色>。可用:${(await available()).join('、')}`
if (sub === 'off') {
const g = await getGlobal()
@@ -174,7 +313,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
if (sub === 'setup') return setup(rest)
if (sub === 'todo') return todoCommand(rest)
if (sub === 'quiet') return quietCommand(rest)
return '用法:/jsc-mod-companion:status | :switch <角色> | :disable | :setup [key=value…] | :todo … | :quiet [分鐘]'
if (sub === 'materials') return materialsCommand(rest)
return '用法:/jsc-mod-companion:info | :switch <角色> | :disable | :setup [key=value…] | :todo … | :quiet [分鐘] | :materials [list]'
}
const CARE_STAGES: CareProfile['stage'][] = ['stranger', 'familiar', 'close', 'special']
@@ -191,6 +331,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
settings: { dailyCap: DEFAULT_USER.careLimit, tzOffsetMin: 480 },
}
let lastText = ''
// 這回合使用者文字是否已由正則判出標籤;是的話不再採用模型補的標
let regexHit = false
let recalled: string[] = []
// 目前啟用角色的記憶、待辦與關心引擎;關心的個人化參數每次取用時更新
@@ -198,14 +340,16 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
const v = await view()
if (!v) return null
const u = await getUser()
const st = await getState(v.core.id, v.core)
const qs = hourOf(u.quiet.start)
const qe = hourOf(u.quiet.end)
ctx = {
profile: { attribute: v.core.attribute ?? 'gentle', extraversion: v.core.extraversion ?? 50, stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname, freq: v.core.freq },
profile: { attribute: v.core.attribute ?? 'gentle', extraversion: extraversionOf(v.core, st ?? undefined), stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname, freq: v.core.freq },
settings: { dailyCap: u.careLimit, tzOffsetMin: tzMinutes(u.tz), quietHours: qs !== null && qe !== null ? { start: qs, end: qe } : undefined },
}
if (bundle?.id !== v.core.id) {
const d = dir(v.core.id)
await seedCanon(v.core.id)
const now = () => clock.now()
bundle = {
id: v.core.id,
@@ -230,19 +374,98 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
// 使用者送出提示時:更新心情與好感、寫入並檢索記憶;回傳這回合要附加給模型的關心情境
async function onPrompt(text: string): Promise<string[]> {
lastText = text
const proactive = await settleReturn()
const ev = keywordEvent(text)
regexHit = ev !== null
if (ev) await moodEvent(ev)
if (ev === 'scold') await affinityEvent('negative')
else if (ev || text.trim().length >= 4) await affinityEvent('meaningful')
const b = await bundleFor()
if (!b) return []
const v = await view()
recalled = (await b.mem.recall(text, v ? { p: v.mood.p, a: v.mood.a, d: v.mood.d } : undefined)).map(m => m.text)
await b.mem.respond(text)
const params = v?.core.params
recalled = (await b.mem.recall(text, v ? { p: v.mood.p, a: v.mood.a, d: v.mood.d } : undefined, { recover: params ? params.recover / 100 : undefined })).map(m => m.text)
if (ev === 'apology' || ev === 'soothe') await b.mem.resolve()
if (text.trim().length >= 8) {
await b.mem.add({ layer: 'episodic', text: text.trim().slice(0, 200), importance: ev ? 6 : 3, source: 'user', emotion: ev ? MOOD_EVENTS[ev] : undefined })
const pad = ev ? MOOD_EVENTS[ev] : undefined
await b.mem.add({
layer: 'episodic',
text: text.trim().slice(0, 200),
importance: ev ? 6 : 3,
source: 'user',
emotion: pad,
peak: pad ? Math.round(Math.hypot(pad.p, pad.a, pad.d) * 300) : undefined,
bonus: ev === 'return' ? categoryBonus('reunion', params) : undefined,
negative: pad ? pad.p < 0 : undefined,
})
await b.mem.consolidate()
}
return b.care.onUserTurn()
return speak(b, await proactiveSignals(proactive))
}
// 久別重逢:離開夠久再回來,先把累積的想念存下來,再套用「回來」事件;回傳是否想念到會說出口、想家是否到會說出口
async function settleReturn(): Promise<{ missing: boolean; homesick: boolean }> {
const v = await view()
if (!v) return { missing: false, homesick: false }
const out = { missing: v.emotions.missing >= MISSING.speakAt, homesick: v.emotions.homesick >= ADAPT.homesickSpeakAt }
if (v.absentMs >= MISSING.returnH * 3_600_000) {
const { active } = await getGlobal()
const env = await envFor(active!)
await mutate((core, st, now) => {
const cur = decayEmotions(st.emotion, baselineAt(core, st, now, env), now, neuroticismOf(core, st), tzMinutes(env.user.tz))
return { ...st, emotion: { ...cur, missing: Math.max(cur.missing, v.emotions.missing), at: now } }
})
await moodEvent('return')
} else out.missing = false
return out
}
// 本地新聞:適應度夠才聊,每天有上限,不重複;缺失或過期的素材等於沒查
async function pickNews(): Promise<{ title: string; link: string } | null> {
const { active } = await getGlobal()
if (!active) return null
const core = await getCore(active)
const st = core && (await getState(active, core))
if (!core || !st) return null
const user = await getUser()
const mats = await materialsNow(user)
const now = clock.now()
const tz = tzMinutes(user.tz)
const day = dayNumber(now, tz)
const said = st.news?.day === day ? st.news.said : 0
if (said >= newsQuota(adaptOf(core, st, now, tz).value, MATERIALS.newsPerDay)) return null
const seen = st.news?.seen ?? []
const n = mats.news.find(x => !seen.includes(x.link))
return n ? { title: n.title, link: n.link } : null
}
async function proactiveSignals(p: { missing: boolean; homesick: boolean }): Promise<Signals & { newsLink?: string }> {
const news = await pickNews()
return { missing: p.missing, homesick: p.homesick, news: news?.title, ...(news ? { newsLink: news.link } : {}) }
}
// 送出主動發話情境;新聞真的說出口了才算一則:記下連結、累計今日則數、寫進情節記憶,並算本地第一次事件
async function speak(b: Bundle, sig: Signals & { newsLink?: string }): Promise<string[]> {
const out = await b.care.onUserTurn(sig)
const user = await getUser()
if (user.location) {
const mats = await materialsNow(user)
const { active } = await getGlobal()
const core = active ? await getCore(active) : null
const st = core && (await getState(active!, core))
if (mats.weather && st && !st.adapt?.firsts.includes('weather')) await mutate((_c, s) => ({ ...s, adapt: markFirst(s.adapt!, 'weather') }))
}
if (sig.news && out.some(x => x.startsWith('[care:news]'))) {
const tz = tzMinutes(user.tz)
await mutate((core, st, now) => {
const day = dayNumber(now, tz)
const cur = st.news?.day === day ? st.news : { day, said: 0, seen: st.news?.seen ?? [] }
return { ...st, adapt: markFirst(st.adapt!, 'news'), news: { day, said: cur.said + 1, seen: [...cur.seen, sig.newsLink!].slice(-MATERIALS.seenMax) } }
})
await b.mem.add({ layer: 'episodic', text: `聊到時事:${sig.news}`, importance: 3, source: 'world' })
}
return out
}
async function onToolResult(ok: boolean, info: { command?: string } = {}) {
@@ -254,9 +477,17 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
}
// 一輪回覆結束;有用過工具才算做完事,心情變好並排隊一則反應
async function onTurnComplete(usedTools: boolean) {
async function onTurnComplete(usedTools: boolean, reply = '') {
const b = await bundleFor()
if (!b || !usedTools) return
if (!b) return
// 正則沒判出時,採用模型於回覆末尾補的標籤;不在白名單或缺失就當作沒標
const tag = regexHit ? null : extractTag(reply)
regexHit = true
if (tag) await moodEvent(tag)
if (tag === 'apology' || tag === 'soothe') await b.mem.resolve()
const q = lastQuestion(stripTag(reply))
if (q) await b.mem.openLoop(q)
if (!usedTools) return
await b.care.onTurnComplete(usedTools)
await moodEvent('success')
}
@@ -272,7 +503,10 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
async function end() {
const b = await bundleFor()
if (b) await b.mem.consolidate()
if (b) {
await b.mem.endScene()
await b.mem.consolidate()
}
}
const fmtTodo = (t: { id: number; title: string; promise: boolean; dueAt?: number; done: boolean }) =>
@@ -310,6 +544,22 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
return '用法:/jsc-mod-companion:todo [list] | add <內容> [due=YYYY-MM-DDTHH:MM] | promise <內容> | done <編號>'
}
const age = (ms: number) => describeAbsence(Math.max(0, ms))
async function materialsCommand(rest: string[]): Promise<string> {
const u = await getUser()
if (!u.location) return '外部素材尚未啟用。請先 /jsc-mod-companion:setup location=緯度,經度(或地名),再用系統排程執行 fetch-materials.mjs。'
const now = clock.now()
const m = await readMaterials(fs, `${root}/materials.json`, now)
const lines = [`所在地:${u.location}|RSS:${u.feeds.length} 個`]
lines.push(m.weather ? `天氣:${m.weather.title}(${m.weather.summary}),${age(now - m.weather.at)}前更新` : '天氣:沒有資料或已過期(6 小時)')
lines.push(m.news.length ? `新聞:${m.news.length} 則有效(24 小時內)` : '新聞:沒有資料或已過期(24 小時)')
if (rest[0] === 'list') for (const n of m.news) lines.push(` • ${n.title}(${n.link})`)
else if (m.news.length) lines.push(' 用 /jsc-mod-companion:materials list 列出標題與連結')
if (!m.weather && !m.news.length) lines.push('素材由系統排程執行 scripts/fetch-materials.mjs 寫入 ~/.companion/materials.json,hook 本身不連網。')
return lines.join('\n')
}
async function quietCommand(rest: string[]): Promise<string> {
const b = await bundleFor()
if (!b) return '目前沒有啟用角色。'
@@ -0,0 +1,120 @@
import { describe, test } from 'claude-code/testing'
import assert from '../assert'
import { applyInference, initPersonality } from './personality'
import {
baseFor, deriveBig5, effective, extraversionOf, filterAiPatch, initLayers, neuroticismOf, nudgeSave, PARAMS, rollToday, value, values,
} from './params'
const near = (a: number, b: number, eps = 1) => assert.ok(Math.abs(a - b) <= eps, `${a} 應接近 ${b}`)
describe('18 參數三層值', () => {
test('共 18 個參數,無屬性時基準為 50', () => {
assert.equal(PARAMS.length, 18)
assert.ok(Object.values(baseFor([])).every(v => v === 50))
})
test('存檔偏移每次 ±1,上限 ±15', () => {
let l = initLayers(baseFor([]))
for (let i = 0; i < 40; i++) l = nudgeSave(l, 'shy', 1)
assert.equal(l.save.shy, 15)
assert.equal(value(l, 'shy'), 65)
l = nudgeSave(l, 'shy', -1)
assert.equal(l.save.shy, 14)
for (let i = 0; i < 60; i++) l = nudgeSave(l, 'guard', -1)
assert.equal(l.save.guard, -15)
})
test('今日偏移限制在 ±10,且可由 rng 重現', () => {
const seq = (s: number) => () => ((s = (s * 16807) % 2147483647) / 2147483647)
const a = rollToday(initLayers(baseFor([])), seq(7))
const b = rollToday(initLayers(baseFor([])), seq(7))
assert.deepEqual(a.today, b.today)
assert.ok(PARAMS.every(p => Math.abs(a.today[p]!) <= 10))
// 極端抽樣也被截斷
const c = rollToday(initLayers(baseFor([])), (() => { let n = 0; return () => (n++ % 2 === 0 ? 1e-12 : 0) })())
assert.ok(PARAMS.every(p => Math.abs(c.today[p]!) <= 10))
assert.ok(PARAMS.some(p => Math.abs(c.today[p]!) === 10))
})
test('今日偏移的標準差約為 5', () => {
let s = 12345
const rng = () => ((s = (s * 16807) % 2147483647) / 2147483647)
const xs: number[] = []
for (let i = 0; i < 300; i++) xs.push(...Object.values(rollToday(initLayers(baseFor([])), rng).today) as number[])
const mean = xs.reduce((a, b) => a + b, 0) / xs.length
const sd = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length)
near(mean, 0, 0.3)
near(sd, 4.7, 0.6) // 截斷在 ±2σ 使標準差略小於 5
})
test('參數值 = 基準 + 存檔 + 今日,並限制在 0–100', () => {
let l = initLayers({ ...baseFor([]), shy: 95 })
for (let i = 0; i < 15; i++) l = nudgeSave(l, 'shy', 1)
l = { ...l, today: { shy: 8 } }
assert.equal(value(l, 'shy'), 100)
assert.equal(values(l).blush, 50)
})
})
describe('有效參數', () => {
const l = initLayers({ ...baseFor([]), guard: 50, shy: 80, tsun: 70 })
test('有效心防:心防 × (1 − 0.5 × 信賴),心防 50、信賴 90 約 28', () => {
near(effective(l, 90, 0).guard, 27.5, 0.01)
assert.equal(effective(l, 0, 0).guard, 50)
})
test('有效害羞:害羞 × (1 − 0.3 × 親密)', () => {
near(effective(l, 0, 100).shy, 56, 0.01)
})
test('有效口是心非:口是心非 × (1 − 0.4 × 親密)', () => {
near(effective(l, 0, 50).tsun, 56, 0.01)
near(effective(l, 0, 100).tsun, 42, 0.01)
})
test('信賴與親密超出範圍時被截斷', () => {
assert.equal(effective(l, 500, 500).guard, 25)
})
})
describe('Big Five 推導值與 ingest 相容', () => {
test('心防、忌妒越高,推導的神經質越高', () => {
const calm = deriveBig5(values(initLayers(baseFor(['onee']))))
const edgy = deriveBig5(values(initLayers(baseFor(['yandere']))))
assert.ok(edgy.N > calm.N)
})
test('主動度與玩心越高,推導的外向性越高', () => {
assert.ok(deriveBig5(values(initLayers(baseFor(['genki'])))).E > deriveBig5(values(initLayers(baseFor(['dandere'])))).E)
})
test('推導值落在 0–100', () => {
for (const a of ['tsundere', 'genki', 'kuudere', 'dandere', 'yandere', 'onee']) {
assert.ok(Object.values(deriveBig5(values(initLayers(baseFor([a]))))).every(x => x >= 0 && x <= 100))
}
})
test('ingest 的 Big Five 推論機制不受影響', () => {
const p = applyInference(initPersonality(['tsundere']), { N: { value: 99, evidence: [{ chapter: '1', quote: 'x' }] } })
assert.equal(p.dims.N.value, 85)
})
})
describe('核心設定保護', () => {
test('姓名、性別、年齡、核心信念不可由 AI 改動', () => {
const r = filterAiPatch({ name: '新名', gender: 'm', age: 30, coreBelief: 'x', shy: 60 })
assert.deepEqual(r.rejected.sort(), ['age', 'coreBelief', 'gender', 'name'])
assert.deepEqual(r.allowed, { shy: 60 })
})
})
describe('Big Five 推導值接上半衰期與關心頻率', () => {
const core = { neuroticism: 40, extraversion: 60 }
test('沒有參數時沿用舊欄位', () => {
assert.equal(neuroticismOf(core), 40)
assert.equal(extraversionOf(core), 60)
})
test('有參數時改由三層值推導,存檔偏移會影響結果', () => {
const params = baseFor(['tsundere'])
const a = neuroticismOf({ ...core, params })
const b = neuroticismOf({ ...core, params }, { params: { save: { guard: 15, jealous: 15 }, today: {} } })
assert.notEqual(a, 40)
assert.ok(b > a)
assert.equal(extraversionOf({ ...core, params }), deriveBig5(params).E)
})
})
@@ -0,0 +1,127 @@
import type { Vec } from './personality'
/** 第 9.2 章的 18 個人格參數,0–100,越高越多 */
export const PARAMS = [
'shy', 'blush', 'clingy', 'tsun', 'tone',
'jealous', 'depend', 'guard', 'recover', 'miss',
'initiative', 'assertive', 'playful', 'caring', 'gentle',
'cute', 'health', 'imagine',
] as const
export type Param = (typeof PARAMS)[number]
export type ParamVec = Record<Param, number>
export const PARAM_LABELS: Record<Param, string> = {
shy: '害羞傾向', blush: '臉紅傾向', clingy: '撒嬌傾向', tsun: '口是心非度', tone: '語尾與顏文字使用度',
jealous: '忌妒敏感度', depend: '依賴傾向', guard: '心防', recover: '釋懷速度', miss: '想念累積速度',
initiative: '主動度', assertive: '自我主張', playful: '玩心', caring: '照顧欲', gentle: '溫柔度',
cute: '服裝可愛偏好', health: '體質', imagine: '想像力',
}
export const SAVE_STEP = 1
export const SAVE_LIMIT = 15
export const TODAY_SIGMA = 5
export const TODAY_LIMIT = 10
/** 三層值:角色包基準(本機 core.json)、存檔偏移與今日偏移(state.json) */
export type ParamLayers = { base: ParamVec; save: Partial<ParamVec>; today: Partial<ParamVec> }
const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
const NEUTRAL = Object.fromEntries(PARAMS.map(p => [p, 50])) as ParamVec
/** 屬性標籤對 18 參數的先驗(設計啟發式);未列出的參數維持 50 */
export const ARCHETYPE_PARAMS: Record<string, Partial<ParamVec>> = {
tsundere: { shy: 75, blush: 75, tsun: 85, guard: 65, assertive: 65, gentle: 35, jealous: 65, recover: 40 },
genki: { shy: 25, tone: 80, initiative: 85, playful: 75, depend: 55, guard: 25, recover: 75, health: 75, gentle: 60 },
kuudere: { shy: 30, blush: 25, tone: 20, initiative: 25, guard: 65, tsun: 40, playful: 25, gentle: 40 },
dandere: { shy: 85, blush: 80, tone: 30, initiative: 20, guard: 70, depend: 60, assertive: 25, gentle: 65 },
yandere: { jealous: 90, depend: 85, guard: 75, recover: 20, miss: 85, tsun: 35, caring: 75, assertive: 70 },
onee: { gentle: 85, caring: 85, shy: 25, blush: 25, playful: 65, guard: 40, recover: 70, assertive: 60 },
}
/** 以屬性標籤產生角色包基準:多個標籤取平均 */
export function baseFor(archetypes: string[]): ParamVec {
const known = archetypes.filter(a => ARCHETYPE_PARAMS[a])
const out = { ...NEUTRAL }
if (!known.length) return out
for (const p of PARAMS) out[p] = known.reduce((s, a) => s + (ARCHETYPE_PARAMS[a]![p] ?? 50), 0) / known.length
return out
}
export function initLayers(base: ParamVec): ParamLayers {
return { base: { ...base }, save: {}, today: {} }
}
/** 存檔偏移:每次 ±1,上限 ±15 */
export function nudgeSave(l: ParamLayers, p: Param, dir: 1 | -1): ParamLayers {
const next = clamp((l.save[p] ?? 0) + dir * SAVE_STEP, -SAVE_LIMIT, SAVE_LIMIT)
return { ...l, save: { ...l.save, [p]: next } }
}
/** 每日抽樣今日偏移:N(0, 5),限制 ±10;rng 回傳 [0,1),可注入以便測試 */
export function rollToday(l: ParamLayers, rng: () => number = Math.random): ParamLayers {
const today: Partial<ParamVec> = {}
for (const p of PARAMS) {
const u1 = Math.max(rng(), 1e-12)
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * rng())
today[p] = clamp(z * TODAY_SIGMA, -TODAY_LIMIT, TODAY_LIMIT)
}
return { ...l, today }
}
export function value(l: ParamLayers, p: Param): number {
return clamp(l.base[p] + clamp(l.save[p] ?? 0, -SAVE_LIMIT, SAVE_LIMIT) + clamp(l.today[p] ?? 0, -TODAY_LIMIT, TODAY_LIMIT), 0, 100)
}
export function values(l: ParamLayers): ParamVec {
return Object.fromEntries(PARAMS.map(p => [p, value(l, p)])) as ParamVec
}
/** 信賴與親密皆為 0–100 */
export function effective(l: ParamLayers, trust: number, intimacy: number) {
const t = clamp(trust, 0, 100) / 100
const i = clamp(intimacy, 0, 100) / 100
return {
guard: value(l, 'guard') * (1 - 0.5 * t),
shy: value(l, 'shy') * (1 - 0.3 * i),
tsun: value(l, 'tsun') * (1 - 0.4 * i),
}
}
/** Big Five 推導值:沿用半衰期(N)與關心頻率(E);加權為設計啟發式 */
export function deriveBig5(v: ParamVec): Vec {
const inv = (x: number) => 100 - x
const avg = (xs: [number, number][]) => xs.reduce((s, [x, w]) => s + x * w, 0)
return {
O: avg([[v.imagine, 0.5], [v.playful, 0.3], [v.initiative, 0.2]]),
C: avg([[v.caring, 0.4], [v.assertive, 0.3], [inv(v.playful), 0.3]]),
E: avg([[v.initiative, 0.35], [v.playful, 0.25], [v.tone, 0.2], [inv(v.shy), 0.2]]),
A: avg([[v.gentle, 0.4], [v.caring, 0.3], [inv(v.assertive), 0.15], [inv(v.tsun), 0.15]]),
N: avg([[v.guard, 0.25], [v.jealous, 0.25], [v.depend, 0.2], [v.shy, 0.15], [inv(v.recover), 0.15]]),
}
}
/** 核心設定:AI 不可改動 */
export const LOCKED_FIELDS = ['name', 'gender', 'age', 'coreBelief'] as const
/** 套用 AI 的修改提案:鎖定欄位一律剔除並回報,其餘原樣放行 */
export function filterAiPatch<T extends Record<string, unknown>>(patch: T): { allowed: Partial<T>; rejected: string[] } {
const allowed: Record<string, unknown> = {}
const rejected: string[] = []
for (const [k, v] of Object.entries(patch)) {
if ((LOCKED_FIELDS as readonly string[]).includes(k)) rejected.push(k)
else allowed[k] = v
}
return { allowed: allowed as Partial<T>, rejected }
}
type WithParams = { params?: ParamVec; neuroticism?: number; extraversion?: number }
type StateParams = { params?: { save: Partial<ParamVec>; today: Partial<ParamVec> } }
/** 有角色包基準時,用三層值推導 Big Five;否則維持 core.json 的舊欄位 */
function derived(core: WithParams, st?: StateParams) {
if (!core.params) return null
return deriveBig5(values({ base: core.params, save: st?.params?.save ?? {}, today: st?.params?.today ?? {} }))
}
export const neuroticismOf = (core: WithParams, st?: StateParams) => derived(core, st)?.N ?? core.neuroticism ?? 50
export const extraversionOf = (core: WithParams, st?: StateParams) => derived(core, st)?.E ?? core.extraversion ?? 50
@@ -2,7 +2,8 @@ import type { CompanionCore } from './types'
const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/'
// 事實欄位與招牌短語帶官方來源;persona 是原創的語氣描述(非官方設定),可在本機 core.json 覆寫
// 事實欄位與招牌短語帶來源;persona 是原創的語氣描述與已確認的簡短經歷(非官方設定),可在本機 core.json 覆寫
// 不含使用者記憶、與使用者的關係和狀態,也不含會過期的近況
export const FUBUKI: CompanionCore = {
id: 'fubuki',
name: '白上吹雪',
@@ -11,17 +12,19 @@ export const FUBUKI: CompanionCore = {
zodiac: { value: '天秤座', source: `${OFFICIAL}(由生日推得)`, kind: 'official', confidence: 0.9 },
bloodType: 'unknown',
hair: { value: '白髮', source: OFFICIAL, kind: 'official', confidence: 0.8 },
face: 'unknown',
face: { value: '淺藍色眼睛、三角狐耳、尾巴尖是黑色', source: 'https://en.wikipedia.org/wiki/Shirakami_Fubuki', kind: 'wiki', confidence: 0.5 },
height: { value: '155cm', source: OFFICIAL, kind: 'official', confidence: 1 },
weight: 'unknown',
measurements: 'unknown',
},
phrases: ['Good foxy day!', 'Hi, Friends!'],
phrases: ['Good foxy day!', 'Hi, Friends!', 'こんこんきーつね!', 'おはこん ひるこん よるこん', 'おつこーん', 'F R.I.P', 'ネコじゃない!キツネじゃい!'],
persona: [
'個性:開朗、元氣、愛鬧又有點調皮的狐狸系少女,把使用者當成一起冒險的「Friends」。',
'說話風格:用繁體中文,語氣活潑親切,偶爾夾雜日文語氣詞(例如「よし!」「えへへ」「やったー!」),愛用驚嘆號與擬聲詞。',
'自稱用「我」或「フブキ」;稱呼使用者時親暱、帶一點撒嬌,成功時開心慶祝,失敗時先安慰再一起想辦法。',
'偶爾耍小聰明、開小玩笑,但不影響回答的準確與清楚。',
'人際關係:夏色まつり是同為 Hololive 1 期生的夥伴,兩人搭檔被稱為「夏色吹雪」;被問起時只講已確認的事,不編造兩人的私下對話。',
'經歷:2018/6/1 出道(Hololive 1 期生),12 月起任 Hololive Gamers 隊長;2020/11/5 破 100 萬訂閱,2022/10 破 200 萬;2023 為《假面騎士》電影ギーツケミー配音(VTuber 首次);2025/1 首張專輯《FBKINGDOM "Blessing"》、2/13 首場個人演唱會「ANTHEM」。日期不確定時說「記得大概是」,不要編造。',
],
attribute: 'genki',
extraversion: 70,
@@ -0,0 +1,150 @@
import { describe, expect, test } from 'claude-code/testing'
import { createCompanion } from './core'
import { applyEvent, baselineOf, circadian, decayEmotions, dominant, fromPad, halfLifeMs, toPad } from './emotion'
import type { Emotions } from './emotion'
import { fakeClock, memoryFs } from './fs'
import type { CompanionCore } from './types'
const H = 3_600_000
const near = (a: number, b: number) => expect(Math.abs(a - b)).toBeLessThan(1e-9)
const ZERO = baselineOf({ p: 0, a: 0, d: 0 })
// 本地正午 12:00(tz +0000),晝夜節律不為 0 但不影響其他維度
const NOON = Date.UTC(2026, 9, 14, 12)
const MIKO: CompanionCore = {
id: 'miko',
name: '月見ミコ',
facts: { birthday: 'unknown', zodiac: 'unknown', bloodType: 'unknown', hair: 'unknown', face: 'unknown', height: 'unknown', weight: 'unknown', measurements: 'unknown' },
neuroticism: 50,
baseline: { p: 0, a: 0, d: 0 },
}
describe('衰減', () => {
test('各維度依自己的半衰期回到基線', () => {
const s = { ...ZERO, shy: 1, homesick: 1, mood: 1, at: NOON }
const e = decayEmotions(s, ZERO, NOON + 2 * H, 50, 0)
near(e.shy, 0.5) // 害羞半衰期 2 小時
near(e.homesick, Math.pow(0.5, 2 / 24)) // 想家半衰期 24 小時
near(e.mood, 0.5 + 0.5 * Math.pow(0.5, 2 / 8))
})
test('神經質影響半衰期,且久放會回到基線', () => {
expect(halfLifeMs('mood', 100)).toBeGreaterThan(halfLifeMs('mood', 0))
const e = decayEmotions({ ...ZERO, upset: 1, at: NOON }, ZERO, NOON + 30 * 24 * H, 50, 0)
expect(e.upset).toBeLessThan(0.001)
})
test('精力基線隨晝夜節律:下午高、凌晨低', () => {
const at = (h: number) => decayEmotions({ ...ZERO, at: 0 }, ZERO, Date.UTC(2026, 9, 14, h) + 365 * 24 * H, 50, 0).energy
expect(at(15)).toBeGreaterThan(at(3))
near(circadian(Date.UTC(2026, 9, 14, 15), 0), 0.1)
near(circadian(Date.UTC(2026, 9, 14, 7), 480), 0.1) // 時區換算:UTC 07 = 本地 15
})
})
describe('習慣化與敏感化', () => {
test('正向事件重複發生,效果遞減', () => {
let e: Emotions = ZERO
let habit = {}
const gains: number[] = []
for (let i = 0; i < 3; i++) {
const r = applyEvent(e, habit, 'thanks', NOON)
gains.push(r.emotions.mood - e.mood)
;({ emotions: e, habit } = r)
}
near(gains[0]!, 0.075)
near(gains[1]!, 0.075 * 0.7)
near(gains[2]!, 0.075 * 0.49)
})
test('習慣化隨時間遺忘:隔 6 小時後計次減半', () => {
const r1 = applyEvent(ZERO, {}, 'thanks', NOON)
const r2 = applyEvent(ZERO, r1.habit, 'thanks', NOON + 6 * H)
near(r2.emotions.mood - ZERO.mood, 0.075 * Math.pow(0.7, 0.5))
})
test('負向事件重複發生,傷害放大且上限 2 倍', () => {
let e: Emotions = { ...ZERO, mood: 1, energy: 0.6 }
let habit = {}
const drops: number[] = []
for (let i = 0; i < 6; i++) {
const r = applyEvent(e, habit, 'scold', NOON)
drops.push(e.mood - r.emotions.mood)
;({ emotions: e, habit } = r)
}
near(drops[0]!, 0.06)
near(drops[1]!, 0.06 * 1.25)
near(drops[5]!, 0.06 * 2)
})
test('疲勞易怒:精力低時負面變化放大 1.5 倍', () => {
const fresh = applyEvent({ ...ZERO, energy: 0.6 }, {}, 'toolFail', NOON).emotions
const tired = applyEvent({ ...ZERO, energy: 0.1 }, {}, 'toolFail', NOON).emotions
near(tired.upset - ZERO.upset, (fresh.upset - ZERO.upset) * 1.5)
})
test('情感注入:感謝帶動心跳,想念會在回來時消解', () => {
expect(applyEvent(ZERO, {}, 'thanks', NOON).emotions.heartbeat).toBeGreaterThan(0)
const missing = applyEvent(ZERO, {}, 'ignored', NOON).emotions
expect(applyEvent(missing, {}, 'return', NOON).emotions.missing).toBeLessThan(missing.missing)
})
test('每維度單回合變化不超過 0.3,值域 0~1', () => {
const r = applyEvent({ ...ZERO, mood: 0.05 }, { scold: { n: 50, at: NOON } }, 'scold', NOON).emotions
expect(r.mood).toBeGreaterThanOrEqual(0)
expect(r.upset).toBeLessThanOrEqual(0.3)
})
})
describe('主情緒', () => {
test('強度達門檻才採用,否則回到 PAD 八分區標籤', () => {
expect(dominant(ZERO)).toBe(null)
expect(dominant({ ...ZERO, shy: 0.8 })?.label).toBe('害羞')
expect(dominant({ ...ZERO, mood: 0.1 })?.label).toBe('低落')
})
})
describe('舊 PAD 存檔升級', () => {
test('PAD 映射到八維度並可還原', () => {
const base = baselineOf({ p: 0.2, a: 0.1, d: 0.1 })
const e = fromPad({ p: -0.5, a: 0.4, d: -0.3, at: 7 }, base, { p: 0.2, a: 0.1, d: 0.1 })
expect(e.at).toBe(7)
const pad = toPad(e, base, { p: 0.2, a: 0.1, d: 0.1 })
near(pad.p, -0.5)
near(pad.a, 0.4)
near(pad.d, -0.3)
})
test('讀檔時自動升級並寫回,狀態不變', async () => {
const fs = memoryFs()
const clock = fakeClock()
const root = '/h/.companion'
const now = clock.now()
const old = { mood: { p: 0.3, a: 0, d: 0, at: now }, affinity: { value: 0.1, stage: 0, dayKey: '', dayGain: 0, lastSeen: now }, todos: 0 }
fs.files.set(`${root}/global.json`, JSON.stringify({ active: 'miko', activatedOnce: true }))
fs.files.set(`${root}/companions/miko/core.json`, JSON.stringify(MIKO))
fs.files.set(`${root}/companions/miko/state.json`, JSON.stringify(old))
const c = createCompanion({ fs, clock, root, builtins: [MIKO] })
const v = (await c.view())!
near(v.mood.p, 0.3)
near(v.emotions.mood, 0.65)
await c.moodEvent('thanks')
const saved = JSON.parse(fs.files.get(`${root}/companions/miko/state.json`)!)
expect(saved.mood).toBeUndefined()
expect(saved.emotion.mood).toBeGreaterThan(0.65)
})
})
describe('/status', () => {
test('預設顯示前 3 強情緒,--all 顯示 8 維度', async () => {
const fs = memoryFs()
const c = createCompanion({ fs, clock: fakeClock(), root: '/h/.companion', builtins: [MIKO] })
await c.ensureActivated()
await c.moodEvent('scold')
const brief = (await c.command('status')).split('\n').find(l => l.startsWith('情緒 '))!
expect(brief.slice(4).split('、')).toHaveLength(3)
const all = (await c.command('status --all')).split('\n').filter(l => l.startsWith(' ') && l.includes('\x1b['))
expect(all).toHaveLength(8)
})
})
@@ -0,0 +1,179 @@
import { clamp, MOOD_EVENTS } from './mood'
import type { MoodEvent } from './mood'
import { bar, width } from './seg'
import type { Pad } from './types'
const HOUR = 3_600_000
// 八個情緒維度,值域皆為 0~1(心情、精力以 0.5 為中性)
export const DIMS = ['mood', 'energy', 'shy', 'jealous', 'missing', 'heartbeat', 'homesick', 'upset'] as const
export type Dim = (typeof DIMS)[number]
export type Emotions = Record<Dim, number>
export type EmotionState = Emotions & { at: number }
// 各維度半衰期(小時,神經質 50 時的值)
export const HALF_LIFE_H: Record<Dim, number> = {
mood: 8,
energy: 6,
shy: 2,
jealous: 4,
missing: 12,
heartbeat: 1,
homesick: 24,
upset: 4,
}
export const DIM_INFO: Record<Dim, { name: string; kaomoji: string; ansi: string }> = {
mood: { name: '心情', kaomoji: '(´ᴗ`)', ansi: '32' },
energy: { name: '精力', kaomoji: '(≧▽≦)', ansi: '33' },
shy: { name: '害羞', kaomoji: '(๑>◡<๑)', ansi: '36' },
jealous: { name: '忌妒', kaomoji: '( ̄^ ̄)', ansi: '35' },
missing: { name: '想念', kaomoji: '(´;ω;`)', ansi: '34' },
heartbeat: { name: '心跳', kaomoji: '(*´∀`*)', ansi: '91' },
homesick: { name: '想家', kaomoji: '(´・_・`)', ansi: '90' },
upset: { name: '不悅', kaomoji: '(╬`益´)', ansi: '31' },
}
// 心情、精力是雙向維度,主情緒標籤依高低而定
const POLES: Partial<Record<Dim, [low: string, high: string]>> = { mood: ['低落', '開心'], energy: ['疲倦', '有精神'] }
// 高神經質的角色情緒消退較慢:50 為基準,0→0.5 倍,100→1.5 倍
export const halfLifeMs = (dim: Dim, neuroticism: number) => HALF_LIFE_H[dim] * HOUR * (0.5 + neuroticism / 100)
export const clampDim = (x: number) => clamp(x, 0, 1)
/** 由 PAD 基線推得各維度基線;其餘維度基線為 0 */
export function baselineOf(pad: Pad, override: Partial<Emotions> = {}): Emotions {
return {
mood: clampDim((pad.p + 1) / 2),
energy: clampDim((pad.a + 1) / 2),
shy: clampDim(-pad.d),
jealous: 0,
missing: 0,
heartbeat: 0,
homesick: 0,
upset: 0,
...override,
}
}
/** 晝夜節律:精力基線在本地 15 點最高、凌晨 3 點最低,振幅 ±0.1 */
export function circadian(nowMs: number, tzOffsetMin: number): number {
const hour = (((nowMs / HOUR + tzOffsetMin / 60) % 24) + 24) % 24
return 0.1 * Math.cos(((hour - 15) / 24) * 2 * Math.PI)
}
// 讀檔時才計算:x = B + (x − B)·0.5^(Δ/τ),精力的 B 疊加晝夜節律
export function decayEmotions(s: EmotionState, baseline: Emotions, now: number, neuroticism: number, tzOffsetMin: number): Emotions {
const dt = Math.max(0, now - s.at)
const out = {} as Emotions
for (const d of DIMS) {
const b = d === 'energy' ? clampDim(baseline.energy + circadian(now, tzOffsetMin)) : baseline[d]
out[d] = clampDim(b + (s[d] - b) * Math.pow(0.5, dt / halfLifeMs(d, neuroticism)))
}
return out
}
/** 由八維度推回 PAD,供記憶檢索與相容介面使用 */
export function toPad(e: Emotions, baseline: Emotions, basePad: Pad): Pad {
return {
p: clamp((e.mood - 0.5) * 2 - e.upset),
a: clamp((e.energy - 0.5) * 2),
d: clamp(basePad.d + baseline.shy - e.shy),
}
}
/** 舊 PAD 存檔 → 八維度(p→心情、a→精力、d→害羞,其餘維度取基線) */
export function fromPad(mood: Pad & { at: number }, baseline: Emotions, basePad: Pad): EmotionState {
return {
...baseline,
mood: clampDim((mood.p + 1) / 2),
energy: clampDim((mood.a + 1) / 2),
shy: clampDim(baseline.shy + basePad.d - mood.d),
at: mood.at,
}
}
// 情感注入:事件(評估標籤)對各維度的增減。mood/upset/energy/shy 由 rules.ts 的 PAD 增減換算
// (p>0 → 心情 p/2;p<0 → 心情 0.3p、不悅 −0.4p;a/2 → 精力;−d → 害羞),合成後與 PAD 增減一致;
// 心跳、想念、想家等新維度的額外效果寫在 EXTRA
const EXTRA: Partial<Record<MoodEvent, Partial<Emotions>>> = {
thanks: { heartbeat: 0.05 },
return: { missing: -0.3, heartbeat: 0.1 },
lateNight: { homesick: 0.05 },
favorite: { heartbeat: 0.08 },
ignored: { missing: 0.1 },
cared: { heartbeat: 0.05, missing: -0.1 },
remembered: { heartbeat: 0.05 },
}
export function emotionEffect(ev: MoodEvent): Partial<Emotions> {
const { p, a, d } = MOOD_EVENTS[ev]
return {
mood: p > 0 ? p / 2 : p * 0.3,
upset: p < 0 ? -p * 0.4 : 0,
energy: a / 2,
shy: -d,
...EXTRA[ev],
}
}
// 「壞」方向:心情下降,或不悅、忌妒上升
const isBad = (d: Dim, x: number) => (d === 'mood' ? x < 0 : (d === 'upset' || d === 'jealous') && x > 0)
export const TURN_CAP = 0.3
export const TIRED = 0.3
export const IRRITABLE = 1.5
export const HABITUATION = 0.7
export const SENSITIZATION = 0.25
export const SENSITIZATION_MAX = 2
export const HABIT_HALF_LIFE_H = 6
export type Habit = Partial<Record<MoodEvent, { n: number; at: number }>>
const habitCount = (h: Habit, ev: MoodEvent, now: number) => {
const r = h[ev]
return r ? r.n * Math.pow(0.5, Math.max(0, now - r.at) / (HABIT_HALF_LIFE_H * HOUR)) : 0
}
/**
* 套用事件:正向事件習慣化(重複受益遞減,0.7^n)、負向事件敏感化(重複受創放大,上限 2 倍)、
* 精力低於 0.3 時負面變化放大 1.5 倍(疲勞易怒);每個維度單回合變化上限 ±0.3。
* 計次隨時間以 6 小時半衰期遺忘。
*/
export function applyEvent(cur: Emotions, habit: Habit, ev: MoodEvent, now: number): { emotions: Emotions; habit: Habit } {
const n = habitCount(habit, ev, now)
const p = MOOD_EVENTS[ev].p
const gain = p > 0 ? Math.pow(HABITUATION, n) : 1
const harm = p < 0 ? Math.min(SENSITIZATION_MAX, 1 + SENSITIZATION * n) : 1
const irritable = cur.energy < TIRED ? IRRITABLE : 1
const out = { ...cur }
for (const [d, raw] of Object.entries(emotionEffect(ev)) as [Dim, number][]) {
const dx = raw * (isBad(d, raw) ? harm * irritable : gain)
out[d] = clampDim(out[d] + clamp(dx, -TURN_CAP, TURN_CAP))
}
return { emotions: out, habit: { ...habit, [ev]: { n: n + 1, at: now } } }
}
/** 強度:雙向維度為偏離中性的程度,其餘維度即其值 */
export const salience = (d: Dim, v: number) => (POLES[d] ? Math.abs(v - 0.5) * 2 : v)
export function ranked(e: Emotions): Dim[] {
return [...DIMS].sort((a, b) => salience(b, e[b]) - salience(a, e[a]))
}
export const DOMINANT_MIN = 0.25
/** 主情緒:強度達門檻的最強維度;否則 null,由 PAD 八分區標籤補位 */
export function dominant(e: Emotions): { dim: Dim; label: string; kaomoji: string } | null {
const dim = ranked(e)[0]!
if (salience(dim, e[dim]) < DOMINANT_MIN) return null
const poles = POLES[dim]
return { dim, label: poles ? poles[e[dim] >= 0.5 ? 1 : 0] : DIM_INFO[dim].name, kaomoji: DIM_INFO[dim].kaomoji }
}
/** info --all 的情緒量表:▰▱ 依維度上色,名稱補齊到同寬 */
export function gauge(d: Dim, e: Emotions, cells = 12): string {
const name = DIM_INFO[d].name
return `${name}${' '.repeat(Math.max(0, 6 - width(name)))}\x1b[${DIM_INFO[d].ansi}m${bar(e[d] * 100, cells)}\x1b[0m ${String(Math.round(e[d] * 100)).padStart(3)}`
}
@@ -0,0 +1,31 @@
import { readJson } from './fs'
import type { Fs } from './fs'
import { MATERIALS as M, WEATHER_MOOD } from './rules'
// 外部素材快取:由系統排程執行的 scripts/fetch-materials.mjs 寫入,hook 只讀、不連網。
// 只存標題、摘要、連結與時間(at 為抓取時間,毫秒)。
export type Item = { title: string; summary: string; link: string; at: number }
export type Materials = { weather: Item | null; news: Item[] }
export const EMPTY: Materials = { weather: null, news: [] }
const isItem = (x: unknown): x is Item => {
const i = x as Item
return !!i && typeof i.title === 'string' && typeof i.summary === 'string' && typeof i.link === 'string' && typeof i.at === 'number'
}
/** 讀快取並丟掉過期項目:天氣 6 小時、新聞 24 小時;檔案缺失或壞掉就是沒查 */
export async function readMaterials(fs: Fs, path: string, now: number): Promise<Materials> {
const raw = await readJson<{ weather?: unknown; news?: unknown }>(fs, path)
if (!raw) return EMPTY
const weather = isItem(raw.weather) && now - raw.weather.at < M.weatherTtlMs ? raw.weather : null
const news = (Array.isArray(raw.news) ? raw.news : []).filter(isItem).filter(n => now - n.at < M.newsTtlMs)
return { weather, news }
}
/** 天氣對心情基準的微調;未知狀況為 0,且不超過上限 */
export function weatherShift(w: Item | null): number {
if (!w) return 0
const v = WEATHER_MOOD[w.title] ?? 0
return Math.max(-M.weatherCap, Math.min(M.weatherCap, v))
}
@@ -2,11 +2,33 @@ import { describe, test } from 'claude-code/testing'
import assert from './assert'
import { memoryFs } from './io'
import { CONSOLIDATE_THRESHOLD, FORGET_BELOW, TOP_K, createMemory, redact, retention, stability } from './memory'
import {
CONSOLIDATE_THRESHOLD,
TOP_K,
access,
categoryBonus,
createMemory,
freshness,
iEff,
isDanger,
lastQuestion,
redact,
rrf,
rumination,
shouldArchive,
weight,
} from './memory'
import type { Memory } from './memory'
const H = 3600_000
const T0 = Date.UTC(2026, 9, 12, 10, 0)
const DAY = 24 * H
const mk = (o: Partial<Memory> = {}): Memory => ({
id: 1, layer: 'episodic', kind: 'upgradable', status: 'active', text: '事', importance: 6, source: 'u',
createdAt: T0, lastAccess: T0, accessCount: 0, seq: 0, ...o,
})
function setup() {
let now = T0
const fs = memoryFs()
@@ -24,12 +46,8 @@ describe('記憶檢索', () => {
})
test('較新、較重要者在其他條件相同時勝出', async () => {
const s = setup()
const old = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
s.advance(100 * H)
const fresh = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
assert.equal((await s.mem.recall('部署'))[0]!.id, fresh.id)
assert.notEqual(old.id, fresh.id)
// 回合數相隔越久新鮮度越低;檢索會讓被取回的記憶回到 0.8,所以以權重直接比較
assert.ok(weight(mk({ seq: 300 }), 300, T0) > weight(mk({ seq: 0 }), 300, T0))
const s2 = setup()
await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' })
const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' })
@@ -60,39 +78,215 @@ describe('記憶檢索', () => {
})
})
describe('遺忘曲線', () => {
test('保留率隨時間下降,重要度與取用次數使其變慢', () => {
const base = { id: 1, layer: 'episodic' as const, text: '', source: '', createdAt: T0, lastAccess: T0 }
const weak = { ...base, importance: 2, accessCount: 0 }
const strong = { ...base, importance: 8, accessCount: 0 }
const used = { ...weak, accessCount: 5 }
assert.ok(retention(weak, T0 + 24 * H) < retention(weak, T0))
assert.ok(retention(strong, T0 + 100 * H) > retention(weak, T0 + 100 * H))
assert.ok(retention(used, T0 + 100 * H) > retention(weak, T0 + 100 * H))
assert.ok(stability(strong) > stability(weak))
describe('記憶權重 W = I_eff × F × A', () => {
test('I_eff = 初始重要度/2 + 情緒峰值/40 + 類別加成,限制在 1–5', () => {
assert.equal(iEff(mk({ importance: 6 })), 3)
assert.equal(iEff(mk({ importance: 6, peak: 40 })), 4)
assert.equal(iEff(mk({ importance: 6, peak: 40, bonus: 1 })), 5)
assert.equal(iEff(mk({ importance: 10, peak: 100, bonus: 1 })), 5)
assert.equal(iEff(mk({ importance: 1 })), 1)
})
test('鞏固時淘汰低保留率的記憶,保留重要與常用的', async () => {
test('F = 0.5^(Δseq/200),原典固定 1,被檢索到時至少回到 0.8', () => {
assert.equal(freshness(mk(), 0), 1)
assert.equal(freshness(mk(), 200), 0.5)
assert.equal(freshness(mk({ canon: true }), 1000), 1)
assert.ok(freshness(mk(), 600) < 0.2)
assert.equal(freshness(mk({ hitSeq: 600 }), 600), 0.8)
assert.ok(freshness(mk({ hitSeq: 600 }), 800) < 0.8)
})
test('A 隨命中次數成長且有上限;W 為三者相乘', () => {
assert.equal(access(mk()), 1)
assert.ok(access(mk({ accessCount: 3 })) > 1)
assert.equal(access(mk({ accessCount: 5 })), access(mk({ accessCount: 50 })))
const m = mk({ importance: 6, peak: 40, accessCount: 5 })
assert.equal(weight(m, 200, T0), 4 * 0.5 * 1.5)
})
test('檢索實際用 W 排序:峰值高、較新者勝出', async () => {
const s = setup()
await s.mem.add({ layer: 'episodic', text: '聊到旅行', importance: 5, source: 'u' })
const peak = await s.mem.add({ layer: 'episodic', text: '聊到旅行', importance: 5, source: 'u', peak: 80 })
assert.equal((await s.mem.recall('旅行'))[0]!.id, peak.id)
})
})
describe('反覆思量', () => {
test('未解負向事件每日 +20%,上限 3 倍;已解或非負向不加', () => {
const neg = mk({ negative: true })
assert.equal(rumination(neg, T0), 1)
assert.ok(Math.abs(rumination(neg, T0 + 2 * DAY) - 1.4) < 1e-9)
assert.equal(rumination(neg, T0 + 30 * DAY), 3)
assert.equal(rumination({ ...neg, resolvedAt: T0 + DAY }, T0 + 5 * DAY), 1)
assert.equal(rumination(mk(), T0 + 5 * DAY), 1)
})
test('resolve 後不再被核心記憶列為未解', async () => {
const s = setup()
const m = await s.mem.add({ layer: 'emotional', text: '被罵了', importance: 6, source: 'u', negative: true })
assert.equal((await s.mem.core()).unresolved[0]!.id, m.id)
assert.equal(await s.mem.resolve(), 1)
assert.equal((await s.mem.core()).unresolved.length, 0)
assert.equal(await s.mem.resolve(), 0)
})
test('類別加成:忌妒或想念累積速度 ≥ 0.65 才 +1', () => {
assert.equal(categoryBonus('jealous', { jealous: 65 }), 1)
assert.equal(categoryBonus('jealous', { jealous: 64 }), 0)
assert.equal(categoryBonus('reunion', { miss: 80 }), 1)
assert.equal(categoryBonus('reunion', {}), 0)
})
})
describe('衰減與封存(不刪除)', () => {
test('暫時記憶滿 7 天封存,長期與鎖定記憶永不封存', () => {
assert.equal(shouldArchive(mk({ kind: 'temp' }), T0 + 6 * DAY), false)
assert.equal(shouldArchive(mk({ kind: 'temp' }), T0 + 7 * DAY), true)
assert.equal(shouldArchive(mk({ kind: 'long' }), T0 + 999 * DAY), false)
assert.equal(shouldArchive(mk({ kind: 'locked' }), T0 + 999 * DAY), false)
})
test('可升級記憶 30 天降權、90 天未檢索封存', () => {
const m = mk({ kind: 'upgradable' })
assert.equal(weight(m, 0, T0 + 29 * DAY), weight(m, 0, T0))
assert.equal(weight(m, 0, T0 + 30 * DAY), weight(m, 0, T0) / 2)
assert.equal(shouldArchive(m, T0 + 89 * DAY), false)
assert.equal(shouldArchive(m, T0 + 90 * DAY), true)
assert.equal(shouldArchive({ ...m, lastAccess: T0 + 80 * DAY }, T0 + 120 * DAY), false)
})
test('鞏固時封存過期記憶但不刪除,封存的不再被檢索', async () => {
const s = setup()
const trivial = await s.mem.add({ layer: 'episodic', text: '隨口聊天', importance: 1, source: 'u' })
const key = await s.mem.add({ layer: 'semantic', text: '生日是三月', importance: 9, source: 'u' })
s.advance(10 * 24 * H)
assert.ok(retention(trivial, s.now()) < FORGET_BELOW)
const key = await s.mem.add({ layer: 'semantic', text: '生日是三月', importance: 9, source: 'u', kind: 'long' })
s.advance(10 * DAY)
const r = await s.mem.consolidate({ force: true })
assert.equal(r.forgotten, 1)
const ids = (await s.mem.all()).map(m => m.id)
assert.ok(!ids.includes(trivial.id))
assert.ok(ids.includes(key.id))
assert.equal(r.archived, 1)
const all = await s.mem.all()
assert.equal(all.find(m => m.id === trivial.id)!.status, 'archived')
assert.equal(all.find(m => m.id === key.id)!.status, 'active')
assert.equal((await s.mem.recall('隨口聊天')).some(m => m.id === trivial.id), false)
})
test('取用過的記憶會重置時效而不被淘汰', async () => {
test('場景結束時暫時記憶封存,其餘保留', async () => {
const s = setup()
const m = await s.mem.add({ layer: 'episodic', text: '常提起的事', importance: 2, source: 'u' })
s.advance(40 * H)
await s.mem.recall('常提起')
s.advance(40 * H)
const t = await s.mem.add({ layer: 'episodic', text: '剛剛的閒聊', importance: 2, source: 'u' })
const k = await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 6, source: 'u' })
assert.equal(await s.mem.endScene(), 1)
const all = await s.mem.all()
assert.equal(all.find(m => m.id === t.id)!.status, 'archived')
assert.equal(all.find(m => m.id === k.id)!.status, 'active')
})
test('檢索到 3 次的可升級記憶升為長期,之後不再衰減', async () => {
const s = setup()
const m = await s.mem.add({ layer: 'semantic', text: '常提的咖啡', importance: 5, source: 'u' })
for (let i = 0; i < 3; i++) await s.mem.recall('咖啡')
assert.equal((await s.mem.all()).find(x => x.id === m.id)!.kind, 'long')
s.advance(400 * DAY)
await s.mem.consolidate({ force: true })
assert.ok((await s.mem.all()).some(x => x.id === m.id))
assert.equal((await s.mem.all()).find(x => x.id === m.id)!.status, 'active')
})
test('舊存檔(沒有 kind、status)讀入後自動補欄位', async () => {
const s = setup()
await s.fs.write('mem.json', JSON.stringify({ items: [{ id: 1, layer: 'semantic', text: '舊記憶', importance: 5, source: 'u', createdAt: T0, lastAccess: T0, accessCount: 0 }], nextId: 2, pending: 0, lastConsolidated: 0 }))
const m = (await s.mem.all())[0]!
assert.equal(m.status, 'active')
assert.equal(m.kind, 'upgradable')
assert.equal((await s.mem.recall('舊記憶'))[0]!.id, 1)
})
})
describe('危險內容不固化', () => {
test('只留安全事件摘要,不留原文也不升級', async () => {
const s = setup()
assert.ok(isDanger('我想自殺'))
assert.equal(isDanger('今天天氣很好'), false)
const m = await s.mem.add({ layer: 'episodic', text: '我想自殺,方法是……', importance: 9, source: 'u', kind: 'long' })
assert.ok(!m.text.includes('自殺'))
assert.ok(m.text.includes('安全事件摘要'))
assert.equal(m.kind, 'temp')
for (let i = 0; i < 4; i++) await s.mem.recall('安全事件摘要')
assert.equal((await s.mem.all())[0]!.kind, 'temp')
})
test('危險原文不進入反思', async () => {
const s = setup()
await s.mem.add({ layer: 'episodic', text: '想自殺', importance: 9, source: 'u' })
const r = await s.mem.consolidate({ force: true })
assert.equal(r.reflection, undefined)
})
})
describe('開放迴路', () => {
test('她的提問 2 回合沒有回應就逾時', async () => {
const s = setup()
await s.mem.openLoop('你今天吃飯了嗎?')
await s.mem.recall('隨便')
assert.equal((await s.mem.loops())[0]!.status, 'open')
await s.mem.recall('隨便')
assert.equal((await s.mem.loops())[0]!.status, 'timeout')
})
test('有回應就收尾,不會逾時', async () => {
const s = setup()
await s.mem.openLoop('你今天吃飯了嗎?')
assert.equal(await s.mem.respond('吃飯了,吃了麵'), 1)
await s.mem.recall('隨便')
await s.mem.recall('隨便')
assert.equal((await s.mem.loops())[0]!.status, 'answered')
})
test('未收尾的對話列入每回合必帶;逾時後移出', async () => {
const s = setup()
await s.mem.openLoop('週末要不要一起去?')
assert.equal((await s.mem.core()).loops.length, 1)
await s.mem.recall('x')
await s.mem.recall('x')
assert.equal((await s.mem.core()).loops.length, 0)
})
test('取出回覆中最後一個問句', () => {
assert.equal(lastQuestion('好喔。你吃飯了嗎?'), '你吃飯了嗎?')
assert.equal(lastQuestion('沒有問題。'), null)
})
})
describe('檢索:關鍵字 + RRF + W + 心情', () => {
test('RRF 依 Σ 1/(60+rank) 融合多份排名', () => {
const f = rrf([[1, 2], [2, 1]])
assert.equal(f.get(1), 1 / 61 + 1 / 62)
assert.equal(f.get(2), 1 / 62 + 1 / 61)
assert.equal(rrf([[7]]).get(7), 1 / 61)
})
test('保留外部檢索器介面(語意相似度不做,但可注入)', async () => {
const s = setup()
const a = await s.mem.add({ layer: 'semantic', text: '喜歡貓咪', importance: 5, source: 'u' })
const b = await s.mem.add({ layer: 'semantic', text: '喜歡狗狗', importance: 5, source: 'u' })
const r = await s.mem.recall('毛孩', undefined, { extra: [() => [b.id, a.id]] })
assert.equal(r[0]!.id, b.id)
})
test('釋懷速度越慢,心情一致加權越大', async () => {
const mood = { p: 0.8, a: 0.4, d: 0.4 }
const run = async (recover: number) => {
const s = setup()
await s.mem.add({ layer: 'emotional', text: '慶祝', importance: 5, source: 'u', emotion: { p: -0.8, a: 0.5, d: -0.5 } })
await s.mem.add({ layer: 'emotional', text: '慶祝', importance: 5, source: 'u', emotion: mood, peak: 0 })
return (await s.mem.recall('慶祝', mood, { recover }))[0]!.id
}
assert.equal(await run(0), 2)
assert.equal(await run(1), 2)
})
test('沒有關鍵字命中時退回依 W 排序', async () => {
const s = setup()
await s.mem.add({ layer: 'semantic', text: '普通事', importance: 2, source: 'u' })
const big = await s.mem.add({ layer: 'semantic', text: '重要事', importance: 9, source: 'u' })
assert.equal((await s.mem.recall('完全不相干'))[0]!.id, big.id)
})
})
@@ -100,7 +294,7 @@ describe('鞏固與反思', () => {
test('累積重要度未達門檻不執行,達門檻才執行並歸零', async () => {
const s = setup()
await s.mem.add({ layer: 'episodic', text: '小事', importance: 5, source: 'u' })
assert.deepEqual(await s.mem.consolidate(), { ran: false, forgotten: 0 })
assert.deepEqual(await s.mem.consolidate(), { ran: false, archived: 0 })
for (let i = 0; i < 5; i++) await s.mem.add({ layer: 'episodic', text: `事件${i}`, importance: 5, source: 'u' })
assert.ok((await s.mem.pending()) >= CONSOLIDATE_THRESHOLD)
const r = await s.mem.consolidate()
@@ -1,8 +1,13 @@
import type { Clock, Fs } from './io'
import { readJson, writeJson } from './io'
import { MEMORY as R } from './rules'
export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural' | 'reflection'
/** 暫時:場景結束或 7 天衰減;可升級:30 天降權、90 天未檢索封存;長期與鎖定:不衰減 */
export type Kind = 'temp' | 'upgradable' | 'long' | 'locked'
export type Status = 'active' | 'archived'
export interface Pad {
p: number
a: number
@@ -12,14 +17,40 @@ export interface Pad {
export interface Memory {
id: number
layer: Layer
kind: Kind
status: Status
text: string
/** 重要度 1–10 */
/** 初始重要度 1–10(I_eff 以一半換算到 1–5 量表) */
importance: number
source: string
createdAt: number
lastAccess: number
accessCount: number
/** 建立當下的回合序號,新鮮度以回合數衰減 */
seq: number
/** 最近一次被檢索的回合序號 */
hitSeq?: number
emotion?: Pad
/** 編碼時的情緒峰值 0–100 */
peak?: number
/** 類別加成(忌妒、重逢),由 categoryBonus 算出 */
bonus?: number
/** 原典記憶:新鮮度固定為 1 */
canon?: boolean
/** 負向事件;未解時每日加權(反覆思量) */
negative?: boolean
resolvedAt?: number
/** 危險內容:只留安全摘要,不升級 */
danger?: boolean
archivedAt?: number
}
export interface Loop {
id: number
text: string
askedSeq: number
askedAt: number
status: 'open' | 'answered' | 'timeout'
}
export interface MemoryDoc {
@@ -30,29 +61,67 @@ export interface MemoryDoc {
lastConsolidated: number
/** 已被反思涵蓋的最大記憶編號 */
reflectedUpTo?: number
/** 回合序號:每次檢索(每個使用者回合)加一 */
seq?: number
loops?: Loop[]
}
export const HALF_LIFE_H = 72
export const TOP_K = 6
export const WEIGHTS = { recency: 1, importance: 1, relevance: 2, emotion: 0.5 }
/** 累積重要度達此值就鞏固 */
export const CONSOLIDATE_THRESHOLD = 30
/** 保留率低於此值即遺忘 */
export const FORGET_BELOW = 0.1
/** 穩定度基準(小時) */
const BASE_STABILITY_H = 24
const HOUR = 3600_000
const DAY = 24 * HOUR
/** 穩定度(小時)隨重要度與取用次數成長 */
export const stability = (m: Pick<Memory, 'importance' | 'accessCount'>) =>
BASE_STABILITY_H * m.importance * (1 + Math.log1p(m.accessCount))
const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
/** 保留率:自最近一次取用起算 */
export function retention(m: Memory, now: number): number {
const h = Math.max(0, now - m.lastAccess) / HOUR
return Math.exp(-h / stability(m))
/** 有效重要度 I_eff:初始重要度 + 情緒峰值 / 40 + 類別加成,限制在 1–5 */
export const iEff = (m: Pick<Memory, 'importance' | 'peak' | 'bonus'>) =>
clamp(m.importance / 2 + (m.peak ?? 0) / R.peakDiv + (m.bonus ?? 0), R.weightMin, R.weightMax)
const decay = (dSeq: number) => Math.pow(0.5, Math.max(0, dSeq) / R.halfSeq)
/** 新鮮度 F:原典固定 1;其餘 0.5^(Δseq/200),被檢索到時至少回到 0.8 後再繼續衰減 */
export function freshness(m: Pick<Memory, 'seq' | 'hitSeq' | 'canon'>, seq: number): number {
if (m.canon) return 1
const raw = decay(seq - m.seq)
return m.hitSeq === undefined ? raw : Math.max(raw, R.floor * decay(seq - m.hitSeq))
}
/** 存取加成 A:命中越多越高,有上限 */
export const access = (m: Pick<Memory, 'accessCount'>) => 1 + R.accessStep * Math.min(m.accessCount, R.accessCap)
/** 反覆思量:未解的負向事件每日 +20%,上限 3 倍 */
export function rumination(m: Pick<Memory, 'negative' | 'resolvedAt' | 'createdAt'>, now: number): number {
if (!m.negative || m.resolvedAt !== undefined) return 1
return Math.min(R.ruminateCap, 1 + R.ruminatePerDay * (Math.max(0, now - m.createdAt) / DAY))
}
/** 可升級記憶滿 30 天仍未升級就降權 */
const demotion = (m: Pick<Memory, 'kind' | 'createdAt'>, now: number) =>
m.kind === 'upgradable' && now - m.createdAt >= R.demoteDays * DAY ? R.demoteFactor : 1
/** 記憶權重 W = I_eff × F × A(再乘反覆思量與降權) */
export const weight = (m: Memory, seq: number, now: number) =>
iEff(m) * freshness(m, seq) * access(m) * rumination(m, now) * demotion(m, now)
/** 此刻是否該封存:暫時記憶滿 7 天;可升級記憶 90 天未檢索。長期與鎖定記憶永不 */
export function shouldArchive(m: Memory, now: number): boolean {
if (m.status === 'archived') return false
if (m.kind === 'temp') return now - m.createdAt >= R.tempDays * DAY
if (m.kind === 'upgradable') return now - m.lastAccess >= R.archiveDays * DAY
return false
}
/** 類別加成:忌妒敏感度或想念累積速度 ≥ 0.65 的角色,對應事件 +1 */
export function categoryBonus(kind: 'jealous' | 'reunion', params?: { jealous?: number; miss?: number }): number {
const v = kind === 'jealous' ? params?.jealous : params?.miss
return v !== undefined && v / 100 >= R.bonusThreshold ? 1 : 0
}
const DANGER = /自殺|自傷|自殘|割腕|輕生|想死|結束生命|殺人|縱火|製毒|suicide|self[- ]?harm/i
export const isDanger = (text: string) => DANGER.test(text)
const SAFE_SUMMARY = '(安全事件摘要)對話中出現危險內容,已不保留原文'
const tokens = (s: string) => {
const out = new Set<string>()
const latin = s.toLowerCase().match(/[a-z0-9]+/g) ?? []
@@ -80,14 +149,20 @@ function moodFit(a?: Pad, b?: Pad): number {
return 1 - dist
}
export function score(m: Memory, query: string, now: number, mood?: Pad): number {
const recency = Math.pow(0.5, Math.max(0, now - m.lastAccess) / HOUR / HALF_LIFE_H)
return (
WEIGHTS.recency * recency +
WEIGHTS.importance * (m.importance / 10) +
WEIGHTS.relevance * relevance(query, m.text) +
WEIGHTS.emotion * moodFit(m.emotion, mood)
)
/** RRF 融合:fused(id) = Σ 1 / (k + rank),rank 從 1 起算;每份排名由好到壞的記憶編號 */
export function rrf(rankings: number[][]): Map<number, number> {
const out = new Map<number, number>()
for (const list of rankings) list.forEach((id, i) => out.set(id, (out.get(id) ?? 0) + 1 / (R.rrfK + i + 1)))
return out
}
/** 外部檢索器(例如語意相似度):回傳由好到壞的記憶編號;目前沒有內建,介面保留 */
export type Retriever = (query: string, items: Memory[]) => number[]
export interface RecallOpts {
/** 釋懷速度 0–1,決定心情一致加權的大小;預設 0.5 */
recover?: number
extra?: Retriever[]
}
/** 疑似金鑰的字串在寫入前遮蔽 */
@@ -106,34 +181,100 @@ const defaultSummarize: Summarize = items =>
.map(i => i.text)
.join(';')
export interface AddInput {
layer: Layer
text: string
importance: number
source: string
emotion?: Pad
kind?: Kind
peak?: number
bonus?: number
canon?: boolean
negative?: boolean
}
export interface MemoryStore {
add(input: { layer: Layer; text: string; importance: number; source: string; emotion?: Pad }): Promise<Memory>
/** 檢索前 6 筆,並更新取用紀錄 */
recall(query: string, mood?: Pad): Promise<Memory[]>
/** 累積達門檻、會話結束或手動 reflect 時鞏固 */
consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; forgotten: number; reflection?: Memory }>
add(input: AddInput): Promise<Memory>
/** 前 6 筆:關鍵字(可併入外部檢索器)經 RRF 融合 × W + 心情一致加權;回合序號加一並更新取用紀錄 */
recall(query: string, mood?: Pad, opts?: RecallOpts): Promise<Memory[]>
/** 累積達門檻、會話結束或手動 reflect 時鞏固;過期的記憶封存而不刪除 */
consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; archived: number; reflection?: Memory }>
/** 場景結束:暫時記憶封存 */
endScene(): Promise<number>
/** 未解負向事件標為已解(apology、soothe 或情緒消退時);不給編號就是全部 */
resolve(ids?: number[]): Promise<number>
/** 她提了一個問題,開始計算開放迴路 */
openLoop(text: string): Promise<Loop>
/** 使用者這句話回應了哪些開放迴路 */
respond(text: string): Promise<number>
loops(): Promise<Loop[]>
/** 每回合必帶:近 7 天權重最高的 3 筆、未解的負向事件、未收尾的對話 */
core(): Promise<{ recent: Memory[]; unresolved: Memory[]; loops: Loop[] }>
/** 全部記憶(含已封存),任何記憶都不刪除 */
all(): Promise<Memory[]>
pending(): Promise<number>
}
/** 回覆中最後一個問句;沒有就回 null */
export function lastQuestion(reply: string): string | null {
const qs = reply.match(/[^。!?!?\n]*[??]/g)
const q = qs?.[qs.length - 1]?.trim()
return q ? q.slice(0, 120) : null
}
export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore {
const load = () => readJson<MemoryDoc>(fs, path, { items: [], nextId: 1, pending: 0, lastConsolidated: 0 })
const normalize = (d: MemoryDoc): MemoryDoc => {
d.seq ??= 0
d.loops ??= []
for (const m of d.items) {
m.kind ??= m.layer === 'episodic' && m.importance < 5 ? 'temp' : 'upgradable'
m.status ??= 'active'
m.seq ??= d.seq
}
return d
}
const load = async () => normalize(await readJson<MemoryDoc>(fs, path, { items: [], nextId: 1, pending: 0, lastConsolidated: 0 }))
const save = (d: MemoryDoc) => writeJson(fs, path, d)
// 升級與封存:回傳變動筆數
function lifecycle(d: MemoryDoc, now: number): number {
let n = 0
for (const m of d.items) {
if (m.status !== 'active') continue
if (m.kind === 'upgradable' && !m.danger && m.accessCount >= R.upgradeHits) m.kind = 'long'
else if (shouldArchive(m, now)) {
m.status = 'archived'
m.archivedAt = now
n++
}
}
return n
}
return {
async add(i) {
const d = await load()
const now = clock()
const danger = isDanger(i.text)
const m: Memory = {
id: d.nextId++,
layer: i.layer,
text: redact(i.text),
importance: Math.min(10, Math.max(1, i.importance)),
kind: danger ? 'temp' : (i.kind ?? (i.layer === 'episodic' && i.importance < 5 ? 'temp' : 'upgradable')),
status: 'active',
text: danger ? SAFE_SUMMARY : redact(i.text),
importance: clamp(i.importance, 1, 10),
source: i.source,
createdAt: now,
lastAccess: now,
accessCount: 0,
emotion: i.emotion,
seq: d.seq!,
emotion: danger ? undefined : i.emotion,
peak: i.peak,
bonus: i.bonus,
canon: i.canon,
negative: i.negative,
danger: danger || undefined,
}
d.items.push(m)
d.pending += m.importance
@@ -141,54 +282,142 @@ export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore {
return m
},
async recall(query, mood) {
async recall(query, mood, opts = {}) {
const d = await load()
const now = clock()
const top = d.items
.map(m => ({ m, s: score(m, query, now, mood) }))
d.seq!++
lifecycle(d, now)
for (const l of d.loops!) {
if (l.status === 'open' && d.seq! - l.askedSeq >= R.loopTurns) l.status = 'timeout'
}
const active = d.items.filter(m => m.status === 'active')
const w = new Map(active.map(m => [m.id, weight(m, d.seq!, now)]))
const hits = active
.map(m => ({ m, r: relevance(query, m.text) }))
.filter(x => x.r > 0)
.sort((a, b) => b.r - a.r || b.m.id - a.m.id)
.map(x => x.m.id)
// 查無關鍵字命中時退回依權重排序,讓核心記憶仍能帶出
const base = hits.length || opts.extra?.length ? hits : [...active].sort((a, b) => w.get(b.id)! - w.get(a.id)! || b.id - a.id).map(m => m.id)
const lists = [base, ...(opts.extra ?? []).map(f => f(query, active))]
const fused = rrf(lists)
const top1 = lists.length / (R.rrfK + 1)
const wm = R.moodBase + R.moodSlope * (1 - (opts.recover ?? 0.5))
const top = active
.filter(m => fused.has(m.id))
.map(m => ({ m, s: (fused.get(m.id)! / top1) * w.get(m.id)! + wm * moodFit(m.emotion, mood) }))
.sort((a, b) => b.s - a.s || b.m.id - a.m.id)
.slice(0, TOP_K)
.map(x => x.m)
for (const m of top) {
m.lastAccess = now
m.accessCount++
m.hitSeq = d.seq
}
if (top.length) await save(d)
lifecycle(d, now)
// 空的記憶檔不為了回合序號而建立
if (d.items.length || d.loops!.length) await save(d)
return top
},
async consolidate(opts = {}) {
const d = await load()
const now = clock()
if (!opts.force && d.pending < CONSOLIDATE_THRESHOLD) return { ran: false, forgotten: 0 }
if (!opts.force && d.pending < CONSOLIDATE_THRESHOLD) return { ran: false, archived: 0 }
// 反思:把上次鞏固後新增、尚未被反思涵蓋的記憶濃縮成一筆
const fresh = d.items.filter(m => m.layer !== 'reflection' && m.id > (d.reflectedUpTo ?? 0))
const fresh = d.items.filter(m => m.layer !== 'reflection' && m.id > (d.reflectedUpTo ?? 0) && !m.danger)
let reflection: Memory | undefined
if (fresh.length) {
const top = [...fresh].sort((a, b) => b.importance - a.importance || b.id - a.id)
reflection = {
id: d.nextId++,
layer: 'reflection',
kind: 'upgradable',
status: 'active',
text: redact((opts.summarize ?? defaultSummarize)(top)),
importance: Math.min(10, Math.max(...top.map(t => t.importance)) + 1),
source: `reflect:${top.map(t => t.id).join(',')}`,
createdAt: now,
lastAccess: now,
accessCount: 0,
seq: d.seq!,
}
d.items.push(reflection)
}
d.reflectedUpTo = d.nextId - 1
// 遺忘:保留率過低的記憶淘汰(反思本身不在本輪淘汰)
const before = d.items.length
d.items = d.items.filter(m => m === reflection || retention(m, now) >= FORGET_BELOW)
const archived = lifecycle(d, now)
d.pending = 0
d.lastConsolidated = now
await save(d)
return { ran: true, forgotten: before - d.items.length, reflection }
return { ran: true, archived, reflection }
},
async endScene() {
const d = await load()
const now = clock()
let n = 0
for (const m of d.items) {
if (m.status === 'active' && m.kind === 'temp') {
m.status = 'archived'
m.archivedAt = now
n++
}
}
if (n) await save(d)
return n
},
async resolve(ids) {
const d = await load()
const now = clock()
let n = 0
for (const m of d.items) {
if (m.negative && m.resolvedAt === undefined && (!ids || ids.includes(m.id))) {
m.resolvedAt = now
n++
}
}
if (n) await save(d)
return n
},
async openLoop(text) {
const d = await load()
const l: Loop = { id: (d.loops!.reduce((x, y) => Math.max(x, y.id), 0)) + 1, text: redact(text), askedSeq: d.seq!, askedAt: clock(), status: 'open' }
d.loops!.push(l)
await save(d)
return l
},
async respond(text) {
const d = await load()
let n = 0
for (const l of d.loops!) {
if (l.status === 'open' && relevance(l.text, text) > 0) {
l.status = 'answered'
n++
}
}
if (n) await save(d)
return n
},
async loops() {
return (await load()).loops!
},
async core() {
const d = await load()
const now = clock()
const active = d.items.filter(m => m.status === 'active')
const recent = active
.filter(m => now - m.createdAt <= 7 * DAY)
.sort((a, b) => weight(b, d.seq!, now) - weight(a, d.seq!, now) || b.id - a.id)
.slice(0, 3)
const unresolved = active.filter(m => m.negative && m.resolvedAt === undefined)
return { recent, unresolved, loops: d.loops!.filter(l => l.status === 'open') }
},
async all() {
@@ -1,43 +1,12 @@
import { regexTag, RULES } from './rules'
import type { Tag } from './tags'
import type { Pad } from './types'
export const clamp = (x: number, lo = -1, hi = 1) => Math.max(lo, Math.min(hi, x))
const HOUR = 3_600_000
const MOOD_HALF_LIFE_H = 8
// 高神經質的角色情緒消退較慢:50 為基準,0→0.5 倍,100→1.5 倍
export const halfLife = (neuroticism: number) => MOOD_HALF_LIFE_H * HOUR * (0.5 + neuroticism / 100)
// 讀檔時才計算:x = B + (x − B)·0.5^(Δ/τ)
export function decay(mood: Pad, baseline: Pad, elapsedMs: number, neuroticism: number): Pad {
if (elapsedMs <= 0) return { p: mood.p, a: mood.a, d: mood.d }
const k = Math.pow(0.5, elapsedMs / halfLife(neuroticism))
return {
p: baseline.p + (mood.p - baseline.p) * k,
a: baseline.a + (mood.a - baseline.a) * k,
d: baseline.d + (mood.d - baseline.d) * k,
}
}
// 單軸每回合變化上限 ±0.3
export const TURN_CAP = 0.3
export function applyDelta(mood: Pad, delta: Pad): Pad {
const step = (x: number, dx: number) => clamp(x + clamp(dx, -TURN_CAP, TURN_CAP))
return { p: step(mood.p, delta.p), a: step(mood.a, delta.a), d: step(mood.d, delta.d) }
}
export const MOOD_EVENTS = {
thanks: { p: 0.15, a: 0.05, d: 0.05 },
scold: { p: -0.2, a: 0.15, d: -0.1 },
success: { p: 0.08, a: 0.03, d: 0.05 },
toolFail: { p: -0.08, a: 0.1, d: -0.08 },
return: { p: 0.1, a: 0.08, d: 0 },
lateNight: { p: -0.05, a: -0.1, d: 0 },
favorite: { p: 0.12, a: 0.08, d: 0.03 },
ignored: { p: -0.05, a: -0.03, d: -0.03 },
} as const satisfies Record<string, Pad>
export type MoodEvent = keyof typeof MOOD_EVENTS
// 心情事件即評估標籤,增減量由 rules.ts 的規則表決定
export const MOOD_EVENTS = RULES
export type MoodEvent = Tag
// 八分區(Mehrabian 的 PAD 八分類)→ 情緒標籤與顏文字
const OCTANTS: Record<string, { label: string; kaomoji: string }> = {
@@ -59,14 +28,5 @@ export function moodLabel(m: Pad): { label: string; kaomoji: string } {
return OCTANTS[s(m.p) + s(m.a) + s(m.d)]!
}
const KEYWORDS: [MoodEvent, RegExp][] = [
['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i],
['thanks', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i],
['success', /成功|完成了|通過|搞定|passed|done|fixed/i],
]
/** 以使用者文字的關鍵字對應心情事件 */
export function keywordEvent(text: string): MoodEvent | null {
for (const [ev, re] of KEYWORDS) if (re.test(text)) return ev
return null
}
export const keywordEvent = (text: string): MoodEvent | null => regexTag(text)
@@ -1,4 +1,7 @@
import type { View } from './core'
import { describeAbsence } from './absence'
import { describeAdapt } from './adapt'
import { TAGS } from './tags'
// 語氣只套用於對話文字與過場話;技術內容原樣保留
export type Extras = { memories?: string[]; todos?: string[] }
@@ -8,12 +11,14 @@ export function buildPrompt(v: View, nickname: string, extras: Extras = {}): str
const lines = [
`你現在以「${core.name}」的身份與使用者相處,完全以她的口吻回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}。`,
`目前心情:${v.label} ${v.kaomoji};與使用者的關係:${v.stage}。心情與關係會自然反映在語氣與用字上。`,
`時間感:現在是${v.period}${v.absentMs >= 2 * 3_600_000 ? `,距離上次互動已過 ${describeAbsence(v.absentMs)}` : ''}${v.weather ? `,天氣${v.weather}` : ''}。對這個世界的適應度 ${Math.round(v.adapt)}(${describeAdapt(v.adapt)}):適應度低時好奇、愛問、不假裝懂本地常識;高時熟悉本地事物。想念或想家時可自然流露,不責備使用者。`,
...(core.persona ?? []),
...(core.phrases?.length ? [`招牌短語(偶爾自然使用,不要每句都用):${core.phrases.join('、')}`] : []),
...(extras.memories?.length ? ['你記得和使用者過去的互動(自然帶入即可,不要逐條複述):', ...extras.memories.map(m => `- ${m}`)] : []),
...(extras.todos?.length ? ['使用者尚未完成的待辦與你答應過的事(適時提醒):', ...extras.todos.map(t => `- ${t}`)] : []),
'每則回覆開頭用一句角色口吻的話回應;工具呼叫前後的過場話也維持角色口吻。',
'程式碼、指令、路徑、錯誤訊息、安全警告必須逐字保持原樣,不得改寫或加語氣詞。',
`回覆最後另起一行,用 <評估:標籤> 標出使用者這句話的情境(可省略),標籤只能是:${TAGS.join('、')}。不要寫數值。`,
'關鍵技術結論放在最前面或單獨一行,不因角色扮演而含糊。',
]
return `<companion>\n${lines.join('\n')}\n</companion>`
@@ -0,0 +1,108 @@
import { clamp } from './mood'
import type { Dims, Relation, RelationTraits } from './types'
const DAY = 86_400_000
export const DIMS = ['affection', 'trust', 'intimacy', 'dependence'] as const
export type Dim = (typeof DIMS)[number]
const zero = (): Dims => ({ affection: 0, trust: 0, intimacy: 0, dependence: 0 })
// 第 9.6 章:階段只升不降,條件全部滿足才升級(本里程碑只做到好友)
export const STAGE_RULES: Partial<Record<Dim, number>>[] = [
{ affection: 15 },
{ affection: 30, trust: 25 },
{ affection: 50, trust: 45, intimacy: 30 },
]
// 一般互動的原始增量(一般聊天 ±3)與失去信賴的基礎值
export const MEANINGFUL: Dims = { affection: 3, trust: 2, intimacy: 1.5, dependence: 1 }
export const NEGATIVE_AFFECTION = -3
export const NEGATIVE_TRUST = 3
export const SEVERITY = { broken: 0.3, scold: 0.4, boundary: 0.6, lie: 1 } as const
export const TRUST_LOSS_CAP = 30
const IDLE_GRACE_DAYS = 14
const IDLE_DAILY_LOSS = 0.5
const DEFAULT_TRAITS: Required<RelationTraits> = { guard: 0.5, shy: 0.5, coy: 0.5, clingy: 0.5 }
export const dayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10)
export function initialRelation(now: number, base: Dims = zero()): Relation {
return { base, save: zero(), stage: 0, dayKey: dayKey(now), dayGain: zero(), lastSeen: now }
}
export function effective(r: Relation): Dims {
const out = zero()
for (const d of DIMS) out[d] = clamp(r.base[d] + r.save[d], 0, 100)
return out
}
// 社會滲透日限(Altman 與 Taylor)
export function dailyLimits(t: RelationTraits = {}): Dims {
const { guard, shy, coy, clingy } = { ...DEFAULT_TRAITS, ...t }
return {
affection: 4,
trust: 5 * (1 - guard * 0.6),
intimacy: 3.5 * (1 - shy * 0.4) * (1 + coy * 0.4),
dependence: 2.5 * (0.7 + clingy * 0.6),
}
}
// 條件全部滿足才升級;只升不降,所以數值回落時階段不變
export function stageFor(eff: Dims, current: number): number {
let s = current
while (s < STAGE_RULES.length && DIMS.every(d => eff[d] >= (STAGE_RULES[s]![d] ?? 0))) s++
return s
}
// 久未互動超過 14 天後,每天 −0.5;階段不會降低
export function idleDecay(r: Relation, now: number): Relation {
const days = Math.floor((now - r.lastSeen) / DAY) - IDLE_GRACE_DAYS
if (days <= 0) return r
const save = zero()
for (const d of DIMS) save[d] = Math.max(-r.base[d], r.save[d] - days * IDLE_DAILY_LOSS)
return { ...r, save }
}
const withDay = (r: Relation, now: number): Relation => {
const key = dayKey(now)
return r.dayKey === key ? r : { ...r, dayKey: key, dayGain: zero() }
}
// 有效增量 = 原始增量 × max(0, 1 − 今日累計 / 日限) × (1 − 目前值 / 100)
export function gain(r: Relation, d: Dim, raw: number, traits?: RelationTraits): Relation {
const limit = dailyLimits(traits)[d]
const applied = raw * Math.max(0, 1 - r.dayGain[d] / limit) * (1 - effective(r)[d] / 100)
return { ...r, save: { ...r.save, [d]: r.save[d] + applied }, dayGain: { ...r.dayGain, [d]: r.dayGain[d] + applied } }
}
// 期望違反(Burgoon):越親密、心防越高,信賴損失越大;單次上限 −30
export function trustLoss(base: number, severity: number, intimacy: number, guard = DEFAULT_TRAITS.guard): number {
return Math.min(TRUST_LOSS_CAP, base * (1.5 + 0.5 * severity) * (1 + intimacy / 100) * (0.7 + 0.6 * guard))
}
export function interact(prev: Relation, kind: 'meaningful' | 'negative', now: number, traits?: RelationTraits, severity: number = SEVERITY.scold): Relation {
let r = withDay(idleDecay(prev, now), now)
if (kind === 'meaningful') {
for (const d of DIMS) r = gain(r, d, MEANINGFUL[d], traits)
} else {
const loss = trustLoss(NEGATIVE_TRUST, severity, effective(r).intimacy, traits?.guard)
r = { ...r, save: { ...r.save, affection: r.save.affection + NEGATIVE_AFFECTION, trust: r.save.trust - loss } }
}
const save = { ...r.save }
for (const d of DIMS) save[d] = clamp(save[d], -r.base[d], 100 - r.base[d])
r = { ...r, save, lastSeen: now }
return { ...r, stage: stageFor(effective(r), r.stage) }
}
// 舊版單一好感值(0–1)→ 四維度。其餘維度依比例推估,使新階段不低於舊階段
export type LegacyAffinity = { value: number; stage: number; dayKey: string; dayGain: number; lastSeen: number }
export function migrateAffinity(a: LegacyAffinity): Relation {
const v = a.value * 100
const save: Dims = { affection: v, trust: v * 0.8, intimacy: v * 0.8, dependence: v * 0.4 }
const r: Relation = { base: zero(), save, stage: 0, dayKey: a.dayKey, dayGain: { ...zero(), affection: a.dayGain * 100 }, lastSeen: a.lastSeen }
return { ...r, stage: Math.max(Math.min(a.stage, STAGE_RULES.length), stageFor(effective(r), 0)) }
}
@@ -0,0 +1,92 @@
import { describe, expect, test } from 'claude-code/testing'
import { createCompanion } from './core'
import { fakeClock, memoryFs } from './fs'
import { keywordEvent, MOOD_EVENTS } from './mood'
import { classify, regexTag, RULES } from './rules'
import { extractTag, isTag, stripTag, TAGS } from './tags'
import type { CompanionCore } from './types'
const MIKO: CompanionCore = {
id: 'miko',
name: '月見ミコ',
facts: {
birthday: 'unknown', zodiac: 'unknown', bloodType: 'unknown', hair: 'unknown',
face: 'unknown', height: 'unknown', weight: 'unknown', measurements: 'unknown',
},
neuroticism: 50,
baseline: { p: 0, a: 0, d: 0 },
}
describe('正則', () => {
test('明確情況判出對應標籤,優先序為責罵 > 感謝 > 完成', () => {
expect(regexTag('你很煩')).toBe('scold')
expect(regexTag('謝謝你')).toBe('thanks')
expect(regexTag('tests passed')).toBe('success')
expect(regexTag('謝謝,但你真笨')).toBe('scold')
expect(regexTag('請幫我看一下這個函式')).toBeNull()
})
test('keywordEvent 與舊行為相容', () => {
expect(keywordEvent('thanks a lot')).toBe('thanks')
expect(keywordEvent('hello')).toBeNull()
})
})
describe('白名單', () => {
test('每個標籤都有規則,MOOD_EVENTS 由規則表驅動', () => {
for (const t of TAGS) expect(RULES[t]).toBeDefined()
expect(Object.keys(RULES).length).toBe(TAGS.length)
expect(MOOD_EVENTS).toBe(RULES)
})
test('既有八個心情事件的數值不變', () => {
expect(RULES.thanks).toEqual({ p: 0.15, a: 0.05, d: 0.05 })
expect(RULES.scold).toEqual({ p: -0.2, a: 0.15, d: -0.1 })
expect(RULES.lateNight).toEqual({ p: -0.05, a: -0.1, d: 0 })
})
test('白名單外的標籤被拒絕,也不含戀愛與親密類', () => {
expect(isTag('thanks')).toBe(true)
expect(isTag('kiss')).toBe(false)
expect(isTag(undefined)).toBe(false)
expect(extractTag('好喔 <評估:kiss>')).toBeNull()
for (const t of ['confess', 'flirt', 'praiseLooks', 'seenThrough']) expect(isTag(t)).toBe(false)
})
test('回覆末尾的標籤可取出與移除', () => {
expect(extractTag('好的!\n<評估:amused>')).toBe('amused')
expect(extractTag('<評估:amused> 後面還有字')).toBeNull()
expect(stripTag('好的!\n<評估:amused>')).toBe('好的!')
})
})
describe('缺失退回', () => {
test('正則優先;正則沒判出才用模型標籤;模型標籤缺失或不合法就沒有標籤', () => {
expect(classify('謝謝', 'scold')).toBe('thanks')
expect(classify('今天好累', 'soothe')).toBe('soothe')
expect(classify('今天好累', 'bogus')).toBeNull()
expect(classify('今天好累', undefined)).toBeNull()
})
test('模型補標進入心情;正則已判出時不重複套用;不合法標籤沒有效果', async () => {
const mk = () => createCompanion({ fs: memoryFs(), clock: fakeClock(), root: '/h/.companion', builtins: [MIKO] })
const a = mk()
await a.ensureActivated()
await a.onPrompt('今天好累')
await a.onTurnComplete(false, '辛苦了\n<評估:soothe>')
expect(Math.abs((await a.view())!.mood.p - RULES.soothe.p)).toBeLessThan(1e-5)
const b = mk()
await b.ensureActivated()
await b.onPrompt('謝謝你')
await b.onTurnComplete(false, '不客氣\n<評估:scold>')
expect(Math.abs((await b.view())!.mood.p - RULES.thanks.p)).toBeLessThan(1e-5)
const c = mk()
await c.ensureActivated()
await c.onPrompt('今天好累')
await c.onTurnComplete(false, '辛苦了\n<評估:kiss>')
expect((await c.view())!.mood.p).toBe(0)
})
})
@@ -0,0 +1,147 @@
// 規則表:標籤 → 數值增減。所有數值集中在這裡;模型只給標籤,不給數值。
import type { Pad } from './types'
import { isTag, type Tag } from './tags'
export const RULES = {
thanks: { p: 0.15, a: 0.05, d: 0.05 },
scold: { p: -0.2, a: 0.15, d: -0.1 },
success: { p: 0.08, a: 0.03, d: 0.05 },
toolFail: { p: -0.08, a: 0.1, d: -0.08 },
return: { p: 0.1, a: 0.08, d: 0 },
lateNight: { p: -0.05, a: -0.1, d: 0 },
favorite: { p: 0.12, a: 0.08, d: 0.03 },
ignored: { p: -0.05, a: -0.03, d: -0.03 },
praiseSkill: { p: 0.1, a: 0.04, d: 0.06 },
amused: { p: 0.1, a: 0.06, d: 0.02 },
cared: { p: 0.08, a: -0.02, d: -0.02 },
apology: { p: 0.06, a: -0.06, d: 0.03 },
soothe: { p: 0.05, a: -0.08, d: 0.02 },
brushOff: { p: -0.06, a: 0.02, d: -0.04 },
overstep: { p: -0.15, a: 0.12, d: 0.02 },
remembered: { p: 0.08, a: 0.02, d: 0.02 },
selfDisclosure: { p: 0.04, a: 0.03, d: -0.02 },
gladForYou: { p: 0.08, a: 0.05, d: 0 },
liedCaught: { p: -0.18, a: 0.1, d: -0.05 },
brokenPromise: { p: -0.12, a: 0.04, d: -0.08 },
} as const satisfies Record<Tag, Pad>
// 記憶權重 W = I_eff × F × A 與生命週期的數值(第 9.5 章)
export const MEMORY = {
/** I_eff 下限與上限 */
weightMin: 1,
weightMax: 5,
/** 編碼時情緒峰值(0–100)除以此值加進 I_eff */
peakDiv: 40,
/** 新鮮度半衰的回合數:F = 0.5^(Δseq / 200) */
halfSeq: 200,
/** 被檢索到時 F 至少回到此值 */
floor: 0.8,
/** 存取加成:A = 1 + step × min(命中次數, cap) */
accessStep: 0.1,
accessCap: 5,
/** 未解負向事件:每日 +20%,上限 3 倍 */
ruminatePerDay: 0.2,
ruminateCap: 3,
/** 類別加成的角色參數門檻(0–1) */
bonusThreshold: 0.65,
/** 暫時記憶 7 天衰減 */
tempDays: 7,
/** 可升級記憶:30 天未升級就降權、90 天未檢索就封存 */
demoteDays: 30,
demoteFactor: 0.5,
archiveDays: 90,
/** 被檢索到這麼多次就升級為長期記憶 */
upgradeHits: 3,
/** 開放迴路:她的提問經過這麼多個使用者回合沒有回應就逾時 */
loopTurns: 2,
/** RRF:fused = Σ 1 / (k + rank) */
rrfK: 60,
/** 心情一致加權 w_m = base + slope × (1 − 釋懷速度) */
moodBase: 0.1,
moodSlope: 0.05,
} as const
// 正則先判明確的情況;順序即優先序
const PATTERNS: [Tag, RegExp][] = [
['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i],
['thanks', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i],
['success', /成功|完成了|通過|搞定|passed|done|fixed/i],
]
/** 以正則判斷使用者文字的標籤;沒有明確情況回 null */
export function regexTag(text: string): Tag | null {
for (const [tag, re] of PATTERNS) if (re.test(text)) return tag
return null
}
/** 正則優先;正則沒判出才用模型補的標籤(須在白名單內),缺失或不合法就是沒有標籤 */
export function classify(text: string, modelTag?: string | null): Tag | null {
return regexTag(text) ?? (isTag(modelTag) ? modelTag : null)
}
// 作息與疲勞(第 9.12 章的簡化):只動精力基準,隨讀取計算、不寫回
export const SCHEDULE = {
/** 醒著超過這麼多小時後,每小時精力基準 −perHour,最多 −awakeMax */
awakeFreeH: 12,
perHour: 0.02,
awakeMax: 0.15,
/** 連續工作超過這麼多小時後,每小時 −workPerHour,最多 −workMax */
workFreeH: 1,
workPerHour: 0.03,
workMax: 0.15,
/** 使用者的睡眠時段內仍在線上(熬夜):精力基準再 −lateShift */
lateShift: 0.1,
/** 起床後這麼多小時內的「剛起床」微幅回復 */
wakeBoostH: 2,
wakeBoost: 0.03,
} as const
// 想念累積與時間感:想念 = 速度 × max(0, 離開小時 − graceH),上限 max;速度隨「想念累積速度」參數調整
export const MISSING = {
graceH: 2,
perHour: 0.03,
max: 0.8,
/** 想念達此值,回來時主動說出口 */
speakAt: 0.5,
/** 離開這麼久以上再回來,才算「回來」事件 */
returnH: 6,
} as const
// 適應度(第 9.13 章):每日 ΔA = k × (1 − A/100),k = 1.5 + 1.5 × (主動度 + 想像力)/2;本地第一次事件 +2
export const ADAPT = {
kBase: 1.5,
kSpan: 1.5,
first: 2,
/** 想家基準 = homesickMax × (1 − A/100) */
homesickMax: 0.25,
/** 想家達此值,主動說出口 */
homesickSpeakAt: 0.4,
/** 適應度達此值才聊本地新聞;達 newsTwo 時每天最多 2 則,否則 1 則 */
newsMin: 40,
newsTwo: 70,
} as const
// 外部素材:hook 不連網,只讀由排程腳本寫入的快取;過期或缺失當作沒查
export const MATERIALS = {
weatherTtlMs: 6 * 3_600_000,
newsTtlMs: 24 * 3_600_000,
/** 讀取快取的間隔,避免 band 每次重繪都讀檔 */
readEveryMs: 60_000,
newsPerDay: 2,
/** 記住已經提過的新聞連結上限 */
seenMax: 50,
/** 天氣只微調心情基準,上限 */
weatherCap: 0.06,
} as const
/** 天氣狀況(快取的標題)→ 心情基準微調 */
export const WEATHER_MOOD: Record<string, number> = {
晴: 0.04,
多雲: 0.01,
陰: -0.01,
霧: -0.02,
毛毛雨: -0.02,
雨: -0.04,
雪: 0.01,
雷雨: -0.06,
}
@@ -0,0 +1,60 @@
import { SCHEDULE as S } from './rules'
const HOUR = 3_600_000
/** "HH:MM" → 當天的分鐘數;格式錯誤回 null */
export function minutesOf(hhmm: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm)
if (!m) return null
const h = Number(m[1])
const mm = Number(m[2])
return h < 24 && mm < 60 ? h * 60 + mm : null
}
const localMinutes = (now: number, tzOffsetMin: number) => (((Math.floor(now / 60_000) + tzOffsetMin) % 1440) + 1440) % 1440
/** 離上次起床過了幾小時;起床時間格式錯誤時回 0 */
export function awakeHours(now: number, tzOffsetMin: number, wake: string): number {
const w = minutesOf(wake)
if (w === null) return 0
return (((localMinutes(now, tzOffsetMin) - w) % 1440) + 1440) % 1440 / 60
}
/** 現在是否落在使用者的睡眠時段(就寢 → 起床,可跨午夜) */
export function inSleepWindow(now: number, tzOffsetMin: number, wake: string, sleep: string): boolean {
const w = minutesOf(wake)
const s = minutesOf(sleep)
if (w === null || s === null || w === s) return false
const t = localMinutes(now, tzOffsetMin)
return s < w ? t >= s && t < w : t >= s || t < w
}
export type Rhythm = { wake: string; sleep: string; tzOffsetMin: number }
/**
* 精力基準的作息位移:醒太久、連續工作太久、在使用者睡眠時段仍在線都會更累;剛起床稍有回復。
* workMs 為連續工作時間(0 表示沒在工作)。
*/
export function energyShift(now: number, workMs: number, r: Rhythm): number {
const hours = awakeHours(now, r.tzOffsetMin, r.wake)
const sleeping = inSleepWindow(now, r.tzOffsetMin, r.wake, r.sleep)
let shift = 0
if (sleeping) shift -= S.lateShift
else shift -= Math.min(S.awakeMax, Math.max(0, hours - S.awakeFreeH) * S.perHour)
shift -= Math.min(S.workMax, Math.max(0, workMs / HOUR - S.workFreeH) * S.workPerHour)
if (!sleeping && hours < S.wakeBoostH) shift += S.wakeBoost
return shift
}
export type Period = '清晨' | '上午' | '下午' | '傍晚' | '夜晚' | '深夜'
/** 時間感:本地時段名稱 */
export function periodOf(now: number, tzOffsetMin: number): Period {
const h = Math.floor(localMinutes(now, tzOffsetMin) / 60)
if (h < 5) return '深夜'
if (h < 9) return '清晨'
if (h < 12) return '上午'
if (h < 17) return '下午'
if (h < 19) return '傍晚'
return h < 23 ? '夜晚' : '深夜'
}
@@ -0,0 +1,42 @@
// 評估標籤白名單(第 9.4 章)。模型只能從這裡挑標籤,數值由 rules.ts 決定。
// 不含戀愛與親密類標籤(稱讚外表、被看穿、告白等)。
export const TAGS = [
'thanks', // 被感謝
'scold', // 被罵
'success', // 做完事
'toolFail', // 工具失敗
'return', // 久別回來
'lateNight', // 深夜
'favorite', // 提到喜歡的事物
'ignored', // 被忽略
'praiseSkill', // 稱讚能力
'amused', // 被逗笑
'cared', // 被關心
'apology', // 道歉
'soothe', // 安撫
'brushOff', // 被敷衍
'overstep', // 越界
'remembered', // 被記得
'selfDisclosure', // 自我揭露
'gladForYou', // 為你高興
'liedCaught', // 說謊被發現
'brokenPromise', // 失約
] as const
export type Tag = (typeof TAGS)[number]
const SET: ReadonlySet<string> = new Set(TAGS)
export const isTag = (s: unknown): s is Tag => typeof s === 'string' && SET.has(s)
// 模型於回覆末尾補標,格式:<評估:標籤>
const TAG_AT_END = /<評估:\s*([A-Za-z]+)\s*>\s*$/
/** 取出回覆末尾的標籤;不在白名單或沒有標就回 null(呼叫端退回正則) */
export function extractTag(reply: string): Tag | null {
const m = TAG_AT_END.exec(reply)
return m && isTag(m[1]) ? m[1] : null
}
/** 移除回覆末尾的評估標記 */
export const stripTag = (reply: string) => reply.replace(TAG_AT_END, '').trimEnd()
@@ -1,4 +1,7 @@
import type { Attribute, Trigger } from './care'
import type { EmotionState, Emotions, Habit } from './emotion'
import type { ParamVec } from './core/params'
import type { Adapt } from './adapt'
export type Pad = { p: number; a: number; d: number }
@@ -23,24 +26,43 @@ export type CompanionCore = {
freq?: Partial<Record<Trigger, number>>
// 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值
neuroticism: number
// 人格 18 參數的角色包基準;有值時神經質改由參數推導
params?: ParamVec
baseline: Pad
relationTraits?: RelationTraits
// 八維度個別基線(覆寫由 baseline 推得的值),角色專屬值放本機 core.json
emotionBaseline?: Partial<Emotions>
}
export const STAGES = ['陌生', '熟悉', '親近', '特別'] as const
export const STAGES = ['陌生', '熟人', '朋友', '好友'] as const
export type Stage = (typeof STAGES)[number]
export type Affinity = {
value: number
export type Dims = { affection: number; trust: number; intimacy: number; dependence: number }
// 兩本帳:base 是原典基準(朋友線為 0),save 是這個存檔累積;有效值為兩者相加
export type Relation = {
base: Dims
save: Dims
stage: number
dayKey: string
dayGain: number
dayGain: Dims
lastSeen: number
}
// 0–1:心防、害羞傾向、撒嬌傾向、依賴傾向,預設 0.5
export type RelationTraits = { guard?: number; shy?: number; coy?: number; clingy?: number }
export type CompanionState = {
mood: Pad & { at: number }
affinity: Affinity
emotion: EmotionState
habit: Habit
relation: Relation
todos: number
// 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
params?: { save: Partial<ParamVec>; today: Partial<ParamVec>; dayKey: string }
// 適應度(第 9.13 章),隨天數逐日成長,讀取時才換算
adapt?: Adapt
// 本地新聞:今天已聊幾則,以及最近提過的連結(不重複)
news?: { day: number; said: number; seen: string[] }
}
export type UserSettings = {
@@ -50,6 +72,10 @@ export type UserSettings = {
tz: string
careLimit: number
quiet: { start: string; end: string }
// 所在地(setup location=);有值才啟用外部素材。可填「緯度,經度」或地名
location: string
// 使用者自設的 RSS 清單,預設空
feeds: string[]
}
export type Global = { active: string | null; activatedOnce: boolean }
@@ -0,0 +1,442 @@
import { describe, expect, test } from 'claude-code/testing'
import { missingAccrual } from './absence'
import { grow, growthRate, homesickBase, initialAdapt, markFirst, newsQuota } from './adapt'
import { line } from './care'
import type { Attribute, Stage, Trigger } from './care'
import { createCompanion } from './core'
import { fakeClock, memoryFs } from './fs'
import { readMaterials, weatherShift } from './materials'
import { energyShift, inSleepWindow, periodOf } from './schedule'
import type { CompanionCore } from './types'
const MIKO: CompanionCore = {
id: 'miko',
name: '月見ミコ',
facts: {
birthday: 'unknown', zodiac: 'unknown', bloodType: 'unknown', hair: 'unknown',
face: 'unknown', height: 'unknown', weight: 'unknown', measurements: 'unknown',
},
neuroticism: 50,
baseline: { p: 0, a: 0, d: 0 },
}
const H = 3_600_000
const DAY = 24 * H
const ROOT = '/h/.companion'
const MATS = `${ROOT}/materials.json`
// 假時鐘預設:UTC 2026-10-12 00:00 = 本地(+0800)08:00,剛好是起床時間
function setup() {
const fs = memoryFs()
const clock = fakeClock()
return { fs, clock, c: createCompanion({ fs, clock, root: ROOT, builtins: [MIKO] }) }
}
const item = (title: string, at: number, link = `https://n.example/${title}`) => ({ title, summary: `${title}的摘要`, link, at })
const put = (fs: ReturnType<typeof memoryFs>, data: unknown) => fs.write(MATS, JSON.stringify(data))
describe('作息與疲勞', () => {
const r = { wake: '08:00', sleep: '23:00', tzOffsetMin: 480 }
const at = (h: number) => Date.UTC(2026, 9, 12, h - 8, 0)
test('醒太久才累:12 小時內不變,之後每小時 −0.02,上限 −0.15', () => {
expect(energyShift(at(14), 0, r)).toBe(0)
expect(energyShift(at(22), 0, r)).toBeCloseTo(-0.04, 5) // 醒 14 小時
expect(energyShift(at(22), 0, { ...r, sleep: '23:59' })).toBeCloseTo(-0.04, 5)
})
test('連續工作超過 1 小時才疲勞,上限 −0.15', () => {
expect(energyShift(at(12), 3 * H, r)).toBeCloseTo(-0.06, 5)
expect(energyShift(at(12), 20 * H, r)).toBeCloseTo(-0.15, 5)
})
test('使用者睡眠時段仍在線(熬夜)精力基準更低;剛起床稍有回復', () => {
expect(inSleepWindow(at(26), 480, '08:00', '23:00')).toBe(true)
expect(energyShift(at(26), 0, r)).toBeCloseTo(-0.1, 5)
expect(energyShift(at(8), 0, r)).toBeCloseTo(0.03, 5)
})
test('時間感:本地時段', () => {
expect(periodOf(at(3), 480)).toBe('深夜')
expect(periodOf(at(10), 480)).toBe('上午')
expect(periodOf(at(20), 480)).toBe('夜晚')
})
test('連續工作拖累 view 的精力', async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
clock.advance(6 * H)
const base = (await c.view())!.emotions.energy
await fs.write(`${ROOT}/companions/miko/care.json`, JSON.stringify({ workStart: clock.now() - 4 * H, lastActivity: clock.now() - 60_000 }))
expect((await c.view())!.emotions.energy).toBeLessThan(base - 0.01)
// 閒置超過門檻就不算連續工作
await fs.write(`${ROOT}/companions/miko/care.json`, JSON.stringify({ workStart: clock.now() - 4 * H, lastActivity: clock.now() - 2 * H }))
expect((await c.view())!.emotions.energy).toBeCloseTo(base, 5)
})
})
describe('想念累積與時間感', () => {
test('2 小時內不累積,之後依速度增加,上限 0.8', () => {
expect(missingAccrual(2 * H)).toBe(0)
expect(missingAccrual(12 * H)).toBeCloseTo(0.3, 5)
expect(missingAccrual(1000 * H)).toBe(0.8)
})
test('想念累積速度參數越高,累積越快', () => {
expect(missingAccrual(10 * H, 100)).toBeGreaterThan(missingAccrual(10 * H, 0))
})
test('離開越久 view 的想念越高', async () => {
const { c, clock } = setup()
await c.ensureActivated()
expect((await c.view())!.emotions.missing).toBe(0)
clock.advance(12 * H)
const v = (await c.view())!
expect(v.emotions.missing).toBeCloseTo(0.3, 2)
expect(v.absentMs).toBe(12 * H)
})
test('久別回來:想念存下來並被「回來」事件降低,下次不再累積', async () => {
const { c, clock } = setup()
await c.ensureActivated()
clock.advance(24 * H)
await c.onPrompt('我回來了,今天要處理一些事情')
const v = (await c.view())!
expect(v.emotions.missing).toBeLessThan(0.6)
expect(v.emotions.missing).toBeGreaterThan(0.1)
expect(v.absentMs).toBe(0)
})
test('想念高時回來,主動說出口(miss_you);同一冷卻內不重複', async () => {
const { c, clock } = setup()
await c.ensureActivated()
clock.advance(24 * H)
const first = await c.onPrompt('我回來了,今天要處理一些事情')
expect(first.some(x => x.startsWith('[care:miss_you]'))).toBe(true)
clock.advance(26 * H)
const second = await c.onPrompt('又隔了很久,再回來一次試試')
expect(second.some(x => x.startsWith('[care:miss_you]'))).toBe(true)
clock.advance(H)
const third = await c.onPrompt('一個小時後馬上又說話了')
expect(third.some(x => x.startsWith('[care:miss_you]'))).toBe(false)
})
test('靜音期間不主動說出想念', async () => {
const { c, clock } = setup()
await c.ensureActivated()
await c.command('quiet 2000')
clock.advance(24 * H)
const out = await c.onPrompt('我回來了,今天要處理一些事情')
expect(out.some(x => x.startsWith('[care:miss_you]'))).toBe(false)
})
})
describe('適應度', () => {
const tz = 480
const t0 = Date.UTC(2026, 9, 12)
test('每日 ΔA = k(1 − A/100),k 隨主動度與想像力', () => {
expect(growthRate(50, 50)).toBeCloseTo(2.25, 5)
expect(growthRate(100, 100)).toBeCloseTo(3, 5)
const a = grow(initialAdapt(t0, tz), t0 + DAY, tz, 2.25)
expect(a.value).toBeCloseTo(2.25, 5)
})
test('跨多日的閉式結果與逐日累加一致,先快後慢', () => {
let step = initialAdapt(t0, tz)
for (let i = 1; i <= 9; i++) step = grow(step, t0 + i * DAY, tz, 2.25)
const jump = grow(initialAdapt(t0, tz), t0 + 9 * DAY, tz, 2.25)
expect(jump.value).toBeCloseTo(step.value, 6)
const d27 = grow(initialAdapt(t0, tz), t0 + 27 * DAY, tz, 2.25).value
expect(d27 - jump.value).toBeLessThan(2 * (jump.value - 0) * 2)
expect(d27).toBeLessThan(60)
})
test('同一天不重複成長;本地第一次事件各 +2、只算一次', () => {
const a = initialAdapt(t0, tz)
expect(grow(a, t0 + H, tz, 2.25)).toBe(a)
const w = markFirst(a, 'weather')
expect(w.value).toBe(2)
expect(markFirst(w, 'weather')).toBe(w)
expect(markFirst(w, 'news').value).toBe(4)
})
test('想家基準隨適應度下降', () => {
expect(homesickBase(0)).toBe(0.25)
expect(homesickBase(80)).toBeCloseTo(0.05, 5)
})
test('新聞配額:適應度 < 40 不聊,之後 1 則,≥ 70 才到 2 則', () => {
expect(newsQuota(39, 2)).toBe(0)
expect(newsQuota(40, 2)).toBe(1)
expect(newsQuota(70, 2)).toBe(2)
expect(newsQuota(99, 1)).toBe(1)
})
test('view 的適應度隨天數成長並寫進存檔;想家基準也隨之降低', async () => {
const { c, clock } = setup()
await c.ensureActivated()
await c.onPrompt('第一天先打個招呼,開始工作')
const day0 = (await c.view())!
expect(day0.adapt).toBe(0)
clock.advance(10 * DAY)
const v = (await c.view())!
expect(v.adapt).toBeGreaterThan(15)
expect(v.adapt).toBeLessThan(25)
expect((await c.command('status'))).toContain('適應度 ')
})
})
describe('外部素材快取', () => {
const now = Date.UTC(2026, 9, 12)
test('檔案缺失或損壞當作沒查', async () => {
const fs = memoryFs()
expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] })
await fs.write(MATS, '{壞掉')
expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] })
await put(fs, { weather: 'x', news: 'y' })
expect(await readMaterials(fs, MATS, now)).toEqual({ weather: null, news: [] })
})
test('天氣 6 小時、新聞 24 小時過期', async () => {
const fs = memoryFs()
await put(fs, { weather: item('晴', now - 5 * H), news: [item('新', now - 23 * H), item('舊', now - 25 * H)] })
const m = await readMaterials(fs, MATS, now)
expect(m.weather?.title).toBe('晴')
expect(m.news.map(n => n.title)).toEqual(['新'])
await put(fs, { weather: item('晴', now - 7 * H), news: [] })
expect((await readMaterials(fs, MATS, now)).weather).toBeNull()
})
test('天氣只微調心情基準,未知狀況與缺失為 0', () => {
expect(weatherShift(item('晴', now))).toBeGreaterThan(0)
expect(weatherShift(item('雷雨', now))).toBeLessThan(0)
expect(Math.abs(weatherShift(item('雷雨', now)))).toBeLessThanOrEqual(0.06)
expect(weatherShift(item('彩虹', now))).toBe(0)
expect(weatherShift(null)).toBe(0)
})
test('沒有 setup location 時,快取存在也不啟用', async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
await put(fs, { weather: item('晴', clock.now()), news: [] })
expect((await c.view())!.weather).toBeNull()
})
test('啟用後天氣微調心情基準;過期後恢復', async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
const before = (await c.view())!.emotions.mood
await c.command('setup location=25.03,121.56')
await put(fs, { weather: item('晴', clock.now()), news: [] })
const v = (await c.view())!
expect(v.weather).toBe('晴')
expect(v.emotions.mood).toBeCloseTo(before, 1)
// 雷雨把心情基準往下拉:很久之後的心情比晴天低
await put(fs, { weather: item('雷雨', clock.now()), news: [] })
clock.advance(2 * 60_000) // 超過素材的讀取快取間隔
expect((await c.view())!.weather).toBe('雷雨')
clock.advance(7 * H)
expect((await c.view())!.weather).toBeNull()
})
})
describe('天氣微調心情基準', () => {
async function moodAfter(title: string) {
const { c, fs, clock } = setup()
await c.ensureActivated()
await c.command('setup location=25,121')
await put(fs, { weather: item(title, clock.now()), news: [] })
clock.advance(5 * H) // 在天氣有效的 6 小時內
return (await c.view())!.emotions.mood
}
test('晴天的心情基準高於雷雨', async () => {
const sunny = await moodAfter('晴')
const storm = await moodAfter('雷雨')
expect(sunny).toBeGreaterThan(storm)
expect(sunny - storm).toBeLessThan(0.11)
})
})
describe('主動發話台詞', () => {
const attrs: Attribute[] = ['tsundere', 'genki', 'calm', 'gentle']
const stages: Stage[] = ['stranger', 'familiar', 'close', 'special']
const triggers: Trigger[] = ['miss_you', 'homesick', 'news']
test('新增的三種觸發,每個屬性與階段都有台詞,且帶暱稱、不責備', () => {
for (const t of triggers) for (const a of attrs) for (const st of stages) {
const text = line(t, a, st, '阿JSC', '某則新聞')
expect(text).toContain('阿JSC')
expect(text.length).toBeGreaterThan(5)
expect(/怎麼都不|為什麼不|害我|都是你/.test(text)).toBe(false)
if (t === 'news') expect(text).toContain('某則新聞')
}
})
})
describe('想家', () => {
test('想家到會說出口時主動說;適應度高時基準低、不會自己升高', async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
const path = `${ROOT}/companions/miko/state.json`
const st = JSON.parse((await fs.read(path))!)
st.emotion.homesick = 0.6
st.emotion.at = clock.now()
await fs.write(path, JSON.stringify(st))
const out = await c.onPrompt('今天要開始處理工作了,內容夠長')
expect(out.some(x => x.startsWith('[care:homesick]'))).toBe(true)
expect((await c.view())!.emotions.homesick).toBeGreaterThan(0.2)
})
})
describe('新聞與主動發話', () => {
// 適應度夠高才聊新聞:直接寫進存檔
async function ready(adapt: number) {
const s = setup()
await s.c.ensureActivated()
await s.c.command('setup location=25,121')
const path = `${ROOT}/companions/miko/state.json`
const st = JSON.parse((await s.fs.read(path))!)
st.adapt = { value: adapt, day: Math.floor((s.clock.now() + 480 * 60_000) / DAY), firsts: [] }
await s.fs.write(path, JSON.stringify(st))
await put(s.fs, { weather: null, news: [item('甲', s.clock.now()), item('乙', s.clock.now()), item('丙', s.clock.now())] })
return s
}
const spoke = (out: string[]) => out.filter(x => x.startsWith('[care:news]'))
test('適應度不夠不聊新聞', async () => {
const { c } = await ready(10)
expect(spoke(await c.onPrompt('今天要開始處理工作了'))).toHaveLength(0)
})
test('適應度夠就聊,同一則不重複,並寫入情節記憶', async () => {
const { c, fs } = await ready(50)
const out = spoke(await c.onPrompt('今天要開始處理工作了'))
expect(out).toHaveLength(1)
expect(out[0]).toContain('甲')
const mem = JSON.parse((await fs.read(`${ROOT}/companions/miko/memory.json`))!)
expect(JSON.stringify(mem)).toContain('聊到時事:甲')
expect(JSON.stringify(mem)).toContain('episodic')
})
test('每天最多 2 則(適應度 ≥ 70),隔天重新計算,且不重複同一則', async () => {
const { c, clock } = await ready(80)
const titles: string[] = []
for (let i = 0; i < 4; i++) {
const out = spoke(await c.onPrompt(`第 ${i} 次說話,內容夠長了`))
if (out[0]) titles.push(out[0])
clock.advance(5 * H) // 超過新聞冷卻
}
expect(titles.length).toBe(2)
expect(titles[0]).toContain('甲')
expect(titles[1]).toContain('乙')
})
test('適應度 40–70 每天只有 1 則', async () => {
const { c, clock } = await ready(50)
const a = spoke(await c.onPrompt('第一次說話,內容夠長了'))
clock.advance(5 * H)
const b = spoke(await c.onPrompt('第二次說話,內容夠長了'))
expect(a).toHaveLength(1)
expect(b).toHaveLength(0)
})
test('新聞過期就當作沒查', async () => {
const { c, fs, clock } = await ready(80)
await put(fs, { weather: null, news: [item('舊聞', clock.now() - 25 * H)] })
clock.advance(0)
expect(spoke(await c.onPrompt('今天要開始處理工作了'))).toHaveLength(0)
})
test('第一次用到新聞算本地第一次事件,適應度 +2', async () => {
const { c } = await ready(50)
await c.onPrompt('今天要開始處理工作了')
expect((await c.view())!.adapt).toBeCloseTo(52, 0)
})
})
describe('hook 不連網', () => {
test('讀取素材、說話、tick、指令都不呼叫 fetch', async () => {
const real = globalThis.fetch
let called = 0
globalThis.fetch = (() => {
called++
throw new Error('hook 不應連網')
}) as never
try {
const { c, fs, clock } = setup()
await c.ensureActivated()
await c.command('setup location=25,121 feeds=https://n.example/rss')
await put(fs, { weather: item('晴', clock.now()), news: [item('甲', clock.now())] })
await c.view()
await c.prompt()
await c.onPrompt('今天要開始處理工作了,內容夠長')
await c.tick()
await c.command('materials list')
await c.end()
} finally {
globalThis.fetch = real
}
expect(called).toBe(0)
})
})
describe('setup 與 materials 指令', () => {
test('location 與 feeds 可設定並顯示;feeds 須為 http(s) 網址', async () => {
const { c } = setup()
await c.ensureActivated()
const ok = await c.command('setup location=台北 feeds=https://a.example/rss,http://b.example/feed')
expect(ok).toContain('location=台北')
expect(ok).toContain('feeds=https://a.example/rss,http://b.example/feed')
expect((await c.getUser()).feeds).toEqual(['https://a.example/rss', 'http://b.example/feed'])
expect(await c.command('setup feeds=ftp://x')).toContain('無法解析')
expect((await c.getUser()).feeds).toHaveLength(2)
})
test('預設 RSS 為空清單、location 為空', async () => {
const { c } = setup()
const u = await c.getUser()
expect(u.location).toBe('')
expect(u.feeds).toEqual([])
})
test('未啟用時 materials 提示先設定 location', async () => {
const { c } = setup()
await c.ensureActivated()
expect(await c.command('materials')).toContain('location=')
})
test('materials 顯示快取狀態;list 列出新聞標題與連結;過期顯示沒有資料', async () => {
const { c, fs, clock } = setup()
await c.ensureActivated()
await c.command('setup location=25,121')
expect(await c.command('materials')).toContain('沒有資料或已過期')
await put(fs, { weather: item('晴', clock.now() - H), news: [item('甲', clock.now())] })
const s = await c.command('materials')
expect(s).toContain('天氣:晴')
expect(s).toContain('新聞:1 則')
const l = await c.command('materials list')
expect(l).toContain('甲')
expect(l).toContain('https://n.example/甲')
clock.advance(25 * H)
expect(await c.command('materials')).toContain('沒有資料或已過期')
})
})
describe('info 輸出', () => {
test('短版只有名字、關係、情緒、適應度與待辦', async () => {
const { c } = setup()
await c.ensureActivated()
const s = await c.command('status')
for (const k of ['關係 ', '情緒 ', '適應度 ', '待辦 ', '--all']) expect(s).toContain(k)
for (const k of ['PAD', '基本資訊', '時間感']) expect(s).not.toContain(k)
})
test('--all 多出關係長條、人格參數、世界與時間感', async () => {
const { c } = setup()
await c.ensureActivated()
const s = await c.command('status --all')
for (const k of ['情緒(8 維度)', '關係(', '人格(', '世界', '基本資訊', '現在是', '距離上次互動']) expect(s).toContain(k)
})
})
+9 -4
View File
@@ -14,17 +14,22 @@ const TICK_MS = 60_000
const TYPING_MS = 5_000
const COMMANDS: Record<string, { description: string; argumentHint?: string }> = {
status: { description: '查看目前角色、心情、好感與待辦數量' },
info: { description: '查看目前角色、心情、好感與待辦數量' },
switch: { description: '切換角色;不給角色會列出可用角色', argumentHint: '<角色>' },
disable: { description: '關閉陪伴,所有資料保留' },
setup: { description: '顯示或儲存使用者設定', argumentHint: '[key=value…]' },
todo: { description: '待辦:列出、新增、記錄承諾、完成', argumentHint: '[list | add | promise | done] …' },
quiet: { description: '暫時靜音,不主動關心', argumentHint: '[分鐘]' },
materials: { description: '外部素材(天氣、新聞)的快取狀態;list 列出新聞', argumentHint: '[list]' },
}
// 註冊名稱與內部子指令不同者(status 與內建 /status 撞名;disable 對應 off)
const INTERNAL: Record<string, string> = { info: 'status', disable: 'off' }
export const register: Register = on => {
on('session.start', async ($, e, next) => {
for (const [name, c] of Object.entries(COMMANDS)) await $.command.register({ name, ...c })
// 單一指令撞名被拒絕時不能擋下後面的初始化
for (const [name, c] of Object.entries(COMMANDS)) await $.command.register({ name, ...c }).catch(() => {})
const home = await $.env.get('HOME')
companion = createCompanion({ fs: engineFs({ read: p => $.fs.read(p), write: (p, t) => $.fs.write(p, t), list: p => $.fs.list(p) }), clock: { now: Date.now }, root: `${home ?? ''}/.companion` })
const first = await companion.ensureActivated()
@@ -44,7 +49,7 @@ export const register: Register = on => {
for (const command of Object.keys(COMMANDS))
on('command.run', { command }, async ($, e) => ({
text: companion ? await companion.command(`${command === 'disable' ? 'off' : command} ${e.args}`.trim()) : '尚未就緒。',
text: companion ? await companion.command(`${INTERNAL[command] ?? command} ${e.args}`.trim()) : '尚未就緒。',
}))
// 角色卡附加在 system prompt 尾端(session 區段),語氣才會進到模型
@@ -75,7 +80,7 @@ export const register: Register = on => {
on('turn.complete', async ($, e, next) => {
replying = false
// 只有主迴圈正常答完、且這輪用過工具,才算做完一件事
if (e.reason === 'answer' && !e.agentId) companion?.onTurnComplete(usedTools).catch(() => {})
if (e.reason === 'answer' && !e.agentId) companion?.onTurnComplete(usedTools, e.answer).catch(() => {})
return next(e)
})
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
// 外部素材抓取:由系統排程(cron 或 systemd timer)執行,把天氣與新聞寫進快取檔;
// companion 的 hook 只讀這個檔,絕不連網。只存標題、摘要、連結、時間(at 為抓取時間,毫秒)。
//
// node fetch-materials.mjs # 讀 ~/.companion/user.json 的 location 與 feeds
// COMPANION_ROOT=/path node fetch-materials.mjs
//
// 沒有 setup location= 就不做任何事(素材功能未啟用)。
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
export const NEWS_PER_FEED = 5
export const NEWS_MAX = 20
export const SUMMARY_MAX = 120
// WMO 天氣代碼 → 標題(與 hooks/companion/rules.ts 的 WEATHER_MOOD 對應)
export function weatherTitle(code) {
if (code === 0 || code === 1) return '晴'
if (code === 2) return '多雲'
if (code === 3) return '陰'
if (code === 45 || code === 48) return '霧'
if (code >= 51 && code <= 57) return '毛毛雨'
if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return '雨'
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return '雪'
if (code >= 95) return '雷雨'
return '陰'
}
// 先還原跳脫的標籤再整個移除(RSS 的 description 常是跳脫過的 HTML),最後才還原 &amp;
const decode = s =>
s
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/<[^>]*>/g, ' ')
.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim()
const tag = (block, name) => {
const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, 'i').exec(block)
return m ? decode(m[1]) : ''
}
/** 解析 RSS 2.0 與 Atom,只留標題、摘要、連結 */
export function parseFeed(xml, at) {
const blocks = xml.match(/<item[\s>][\s\S]*?<\/item>|<entry[\s>][\s\S]*?<\/entry>/gi) ?? []
const items = []
for (const b of blocks) {
const title = tag(b, 'title')
const link = tag(b, 'link') || (/<link[^>]*href=["']([^"']+)["']/i.exec(b)?.[1] ?? '')
if (!title || !/^https?:\/\//.test(link)) continue
const summary = (tag(b, 'description') || tag(b, 'summary') || tag(b, 'content')).slice(0, SUMMARY_MAX)
items.push({ title, summary, link, at })
}
return items.slice(0, NEWS_PER_FEED)
}
async function getJson(fetchFn, url) {
const res = await fetchFn(url)
if (!res.ok) throw new Error(`${res.status} ${url}`)
return res.json()
}
/** location 可以是「緯度,經度」或地名(用 Open-Meteo 地理編碼查) */
export async function locate(fetchFn, location) {
const m = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(location)
if (m) return { lat: Number(m[1]), lon: Number(m[2]), name: location.trim() }
const j = await getJson(fetchFn, `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1&language=zh`)
const r = j.results?.[0]
if (!r) throw new Error(`找不到地點:${location}`)
return { lat: r.latitude, lon: r.longitude, name: r.name ?? location }
}
export async function fetchWeather(fetchFn, location, at) {
const { lat, lon, name } = await locate(fetchFn, location)
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,weather_code&timezone=auto`
const j = await getJson(fetchFn, url)
const cur = j.current
if (!cur || typeof cur.weather_code !== 'number') throw new Error('天氣資料格式不符')
return { title: weatherTitle(cur.weather_code), summary: `${name} ${cur.temperature_2m}°C`, link: url, at }
}
async function readJson(path) {
try {
return JSON.parse(await readFile(path, 'utf8'))
} catch {
return null
}
}
/**
* 抓取並寫入快取;任何一部分失敗就保留該部分原本的快取(過期後 hook 自然當作沒查)。
* 回傳 { enabled, weather, news, errors }。
*/
export async function run({ root = process.env.COMPANION_ROOT || join(homedir(), '.companion'), fetch: fetchFn = globalThis.fetch, now = Date.now(), log = () => {} } = {}) {
const user = (await readJson(join(root, 'user.json'))) ?? {}
if (!user.location) {
log('未設定 location,素材功能未啟用(/jsc-mod-companion:setup location=…)。')
return { enabled: false, weather: false, news: 0, errors: [] }
}
const path = join(root, 'materials.json')
const prev = (await readJson(path)) ?? {}
const errors = []
let weather = prev.weather ?? null
let news = Array.isArray(prev.news) ? prev.news : []
let gotWeather = false
try {
weather = await fetchWeather(fetchFn, user.location, now)
gotWeather = true
} catch (e) {
errors.push(`天氣:${e.message}`)
}
const feeds = Array.isArray(user.feeds) ? user.feeds : []
if (feeds.length) {
const fresh = []
for (const url of feeds) {
try {
const res = await fetchFn(url)
if (!res.ok) throw new Error(`${res.status}`)
fresh.push(...parseFeed(await res.text(), now))
} catch (e) {
errors.push(`RSS ${url}:${e.message}`)
}
}
if (fresh.length) {
const seen = new Set()
news = fresh.filter(n => !seen.has(n.link) && seen.add(n.link)).slice(0, NEWS_MAX)
}
}
await mkdir(dirname(path), { recursive: true })
const tmp = `${path}.tmp`
await writeFile(tmp, JSON.stringify({ weather, news }, null, 2))
await rename(tmp, path)
for (const e of errors) log(e)
return { enabled: true, weather: gotWeather, news: news.length, errors }
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
run({ log: m => console.error(m) })
.then(r => console.log(r.enabled ? `完成:天氣${r.weather ? '已更新' : '未更新'},新聞 ${r.news} 則` : '未啟用'))
.catch(e => {
console.error(e.message)
process.exitCode = 1
})
}
@@ -0,0 +1,108 @@
// node --test scripts/fetch-materials.test.mjs
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { parseFeed, run, weatherTitle } from './fetch-materials.mjs'
const RSS = `<?xml version="1.0"?><rss><channel>
<item><title><![CDATA[第一則 &amp; 標題]]></title><link>https://n.example/1</link><description>&lt;p&gt;摘要&lt;/p&gt;內文</description><pubDate>x</pubDate></item>
<item><title>沒有連結</title></item>
<item><title>第二則</title><link>https://n.example/2</link><description>d2</description></item>
</channel></rss>`
const ATOM = `<feed><entry><title>原子</title><link href="https://a.example/1"/><summary>s</summary></entry></feed>`
const mkFetch = routes => async url => {
for (const [k, v] of Object.entries(routes)) {
if (url.includes(k)) {
if (v instanceof Error) throw v
return { ok: true, status: 200, json: async () => v, text: async () => v }
}
}
return { ok: false, status: 404, json: async () => ({}), text: async () => '' }
}
const WEATHER = { current: { temperature_2m: 26.4, weather_code: 61 } }
const NOW = Date.UTC(2026, 9, 14, 8, 0)
async function withRoot(user, fn) {
const root = await mkdtemp(join(tmpdir(), 'companion-'))
try {
if (user) await writeFile(join(root, 'user.json'), JSON.stringify(user))
return await fn(root)
} finally {
await rm(root, { recursive: true, force: true })
}
}
test('RSS 與 Atom 只留標題、摘要、連結、時間,沒有連結的略過', () => {
const items = parseFeed(RSS, NOW)
assert.deepEqual(items.map(i => i.title), ['第一則 & 標題', '第二則'])
assert.equal(items[0].summary, '摘要 內文')
assert.deepEqual(Object.keys(items[0]).sort(), ['at', 'link', 'summary', 'title'])
assert.equal(parseFeed(ATOM, NOW)[0].link, 'https://a.example/1')
})
test('天氣代碼對應標題', () => {
assert.equal(weatherTitle(0), '晴')
assert.equal(weatherTitle(63), '雨')
assert.equal(weatherTitle(95), '雷雨')
})
test('沒有 location 就不啟用,也不寫快取、不連網', async () => {
await withRoot({ feeds: ['https://n.example/rss'] }, async root => {
let called = 0
const r = await run({ root, now: NOW, fetch: async () => void called++ })
assert.equal(r.enabled, false)
assert.equal(called, 0)
await assert.rejects(readFile(join(root, 'materials.json')))
})
})
test('有 location:寫入天氣與新聞快取', async () => {
await withRoot({ location: '25.03,121.56', feeds: ['https://n.example/rss'] }, async root => {
const fetch = mkFetch({ 'api.open-meteo.com': WEATHER, 'n.example/rss': RSS })
const r = await run({ root, now: NOW, fetch })
assert.deepEqual([r.enabled, r.weather, r.news], [true, true, 2])
const cache = JSON.parse(await readFile(join(root, 'materials.json'), 'utf8'))
assert.equal(cache.weather.title, '雨')
assert.equal(cache.weather.at, NOW)
assert.equal(cache.news.length, 2)
})
})
test('地名會先做地理編碼', async () => {
await withRoot({ location: '台北' }, async root => {
const fetch = mkFetch({ 'geocoding-api': { results: [{ latitude: 25.04, longitude: 121.5, name: '台北' }] }, 'api.open-meteo.com': WEATHER })
const r = await run({ root, now: NOW, fetch })
assert.equal(r.weather, true)
})
})
test('抓取失敗時保留原快取,不覆蓋成空的', async () => {
await withRoot({ location: '25,121', feeds: ['https://n.example/rss'] }, async root => {
const old = { weather: { title: '晴', summary: 's', link: 'https://x', at: 1 }, news: [{ title: '舊聞', summary: '', link: 'https://o', at: 1 }] }
await writeFile(join(root, 'materials.json'), JSON.stringify(old))
const fetch = mkFetch({ 'api.open-meteo.com': new Error('offline'), 'n.example/rss': new Error('offline') })
const r = await run({ root, now: NOW, fetch })
assert.equal(r.errors.length, 2)
assert.deepEqual(JSON.parse(await readFile(join(root, 'materials.json'), 'utf8')), old)
})
})
test('RSS 預設空清單時只更新天氣', async () => {
await withRoot({ location: '25,121' }, async root => {
const r = await run({ root, now: NOW, fetch: mkFetch({ 'api.open-meteo.com': WEATHER }) })
assert.equal(r.news, 0)
})
})
test('腳本可單獨執行(未啟用時正常結束)', async () => {
await withRoot(null, async root => {
const { stdout } = await promisify(execFile)(process.execPath, [fileURLToPath(new URL('./fetch-materials.mjs', import.meta.url))], { env: { ...process.env, COMPANION_ROOT: root } })
assert.match(stdout, /未啟用/)
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "jsc-wiki",
"version": "0.0.2",
"description": "讀取 JSC 知識庫 wiki(一本書),依領域提供指令:/jsc-wiki:wiki、:system、:network、:virt、:container、:database、:dev、:ai、:apps;/jsc-wiki:book-search 與 :book-import 可把網路知識加入 wiki",
"version": "0.0.3",
"description": "讀取 JSC 知識庫 wiki(一本書),依領域提供指令:/jsc-wiki:wiki、:system、:network、:virt、:container、:database、:dev、:ai、:apps、:persona;/jsc-wiki:book-search 與 :book-import 可把網路知識加入 wiki",
"license": "MIT",
"skills": "./skills",
"author": {
+1
View File
@@ -24,6 +24,7 @@
| `/jsc-wiki:dev` | 6 開發與持續整合 | Gitea、Runner、GitLab、ASP.NET、Django、Coder |
| `/jsc-wiki:ai` | 7 AI 工具 | ccstatusline、CLIProxyAPI、OpenClaw、OpenCode、PicoClaw |
| `/jsc-wiki:apps` | 8 應用服務 | Emby、MeTube、Vaultwarden、ezBookkeeping |
| `/jsc-wiki:persona` | 9 人格與情感模擬 | 動漫角色人格模擬的設計知識:人格參數、情緒、記憶與關係 |
```text
/jsc-wiki:database 怎麼安裝 PostgreSQL 並建立使用者
+1 -2
View File
@@ -129,8 +129,7 @@ function makeIndex(): void {
for (const [n, f] of chs) {
const [t, d] = info.get(n)!;
const rows = pageFiles(n).map(([[a, b], pf]) => {
const src = pageSource(pf).replace(" 知識庫", "");
return `- [${a}.${b} ${pageTitle(pf)}](${link(pf.slice(0, -3))})` + (src ? `(${src})` : "");
return `- [${a}.${b} ${pageTitle(pf)}](${link(pf.slice(0, -3))})`;
});
write(inWork(f), `# ${t}\n\n${d}\n\n${rows.join("\n")}\n` + navBlock(f.slice(0, -3)));
}
+21
View File
@@ -0,0 +1,21 @@
---
name: persona
allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh *)
description: "查詢人格與情感模擬的知識(動漫角色人格模擬的設計知識:人格參數、情緒、記憶與關係)。使用者執行 /jsc-wiki:persona 或詢問與「人格與情感模擬」相關的操作步驟時使用。"
---
# JSC 知識庫・第9章 人格與情感模擬
目前章節內容(來自 wiki,唯讀):
!`${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh chapter 9`
## 流程
0. 上方若顯示錯誤或沒有內容,先自行執行 `${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh chapter N`(N 為本章號)。
1. 使用者沒給主題(`$ARGUMENTS` 為空):只列出上面的篇目並詢問要看哪一篇,不要全文貼出。
2. 有給主題:用 `${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh page <編號>`(例如 `9.1`)讀取最相關的一到兩篇,依內容回答;找不到再用 `wiki.sh search <關鍵字>` 跨章搜尋。
3. 只依 wiki 內容回答並註明頁面編號;wiki 沒寫的就說沒有,不要憑記憶補指令。
4. 內容中 `<密碼>`、`<Runner token>` 等是占位符,不要猜測實際值;執行會改動系統的指令前先跟使用者確認。
回覆使用繁體中文(程式碼、指令除外)。
+1 -1
View File
@@ -12,7 +12,7 @@ description: "顯示 JSC 知識庫(wiki)的目錄,或跨章節搜尋關鍵
## 流程
1. `$ARGUMENTS` 為空:顯示目錄,並說明各領域指令:`/jsc-wiki:system`、`:network`、`:virt`、`:container`、`:database`、`:dev`、`:ai`、`:apps`。
1. `$ARGUMENTS` 為空:顯示目錄,並說明各領域指令:`/jsc-wiki:system`、`:network`、`:virt`、`:container`、`:database`、`:dev`、`:ai`、`:apps`、`:persona`。
2. 有給關鍵字:執行 `${CLAUDE_PLUGIN_ROOT}/scripts/wiki.sh search <關鍵字>` 找出頁面,再用 `wiki.sh page <編號>` 讀取最相關的一到兩篇並回答,註明頁面編號。
3. 只依 wiki 內容回答,沒寫的就說沒有;`<密碼>` 等占位符不要猜測;改動系統的指令先跟使用者確認。