dsh-line-bot 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/host.mjs CHANGED
@@ -19,6 +19,7 @@ const tiebaSyncMod = await import(withVersion('./tieba-sync.mjs'))
19
19
  const localPersonasMod = await import(withVersion('./local-personas.mjs'))
20
20
  const tiebaContextMod = await import(withVersion('./tieba-context.mjs'))
21
21
  const balanceMod = await import(withVersion('./deepseek-balance.mjs'))
22
+ const memoryMod = await import(withVersion('./memory.mjs'))
22
23
  const utilMod = await import(withVersion('./util.mjs'))
23
24
  const { createConfigStore, publicConfig, dataDir } = configMod
24
25
  const { createPersonaClient } = personaMod
@@ -30,9 +31,10 @@ const { createTiebaSync, reloadPersonaPlugin } = tiebaSyncMod
30
31
  const { createLocalPersonas, LOCAL_PREFIX } = localPersonasMod
31
32
  const { buildTiebaContext } = tiebaContextMod
32
33
  const { createBalanceMonitor } = balanceMod
33
- const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody } = utilMod
34
+ const { createMemoryStore } = memoryMod
35
+ const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray } = utilMod
34
36
 
35
- export const VERSION = '0.1.0'
37
+ export const VERSION = '0.4.0'
36
38
 
37
39
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
38
40
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -168,6 +170,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
168
170
  // ── DeepSeek 餘額 ───────────────────────────────────────────────────────
169
171
  const balance = createBalanceMonitor({ ctx, logger })
170
172
  let balanceTimer = null
173
+ let balanceNextAt = 0
174
+ const memory = createMemoryStore({ dir, logger, getConfig: () => store.get() })
175
+ let memoryTimer = null
171
176
 
172
177
  function balanceView() {
173
178
  const cached = balance.get()
@@ -177,9 +182,64 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
177
182
  return null
178
183
  }
179
184
 
185
+ function modelSelection() {
186
+ const cfg = store.get()
187
+ let fallback = null
188
+ try {
189
+ fallback = ctx.get?.('agentDefaultModel')?.currentSelection?.() || null
190
+ } catch {
191
+ fallback = null
192
+ }
193
+ const custom = Boolean(String(cfg.agent.provider || '').trim() || String(cfg.agent.model || '').trim())
194
+ const provider = String(cfg.agent.provider || fallback?.provider || '').trim()
195
+ const model = String(cfg.agent.model || fallback?.model || '').trim()
196
+ return {
197
+ provider,
198
+ model,
199
+ source: custom ? 'custom' : 'default',
200
+ defaultProvider: fallback?.provider || '',
201
+ defaultModel: fallback?.model || '',
202
+ }
203
+ }
204
+
205
+ // 只有 DeepSeek 官方帳號(deepseek-account)才查得到餘額;其他來源停用餘額相關功能。
206
+ function balanceApplicable() {
207
+ return modelSelection().provider === 'deepseek-account'
208
+ }
209
+
210
+ let modelCache = null
211
+ async function modelCatalog({ force = false } = {}) {
212
+ const now = Date.now()
213
+ if (!force && modelCache && now - modelCache.at < 60_000) return modelCache.value
214
+ const llm = ctx.get?.('llm')
215
+ if (!llm || typeof llm.listProviders !== 'function') {
216
+ return { providers: [], current: modelSelection(), error: '這個 profile 沒有 llm 服務,無法列出模型' }
217
+ }
218
+ const providers = []
219
+ try {
220
+ const infos = await llm.listProviders()
221
+ for (const info of infos || []) {
222
+ const entry = { id: info.id, name: info.name || info.id, models: [] }
223
+ try {
224
+ const models = await llm.listModels(info.id)
225
+ entry.models = (models || []).map((item) => ({ id: item.id, name: item.name || item.id }))
226
+ } catch (error) {
227
+ entry.error = errorText(error)
228
+ }
229
+ providers.push(entry)
230
+ }
231
+ } catch (error) {
232
+ return { providers: [], current: modelSelection(), error: errorText(error) }
233
+ }
234
+ const value = { providers, current: modelSelection(), at: now }
235
+ modelCache = { at: now, value }
236
+ return value
237
+ }
238
+
180
239
  function balanceContextText() {
181
240
  const cfg = store.get()
182
241
  if (cfg.balance.enabled === false) return ''
242
+ if (!balanceApplicable()) return ''
183
243
  const view = balanceView()
184
244
  if (!view) return ''
185
245
  const cached = balance.get()
@@ -193,8 +253,38 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
193
253
  return lines.join('\n')
194
254
  }
195
255
 
196
- function agentContextText() {
197
- return [tiebaContextText(), balanceContextText()].filter(Boolean).join('\n\n')
256
+ function memoryRulesText() {
257
+ if (store.get().memory?.enabled === false) return ''
258
+ return [
259
+ '【個人記憶】你有一份自己的記憶(每位使用者分開)。',
260
+ '對話中若出現你覺得重要、之後還會用到的資訊(喜好、近況、承諾、重要事件),馬上用 line_memory_add 記下來,不用等對方交代。',
261
+ '一次只記一件事;如果同時有兩三件重要的事,就分開呼叫兩三次,不要合併成一段。',
262
+ '有期限的事(考試、旅行、短期的狀況)請填 ttlDays,到期會自動刪掉;一次性閒聊不要記。',
263
+ '使用者要你忘掉某件事時,用 line_memory_forget 刪掉;想確認自己記了什麼就用 line_memory_list。',
264
+ '很重要:只有真的呼叫了工具才算記住。沒呼叫就不要說「記好了」;使用者明確要你記住時,先呼叫工具再回覆。',
265
+ '不要記密碼、token、證件號等敏感資料,也不要在回覆裡提到這些工具或「記憶系統」。',
266
+ ].join('\n')
267
+ }
268
+
269
+ function memoryContextText(userId) {
270
+ if (!userId) return ''
271
+ const rules = memoryRulesText()
272
+ const remembered = memory.formatForContext(userId)
273
+ return [rules, remembered].filter(Boolean).join('\n\n')
274
+ }
275
+
276
+ function memoryToolsFor(userId) {
277
+ if (!userId || store.get().memory?.enabled === false) return []
278
+ try {
279
+ return memory.toolsFor(userId)
280
+ } catch (error) {
281
+ logger.warn('準備記憶工具失敗:' + errorText(error))
282
+ return []
283
+ }
284
+ }
285
+
286
+ function agentContextText(userId) {
287
+ return [tiebaContextText(), balanceContextText(), memoryContextText(userId)].filter(Boolean).join('\n\n')
198
288
  }
199
289
 
200
290
  async function refreshBalance() {
@@ -204,6 +294,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
204
294
  async function maybeAskForTopUp({ dryRun = true, force = false } = {}) {
205
295
  const cfg = store.get()
206
296
  if (cfg.balance.enabled === false || cfg.balance.ask === false) return { skipped: 'disabled' }
297
+ if (!balanceApplicable()) return { skipped: 'provider-not-deepseek', provider: modelSelection().provider }
207
298
  const view = balanceView()
208
299
  if (!view) {
209
300
  const cached = balance.get()
@@ -219,7 +310,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
219
310
  if (!force && lastAskAt && Date.now() - lastAskAt < cooldownMs) return { skipped: 'cooldown', lastAskAt }
220
311
  if (!force && inQuietWindow(Date.now(), cfg.nudge.quietStart, cfg.nudge.quietEnd)) return { skipped: 'quiet-hours' }
221
312
  const candidates = Object.entries(state.users || {}).filter(([, user]) => user?.sessionId)
222
- const preferred = (cfg.nudge.targetUserIds || []).find((id) => candidates.some(([key]) => key === id))
313
+ const targetIds = safeArray(cfg.balance?.targetUserIds).length ? safeArray(cfg.balance?.targetUserIds) : safeArray(cfg.nudge?.targetUserIds)
314
+ const preferred = targetIds.find((id) => candidates.some(([key]) => key === id))
223
315
  const picked = preferred ? candidates.find(([key]) => key === preferred) : [...candidates].sort((a, b) => (a[1].lastAt || 0) - (b[1].lastAt || 0))[0]
224
316
  if (!picked) return { skipped: 'no-user' }
225
317
  const [userId, user] = picked
@@ -251,14 +343,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
251
343
 
252
344
  function startBalanceWatch() {
253
345
  stopBalanceWatch()
254
- const check = () => {
346
+ const runCheck = () => {
347
+ balanceNextAt = Date.now() + 30 * 60_000
348
+ if (!balanceApplicable()) return
255
349
  refreshBalance()
256
- .then(() => maybeAskForTopUp({ dryRun: false }))
350
+ .then((cache) => {
351
+ if (!cache || cache.status !== 'ready') return null
352
+ return maybeAskForTopUp({ dryRun: false })
353
+ })
354
+ .then((result) => {
355
+ if (!result || !result.skipped) return
356
+ if (['enough', 'disabled', 'provider-not-deepseek'].includes(result.skipped)) return
357
+ logger.info('餘額檢查:這次不提醒(' + result.skipped + ')')
358
+ })
257
359
  .catch((error) => logger.warn('餘額檢查失敗:' + errorText(error)))
258
360
  }
259
- const initial = setTimeout(check, 8_000)
361
+ const initial = setTimeout(runCheck, 8_000)
362
+ balanceNextAt = Date.now() + 8_000
260
363
  initial.unref?.()
261
- balanceTimer = setInterval(check, 30 * 60_000)
364
+ balanceTimer = setInterval(runCheck, 30 * 60_000)
262
365
  balanceTimer.unref?.()
263
366
  }
264
367
 
@@ -267,6 +370,95 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
267
370
  clearInterval(balanceTimer)
268
371
  balanceTimer = null
269
372
  }
373
+ balanceNextAt = 0
374
+ }
375
+
376
+ // ── 個人記憶:清理與每日整理 ────────────────────────────────────────────
377
+ function pruneMemories() {
378
+ try {
379
+ const out = memory.prune()
380
+ if (out.removed > 0) logger.info('個人記憶:清理 ' + out.removed + ' 筆到期項目')
381
+ return out
382
+ } catch (error) {
383
+ logger.warn('清理個人記憶失敗:' + errorText(error))
384
+ return { removed: 0, error: errorText(error) }
385
+ }
386
+ }
387
+
388
+ async function reviewMemories({ userId = '' } = {}) {
389
+ const cfg = store.get()
390
+ if (cfg.memory?.enabled === false) return { skipped: 'disabled' }
391
+ const state = store.getState()
392
+ const targets = userId
393
+ ? [[userId, state.users?.[userId]]]
394
+ : Object.entries(state.users || {})
395
+ const reviewed = []
396
+ for (const [id, user] of targets) {
397
+ if (!user?.sessionId) continue
398
+ const entries = memory.list(id)
399
+ if (entries.length === 0) continue
400
+ const selection = cfg.persona.enforce ? user.personaId || cfg.persona.id : ''
401
+ const applied = await applyPersonaSelection(selection, user.sessionId)
402
+ const lines = entries.map((entry) => {
403
+ const created = new Date(entry.createdAt).toISOString().slice(0, 10)
404
+ const until = entry.expiresAt ? ',' + new Date(entry.expiresAt).toISOString().slice(0, 10) + ' 失效' : ''
405
+ return '- ' + entry.id + ' [' + entry.kind + '] ' + entry.text + '(' + created + until + ')'
406
+ })
407
+ const promptText = [
408
+ '(這是系統提供的背景整理,不是主人說的話)請你檢查自己對這位使用者的記憶。',
409
+ '目前的記憶:',
410
+ ...lines,
411
+ '把已經過期、一次事件已結束、或明顯不再成立的,用 line_memory_forget 刪掉;其餘保留。',
412
+ '不要輸出給主人看的訊息,只呼叫工具,最後回一個 OK。',
413
+ ].join('\n')
414
+ try {
415
+ await runner.turn({
416
+ sessionId: user.sessionId,
417
+ text: promptText,
418
+ persona: applied.persona,
419
+ personaText: applied.personaText,
420
+ personaClient: persona(),
421
+ contextText: '',
422
+ tools: memoryToolsFor(id),
423
+ cwd: ensureUserWorkspace(id) || undefined,
424
+ })
425
+ reviewed.push({ userId: id, entries: entries.length })
426
+ } catch (error) {
427
+ logger.warn('整理 ' + id + ' 的記憶失敗:' + errorText(error))
428
+ }
429
+ }
430
+ store.updateState((current) => ({ ...current, memory: { ...(current.memory || {}), lastReviewAt: Date.now() } }))
431
+ logger.info('個人記憶:整理完成,檢查 ' + reviewed.length + ' 位使用者')
432
+ return { reviewed: reviewed.length, users: reviewed }
433
+ }
434
+
435
+ function maybeDailyMemoryReview() {
436
+ const cfg = store.get()
437
+ if (cfg.memory?.enabled === false || cfg.memory?.reviewDaily === false) return
438
+ const hour = Number(cfg.memory?.reviewHour)
439
+ const wantedHour = Number.isFinite(hour) ? Math.max(0, Math.min(23, Math.round(hour))) : 4
440
+ const now = new Date()
441
+ if (now.getHours() !== wantedHour) return
442
+ const lastReviewAt = Number(store.getState().memory?.lastReviewAt) || 0
443
+ if (lastReviewAt && now.getTime() - lastReviewAt < 20 * 3600_000) return
444
+ reviewMemories().catch((error) => logger.warn('每日記憶整理失敗:' + errorText(error)))
445
+ }
446
+
447
+ function startMemoryWatch() {
448
+ stopMemoryWatch()
449
+ pruneMemories()
450
+ memoryTimer = setInterval(() => {
451
+ pruneMemories()
452
+ maybeDailyMemoryReview()
453
+ }, 10 * 60_000)
454
+ memoryTimer.unref?.()
455
+ }
456
+
457
+ function stopMemoryWatch() {
458
+ if (memoryTimer) {
459
+ clearInterval(memoryTimer)
460
+ memoryTimer = null
461
+ }
270
462
  }
271
463
 
272
464
  // ── LINE 憑證 ────────────────────────────────────────────────────────────
@@ -684,7 +876,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
684
876
  persona: applied.persona,
685
877
  personaText: applied.personaText,
686
878
  personaClient: persona(),
687
- contextText: agentContextText(),
879
+ contextText: agentContextText(userId),
880
+ tools: memoryToolsFor(userId),
688
881
  cwd: ensureUserWorkspace(userId) || cfg.agent.cwd || undefined,
689
882
  })
690
883
  let replyText = String(result?.text || '').trim()
@@ -759,7 +952,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
759
952
  persona: applied.persona,
760
953
  personaText: applied.personaText,
761
954
  personaClient: persona(),
762
- contextText: agentContextText(),
955
+ contextText: agentContextText(owner?.[0]),
956
+ tools: memoryToolsFor(owner?.[0]),
763
957
  cwd: owner ? ensureUserWorkspace(owner[0]) : undefined,
764
958
  })
765
959
  },
@@ -848,6 +1042,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
848
1042
  nudge.start()
849
1043
  tiebaSync.start()
850
1044
  startBalanceWatch()
1045
+ startMemoryWatch()
851
1046
  store.updateState((state) => ({ ...state, lastStartAt: Date.now() }))
852
1047
  if (cfg.line.channelId && cfg.line.channelSecret) refreshBotInfo().catch(() => {})
853
1048
  return buildStatus()
@@ -856,6 +1051,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
856
1051
  async function stopAll() {
857
1052
  stopWebhookGuard()
858
1053
  stopBalanceWatch()
1054
+ stopMemoryWatch()
859
1055
  nudge.stop()
860
1056
  tiebaSync.stop()
861
1057
  await tunnel.stop({ silent: true }).catch(() => {})
@@ -922,20 +1118,35 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
922
1118
  persona: personaSummary,
923
1119
  agent: {
924
1120
  ...runner.stats(),
1121
+ ...modelSelection(),
925
1122
  timeoutMs: cfg.agent.timeoutMs,
926
1123
  lastFinishedAt: runner.stats().lastFinishedAt,
927
1124
  },
928
1125
  nudge: { ...nudge.stats(), config: cfg.nudge },
929
1126
  balance: {
930
1127
  ...balance.get(),
1128
+ applicable: balanceApplicable(),
1129
+ provider: modelSelection().provider,
931
1130
  enabled: cfg.balance.enabled !== false,
932
1131
  ask: cfg.balance.ask !== false,
933
1132
  threshold: Number(cfg.balance.threshold) || 0,
934
1133
  cooldownHours: Number(cfg.balance.cooldownHours) || 12,
935
1134
  lastAskAt: Number(state.balance?.lastAskAt) || 0,
1135
+ nextCheckAt: balanceNextAt,
1136
+ targetUserIds: safeArray(cfg.balance?.targetUserIds),
936
1137
  lastAskText: state.balance?.lastAskText || '',
937
1138
  low: Boolean(balanceView() && Number(cfg.balance.threshold) > 0 && balanceView().value.total < Number(cfg.balance.threshold)),
938
1139
  },
1140
+ memory: {
1141
+ ...memory.stats(),
1142
+ enabled: cfg.memory?.enabled !== false,
1143
+ reviewDaily: cfg.memory?.reviewDaily !== false,
1144
+ reviewHour: Number(cfg.memory?.reviewHour) || 0,
1145
+ maxPerUser: Number(cfg.memory?.maxPerUser) || 120,
1146
+ injectMaxEntries: Number(cfg.memory?.injectMaxEntries) || 20,
1147
+ injectMaxChars: Number(cfg.memory?.injectMaxChars) || 2000,
1148
+ lastReviewAt: Number(state.memory?.lastReviewAt) || 0,
1149
+ },
939
1150
  sync: { ...tiebaSync.stats(), config: cfg.sync },
940
1151
  users,
941
1152
  logs: logger.lines(),
@@ -1092,10 +1303,44 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1092
1303
  personaCache.at = 0
1093
1304
  return created
1094
1305
  }
1306
+ case 'model-catalog':
1307
+ return modelCatalog({ force: payload.force === true })
1095
1308
  case 'balance-refresh':
1309
+ if (!balanceApplicable()) return { skipped: 'provider-not-deepseek', provider: modelSelection().provider }
1096
1310
  return refreshBalance()
1097
1311
  case 'balance-ask':
1098
1312
  return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
1313
+ case 'memory-list': {
1314
+ const only = String(payload.userId || '').trim()
1315
+ const state = store.getState()
1316
+ const rows = Object.entries(state.users || {})
1317
+ .filter(([userId]) => !only || userId === only)
1318
+ .map(([userId, user]) => ({
1319
+ userId,
1320
+ displayName: user?.displayName || '',
1321
+ entries: memory.list(userId).map((entry) => ({
1322
+ ...entry,
1323
+ createdText: new Date(entry.createdAt).toISOString(),
1324
+ expiresText: entry.expiresAt ? new Date(entry.expiresAt).toISOString() : '',
1325
+ })),
1326
+ }))
1327
+ .filter((row) => row.entries.length > 0)
1328
+ return { users: rows, total: rows.reduce((sum, row) => sum + row.entries.length, 0) }
1329
+ }
1330
+ case 'memory-add': {
1331
+ const userId = String(payload.userId || '').trim()
1332
+ if (!userId) throw new Error('缺少 userId')
1333
+ return memory.add(userId, payload)
1334
+ }
1335
+ case 'memory-delete': {
1336
+ const userId = String(payload.userId || '').trim()
1337
+ if (!userId) throw new Error('缺少 userId')
1338
+ return memory.forget(userId, { ids: payload.id ? [payload.id] : payload.ids })
1339
+ }
1340
+ case 'memory-prune':
1341
+ return pruneMemories()
1342
+ case 'memory-review':
1343
+ return reviewMemories({ userId: String(payload.userId || '').trim() })
1099
1344
  case 'nudge-test':
1100
1345
  return nudge.tick({ force: true })
1101
1346
  case 'agent-test': {
@@ -1103,6 +1348,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1103
1348
  const sessionId = String(payload.sessionId || `session-line-selftest-${Date.now()}`)
1104
1349
  const cwd = String(payload.cwd || '').trim() || undefined
1105
1350
  const selection = String(payload.personaId || '').trim() || (cfg.persona.enforce ? cfg.persona.id : '')
1351
+ const testUserId = String(payload.userId || '').trim()
1352
+ || Object.entries(store.getState().users || {}).find(([, item]) => item.sessionId === sessionId)?.[0]
1353
+ || ''
1106
1354
  const applied = await applyPersonaSelection(selection, sessionId)
1107
1355
  const result = await runner.turn({
1108
1356
  sessionId,
@@ -1110,7 +1358,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1110
1358
  persona: applied.persona,
1111
1359
  personaText: applied.personaText,
1112
1360
  personaClient: persona(),
1113
- contextText: agentContextText(),
1361
+ contextText: agentContextText(testUserId),
1362
+ tools: memoryToolsFor(testUserId),
1114
1363
  cwd,
1115
1364
  })
1116
1365
  return { text: result.text, reason: result.reason, sessionId, cwd: cwd || '', persona: selection, personaSource: applied.source }
package/lib/memory.mjs ADDED
@@ -0,0 +1,209 @@
1
+ // 每位 LINE 使用者各自的「個人記憶」:agent 覺得重要就自己寫進來,
2
+ // 有保存期限的到期後由背景清理;也可以被要求翻出來看或忘掉。
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { randomBytes } from 'node:crypto'
6
+ import { readJsonSafe, writeJsonAtomic, nowIso } from './util.mjs'
7
+ import { defineTool } from './toolkit.mjs'
8
+
9
+ const KINDS = ['fact', 'event', 'preference', 'inside']
10
+ const MAX_TEXT = 600
11
+ const DAY = 24 * 60 * 60 * 1000
12
+
13
+ function emptyStore() {
14
+ return { version: 1, users: {}, lastPruneAt: 0 }
15
+ }
16
+
17
+ function normalizeEntry(input) {
18
+ const text = String(input?.text || '').trim().slice(0, MAX_TEXT)
19
+ if (!text) throw new Error('記憶內容不能是空的')
20
+ const kind = KINDS.includes(input?.kind) ? input.kind : 'fact'
21
+ let importance = Number(input?.importance)
22
+ if (!Number.isFinite(importance)) importance = 3
23
+ importance = Math.max(1, Math.min(5, Math.round(importance)))
24
+ let expiresAt = 0
25
+ const ttlDays = Number(input?.ttlDays)
26
+ if (Number.isFinite(ttlDays) && ttlDays > 0) {
27
+ expiresAt = Date.now() + Math.min(ttlDays, 3650) * DAY
28
+ }
29
+ return {
30
+ id: 'm_' + randomBytes(4).toString('hex'),
31
+ text,
32
+ kind,
33
+ importance,
34
+ source: input?.source === 'user' ? 'user' : 'agent',
35
+ createdAt: Date.now(),
36
+ expiresAt,
37
+ }
38
+ }
39
+
40
+ export function createMemoryStore({ dir, logger, getConfig }) {
41
+ const file = path.join(dir, 'memories.json')
42
+ let store = readJsonSafe(file, emptyStore())
43
+ if (!store || typeof store !== 'object') store = emptyStore()
44
+ if (!store.users || typeof store.users !== 'object') store.users = {}
45
+ if (!store.version) store.version = 1
46
+
47
+ function save() {
48
+ writeJsonAtomic(file, store, 0o600)
49
+ }
50
+
51
+ function cfg() {
52
+ return getConfig()?.memory || {}
53
+ }
54
+
55
+ function bucket(userId) {
56
+ const key = String(userId || '')
57
+ if (!key) throw new Error('缺少使用者 ID')
58
+ if (!store.users[key]) store.users[key] = { entries: [], updatedAt: 0 }
59
+ if (!Array.isArray(store.users[key].entries)) store.users[key].entries = []
60
+ return store.users[key]
61
+ }
62
+
63
+ function activeEntries(userId) {
64
+ const now = Date.now()
65
+ return bucket(userId).entries
66
+ .filter((entry) => !entry.expiresAt || entry.expiresAt > now)
67
+ .sort((a, b) => b.createdAt - a.createdAt)
68
+ }
69
+
70
+ function list(userId) {
71
+ return activeEntries(userId).map((entry) => ({ ...entry }))
72
+ }
73
+
74
+ function add(userId, input) {
75
+ const entry = normalizeEntry(input)
76
+ const target = bucket(userId)
77
+ target.entries.push(entry)
78
+ target.updatedAt = Date.now()
79
+ const max = Math.max(10, Math.min(Number(cfg().maxPerUser) || 120, 500))
80
+ if (target.entries.length > max) {
81
+ target.entries.sort((a, b) => a.createdAt - b.createdAt)
82
+ target.entries.splice(0, target.entries.length - max)
83
+ }
84
+ save()
85
+ logger?.info?.(`個人記憶:幫 ${userId} 記下「${entry.text.slice(0, 40)}」`)
86
+ return { ...entry }
87
+ }
88
+
89
+ function forget(userId, { ids, query, expired } = {}) {
90
+ const target = bucket(userId)
91
+ const now = Date.now()
92
+ const wanted = new Set(Array.isArray(ids) ? ids.map(String) : [])
93
+ const needle = String(query || '').trim().toLowerCase()
94
+ const removed = []
95
+ target.entries = target.entries.filter((entry) => {
96
+ const hit = (wanted.size > 0 && wanted.has(entry.id))
97
+ || (expired === true && entry.expiresAt && entry.expiresAt <= now)
98
+ || (needle && String(entry.text || '').toLowerCase().includes(needle))
99
+ if (hit) removed.push(entry.id)
100
+ return !hit
101
+ })
102
+ if (removed.length > 0) {
103
+ target.updatedAt = Date.now()
104
+ save()
105
+ logger?.info?.(`個人記憶:從 ${userId} 刪掉 ${removed.length} 筆`)
106
+ }
107
+ return { removed: removed.length, ids: removed }
108
+ }
109
+
110
+ function prune() {
111
+ const now = Date.now()
112
+ let removed = 0
113
+ for (const [userId, target] of Object.entries(store.users)) {
114
+ const entries = Array.isArray(target?.entries) ? target.entries : []
115
+ const kept = entries.filter((entry) => !entry.expiresAt || entry.expiresAt > now)
116
+ removed += entries.length - kept.length
117
+ if (kept.length !== entries.length) {
118
+ target.entries = kept
119
+ target.updatedAt = now
120
+ }
121
+ if (kept.length === 0 && Object.keys(target).length <= 2) delete store.users[userId]
122
+ }
123
+ if (removed > 0 || !store.lastPruneAt) {
124
+ store.lastPruneAt = now
125
+ save()
126
+ }
127
+ return { removed, at: now }
128
+ }
129
+
130
+ function stats() {
131
+ const now = Date.now()
132
+ let total = 0
133
+ let expired = 0
134
+ const users = []
135
+ for (const [userId, target] of Object.entries(store.users)) {
136
+ const entries = Array.isArray(target?.entries) ? target.entries : []
137
+ const live = entries.filter((entry) => !entry.expiresAt || entry.expiresAt > now)
138
+ expired += entries.length - live.length
139
+ total += live.length
140
+ if (live.length > 0) users.push({ userId, count: live.length, lastAt: target.updatedAt || 0 })
141
+ }
142
+ return { file, total, expired, users, lastPruneAt: store.lastPruneAt || 0 }
143
+ }
144
+
145
+ function formatForContext(userId) {
146
+ const conf = cfg()
147
+ if (conf.enabled === false || !userId) return ''
148
+ const entries = activeEntries(userId)
149
+ if (entries.length === 0) return ''
150
+ const maxEntries = Math.max(1, Math.min(Number(conf.injectMaxEntries) || 20, 80))
151
+ const maxChars = Math.max(200, Math.min(Number(conf.injectMaxChars) || 2000, 8000))
152
+ const lines = []
153
+ let used = 0
154
+ for (const entry of entries.slice(0, maxEntries)) {
155
+ const stamp = new Date(entry.createdAt).toISOString().slice(0, 10)
156
+ const until = entry.expiresAt ? `,${new Date(entry.expiresAt).toISOString().slice(0, 10)} 失效` : ''
157
+ const line = `- [${entry.kind}] ${entry.text}(${stamp}${until})`
158
+ if (used + line.length > maxChars) break
159
+ lines.push(line)
160
+ used += line.length
161
+ }
162
+ if (lines.length === 0) return ''
163
+ return [
164
+ '【你記得的關於這位使用者的事】',
165
+ '(這些是你自己寫下的記憶;可自然引用,但不必逐條複述。若發現過期或不再成立,可用 line_memory_forget 刪掉。)',
166
+ ...lines,
167
+ ].join('\n')
168
+ }
169
+
170
+ function toolsFor(userId) {
171
+ return [
172
+ defineTool({
173
+ name: 'line_memory_add',
174
+ description: '把關於這位使用者的重要事情寫進你的個人記憶。只記長期有用、之後還會影響對話的內容;'
175
+ + '一次性或純閒聊不要記。有期限的(例如「下週考試」「這個月在減肥」)請填 ttlDays,到期會自動刪掉。'
176
+ + '不要記密碼、token、身分證號等敏感資料。',
177
+ parameters: {
178
+ text: { type: 'string', description: '要記住的事,一句到兩句,寫成你自己看得懂的描述', required: true },
179
+ kind: { type: 'string', enum: KINDS, description: 'fact=事實、event=事件、preference=喜好、inside=你們之間的默契' },
180
+ importance: { type: 'number', description: '1~5,越高越重要(預設 3)' },
181
+ ttlDays: { type: 'number', description: '幾天後失效;不填或 0 表示長期保留' },
182
+ },
183
+ execute: async (args) => {
184
+ const entry = add(userId, args || {})
185
+ return { saved: true, id: entry.id, kind: entry.kind, expiresAt: entry.expiresAt || null }
186
+ },
187
+ }),
188
+ defineTool({
189
+ name: 'line_memory_forget',
190
+ description: '刪掉你的個人記憶:用 ids 指定,或給 query 刪掉內容包含這段字的記憶;'
191
+ + '給 expired:true 則清掉所有已到期的。使用者要求「忘掉」時就用這個。',
192
+ parameters: {
193
+ ids: { type: 'array', items: { type: 'string' }, description: '要刪的記憶 id' },
194
+ query: { type: 'string', description: '刪掉內容包含這個字串的記憶' },
195
+ expired: { type: 'boolean', description: '清掉所有已到期的記憶' },
196
+ },
197
+ execute: async (args) => forget(userId, args || {}),
198
+ }),
199
+ defineTool({
200
+ name: 'line_memory_list',
201
+ description: '列出你目前對這位使用者記得的內容(含 id 與到期日),想確認或整理時使用。',
202
+ parameters: {},
203
+ execute: async () => ({ count: activeEntries(userId).length, entries: activeEntries(userId) }),
204
+ }),
205
+ ]
206
+ }
207
+
208
+ return { file, list, add, forget, prune, stats, formatForContext, toolsFor }
209
+ }
@@ -70,6 +70,11 @@ export function buildSection(tieba, date = new Date()) {
70
70
  return `${lines.join('\n')}\n\n`
71
71
  }
72
72
 
73
+ // 只比較內容:標題裡的時間戳每次都會變,不能拿它判斷「語氣有變化」。
74
+ function sansStamp(text) {
75
+ return String(text).replace(/(##\s*貼吧學來的口氣(自動同步 )[^)]*())/, '$1…$2')
76
+ }
77
+
73
78
  function findSection(text) {
74
79
  const heading = /^##\s*貼吧學來的口氣[^\n]*$/m.exec(text)
75
80
  if (!heading) return null
@@ -174,7 +179,7 @@ export function createTiebaSync({ ctx, store, logger }) {
174
179
  const oldText = String(target.text || '')
175
180
  let nextText = applySection(oldText, buildSection(tieba))
176
181
  if (cfg.syncTraits !== false) nextText = applyTraits(nextText, tieba.traits)
177
- const changed = nextText !== oldText
182
+ const changed = sansStamp(nextText) !== sansStamp(oldText)
178
183
  const stateSync = store.getState().sync || {}
179
184
  if (!changed && stateSync.version === tieba.version) {
180
185
  return { skipped: 'unchanged', version: tieba.version, targetId }
@@ -195,17 +200,9 @@ export function createTiebaSync({ ctx, store, logger }) {
195
200
  }
196
201
  writeJsonAtomic(dafeiyuStorePath(), next)
197
202
  logger.info(`已把刷貼吧學到的語氣寫回人設「${target.name || targetId}」(版本 ${tieba.version})`)
198
- if (cfg.reloadPersona !== false) {
199
- try {
200
- reload = await reloadPersonaPlugin(ctx)
201
- if (!reload.ok) logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
202
- } catch (error) {
203
- reload = { ok: false, error: String(error?.message || error) }
204
- logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
205
- }
206
- }
207
203
  }
208
204
 
205
+ // 先記帳再重載:重載要幾百毫秒,中途若插件重啟,新實例讀到的 state 才不會還停在舊的 at 而立刻再跑一次。
209
206
  store.updateState((state) => ({
210
207
  ...state,
211
208
  sync: {
@@ -215,10 +212,21 @@ export function createTiebaSync({ ctx, store, logger }) {
215
212
  samples: tieba.samples.length,
216
213
  changed,
217
214
  backupPath,
218
- reloadOk: reload ? Boolean(reload.ok) : null,
215
+ reloadOk: null,
219
216
  error: '',
220
217
  },
221
218
  }))
219
+
220
+ if (changed && cfg.reloadPersona !== false) {
221
+ try {
222
+ reload = await reloadPersonaPlugin(ctx)
223
+ if (!reload.ok) logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
224
+ } catch (error) {
225
+ reload = { ok: false, error: String(error?.message || error) }
226
+ logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
227
+ }
228
+ store.updateState((state) => ({ ...state, sync: { ...(state.sync || {}), reloadOk: Boolean(reload?.ok) } }))
229
+ }
222
230
  return {
223
231
  at,
224
232
  version: tieba.version,