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/CHANGELOG.md +18 -0
- package/README.en.md +23 -0
- package/README.ja.md +23 -0
- package/README.ko.md +23 -0
- package/README.md +23 -0
- package/README.zh-CN.md +23 -0
- package/bin/cli.mjs +53 -14
- package/client.js +231 -15
- package/lib/agent-runner.mjs +14 -3
- package/lib/config.mjs +21 -0
- package/lib/host.mjs +261 -12
- package/lib/memory.mjs +209 -0
- package/lib/tieba-sync.mjs +19 -11
- package/lib/toolkit.mjs +42 -0
- package/package.json +1 -1
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 {
|
|
34
|
+
const { createMemoryStore } = memoryMod
|
|
35
|
+
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray } = utilMod
|
|
34
36
|
|
|
35
|
-
export const VERSION = '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
|
|
197
|
-
|
|
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
|
|
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
|
|
346
|
+
const runCheck = () => {
|
|
347
|
+
balanceNextAt = Date.now() + 30 * 60_000
|
|
348
|
+
if (!balanceApplicable()) return
|
|
255
349
|
refreshBalance()
|
|
256
|
-
.then(() =>
|
|
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(
|
|
361
|
+
const initial = setTimeout(runCheck, 8_000)
|
|
362
|
+
balanceNextAt = Date.now() + 8_000
|
|
260
363
|
initial.unref?.()
|
|
261
|
-
balanceTimer = setInterval(
|
|
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
|
+
}
|
package/lib/tieba-sync.mjs
CHANGED
|
@@ -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:
|
|
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,
|