Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcbcb79506 | ||
|
|
866863f842 | ||
|
|
4aba330877 | ||
|
|
f9bdf1b2ed | ||
|
|
d4da01098f | ||
|
|
8459330cb9 | ||
|
|
7e6e323473 | ||
|
|
eeed12741e | ||
|
|
6f7b4d91ea |
@@ -10,7 +10,7 @@ JSC 自用 Claude Code mods 的 marketplace。每個插件皆可單獨安裝、
|
|||||||
|
|
||||||
輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟。
|
輸入框上方單行狀態列:模型、目錄、分支、5h/7d 額度與重置、context、CPU/記憶體/磁碟。
|
||||||
|
|
||||||
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.1`
|
### [jsc-mod-companion](plugins/jsc-mod-companion) `0.0.3`
|
||||||
|
|
||||||
小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色。
|
小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色。
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "jsc-mod-companion",
|
"name": "jsc-mod-companion",
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
|
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、作息與主動關心、外部素材與待辦,並可用 ingest skill 解析小說建立角色",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "JSC"
|
"name": "JSC"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
### `/jsc-mod-companion:setup [key=value…]`
|
### `/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
|
```text
|
||||||
/jsc-mod-companion:setup nickname=阿明 wake=07:30 sleep=23:30 tz=+0800 careLimit=3 quietStart=22:00 quietEnd=08:00
|
/jsc-mod-companion:setup nickname=阿明 wake=07:30 sleep=23:30 tz=+0800 careLimit=3 quietStart=22:00 quietEnd=08:00
|
||||||
@@ -77,6 +77,15 @@
|
|||||||
/jsc-mod-companion:quiet 30
|
/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`
|
### `/jsc-mod-companion:ingest`
|
||||||
|
|
||||||
解析單章小說(txt/md),逐一詢問合併、新建或略過後寫入角色資料;提供章節檔或要求建立角色時也會自動使用。
|
解析單章小說(txt/md),逐一詢問合併、新建或略過後寫入角色資料;提供章節檔或要求建立角色時也會自動使用。
|
||||||
@@ -127,3 +136,61 @@
|
|||||||
- 事件的情感注入、疲勞易怒(精力低於 0.3 時負面變化 ×1.5)、正向習慣化(重複 ×0.7ⁿ)、負向敏感化(重複最多 ×2),計次以 6 小時半衰期遺忘。
|
- 事件的情感注入、疲勞易怒(精力低於 0.3 時負面變化 ×1.5)、正向習慣化(重複 ×0.7ⁿ)、負向敏感化(重複最多 ×2),計次以 6 小時半衰期遺忘。
|
||||||
- 舊的 PAD 存檔讀檔時自動映射(p→心情、a→精力、d→害羞)並升級,不需重建。
|
- 舊的 PAD 存檔讀檔時自動映射(p→心情、a→精力、d→害羞)並升級,不需重建。
|
||||||
- 與第 9 章的差異:晝夜節律振幅、習慣化係數等數值為本實作自訂,尚未逐項對照第 9 章。
|
- 與第 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 會帶上目前時段、離上次互動多久;`/status` 顯示同樣資訊。
|
||||||
|
- 主動發話沿用 `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),適應度越高越不想家;`/status` 顯示適應度與描述。
|
||||||
|
- 舊存檔沒有適應度時,從升級當天起算 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 ? '相當熟悉' : '像本地人'
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import type { Clock, Fs } from './io'
|
|||||||
import { readJson, writeJson } from './io'
|
import { readJson, writeJson } from './io'
|
||||||
import { isVerifyCommand } from './verify'
|
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 Stage = 'stranger' | 'familiar' | 'close' | 'special'
|
||||||
export type Attribute = 'tsundere' | 'genki' | 'calm' | 'gentle'
|
export type Attribute = 'tsundere' | 'genki' | 'calm' | 'gentle'
|
||||||
export type Delivery = 'toast' | 'inject'
|
export type Delivery = 'toast' | 'inject'
|
||||||
@@ -62,6 +62,12 @@ export interface Signals {
|
|||||||
quotaWarning?: boolean
|
quotaWarning?: boolean
|
||||||
/** 到期的待辦標題 */
|
/** 到期的待辦標題 */
|
||||||
dueTodo?: string
|
dueTodo?: string
|
||||||
|
/** 想念累積到會說出口的程度(回來時) */
|
||||||
|
missing?: boolean
|
||||||
|
/** 想家到會說出口的程度 */
|
||||||
|
homesick?: boolean
|
||||||
|
/** 想聊的一則本地新聞標題 */
|
||||||
|
news?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COOLDOWN_MS: Record<Trigger, number> = {
|
export const COOLDOWN_MS: Record<Trigger, number> = {
|
||||||
@@ -74,6 +80,9 @@ export const COOLDOWN_MS: Record<Trigger, number> = {
|
|||||||
todo_due: 10 * 60_000,
|
todo_due: 10 * 60_000,
|
||||||
test_recovered: 10 * 60_000,
|
test_recovered: 10 * 60_000,
|
||||||
turn_done: 20 * 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},完成了呢,我們一起休息一下吧。`,
|
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',
|
todo_due: 'toast',
|
||||||
test_recovered: 'toast',
|
test_recovered: 'toast',
|
||||||
turn_done: '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 })
|
const initial = (): CareState => ({ lastFired: {}, day: '', count: 0, quietUntil: 0, workStart: 0, lastActivity: 0, failStreak: 0 })
|
||||||
|
|
||||||
export interface CareEngine {
|
export interface CareEngine {
|
||||||
/** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */
|
/** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */
|
||||||
onUserTurn(): Promise<string[]>
|
onUserTurn(signals?: Signals): Promise<string[]>
|
||||||
/** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */
|
/** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */
|
||||||
onToolResult(ok: boolean, command?: string): Promise<boolean>
|
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 (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 (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 (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 out
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async onUserTurn() {
|
async onUserTurn(sig = {}) {
|
||||||
const s = await load()
|
const s = await load()
|
||||||
const now = d.clock()
|
const now = d.clock()
|
||||||
const first = s.lastActivity === 0
|
const first = s.lastActivity === 0
|
||||||
@@ -466,7 +559,7 @@ export function createCare(d: CareDeps): CareEngine {
|
|||||||
s.lastActivity = now
|
s.lastActivity = now
|
||||||
const toInject: Care[] = []
|
const toInject: Care[] = []
|
||||||
if (!muted(s, now)) {
|
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
|
if (DELIVERY[t] !== 'inject') continue
|
||||||
const c = fire(s, t, now, extra)
|
const c = fire(s, t, now, extra)
|
||||||
if (c) {
|
if (c) {
|
||||||
|
|||||||
@@ -362,6 +362,33 @@ describe('記憶、待辦與關心', () => {
|
|||||||
expect(await c.prompt()).toContain('payment')
|
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 () => {
|
test('太短的提示不寫入記憶', async () => {
|
||||||
const { c, fs } = setup()
|
const { c, fs } = setup()
|
||||||
await c.ensureActivated()
|
await c.ensureActivated()
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ import type { LegacyAffinity } from './relation'
|
|||||||
import { FUBUKI } from './defaults'
|
import { FUBUKI } from './defaults'
|
||||||
import { readJson, writeJson } from './fs'
|
import { readJson, writeJson } from './fs'
|
||||||
import type { Clock, Fs } from './fs'
|
import type { Clock, Fs } from './fs'
|
||||||
import { applyEvent, baselineOf, decayEmotions, dominant, fromPad, gauge, ranked, toPad } from './emotion'
|
import { applyEvent, baselineOf, clampDim, decayEmotions, dominant, fromPad, gauge, ranked, toPad } from './emotion'
|
||||||
import type { Emotions } from './emotion'
|
import type { Emotions } from './emotion'
|
||||||
import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
|
import { keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
|
||||||
import type { MoodEvent } from './mood'
|
import type { MoodEvent } from './mood'
|
||||||
import { extraversionOf, neuroticismOf } from './core/params'
|
import { extraversionOf, neuroticismOf } from './core/params'
|
||||||
import { extractTag } from './tags'
|
import { extractTag, stripTag } from './tags'
|
||||||
import { createCare } from './care'
|
import { createCare, IDLE_MS } from './care'
|
||||||
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
|
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
|
||||||
import { createMemory } from './memory'
|
import { categoryBonus, createMemory, lastQuestion } from './memory'
|
||||||
import type { MemoryStore } from './memory'
|
import type { MemoryStore } from './memory'
|
||||||
import { buildPrompt } from './prompt'
|
import { buildPrompt } from './prompt'
|
||||||
import { createTodos } from './todo'
|
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 type { TodoList } from './todo'
|
||||||
import { STAGES } from './types'
|
import { STAGES } from './types'
|
||||||
import type { CompanionCore, CompanionState, Dims, Global, Pad, UserSettings } from './types'
|
import type { CompanionCore, CompanionState, Dims, Global, Pad, UserSettings } from './types'
|
||||||
@@ -26,6 +33,8 @@ export const DEFAULT_USER: UserSettings = {
|
|||||||
tz: '+0800',
|
tz: '+0800',
|
||||||
careLimit: 5,
|
careLimit: 5,
|
||||||
quiet: { start: '', end: '' },
|
quiet: { start: '', end: '' },
|
||||||
|
location: '',
|
||||||
|
feeds: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export type View = {
|
export type View = {
|
||||||
@@ -37,6 +46,13 @@ export type View = {
|
|||||||
relation: Dims
|
relation: Dims
|
||||||
stage: string
|
stage: string
|
||||||
todos: number
|
todos: number
|
||||||
|
/** 適應度 0–100 */
|
||||||
|
adapt: number
|
||||||
|
/** 離上次互動的毫秒數(時間感) */
|
||||||
|
absentMs: number
|
||||||
|
period: string
|
||||||
|
/** 快取中仍有效的天氣標題;未啟用或過期為 null */
|
||||||
|
weather: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[] }
|
export type CompanionOptions = { fs: Fs; clock: Clock; root: string; builtins?: CompanionCore[] }
|
||||||
@@ -91,6 +107,42 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
return { first: true, name: def.name }
|
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> {
|
async function view(): Promise<View | null> {
|
||||||
const { active } = await getGlobal()
|
const { active } = await getGlobal()
|
||||||
@@ -99,13 +151,20 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
const st = core && (await getState(active, core))
|
const st = core && (await getState(active, core))
|
||||||
if (!core || !st) return null
|
if (!core || !st) return null
|
||||||
const now = clock.now()
|
const now = clock.now()
|
||||||
const base = baseOf(core)
|
const env = await envFor(active)
|
||||||
const emotions = decayEmotions(st.emotion, base, now, neuroticismOf(core, st), tzMinutes((await getUser()).tz))
|
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 mood = toPad(emotions, base, core.baseline)
|
||||||
const main = dominant(emotions)
|
const main = dominant(emotions)
|
||||||
const rel = idleDecay(st.relation, now)
|
const rel = idleDecay(st.relation, now)
|
||||||
const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0
|
const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0
|
||||||
return { core, mood, emotions, ...(main ?? moodLabel(mood)), relation: effective(rel), stage: STAGES[rel.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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) {
|
async function mutate(fn: (core: CompanionCore, st: CompanionState, now: number) => CompanionState) {
|
||||||
@@ -114,13 +173,17 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
const core = await getCore(active)
|
const core = await getCore(active)
|
||||||
const st = core && (await getState(active, core))
|
const st = core && (await getState(active, core))
|
||||||
if (!core || !st) return
|
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) {
|
async function moodEvent(ev: MoodEvent) {
|
||||||
const tz = (await getUser()).tz
|
const { active } = await getGlobal()
|
||||||
|
if (!active) return
|
||||||
|
const env = await envFor(active)
|
||||||
await mutate((core, st, now) => {
|
await mutate((core, st, now) => {
|
||||||
const cur = decayEmotions(st.emotion, baseOf(core), now, neuroticismOf(core, st), tzMinutes(tz))
|
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)
|
const next = applyEvent(cur, st.habit, ev, now)
|
||||||
return { ...st, emotion: { ...next.emotions, at: now }, habit: next.habit }
|
return { ...st, emotion: { ...next.emotions, at: now }, habit: next.habit }
|
||||||
})
|
})
|
||||||
@@ -142,6 +205,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
for (const d of all ? dims : dims.slice(0, 3)) lines.push(` ${gauge(d, v.emotions)}`)
|
for (const d of all ? dims : dims.slice(0, 3)) lines.push(` ${gauge(d, v.emotions)}`)
|
||||||
lines.push(`好感:${v.stage}(${v.relation.affection.toFixed(1)})`)
|
lines.push(`好感:${v.stage}(${v.relation.affection.toFixed(1)})`)
|
||||||
lines.push(`關係:好感 ${v.relation.affection.toFixed(1)} 信賴 ${v.relation.trust.toFixed(1)} 親密 ${v.relation.intimacy.toFixed(1)} 依賴 ${v.relation.dependence.toFixed(1)}`)
|
lines.push(`關係:好感 ${v.relation.affection.toFixed(1)} 信賴 ${v.relation.trust.toFixed(1)} 親密 ${v.relation.intimacy.toFixed(1)} 依賴 ${v.relation.dependence.toFixed(1)}`)
|
||||||
|
lines.push(`適應度:${Math.round(v.adapt)}(${describeAdapt(v.adapt)})`)
|
||||||
|
lines.push(`時間感:現在是${v.period},距離上次互動 ${describeAbsence(v.absentMs)}${v.weather ? `|天氣:${v.weather}` : ''}`)
|
||||||
lines.push(`待辦:${v.todos}`)
|
lines.push(`待辦:${v.todos}`)
|
||||||
lines.push('基本資訊:')
|
lines.push('基本資訊:')
|
||||||
for (const [k, f] of Object.entries(v.core.facts)) {
|
for (const [k, f] of Object.entries(v.core.facts)) {
|
||||||
@@ -167,7 +232,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
|
|
||||||
async function setup(args: string[]): Promise<string> {
|
async function setup(args: string[]): Promise<string> {
|
||||||
const u = await getUser()
|
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[] = []
|
const bad: string[] = []
|
||||||
for (const a of args) {
|
for (const a of args) {
|
||||||
const i = a.indexOf('=')
|
const i = a.indexOf('=')
|
||||||
@@ -178,17 +243,22 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
const n = Number(val)
|
const n = Number(val)
|
||||||
if (!Number.isInteger(n) || n < 0) bad.push(a)
|
if (!Number.isInteger(n) || n < 0) bad.push(a)
|
||||||
else u.careLimit = n
|
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 === 'quietStart') u.quiet.start = val
|
||||||
else if (k === 'quietEnd') u.quiet.end = val
|
else if (k === 'quietEnd') u.quiet.end = val
|
||||||
else (u as Record<string, unknown>)[k] = 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)
|
if (args.length) await writeJson(fs, userPath, u)
|
||||||
return [
|
return [
|
||||||
args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):',
|
args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):',
|
||||||
` nickname=${u.nickname}`,
|
` nickname=${u.nickname}`,
|
||||||
` wake=${u.wake} sleep=${u.sleep} tz=${u.tz}`,
|
` wake=${u.wake} sleep=${u.sleep} tz=${u.tz}`,
|
||||||
` careLimit=${u.careLimit} quietStart=${u.quiet.start} quietEnd=${u.quiet.end}`,
|
` careLimit=${u.careLimit} quietStart=${u.quiet.start} quietEnd=${u.quiet.end}`,
|
||||||
|
` location=${u.location} feeds=${u.feeds.join(',')}`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +274,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
if (sub === 'setup') return setup(rest)
|
if (sub === 'setup') return setup(rest)
|
||||||
if (sub === 'todo') return todoCommand(rest)
|
if (sub === 'todo') return todoCommand(rest)
|
||||||
if (sub === 'quiet') return quietCommand(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:status | :switch <角色> | :disable | :setup [key=value…] | :todo … | :quiet [分鐘] | :materials [list]'
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARE_STAGES: CareProfile['stage'][] = ['stranger', 'familiar', 'close', 'special']
|
const CARE_STAGES: CareProfile['stage'][] = ['stranger', 'familiar', 'close', 'special']
|
||||||
@@ -230,7 +301,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
const v = await view()
|
const v = await view()
|
||||||
if (!v) return null
|
if (!v) return null
|
||||||
const u = await getUser()
|
const u = await getUser()
|
||||||
const st = await getState(v.core.id)
|
const st = await getState(v.core.id, v.core)
|
||||||
const qs = hourOf(u.quiet.start)
|
const qs = hourOf(u.quiet.start)
|
||||||
const qe = hourOf(u.quiet.end)
|
const qe = hourOf(u.quiet.end)
|
||||||
ctx = {
|
ctx = {
|
||||||
@@ -263,6 +334,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
// 使用者送出提示時:更新心情與好感、寫入並檢索記憶;回傳這回合要附加給模型的關心情境
|
// 使用者送出提示時:更新心情與好感、寫入並檢索記憶;回傳這回合要附加給模型的關心情境
|
||||||
async function onPrompt(text: string): Promise<string[]> {
|
async function onPrompt(text: string): Promise<string[]> {
|
||||||
lastText = text
|
lastText = text
|
||||||
|
const proactive = await settleReturn()
|
||||||
const ev = keywordEvent(text)
|
const ev = keywordEvent(text)
|
||||||
regexHit = ev !== null
|
regexHit = ev !== null
|
||||||
if (ev) await moodEvent(ev)
|
if (ev) await moodEvent(ev)
|
||||||
@@ -271,12 +343,89 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
const b = await bundleFor()
|
const b = await bundleFor()
|
||||||
if (!b) return []
|
if (!b) return []
|
||||||
const v = await view()
|
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) {
|
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()
|
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 } = {}) {
|
async function onToolResult(ok: boolean, info: { command?: string } = {}) {
|
||||||
@@ -295,6 +444,9 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
const tag = regexHit ? null : extractTag(reply)
|
const tag = regexHit ? null : extractTag(reply)
|
||||||
regexHit = true
|
regexHit = true
|
||||||
if (tag) await moodEvent(tag)
|
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
|
if (!usedTools) return
|
||||||
await b.care.onTurnComplete(usedTools)
|
await b.care.onTurnComplete(usedTools)
|
||||||
await moodEvent('success')
|
await moodEvent('success')
|
||||||
@@ -311,7 +463,10 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
|
|
||||||
async function end() {
|
async function end() {
|
||||||
const b = await bundleFor()
|
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 }) =>
|
const fmtTodo = (t: { id: number; title: string; promise: boolean; dueAt?: number; done: boolean }) =>
|
||||||
@@ -349,6 +504,22 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
|||||||
return '用法:/jsc-mod-companion:todo [list] | add <內容> [due=YYYY-MM-DDTHH:MM] | promise <內容> | done <編號>'
|
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> {
|
async function quietCommand(rest: string[]): Promise<string> {
|
||||||
const b = await bundleFor()
|
const b = await bundleFor()
|
||||||
if (!b) return '目前沒有啟用角色。'
|
if (!b) return '目前沒有啟用角色。'
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { CompanionCore } from './types'
|
|||||||
|
|
||||||
const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/'
|
const OFFICIAL = 'https://hololive.hololivepro.com/en/talents/shirakami-fubuki/'
|
||||||
|
|
||||||
// 事實欄位與招牌短語帶官方來源;persona 是原創的語氣描述(非官方設定),可在本機 core.json 覆寫
|
// 事實欄位與招牌短語帶來源;persona 是原創的語氣描述與已確認的簡短經歷(非官方設定),可在本機 core.json 覆寫
|
||||||
|
// 不含使用者記憶、與使用者的關係和狀態,也不含會過期的近況
|
||||||
export const FUBUKI: CompanionCore = {
|
export const FUBUKI: CompanionCore = {
|
||||||
id: 'fubuki',
|
id: 'fubuki',
|
||||||
name: '白上吹雪',
|
name: '白上吹雪',
|
||||||
@@ -11,17 +12,19 @@ export const FUBUKI: CompanionCore = {
|
|||||||
zodiac: { value: '天秤座', source: `${OFFICIAL}(由生日推得)`, kind: 'official', confidence: 0.9 },
|
zodiac: { value: '天秤座', source: `${OFFICIAL}(由生日推得)`, kind: 'official', confidence: 0.9 },
|
||||||
bloodType: 'unknown',
|
bloodType: 'unknown',
|
||||||
hair: { value: '白髮', source: OFFICIAL, kind: 'official', confidence: 0.8 },
|
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 },
|
height: { value: '155cm', source: OFFICIAL, kind: 'official', confidence: 1 },
|
||||||
weight: 'unknown',
|
weight: 'unknown',
|
||||||
measurements: 'unknown',
|
measurements: 'unknown',
|
||||||
},
|
},
|
||||||
phrases: ['Good foxy day!', 'Hi, Friends!'],
|
phrases: ['Good foxy day!', 'Hi, Friends!', 'こんこんきーつね!', 'おはこん ひるこん よるこん', 'おつこーん', 'F R.I.P', 'ネコじゃない!キツネじゃい!'],
|
||||||
persona: [
|
persona: [
|
||||||
'個性:開朗、元氣、愛鬧又有點調皮的狐狸系少女,把使用者當成一起冒險的「Friends」。',
|
'個性:開朗、元氣、愛鬧又有點調皮的狐狸系少女,把使用者當成一起冒險的「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',
|
attribute: 'genki',
|
||||||
extraversion: 70,
|
extraversion: 70,
|
||||||
|
|||||||
@@ -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 assert from './assert'
|
||||||
|
|
||||||
import { memoryFs } from './io'
|
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 H = 3600_000
|
||||||
const T0 = Date.UTC(2026, 9, 12, 10, 0)
|
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() {
|
function setup() {
|
||||||
let now = T0
|
let now = T0
|
||||||
const fs = memoryFs()
|
const fs = memoryFs()
|
||||||
@@ -24,12 +46,8 @@ describe('記憶檢索', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('較新、較重要者在其他條件相同時勝出', async () => {
|
test('較新、較重要者在其他條件相同時勝出', async () => {
|
||||||
const s = setup()
|
// 回合數相隔越久新鮮度越低;檢索會讓被取回的記憶回到 0.8,所以以權重直接比較
|
||||||
const old = await s.mem.add({ layer: 'episodic', text: '討論部署', importance: 5, source: 'u' })
|
assert.ok(weight(mk({ seq: 300 }), 300, T0) > weight(mk({ seq: 0 }), 300, T0))
|
||||||
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)
|
|
||||||
const s2 = setup()
|
const s2 = setup()
|
||||||
await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' })
|
await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 2, source: 'u' })
|
||||||
const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' })
|
const hi = await s2.mem.add({ layer: 'episodic', text: '討論測試', importance: 9, source: 'u' })
|
||||||
@@ -60,39 +78,215 @@ describe('記憶檢索', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('遺忘曲線', () => {
|
describe('記憶權重 W = I_eff × F × A', () => {
|
||||||
test('保留率隨時間下降,重要度與取用次數使其變慢', () => {
|
test('I_eff = 初始重要度/2 + 情緒峰值/40 + 類別加成,限制在 1–5', () => {
|
||||||
const base = { id: 1, layer: 'episodic' as const, text: '', source: '', createdAt: T0, lastAccess: T0 }
|
assert.equal(iEff(mk({ importance: 6 })), 3)
|
||||||
const weak = { ...base, importance: 2, accessCount: 0 }
|
assert.equal(iEff(mk({ importance: 6, peak: 40 })), 4)
|
||||||
const strong = { ...base, importance: 8, accessCount: 0 }
|
assert.equal(iEff(mk({ importance: 6, peak: 40, bonus: 1 })), 5)
|
||||||
const used = { ...weak, accessCount: 5 }
|
assert.equal(iEff(mk({ importance: 10, peak: 100, bonus: 1 })), 5)
|
||||||
assert.ok(retention(weak, T0 + 24 * H) < retention(weak, T0))
|
assert.equal(iEff(mk({ importance: 1 })), 1)
|
||||||
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))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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 s = setup()
|
||||||
const trivial = await s.mem.add({ layer: 'episodic', text: '隨口聊天', importance: 1, source: 'u' })
|
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' })
|
const key = await s.mem.add({ layer: 'semantic', text: '生日是三月', importance: 9, source: 'u', kind: 'long' })
|
||||||
s.advance(10 * 24 * H)
|
s.advance(10 * DAY)
|
||||||
assert.ok(retention(trivial, s.now()) < FORGET_BELOW)
|
|
||||||
const r = await s.mem.consolidate({ force: true })
|
const r = await s.mem.consolidate({ force: true })
|
||||||
assert.equal(r.forgotten, 1)
|
assert.equal(r.archived, 1)
|
||||||
const ids = (await s.mem.all()).map(m => m.id)
|
const all = await s.mem.all()
|
||||||
assert.ok(!ids.includes(trivial.id))
|
assert.equal(all.find(m => m.id === trivial.id)!.status, 'archived')
|
||||||
assert.ok(ids.includes(key.id))
|
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 s = setup()
|
||||||
const m = await s.mem.add({ layer: 'episodic', text: '常提起的事', importance: 2, source: 'u' })
|
const t = await s.mem.add({ layer: 'episodic', text: '剛剛的閒聊', importance: 2, source: 'u' })
|
||||||
s.advance(40 * H)
|
const k = await s.mem.add({ layer: 'semantic', text: '喜歡貓', importance: 6, source: 'u' })
|
||||||
await s.mem.recall('常提起')
|
assert.equal(await s.mem.endScene(), 1)
|
||||||
s.advance(40 * H)
|
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 })
|
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 () => {
|
test('累積重要度未達門檻不執行,達門檻才執行並歸零', async () => {
|
||||||
const s = setup()
|
const s = setup()
|
||||||
await s.mem.add({ layer: 'episodic', text: '小事', importance: 5, source: 'u' })
|
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' })
|
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)
|
assert.ok((await s.mem.pending()) >= CONSOLIDATE_THRESHOLD)
|
||||||
const r = await s.mem.consolidate()
|
const r = await s.mem.consolidate()
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { Clock, Fs } from './io'
|
import type { Clock, Fs } from './io'
|
||||||
import { readJson, writeJson } from './io'
|
import { readJson, writeJson } from './io'
|
||||||
|
import { MEMORY as R } from './rules'
|
||||||
|
|
||||||
export type Layer = 'episodic' | 'semantic' | 'emotional' | 'procedural' | 'reflection'
|
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 {
|
export interface Pad {
|
||||||
p: number
|
p: number
|
||||||
a: number
|
a: number
|
||||||
@@ -12,14 +17,40 @@ export interface Pad {
|
|||||||
export interface Memory {
|
export interface Memory {
|
||||||
id: number
|
id: number
|
||||||
layer: Layer
|
layer: Layer
|
||||||
|
kind: Kind
|
||||||
|
status: Status
|
||||||
text: string
|
text: string
|
||||||
/** 重要度 1–10 */
|
/** 初始重要度 1–10(I_eff 以一半換算到 1–5 量表) */
|
||||||
importance: number
|
importance: number
|
||||||
source: string
|
source: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
lastAccess: number
|
lastAccess: number
|
||||||
accessCount: number
|
accessCount: number
|
||||||
|
/** 建立當下的回合序號,新鮮度以回合數衰減 */
|
||||||
|
seq: number
|
||||||
|
/** 最近一次被檢索的回合序號 */
|
||||||
|
hitSeq?: number
|
||||||
emotion?: Pad
|
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 {
|
export interface MemoryDoc {
|
||||||
@@ -30,29 +61,67 @@ export interface MemoryDoc {
|
|||||||
lastConsolidated: number
|
lastConsolidated: number
|
||||||
/** 已被反思涵蓋的最大記憶編號 */
|
/** 已被反思涵蓋的最大記憶編號 */
|
||||||
reflectedUpTo?: number
|
reflectedUpTo?: number
|
||||||
|
/** 回合序號:每次檢索(每個使用者回合)加一 */
|
||||||
|
seq?: number
|
||||||
|
loops?: Loop[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HALF_LIFE_H = 72
|
|
||||||
export const TOP_K = 6
|
export const TOP_K = 6
|
||||||
export const WEIGHTS = { recency: 1, importance: 1, relevance: 2, emotion: 0.5 }
|
|
||||||
/** 累積重要度達此值就鞏固 */
|
/** 累積重要度達此值就鞏固 */
|
||||||
export const CONSOLIDATE_THRESHOLD = 30
|
export const CONSOLIDATE_THRESHOLD = 30
|
||||||
/** 保留率低於此值即遺忘 */
|
|
||||||
export const FORGET_BELOW = 0.1
|
|
||||||
/** 穩定度基準(小時) */
|
|
||||||
const BASE_STABILITY_H = 24
|
|
||||||
const HOUR = 3600_000
|
const HOUR = 3600_000
|
||||||
|
const DAY = 24 * HOUR
|
||||||
|
|
||||||
/** 穩定度(小時)隨重要度與取用次數成長 */
|
const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
|
||||||
export const stability = (m: Pick<Memory, 'importance' | 'accessCount'>) =>
|
|
||||||
BASE_STABILITY_H * m.importance * (1 + Math.log1p(m.accessCount))
|
|
||||||
|
|
||||||
/** 保留率:自最近一次取用起算 */
|
/** 有效重要度 I_eff:初始重要度 + 情緒峰值 / 40 + 類別加成,限制在 1–5 */
|
||||||
export function retention(m: Memory, now: number): number {
|
export const iEff = (m: Pick<Memory, 'importance' | 'peak' | 'bonus'>) =>
|
||||||
const h = Math.max(0, now - m.lastAccess) / HOUR
|
clamp(m.importance / 2 + (m.peak ?? 0) / R.peakDiv + (m.bonus ?? 0), R.weightMin, R.weightMax)
|
||||||
return Math.exp(-h / stability(m))
|
|
||||||
|
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 tokens = (s: string) => {
|
||||||
const out = new Set<string>()
|
const out = new Set<string>()
|
||||||
const latin = s.toLowerCase().match(/[a-z0-9]+/g) ?? []
|
const latin = s.toLowerCase().match(/[a-z0-9]+/g) ?? []
|
||||||
@@ -80,14 +149,20 @@ function moodFit(a?: Pad, b?: Pad): number {
|
|||||||
return 1 - dist
|
return 1 - dist
|
||||||
}
|
}
|
||||||
|
|
||||||
export function score(m: Memory, query: string, now: number, mood?: Pad): number {
|
/** RRF 融合:fused(id) = Σ 1 / (k + rank),rank 從 1 起算;每份排名由好到壞的記憶編號 */
|
||||||
const recency = Math.pow(0.5, Math.max(0, now - m.lastAccess) / HOUR / HALF_LIFE_H)
|
export function rrf(rankings: number[][]): Map<number, number> {
|
||||||
return (
|
const out = new Map<number, number>()
|
||||||
WEIGHTS.recency * recency +
|
for (const list of rankings) list.forEach((id, i) => out.set(id, (out.get(id) ?? 0) + 1 / (R.rrfK + i + 1)))
|
||||||
WEIGHTS.importance * (m.importance / 10) +
|
return out
|
||||||
WEIGHTS.relevance * relevance(query, m.text) +
|
}
|
||||||
WEIGHTS.emotion * moodFit(m.emotion, mood)
|
|
||||||
)
|
/** 外部檢索器(例如語意相似度):回傳由好到壞的記憶編號;目前沒有內建,介面保留 */
|
||||||
|
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)
|
.map(i => i.text)
|
||||||
.join(';')
|
.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 {
|
export interface MemoryStore {
|
||||||
add(input: { layer: Layer; text: string; importance: number; source: string; emotion?: Pad }): Promise<Memory>
|
add(input: AddInput): Promise<Memory>
|
||||||
/** 檢索前 6 筆,並更新取用紀錄 */
|
/** 前 6 筆:關鍵字(可併入外部檢索器)經 RRF 融合 × W + 心情一致加權;回合序號加一並更新取用紀錄 */
|
||||||
recall(query: string, mood?: Pad): Promise<Memory[]>
|
recall(query: string, mood?: Pad, opts?: RecallOpts): Promise<Memory[]>
|
||||||
/** 累積達門檻、會話結束或手動 reflect 時鞏固 */
|
/** 累積達門檻、會話結束或手動 reflect 時鞏固;過期的記憶封存而不刪除 */
|
||||||
consolidate(opts?: { force?: boolean; summarize?: Summarize }): Promise<{ ran: boolean; forgotten: number; reflection?: Memory }>
|
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[]>
|
all(): Promise<Memory[]>
|
||||||
pending(): Promise<number>
|
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 {
|
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)
|
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 {
|
return {
|
||||||
async add(i) {
|
async add(i) {
|
||||||
const d = await load()
|
const d = await load()
|
||||||
const now = clock()
|
const now = clock()
|
||||||
|
const danger = isDanger(i.text)
|
||||||
const m: Memory = {
|
const m: Memory = {
|
||||||
id: d.nextId++,
|
id: d.nextId++,
|
||||||
layer: i.layer,
|
layer: i.layer,
|
||||||
text: redact(i.text),
|
kind: danger ? 'temp' : (i.kind ?? (i.layer === 'episodic' && i.importance < 5 ? 'temp' : 'upgradable')),
|
||||||
importance: Math.min(10, Math.max(1, i.importance)),
|
status: 'active',
|
||||||
|
text: danger ? SAFE_SUMMARY : redact(i.text),
|
||||||
|
importance: clamp(i.importance, 1, 10),
|
||||||
source: i.source,
|
source: i.source,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastAccess: now,
|
lastAccess: now,
|
||||||
accessCount: 0,
|
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.items.push(m)
|
||||||
d.pending += m.importance
|
d.pending += m.importance
|
||||||
@@ -141,54 +282,142 @@ export function createMemory(fs: Fs, clock: Clock, path: string): MemoryStore {
|
|||||||
return m
|
return m
|
||||||
},
|
},
|
||||||
|
|
||||||
async recall(query, mood) {
|
async recall(query, mood, opts = {}) {
|
||||||
const d = await load()
|
const d = await load()
|
||||||
const now = clock()
|
const now = clock()
|
||||||
const top = d.items
|
d.seq!++
|
||||||
.map(m => ({ m, s: score(m, query, now, mood) }))
|
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)
|
.sort((a, b) => b.s - a.s || b.m.id - a.m.id)
|
||||||
.slice(0, TOP_K)
|
.slice(0, TOP_K)
|
||||||
.map(x => x.m)
|
.map(x => x.m)
|
||||||
for (const m of top) {
|
for (const m of top) {
|
||||||
m.lastAccess = now
|
m.lastAccess = now
|
||||||
m.accessCount++
|
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
|
return top
|
||||||
},
|
},
|
||||||
|
|
||||||
async consolidate(opts = {}) {
|
async consolidate(opts = {}) {
|
||||||
const d = await load()
|
const d = await load()
|
||||||
const now = clock()
|
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
|
let reflection: Memory | undefined
|
||||||
if (fresh.length) {
|
if (fresh.length) {
|
||||||
const top = [...fresh].sort((a, b) => b.importance - a.importance || b.id - a.id)
|
const top = [...fresh].sort((a, b) => b.importance - a.importance || b.id - a.id)
|
||||||
reflection = {
|
reflection = {
|
||||||
id: d.nextId++,
|
id: d.nextId++,
|
||||||
layer: 'reflection',
|
layer: 'reflection',
|
||||||
|
kind: 'upgradable',
|
||||||
|
status: 'active',
|
||||||
text: redact((opts.summarize ?? defaultSummarize)(top)),
|
text: redact((opts.summarize ?? defaultSummarize)(top)),
|
||||||
importance: Math.min(10, Math.max(...top.map(t => t.importance)) + 1),
|
importance: Math.min(10, Math.max(...top.map(t => t.importance)) + 1),
|
||||||
source: `reflect:${top.map(t => t.id).join(',')}`,
|
source: `reflect:${top.map(t => t.id).join(',')}`,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastAccess: now,
|
lastAccess: now,
|
||||||
accessCount: 0,
|
accessCount: 0,
|
||||||
|
seq: d.seq!,
|
||||||
}
|
}
|
||||||
d.items.push(reflection)
|
d.items.push(reflection)
|
||||||
}
|
}
|
||||||
|
|
||||||
d.reflectedUpTo = d.nextId - 1
|
d.reflectedUpTo = d.nextId - 1
|
||||||
|
const archived = lifecycle(d, now)
|
||||||
// 遺忘:保留率過低的記憶淘汰(反思本身不在本輪淘汰)
|
|
||||||
const before = d.items.length
|
|
||||||
d.items = d.items.filter(m => m === reflection || retention(m, now) >= FORGET_BELOW)
|
|
||||||
d.pending = 0
|
d.pending = 0
|
||||||
d.lastConsolidated = now
|
d.lastConsolidated = now
|
||||||
await save(d)
|
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() {
|
async all() {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { View } from './core'
|
import type { View } from './core'
|
||||||
|
import { describeAbsence } from './absence'
|
||||||
|
import { describeAdapt } from './adapt'
|
||||||
import { TAGS } from './tags'
|
import { TAGS } from './tags'
|
||||||
|
|
||||||
// 語氣只套用於對話文字與過場話;技術內容原樣保留
|
// 語氣只套用於對話文字與過場話;技術內容原樣保留
|
||||||
@@ -9,6 +11,7 @@ export function buildPrompt(v: View, nickname: string, extras: Extras = {}): str
|
|||||||
const lines = [
|
const lines = [
|
||||||
`你現在以「${core.name}」的身份與使用者相處,完全以她的口吻回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}。`,
|
`你現在以「${core.name}」的身份與使用者相處,完全以她的口吻回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}。`,
|
||||||
`目前心情:${v.label} ${v.kaomoji};與使用者的關係:${v.stage}。心情與關係會自然反映在語氣與用字上。`,
|
`目前心情:${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.persona ?? []),
|
||||||
...(core.phrases?.length ? [`招牌短語(偶爾自然使用,不要每句都用):${core.phrases.join('、')}`] : []),
|
...(core.phrases?.length ? [`招牌短語(偶爾自然使用,不要每句都用):${core.phrases.join('、')}`] : []),
|
||||||
...(extras.memories?.length ? ['你記得和使用者過去的互動(自然帶入即可,不要逐條複述):', ...extras.memories.map(m => `- ${m}`)] : []),
|
...(extras.memories?.length ? ['你記得和使用者過去的互動(自然帶入即可,不要逐條複述):', ...extras.memories.map(m => `- ${m}`)] : []),
|
||||||
|
|||||||
@@ -25,6 +25,42 @@ export const RULES = {
|
|||||||
brokenPromise: { p: -0.12, a: 0.04, d: -0.08 },
|
brokenPromise: { p: -0.12, a: 0.04, d: -0.08 },
|
||||||
} as const satisfies Record<Tag, Pad>
|
} 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][] = [
|
const PATTERNS: [Tag, RegExp][] = [
|
||||||
['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i],
|
['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i],
|
||||||
@@ -42,3 +78,70 @@ export function regexTag(text: string): Tag | null {
|
|||||||
export function classify(text: string, modelTag?: string | null): Tag | null {
|
export function classify(text: string, modelTag?: string | null): Tag | null {
|
||||||
return regexTag(text) ?? (isTag(modelTag) ? modelTag : 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 ? '夜晚' : '深夜'
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Attribute, Trigger } from './care'
|
import type { Attribute, Trigger } from './care'
|
||||||
import type { EmotionState, Emotions, Habit } from './emotion'
|
import type { EmotionState, Emotions, Habit } from './emotion'
|
||||||
import type { ParamVec } from './core/params'
|
import type { ParamVec } from './core/params'
|
||||||
|
import type { Adapt } from './adapt'
|
||||||
|
|
||||||
export type Pad = { p: number; a: number; d: number }
|
export type Pad = { p: number; a: number; d: number }
|
||||||
|
|
||||||
@@ -58,6 +59,10 @@ export type CompanionState = {
|
|||||||
todos: number
|
todos: number
|
||||||
// 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
|
// 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
|
||||||
params?: { save: Partial<ParamVec>; today: Partial<ParamVec>; dayKey: string }
|
params?: { save: Partial<ParamVec>; today: Partial<ParamVec>; dayKey: string }
|
||||||
|
// 適應度(第 9.13 章),隨天數逐日成長,讀取時才換算
|
||||||
|
adapt?: Adapt
|
||||||
|
// 本地新聞:今天已聊幾則,以及最近提過的連結(不重複)
|
||||||
|
news?: { day: number; said: number; seen: string[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserSettings = {
|
export type UserSettings = {
|
||||||
@@ -67,6 +72,10 @@ export type UserSettings = {
|
|||||||
tz: string
|
tz: string
|
||||||
careLimit: number
|
careLimit: number
|
||||||
quiet: { start: string; end: string }
|
quiet: { start: string; end: string }
|
||||||
|
// 所在地(setup location=);有值才啟用外部素材。可填「緯度,經度」或地名
|
||||||
|
location: string
|
||||||
|
// 使用者自設的 RSS 清單,預設空
|
||||||
|
feeds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Global = { active: string | null; activatedOnce: boolean }
|
export type Global = { active: string | null; activatedOnce: boolean }
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
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('/status 相容', () => {
|
||||||
|
test('保留原有各行,並多出適應度與時間感', async () => {
|
||||||
|
const { c } = setup()
|
||||||
|
await c.ensureActivated()
|
||||||
|
const s = await c.command('status')
|
||||||
|
for (const k of ['心情 PAD:', '情緒(前 3 強', '好感:', '關係:', '待辦:', '基本資訊:', '適應度:', '時間感:']) expect(s).toContain(k)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,6 +20,7 @@ const COMMANDS: Record<string, { description: string; argumentHint?: string }> =
|
|||||||
setup: { description: '顯示或儲存使用者設定', argumentHint: '[key=value…]' },
|
setup: { description: '顯示或儲存使用者設定', argumentHint: '[key=value…]' },
|
||||||
todo: { description: '待辦:列出、新增、記錄承諾、完成', argumentHint: '[list | add | promise | done] …' },
|
todo: { description: '待辦:列出、新增、記錄承諾、完成', argumentHint: '[list | add | promise | done] …' },
|
||||||
quiet: { description: '暫時靜音,不主動關心', argumentHint: '[分鐘]' },
|
quiet: { description: '暫時靜音,不主動關心', argumentHint: '[分鐘]' },
|
||||||
|
materials: { description: '外部素材(天氣、新聞)的快取狀態;list 列出新聞', argumentHint: '[list]' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const register: Register = on => {
|
export const register: Register = on => {
|
||||||
|
|||||||
+147
@@ -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),最後才還原 &
|
||||||
|
const decode = s =>
|
||||||
|
s
|
||||||
|
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||||
|
.replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replace(/"/g, '"').replace(/'|'/g, "'").replace(/&/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}¤t=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[第一則 & 標題]]></title><link>https://n.example/1</link><description><p>摘要</p>內文</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, /未啟用/)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user