diff --git a/plugins/jsc-mod-companion/.claude-plugin/plugin.json b/plugins/jsc-mod-companion/.claude-plugin/plugin.json index d104773..685d243 100644 --- a/plugins/jsc-mod-companion/.claude-plugin/plugin.json +++ b/plugins/jsc-mod-companion/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "jsc-mod-companion", - "version": "0.0.4", - "description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色", + "version": "0.0.5", + "description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、作息與主動關心、外部素材與待辦,並可用 ingest skill 解析小說建立角色", "license": "MIT", "author": { "name": "JSC" diff --git a/plugins/jsc-mod-companion/README.md b/plugins/jsc-mod-companion/README.md index 90f13d4..235985b 100644 --- a/plugins/jsc-mod-companion/README.md +++ b/plugins/jsc-mod-companion/README.md @@ -31,7 +31,7 @@ ### `/jsc-mod-companion:setup [key=value…]` -不給參數時顯示目前設定;給 key=value 則儲存。可設定:`nickname`、`wake=HH:MM`、`sleep=HH:MM`、`tz=+0800`、`careLimit=N`(每日主動關心上限)、`quietStart=HH:MM`、`quietEnd=HH:MM`。 +不給參數時顯示目前設定;給 key=value 則儲存。可設定:`nickname`、`wake=HH:MM`、`sleep=HH:MM`、`tz=+0800`、`careLimit=N`(每日主動關心上限)、`quietStart=HH:MM`、`quietEnd=HH:MM`、`location=緯度,經度或地名`(啟用外部素材)、`feeds=網址,網址…`(自設 RSS,預設空清單)。 ```text /jsc-mod-companion:setup nickname=阿明 wake=07:30 sleep=23:30 tz=+0800 careLimit=3 quietStart=22:00 quietEnd=08:00 @@ -77,6 +77,15 @@ /jsc-mod-companion:quiet 30 ``` +### `/jsc-mod-companion:materials [list]` + +查看外部素材(天氣、新聞)的快取狀態;`list` 列出有效新聞的標題與連結。需先 `setup location=` 才啟用。 + +```text +/jsc-mod-companion:materials +/jsc-mod-companion:materials list +``` + ### `/jsc-mod-companion:ingest` 解析單章小說(txt/md),逐一詢問合併、新建或略過後寫入角色資料;提供章節檔或要求建立角色時也會自動使用。 @@ -141,3 +150,47 @@ - 舊存檔缺少的欄位讀檔時自動補上。 與第 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 指向同一支腳本即可。 diff --git a/plugins/jsc-mod-companion/hooks/companion/absence.ts b/plugins/jsc-mod-companion/hooks/companion/absence.ts new file mode 100644 index 0000000..6be45f4 --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/absence.ts @@ -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)} 天` +} diff --git a/plugins/jsc-mod-companion/hooks/companion/adapt.ts b/plugins/jsc-mod-companion/hooks/companion/adapt.ts new file mode 100644 index 0000000..a479f72 --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/adapt.ts @@ -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 ? '相當熟悉' : '像本地人' +} diff --git a/plugins/jsc-mod-companion/hooks/companion/care.ts b/plugins/jsc-mod-companion/hooks/companion/care.ts index b71d3ee..37b135d 100644 --- a/plugins/jsc-mod-companion/hooks/companion/care.ts +++ b/plugins/jsc-mod-companion/hooks/companion/care.ts @@ -2,7 +2,7 @@ import type { Clock, Fs } from './io' import { readJson, writeJson } from './io' import { isVerifyCommand } from './verify' -export type Trigger = 'session_start' | 'idle_return' | 'long_work' | 'late_night' | 'tool_failures' | 'quota_warning' | 'todo_due' | 'test_recovered' | 'turn_done' +export type Trigger = 'session_start' | 'idle_return' | 'long_work' | 'late_night' | 'tool_failures' | 'quota_warning' | 'todo_due' | 'test_recovered' | 'turn_done' | 'miss_you' | 'homesick' | 'news' export type Stage = 'stranger' | 'familiar' | 'close' | 'special' export type Attribute = 'tsundere' | 'genki' | 'calm' | 'gentle' export type Delivery = 'toast' | 'inject' @@ -62,6 +62,12 @@ export interface Signals { quotaWarning?: boolean /** 到期的待辦標題 */ dueTodo?: string + /** 想念累積到會說出口的程度(回來時) */ + missing?: boolean + /** 想家到會說出口的程度 */ + homesick?: boolean + /** 想聊的一則本地新聞標題 */ + news?: string } export const COOLDOWN_MS: Record = { @@ -74,6 +80,9 @@ export const COOLDOWN_MS: Record = { todo_due: 10 * 60_000, test_recovered: 10 * 60_000, turn_done: 20 * 60_000, + miss_you: 6 * 3600_000, + homesick: 12 * 3600_000, + news: 4 * 3600_000, } /** 會讓角色疲乏的工作事件反應:越常說越容易膩 */ @@ -358,6 +367,84 @@ const L: Record>> = { 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 = { todo_due: 'toast', test_recovered: 'toast', turn_done: 'toast', + miss_you: 'inject', + homesick: 'inject', + news: 'inject', } const initial = (): CareState => ({ lastFired: {}, day: '', count: 0, quietUntil: 0, workStart: 0, lastActivity: 0, failStreak: 0 }) export interface CareEngine { /** 使用者送出訊息:維護作息並回傳這回合要注入的情境 */ - onUserTurn(): Promise + onUserTurn(signals?: Signals): Promise /** 工具結果:連續失敗計數;回傳這次是否讓驗證指令轉為通過 */ onToolResult(ok: boolean, command?: string): Promise /** 一輪回覆結束;有呼叫過工具才算做完事 */ @@ -451,11 +541,14 @@ export function createCare(d: CareDeps): CareEngine { if (s.failStreak >= FAIL_THRESHOLD) out.push({ t: 'tool_failures' }) if (inRange(hourOf(now), LATE.start, LATE.end) && s.lastActivity > 0) out.push({ t: 'late_night' }) if (s.workStart > 0 && now - s.workStart >= LONG_WORK_MS) out.push({ t: 'long_work' }) + if (sig.missing) out.push({ t: 'miss_you' }) + if (sig.homesick) out.push({ t: 'homesick' }) + if (sig.news) out.push({ t: 'news', extra: sig.news }) return out } return { - async onUserTurn() { + async onUserTurn(sig = {}) { const s = await load() const now = d.clock() const first = s.lastActivity === 0 @@ -466,7 +559,7 @@ export function createCare(d: CareDeps): CareEngine { s.lastActivity = now const toInject: Care[] = [] if (!muted(s, now)) { - for (const { t, extra } of candidates(s, now, {})) { + for (const { t, extra } of candidates(s, now, sig)) { if (DELIVERY[t] !== 'inject') continue const c = fire(s, t, now, extra) if (c) { diff --git a/plugins/jsc-mod-companion/hooks/companion/core.ts b/plugins/jsc-mod-companion/hooks/companion/core.ts index ecddfce..4acd7bb 100644 --- a/plugins/jsc-mod-companion/hooks/companion/core.ts +++ b/plugins/jsc-mod-companion/hooks/companion/core.ts @@ -3,18 +3,25 @@ import type { LegacyAffinity } from './relation' import { FUBUKI } from './defaults' import { readJson, writeJson } 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 { keywordEvent, MOOD_EVENTS, moodLabel } from './mood' import type { MoodEvent } from './mood' import { extraversionOf, neuroticismOf } from './core/params' 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 { categoryBonus, createMemory, lastQuestion } from './memory' import type { MemoryStore } from './memory' import { buildPrompt } from './prompt' import { createTodos } from './todo' +import { describeAbsence, missingAccrual } from './absence' +import { dayNumber, describeAdapt, growthRate, homesickBase, initialAdapt, grow, markFirst, newsQuota } from './adapt' +import type { Adapt } from './adapt' +import { EMPTY, readMaterials, weatherShift } from './materials' +import type { Materials } from './materials' +import { ADAPT, MATERIALS, MISSING } from './rules' +import { energyShift, minutesOf, periodOf } from './schedule' import type { TodoList } from './todo' import { STAGES } from './types' import type { CompanionCore, CompanionState, Dims, Global, Pad, UserSettings } from './types' @@ -26,6 +33,8 @@ export const DEFAULT_USER: UserSettings = { tz: '+0800', careLimit: 5, quiet: { start: '', end: '' }, + location: '', + feeds: [], } export type View = { @@ -37,6 +46,13 @@ export type View = { relation: Dims stage: string 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[] } @@ -91,6 +107,42 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan return { first: true, name: def.name } } + // 外部素材:只讀排程腳本寫的快取,絕不連網;一分鐘內重複讀取用記憶中的值 + let matsCache: { at: number; data: Materials } | null = null + async function materialsNow(user: UserSettings): Promise { + 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 { + 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 { const { active } = await getGlobal() @@ -99,13 +151,20 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan const st = core && (await getState(active, core)) if (!core || !st) return null const now = clock.now() - const base = baseOf(core) - const emotions = decayEmotions(st.emotion, base, now, neuroticismOf(core, st), tzMinutes((await getUser()).tz)) + const env = await envFor(active) + const base = baselineAt(core, st, now, env) + const absentMs = Math.max(0, now - st.relation.lastSeen) + const emotions = decayEmotions(st.emotion, base, now, neuroticismOf(core, st), tzMinutes(env.user.tz)) + // 想念隨離開時間累積(時間感):取累積值與已存的較大者 + emotions.missing = Math.max(emotions.missing, missingAccrual(absentMs, core.params?.miss)) const mood = toPad(emotions, base, core.baseline) const main = dominant(emotions) const rel = idleDecay(st.relation, now) const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0 - return { core, mood, 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) { @@ -114,13 +173,17 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan const core = await getCore(active) const st = core && (await getState(active, core)) if (!core || !st) return - await putState(active, fn(core, st, clock.now())) + const now = clock.now() + const tz = tzMinutes((await getUser()).tz) + await putState(active, fn(core, { ...st, adapt: adaptOf(core, st, now, tz) }, now)) } async function moodEvent(ev: MoodEvent) { - const tz = (await getUser()).tz + const { active } = await getGlobal() + if (!active) return + const env = await envFor(active) 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) 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)}`) 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(`適應度:${Math.round(v.adapt)}(${describeAdapt(v.adapt)})`) + lines.push(`時間感:現在是${v.period},距離上次互動 ${describeAbsence(v.absentMs)}${v.weather ? `|天氣:${v.weather}` : ''}`) lines.push(`待辦:${v.todos}`) lines.push('基本資訊:') 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 { const u = await getUser() - const known = ['nickname', 'wake', 'sleep', 'tz', 'careLimit', 'quietStart', 'quietEnd'] + const known = ['nickname', 'wake', 'sleep', 'tz', 'careLimit', 'quietStart', 'quietEnd', 'location', 'feeds'] const bad: string[] = [] for (const a of args) { const i = a.indexOf('=') @@ -178,17 +243,22 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan const n = Number(val) if (!Number.isInteger(n) || n < 0) bad.push(a) else u.careLimit = n + } else if (k === 'feeds') { + const urls = val.split(',').map(x => x.trim()).filter(Boolean) + if (urls.some(x => !/^https?:\/\/\S+$/.test(x))) bad.push(a) + else u.feeds = urls } else if (k === 'quietStart') u.quiet.start = val else if (k === 'quietEnd') u.quiet.end = val else (u as Record)[k] = val } - if (bad.length) return `無法解析:${bad.join(' ')}\n用法:/jsc-mod-companion:setup nickname=… wake=HH:MM sleep=HH:MM tz=+0800 careLimit=N quietStart=HH:MM quietEnd=HH:MM` + if (bad.length) return `無法解析:${bad.join(' ')}\n用法:/jsc-mod-companion:setup nickname=… wake=HH:MM sleep=HH:MM tz=+0800 careLimit=N quietStart=HH:MM quietEnd=HH:MM location=緯度,經度或地名 feeds=RSS網址,…` if (args.length) await writeJson(fs, userPath, u) return [ args.length ? '設定已儲存:' : '目前設定(以 key=value 修改):', ` nickname=${u.nickname}`, ` wake=${u.wake} sleep=${u.sleep} tz=${u.tz}`, ` careLimit=${u.careLimit} quietStart=${u.quiet.start} quietEnd=${u.quiet.end}`, + ` location=${u.location} feeds=${u.feeds.join(',')}`, ].join('\n') } @@ -204,7 +274,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan if (sub === 'setup') return setup(rest) if (sub === 'todo') return todoCommand(rest) if (sub === 'quiet') return quietCommand(rest) - return '用法:/jsc-mod-companion:status | :switch <角色> | :disable | :setup [key=value…] | :todo … | :quiet [分鐘]' + if (sub === 'materials') return materialsCommand(rest) + return '用法:/jsc-mod-companion:status | :switch <角色> | :disable | :setup [key=value…] | :todo … | :quiet [分鐘] | :materials [list]' } 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() if (!v) return null 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 qe = hourOf(u.quiet.end) ctx = { @@ -263,6 +334,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan // 使用者送出提示時:更新心情與好感、寫入並檢索記憶;回傳這回合要附加給模型的關心情境 async function onPrompt(text: string): Promise { lastText = text + const proactive = await settleReturn() const ev = keywordEvent(text) regexHit = ev !== null if (ev) await moodEvent(ev) @@ -289,7 +361,71 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan }) 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 { + 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 { + 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 } = {}) { @@ -368,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 <編號>' } + const age = (ms: number) => describeAbsence(Math.max(0, ms)) + + async function materialsCommand(rest: string[]): Promise { + 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 { const b = await bundleFor() if (!b) return '目前沒有啟用角色。' diff --git a/plugins/jsc-mod-companion/hooks/companion/materials.ts b/plugins/jsc-mod-companion/hooks/companion/materials.ts new file mode 100644 index 0000000..a9cbb95 --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/materials.ts @@ -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 { + 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)) +} diff --git a/plugins/jsc-mod-companion/hooks/companion/prompt.ts b/plugins/jsc-mod-companion/hooks/companion/prompt.ts index 03bec2c..c6959e9 100644 --- a/plugins/jsc-mod-companion/hooks/companion/prompt.ts +++ b/plugins/jsc-mod-companion/hooks/companion/prompt.ts @@ -1,4 +1,6 @@ import type { View } from './core' +import { describeAbsence } from './absence' +import { describeAdapt } from './adapt' import { TAGS } from './tags' // 語氣只套用於對話文字與過場話;技術內容原樣保留 @@ -9,6 +11,7 @@ export function buildPrompt(v: View, nickname: string, extras: Extras = {}): str const lines = [ `你現在以「${core.name}」的身份與使用者相處,完全以她的口吻回覆${nickname ? `,稱呼使用者「${nickname}」` : ''}。`, `目前心情:${v.label} ${v.kaomoji};與使用者的關係:${v.stage}。心情與關係會自然反映在語氣與用字上。`, + `時間感:現在是${v.period}${v.absentMs >= 2 * 3_600_000 ? `,距離上次互動已過 ${describeAbsence(v.absentMs)}` : ''}${v.weather ? `,天氣${v.weather}` : ''}。對這個世界的適應度 ${Math.round(v.adapt)}(${describeAdapt(v.adapt)}):適應度低時好奇、愛問、不假裝懂本地常識;高時熟悉本地事物。想念或想家時可自然流露,不責備使用者。`, ...(core.persona ?? []), ...(core.phrases?.length ? [`招牌短語(偶爾自然使用,不要每句都用):${core.phrases.join('、')}`] : []), ...(extras.memories?.length ? ['你記得和使用者過去的互動(自然帶入即可,不要逐條複述):', ...extras.memories.map(m => `- ${m}`)] : []), diff --git a/plugins/jsc-mod-companion/hooks/companion/rules.ts b/plugins/jsc-mod-companion/hooks/companion/rules.ts index a6a1b31..88dd76f 100644 --- a/plugins/jsc-mod-companion/hooks/companion/rules.ts +++ b/plugins/jsc-mod-companion/hooks/companion/rules.ts @@ -78,3 +78,70 @@ export function regexTag(text: string): Tag | null { export function classify(text: string, modelTag?: string | null): Tag | null { return regexTag(text) ?? (isTag(modelTag) ? modelTag : null) } + +// 作息與疲勞(第 9.12 章的簡化):只動精力基準,隨讀取計算、不寫回 +export const SCHEDULE = { + /** 醒著超過這麼多小時後,每小時精力基準 −perHour,最多 −awakeMax */ + awakeFreeH: 12, + perHour: 0.02, + awakeMax: 0.15, + /** 連續工作超過這麼多小時後,每小時 −workPerHour,最多 −workMax */ + workFreeH: 1, + workPerHour: 0.03, + workMax: 0.15, + /** 使用者的睡眠時段內仍在線上(熬夜):精力基準再 −lateShift */ + lateShift: 0.1, + /** 起床後這麼多小時內的「剛起床」微幅回復 */ + wakeBoostH: 2, + wakeBoost: 0.03, +} as const + +// 想念累積與時間感:想念 = 速度 × max(0, 離開小時 − graceH),上限 max;速度隨「想念累積速度」參數調整 +export const MISSING = { + graceH: 2, + perHour: 0.03, + max: 0.8, + /** 想念達此值,回來時主動說出口 */ + speakAt: 0.5, + /** 離開這麼久以上再回來,才算「回來」事件 */ + returnH: 6, +} as const + +// 適應度(第 9.13 章):每日 ΔA = k × (1 − A/100),k = 1.5 + 1.5 × (主動度 + 想像力)/2;本地第一次事件 +2 +export const ADAPT = { + kBase: 1.5, + kSpan: 1.5, + first: 2, + /** 想家基準 = homesickMax × (1 − A/100) */ + homesickMax: 0.25, + /** 想家達此值,主動說出口 */ + homesickSpeakAt: 0.4, + /** 適應度達此值才聊本地新聞;達 newsTwo 時每天最多 2 則,否則 1 則 */ + newsMin: 40, + newsTwo: 70, +} as const + +// 外部素材:hook 不連網,只讀由排程腳本寫入的快取;過期或缺失當作沒查 +export const MATERIALS = { + weatherTtlMs: 6 * 3_600_000, + newsTtlMs: 24 * 3_600_000, + /** 讀取快取的間隔,避免 band 每次重繪都讀檔 */ + readEveryMs: 60_000, + newsPerDay: 2, + /** 記住已經提過的新聞連結上限 */ + seenMax: 50, + /** 天氣只微調心情基準,上限 */ + weatherCap: 0.06, +} as const + +/** 天氣狀況(快取的標題)→ 心情基準微調 */ +export const WEATHER_MOOD: Record = { + 晴: 0.04, + 多雲: 0.01, + 陰: -0.01, + 霧: -0.02, + 毛毛雨: -0.02, + 雨: -0.04, + 雪: 0.01, + 雷雨: -0.06, +} diff --git a/plugins/jsc-mod-companion/hooks/companion/schedule.ts b/plugins/jsc-mod-companion/hooks/companion/schedule.ts new file mode 100644 index 0000000..9625b76 --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/schedule.ts @@ -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 ? '夜晚' : '深夜' +} diff --git a/plugins/jsc-mod-companion/hooks/companion/types.ts b/plugins/jsc-mod-companion/hooks/companion/types.ts index cb5f00e..4e28361 100644 --- a/plugins/jsc-mod-companion/hooks/companion/types.ts +++ b/plugins/jsc-mod-companion/hooks/companion/types.ts @@ -1,6 +1,7 @@ import type { Attribute, Trigger } from './care' import type { EmotionState, Emotions, Habit } from './emotion' import type { ParamVec } from './core/params' +import type { Adapt } from './adapt' export type Pad = { p: number; a: number; d: number } @@ -58,6 +59,10 @@ export type CompanionState = { todos: number // 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效) params?: { save: Partial; today: Partial; dayKey: string } + // 適應度(第 9.13 章),隨天數逐日成長,讀取時才換算 + adapt?: Adapt + // 本地新聞:今天已聊幾則,以及最近提過的連結(不重複) + news?: { day: number; said: number; seen: string[] } } export type UserSettings = { @@ -67,6 +72,10 @@ export type UserSettings = { tz: string careLimit: number quiet: { start: string; end: string } + // 所在地(setup location=);有值才啟用外部素材。可填「緯度,經度」或地名 + location: string + // 使用者自設的 RSS 清單,預設空 + feeds: string[] } export type Global = { active: string | null; activatedOnce: boolean } diff --git a/plugins/jsc-mod-companion/hooks/companion/world.test.ts b/plugins/jsc-mod-companion/hooks/companion/world.test.ts new file mode 100644 index 0000000..559c0cb --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/world.test.ts @@ -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, 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) + }) +}) diff --git a/plugins/jsc-mod-companion/hooks/register.tsx b/plugins/jsc-mod-companion/hooks/register.tsx index e0a408d..414f27d 100644 --- a/plugins/jsc-mod-companion/hooks/register.tsx +++ b/plugins/jsc-mod-companion/hooks/register.tsx @@ -20,6 +20,7 @@ const COMMANDS: Record = setup: { description: '顯示或儲存使用者設定', argumentHint: '[key=value…]' }, todo: { description: '待辦:列出、新增、記錄承諾、完成', argumentHint: '[list | add | promise | done] …' }, quiet: { description: '暫時靜音,不主動關心', argumentHint: '[分鐘]' }, + materials: { description: '外部素材(天氣、新聞)的快取狀態;list 列出新聞', argumentHint: '[list]' }, } export const register: Register = on => { diff --git a/plugins/jsc-mod-companion/scripts/fetch-materials.mjs b/plugins/jsc-mod-companion/scripts/fetch-materials.mjs new file mode 100755 index 0000000..e2b54fc --- /dev/null +++ b/plugins/jsc-mod-companion/scripts/fetch-materials.mjs @@ -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(//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]*?)`, 'i').exec(block) + return m ? decode(m[1]) : '' +} + +/** 解析 RSS 2.0 與 Atom,只留標題、摘要、連結 */ +export function parseFeed(xml, at) { + const blocks = xml.match(/][\s\S]*?<\/item>|][\s\S]*?<\/entry>/gi) ?? [] + const items = [] + for (const b of blocks) { + const title = tag(b, 'title') + const link = tag(b, '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 + }) +} diff --git a/plugins/jsc-mod-companion/scripts/fetch-materials.test.mjs b/plugins/jsc-mod-companion/scripts/fetch-materials.test.mjs new file mode 100644 index 0000000..0ef5f87 --- /dev/null +++ b/plugins/jsc-mod-companion/scripts/fetch-materials.test.mjs @@ -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 = ` +<![CDATA[第一則 & 標題]]>https://n.example/1<p>摘要</p>內文x +沒有連結 +第二則https://n.example/2d2 +` +const ATOM = `原子s` + +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, /未啟用/) + }) +})