dsh-line-bot 0.2.2 → 0.3.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 +7 -0
- package/README.en.md +12 -0
- package/README.ja.md +12 -0
- package/README.ko.md +12 -0
- package/README.md +12 -0
- package/README.zh-CN.md +12 -0
- package/client.js +100 -0
- package/lib/agent-runner.mjs +14 -3
- package/lib/config.mjs +19 -0
- package/lib/host.mjs +177 -6
- package/lib/memory.mjs +209 -0
- package/lib/toolkit.mjs +42 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- 新增「個人記憶」:每位使用者一份長期記憶(`<DSH_HOME>/line-bot/memories.json`),角色可在對話中自己呼叫 `line_memory_add` 記下重要資訊、用 `line_memory_forget` 忘掉、用 `line_memory_list` 確認。
|
|
6
|
+
- 有期限的記憶可帶 `ttlDays`,到期由排程自動刪除;每天(預設 4 點,面板可調)角色會自己整理一次,清掉過期或不再成立的內容。
|
|
7
|
+
- 每輪只注入最近 N 筆/限量字元;面板「個人記憶」卡可查看、刪除、立即清理、立即整理,並調整每人上限與每日整理時間。
|
|
8
|
+
- README 五種語言補上個人記憶說明。
|
|
9
|
+
|
|
3
10
|
## 0.2.1
|
|
4
11
|
|
|
5
12
|
- 新增「DeepSeek 餘額」:每輪把錢包餘額放進角色背景資訊;低於門檻時由角色用自己的語氣主動提醒儲值(冷卻、靜音時段、可在面板調整)。
|
package/README.en.md
CHANGED
|
@@ -71,6 +71,8 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
71
71
|
| Section | Contents |
|
|
72
72
|
| --- | --- |
|
|
73
73
|
| Overview | Green-light status for webhook server / tunnel / LINE / persona / proactive messages, public URL, one-click bring-up and start/stop |
|
|
74
|
+
| DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown |
|
|
75
|
+
| Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
|
|
74
76
|
| LINE credentials | Channel ID, Channel Secret, Access Token, auto-issue, loading animation, bot info, webhook apply/test, allow-list |
|
|
75
77
|
| Cloudflare Tunnel | quick / API token modes, token, hostname, tunnel name, Account/Zone ID, download cloudflared, start/stop, auto-configure webhook, bring your own public URL |
|
|
76
78
|
| Reply persona | Choose a persona (dafeiyu or a plugin custom one), add/edit/delete custom personas, copy the current persona into a custom one, enforce, allow users to switch personas, switch dafeiyu to session mode, test an Agent reply |
|
|
@@ -141,6 +143,16 @@ The **DeepSeek balance** card reads the wallet balance of the DeepSeek account D
|
|
|
141
143
|
- Requires DSH to be signed in to a DeepSeek account (deepseek-account). If it is not, the card explains why and everything else keeps working.
|
|
142
144
|
- The threshold is money, not tokens: Platform shows a wallet balance, and token conversion varies by model and cache hits.
|
|
143
145
|
|
|
146
|
+
## Personal memory
|
|
147
|
+
|
|
148
|
+
The character keeps its own memory at `<DSH_HOME>/line-bot/memories.json`, separated per user:
|
|
149
|
+
|
|
150
|
+
- When something important comes up (preferences, current situation, promises, notable events), it saves it with `line_memory_add` on its own.
|
|
151
|
+
- Time-limited items (exams, trips, short-term situations) can carry an expiry date and are deleted automatically; you can also just tell it to forget something.
|
|
152
|
+
- Once a day (4 AM by default, configurable in the panel) it reviews the list and removes expired items, one-off events that are over, or facts that no longer hold.
|
|
153
|
+
- Each turn only injects the most recent N entries (20 entries / 2000 characters by default); the rest stay in the file. The per-user cap defaults to 120 entries and evicts the oldest.
|
|
154
|
+
- Passwords, tokens, ID numbers and similar secrets should not be stored; it avoids them.
|
|
155
|
+
- The "Personal memory" panel card shows what it remembers, lets you delete entries, prune expired ones, or trigger a review right away.
|
|
144
156
|
## Troubleshooting
|
|
145
157
|
|
|
146
158
|
- **The panel says "cannot connect to the LINE Bot runtime"**: webServer may not be ready yet; refresh. If it still fails, look for `[line-bot]` messages in the DSH logs.
|
package/README.ja.md
CHANGED
|
@@ -71,6 +71,8 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
71
71
|
| セクション | 内容 |
|
|
72
72
|
| --- | --- |
|
|
73
73
|
| 概要 | webhook サーバー/トンネル/LINE/ペルソナ/自発メッセージの状態、公開 URL、ワンクリック起動と停止 |
|
|
74
|
+
| DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン |
|
|
75
|
+
| 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
|
|
74
76
|
| LINE 認証情報 | Channel ID、Channel Secret、Access Token、自動発行、ローディング表示、bot 情報、webhook の適用/テスト、許可リスト |
|
|
75
77
|
| Cloudflare Tunnel | quick/API トークンモード、トークン、hostname、トンネル名、Account/Zone ID、cloudflared のダウンロード、起動/停止、webhook 自動設定、独自の公開 URL |
|
|
76
78
|
| 返信ペルソナ | ペルソナ選択(大肥魚またはプラグインのカスタム)、カスタムペルソナの追加・編集・削除、現在のペルソナをカスタムに複製、強制適用、ユーザーによる切り替え許可、大肥魚を session モードに切り替え、Agent 返信のテスト |
|
|
@@ -141,6 +143,16 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
141
143
|
- DSH が DeepSeek アカウントにログインしている必要があります。未ログインの場合はカードに理由が表示され、他の機能はそのまま使えます。
|
|
142
144
|
- しきい値は金額であって token 数ではありません。
|
|
143
145
|
|
|
146
|
+
## 個人メモリ
|
|
147
|
+
|
|
148
|
+
キャラクターは自分用のメモリを `<DSH_HOME>/line-bot/memories.json` に持ち、ユーザーごとに分けて保存します:
|
|
149
|
+
|
|
150
|
+
- 好み、近況、約束、重要な出来事など、あとで必要になりそうな情報は `line_memory_add` で自分から記録します。
|
|
151
|
+
- 期限のあるもの(試験、旅行、短期の状況)は期限日を付けられ、期限が来ると自動で削除されます。「忘れて」と言えば削除します。
|
|
152
|
+
- 毎日(既定は午前 4 時、パネルで変更可)一度見直し、期限切れ・終わった一度きりの出来事・もう成り立たない内容を削除します。
|
|
153
|
+
- 毎ターンは直近 N 件(既定 20 件・2000 文字)だけを注入し、残りはファイルに残します。1 人あたりの上限は既定 120 件で、超えると古いものから消えます。
|
|
154
|
+
- パスワード、token、身分証番号などの機密情報は記録しません。
|
|
155
|
+
- パネルの「個人メモリ」カードで、記録内容の確認、手動削除、期限切れの一括削除、今すぐの見直しができます。
|
|
144
156
|
## トラブルシューティング
|
|
145
157
|
|
|
146
158
|
- **パネルに「LINE Bot ランタイムに接続できません」と出る**:webServer がまだ準備できていない可能性があります。再読み込みしてください。それでも失敗する場合は DSH ログの `[line-bot]` を確認します。
|
package/README.ko.md
CHANGED
|
@@ -71,6 +71,8 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
71
71
|
| 섹션 | 내용 |
|
|
72
72
|
| --- | --- |
|
|
73
73
|
| 개요 | webhook 서버/터널/LINE/페르소나/능동 메시지 상태, 공개 URL, 원클릭 기동과 시작/중지 |
|
|
74
|
+
| DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운 |
|
|
75
|
+
| 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
|
|
74
76
|
| LINE 자격 증명 | Channel ID, Channel Secret, Access Token, 자동 발급, 로딩 표시, bot 정보, webhook 적용/테스트, 허용 목록 |
|
|
75
77
|
| Cloudflare Tunnel | quick/API 토큰 모드, 토큰, hostname, 터널 이름, Account/Zone ID, cloudflared 다운로드, 시작/중지, webhook 자동 설정, 직접 제공하는 공개 URL |
|
|
76
78
|
| 답장 페르소나 | 페르소나 선택(大肥魚 또는 플러그인 커스텀), 커스텀 페르소나 추가/편집/삭제, 현재 페르소나를 커스텀으로 복사, 강제 적용, 사용자 직접 전환 허용, 大肥魚를 session 모드로 전환, Agent 답장 테스트 |
|
|
@@ -141,6 +143,16 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
141
143
|
- DSH가 DeepSeek 계정(deepseek-account)에 로그인되어 있어야 합니다. 아니면 카드에 이유가 표시되며 다른 기능은 그대로 동작합니다.
|
|
142
144
|
- 기준값은 금액이며 token 수가 아닙니다.
|
|
143
145
|
|
|
146
|
+
## 개인 기억
|
|
147
|
+
|
|
148
|
+
캐릭터는 `<DSH_HOME>/line-bot/memories.json`에 자기 기억을 저장하며, 사용자별로 분리됩니다:
|
|
149
|
+
|
|
150
|
+
- 선호, 근황, 약속, 중요한 사건처럼 나중에 쓸 만한 정보는 `line_memory_add`로 스스로 기록합니다.
|
|
151
|
+
- 기한이 있는 일(시험, 여행, 단기 상황)은 만료일을 붙일 수 있고, 기한이 지나면 자동으로 삭제됩니다. "잊어줘"라고 하면 지웁니다.
|
|
152
|
+
- 매일(기본 새벽 4시, 패널에서 변경 가능) 한 번 정리하며, 만료된 항목·끝난 일회성 사건·더 이상 맞지 않는 내용을 삭제합니다.
|
|
153
|
+
- 매 턴에는 최근 N개(기본 20개·2000자)만 주입하고 나머지는 파일에 남깁니다. 사용자당 상한은 기본 120개이며, 넘으면 오래된 것부터 지웁니다.
|
|
154
|
+
- 비밀번호, token, 신분증 번호 같은 민감 정보는 기록하지 않습니다.
|
|
155
|
+
- 패널의 "개인 기억" 카드에서 기록 내용 확인, 수동 삭제, 만료 일괄 정리, 지금 정리를 할 수 있습니다.
|
|
144
156
|
## 문제 해결
|
|
145
157
|
|
|
146
158
|
- **패널에 "LINE Bot 런타임에 연결할 수 없습니다"**: webServer가 아직 준비되지 않았을 수 있습니다. 새로 고침하세요. 그래도 실패하면 DSH 로그의 `[line-bot]` 메시지를 확인합니다.
|
package/README.md
CHANGED
|
@@ -71,6 +71,8 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
71
71
|
| 區塊 | 內容 |
|
|
72
72
|
| --- | --- |
|
|
73
73
|
| 狀態總覽 | webhook server/隧道/LINE/人設/主動傳訊的綠燈狀態、公開網址、一鍵上線與啟停 |
|
|
74
|
+
| DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻 |
|
|
75
|
+
| 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
|
|
74
76
|
| LINE 憑證 | Channel ID、Channel Secret、Access Token、自動簽發、loading 動畫、bot 資訊、webhook 套用/測試、白名單 |
|
|
75
77
|
| Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名稱、Account/Zone ID、下載 cloudflared、啟停、自動設定 webhook、自行提供的公開網址 |
|
|
76
78
|
| 回覆人設 | 選擇人設(大肥魚或插件自訂)、新增/編輯/刪除自訂人設、把目前人設複製成自訂、強制套用、允許使用者自己換人設、切換大肥魚 session 模式、測試 Agent 回覆 |
|
|
@@ -141,6 +143,16 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
141
143
|
- 需要 DSH 已登入 DeepSeek 帳號(deepseek-account)。沒登入或該 profile 沒掛這個服務時,卡片會顯示原因,其餘功能不受影響。
|
|
142
144
|
- 門檻用的是錢包金額,不是 token 數:DeepSeek 平台顯示的是金額,token 換算會隨模型與快取變動。
|
|
143
145
|
|
|
146
|
+
## 個人記憶
|
|
147
|
+
|
|
148
|
+
角色有一份自己的記憶,存在 `<DSH_HOME>/line-bot/memories.json`,每位使用者分開:
|
|
149
|
+
|
|
150
|
+
- 對話中出現它覺得重要的資訊(喜好、近況、承諾、重要事件),它會自己用 `line_memory_add` 記下來,不用你交代。
|
|
151
|
+
- 有期限的事(考試、旅行、短期狀況)可以標到期日,時間到自動刪除;你也可以直接叫它「忘掉這件事」。
|
|
152
|
+
- 每天(預設凌晨 4 點,面板可改)它會自己整理一次:把過期、一次事件已結束、或明顯不再成立的記憶刪掉。
|
|
153
|
+
- 每輪只帶入最近的 N 筆(預設 20 筆、2000 字),其餘仍留在檔案裡;每人上限預設 120 筆,超過時刪最舊的。
|
|
154
|
+
- 密碼、token、證件號等敏感資料不該叫它記,它也會避免。
|
|
155
|
+
- 面板的「個人記憶」卡可以看目前記了什麼、手動刪除、立即清理到期、或現在就叫它整理一次。
|
|
144
156
|
## 疑難排解
|
|
145
157
|
|
|
146
158
|
- **面板顯示「無法連線到 LINE Bot 執行期」**:webServer 可能還沒就緒,重新整理即可;仍失敗就看 DSH 日誌裡 `[line-bot]` 的訊息。
|
package/README.zh-CN.md
CHANGED
|
@@ -71,6 +71,8 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
71
71
|
| 区块 | 内容 |
|
|
72
72
|
| --- | --- |
|
|
73
73
|
| 状态总览 | webhook server/隧道/LINE/人设/主动传讯的绿灯状态、公开网址、一键上线与启停 |
|
|
74
|
+
| DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却 |
|
|
75
|
+
| 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
|
|
74
76
|
| LINE 凭证 | Channel ID、Channel Secret、Access Token、自动签发、loading 动画、bot 信息、webhook 套用/测试、白名单 |
|
|
75
77
|
| Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名称、Account/Zone ID、下载 cloudflared、启停、自动设置 webhook、自行提供的公开网址 |
|
|
76
78
|
| 回复人设 | 选择人设(大肥鱼或插件自定义)、新增/编辑/删除自定义人设、把目前人设复制成自定义、强制套用、允许用户自己换人设、切换大肥鱼 session 模式、测试 Agent 回复 |
|
|
@@ -141,6 +143,16 @@ dsh plugin --profile desktop add dsh-line-bot
|
|
|
141
143
|
- 需要 DSH 已登录 DeepSeek 账号(deepseek-account)。未登录或该 profile 没有这个服务时,卡片会说明原因,其他功能不受影响。
|
|
142
144
|
- 门槛用的是钱包金额,不是 token 数。
|
|
143
145
|
|
|
146
|
+
## 个人记忆
|
|
147
|
+
|
|
148
|
+
角色有一份自己的记忆,保存在 `<DSH_HOME>/line-bot/memories.json`,每位用户分开:
|
|
149
|
+
|
|
150
|
+
- 对话中出现它觉得重要的信息(喜好、近况、承诺、重要事件),它会自己用 `line_memory_add` 记下来,不用你交代。
|
|
151
|
+
- 有期限的事(考试、旅行、短期状况)可以标到期日,时间到自动删除;你也可以直接叫它「忘掉这件事」。
|
|
152
|
+
- 每天(默认凌晨 4 点,面板可改)它会自己整理一次:把过期、一次性事件已结束、或明显不再成立的记忆删掉。
|
|
153
|
+
- 每轮只带入最近的 N 笔(默认 20 笔、2000 字),其余仍留在文件里;每人上限默认 120 笔,超过时删除最旧的。
|
|
154
|
+
- 密码、token、证件号等敏感资料不该叫它记,它也会避免。
|
|
155
|
+
- 面板的「个人记忆」卡可以看目前记了什么、手动删除、立即清理到期、或现在就叫它整理一次。
|
|
144
156
|
## 疑难排解
|
|
145
157
|
|
|
146
158
|
- **面板显示「无法连接到 LINE Bot 运行时」**:webServer 可能还没就绪,刷新即可;仍失败就看 DSH 日志里 `[line-bot]` 的消息。
|
package/client.js
CHANGED
|
@@ -14,6 +14,7 @@ window.__ModuleLoader__.load({
|
|
|
14
14
|
const NAV = [
|
|
15
15
|
{ key: 'overview', label: '狀態總覽' },
|
|
16
16
|
{ key: 'balance', label: 'DeepSeek 餘額' },
|
|
17
|
+
{ key: 'memory', label: '個人記憶' },
|
|
17
18
|
{ key: 'line', label: 'LINE 憑證' },
|
|
18
19
|
{ key: 'tunnel', label: 'Cloudflare' },
|
|
19
20
|
{ key: 'persona', label: '回覆人設' },
|
|
@@ -293,6 +294,10 @@ window.__ModuleLoader__.load({
|
|
|
293
294
|
'persona-mode': '切換人設模式',
|
|
294
295
|
'tieba-preview': '預覽貼吧同步差異',
|
|
295
296
|
'balance-refresh': '查詢 DeepSeek 餘額',
|
|
297
|
+
'memory-list': '讀取個人記憶',
|
|
298
|
+
'memory-delete': '刪除記憶',
|
|
299
|
+
'memory-prune': '清理到期記憶',
|
|
300
|
+
'memory-review': '請角色整理記憶',
|
|
296
301
|
'balance-ask': '餘額提醒',
|
|
297
302
|
'tieba-sync': '同步貼吧學習',
|
|
298
303
|
'local-persona-list': '讀取自訂人設',
|
|
@@ -317,6 +322,7 @@ window.__ModuleLoader__.load({
|
|
|
317
322
|
const [active, setActive] = useState('overview')
|
|
318
323
|
const [syncPreview, setSyncPreview] = useState(null)
|
|
319
324
|
const [balancePreview, setBalancePreview] = useState(null)
|
|
325
|
+
const [memoryData, setMemoryData] = useState(null)
|
|
320
326
|
const [localEdit, setLocalEdit] = useState({ id: '', name: '', text: '' })
|
|
321
327
|
|
|
322
328
|
const dirty = useMemo(() => {
|
|
@@ -436,6 +442,50 @@ window.__ModuleLoader__.load({
|
|
|
436
442
|
await act('balance-ask', { dryRun: false, force: true })
|
|
437
443
|
}
|
|
438
444
|
|
|
445
|
+
async function loadMemory({ quiet = false } = {}) {
|
|
446
|
+
if (!quiet) setBusy('memory-list')
|
|
447
|
+
try {
|
|
448
|
+
if (dirty && !quiet) await persist(false)
|
|
449
|
+
const value = await jpost('/action', { action: 'memory-list', payload: {} })
|
|
450
|
+
if (value?.status) {
|
|
451
|
+
setStatus(value.status)
|
|
452
|
+
setDraft(stripSecrets(value.status.config))
|
|
453
|
+
}
|
|
454
|
+
setMemoryData(value?.result || null)
|
|
455
|
+
} catch (err) {
|
|
456
|
+
if (!quiet) setToast({ kind: 'error', text: '讀取記憶失敗:' + (err.message || err) })
|
|
457
|
+
} finally {
|
|
458
|
+
if (!quiet) setBusy('')
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function removeMemory(userId, entry) {
|
|
463
|
+
setBusy('memory-delete')
|
|
464
|
+
try {
|
|
465
|
+
const value = await jpost('/action', { action: 'memory-delete', payload: { userId, id: entry.id } })
|
|
466
|
+
if (value?.status) {
|
|
467
|
+
setStatus(value.status)
|
|
468
|
+
setDraft(stripSecrets(value.status.config))
|
|
469
|
+
}
|
|
470
|
+
await loadMemory({ quiet: true })
|
|
471
|
+
setToast({ kind: 'ok', text: '已刪掉一筆記憶。' })
|
|
472
|
+
} catch (err) {
|
|
473
|
+
setToast({ kind: 'error', text: '刪除記憶失敗:' + (err.message || err) })
|
|
474
|
+
} finally {
|
|
475
|
+
setBusy('')
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function runMemory(action) {
|
|
480
|
+
await act(action)
|
|
481
|
+
await loadMemory({ quiet: true })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
loadMemory({ quiet: true })
|
|
486
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
487
|
+
}, [])
|
|
488
|
+
|
|
439
489
|
async function previewSync() {
|
|
440
490
|
setBusy('tieba-preview')
|
|
441
491
|
setToast(null)
|
|
@@ -566,6 +616,9 @@ window.__ModuleLoader__.load({
|
|
|
566
616
|
const balanceStatus = status.balance || {}
|
|
567
617
|
const balanceSymbol = balanceStatus.cny ? '¥' : balanceStatus.usd ? '$' : '¥'
|
|
568
618
|
const balanceTotal = balanceStatus.cny ? balanceStatus.cny.total : balanceStatus.usd ? balanceStatus.usd.total : null
|
|
619
|
+
const memoryCfg = cfg.memory || {}
|
|
620
|
+
const memoryStatus = status.memory || {}
|
|
621
|
+
const memoryUsers = memoryData?.users || []
|
|
569
622
|
const syncStats = status.sync || {}
|
|
570
623
|
const runtime = status
|
|
571
624
|
const persona = status.persona || {}
|
|
@@ -705,6 +758,53 @@ window.__ModuleLoader__.load({
|
|
|
705
758
|
balancePreview && balancePreview.text ? h('div', { style: S.panelBox }, h('div', { style: S.fieldTitle }, '角色會傳這則:'), h('pre', { style: S.pre }, balancePreview.text)) : null,
|
|
706
759
|
),
|
|
707
760
|
|
|
761
|
+
// 1.6 個人記憶
|
|
762
|
+
h(
|
|
763
|
+
Card,
|
|
764
|
+
{
|
|
765
|
+
id: sectionDomId('memory'),
|
|
766
|
+
title: '個人記憶',
|
|
767
|
+
desc: '每位使用者分開。角色覺得重要的事會自己記下來;有期限的到期自動刪,每天也會請角色自己整理一次。',
|
|
768
|
+
right: h(Btn, { onClick: () => loadMemory(), disabled: !!busy }, busy === 'memory-list' ? '讀取中…' : '重新整理'),
|
|
769
|
+
},
|
|
770
|
+
h('div', { style: S.row },
|
|
771
|
+
h(Badge, { ok: memoryStatus.enabled !== false }, '共 ' + (memoryStatus.total || 0) + ' 筆'),
|
|
772
|
+
h(Badge, { ok: memoryCfg.reviewDaily !== false }, '每日整理:' + (memoryCfg.reviewDaily !== false ? (Number(memoryCfg.reviewHour) || 0) + ' 點' : '關閉')),
|
|
773
|
+
h(Badge, { ok: true }, '到期自動刪'),
|
|
774
|
+
),
|
|
775
|
+
h(KV, { rows: [
|
|
776
|
+
['上次清理', memoryStatus.lastPruneAt ? fmtTime(memoryStatus.lastPruneAt) : '還沒清過'],
|
|
777
|
+
['上次角色整理', memoryStatus.lastReviewAt ? fmtTime(memoryStatus.lastReviewAt) : '還沒整理過'],
|
|
778
|
+
['記憶檔', memoryStatus.file || ''],
|
|
779
|
+
] }),
|
|
780
|
+
h('div', { style: S.grid },
|
|
781
|
+
h(Field, { label: '每天整理時間(0–23 點)', hint: '由角色自己判斷哪幾筆該忘掉' }, h(Num, { value: memoryCfg.reviewHour, min: 0, max: 23, step: 1, onChange: (value) => update({ memory: { reviewHour: value } }) })),
|
|
782
|
+
h(Field, { label: '每輪最多帶入幾筆', hint: '其餘仍留在檔案裡' }, h(Num, { value: memoryCfg.injectMaxEntries, min: 1, max: 80, step: 1, onChange: (value) => update({ memory: { injectMaxEntries: value } }) })),
|
|
783
|
+
h(Field, { label: '每人上限(筆)', hint: '超過時刪最舊的' }, h(Num, { value: memoryCfg.maxPerUser, min: 10, max: 500, step: 10, onChange: (value) => update({ memory: { maxPerUser: value } }) })),
|
|
784
|
+
h(Field, { label: '帶入字數上限', hint: '避免佔用太多 prompt' }, h(Num, { value: memoryCfg.injectMaxChars, min: 200, max: 8000, step: 200, onChange: (value) => update({ memory: { injectMaxChars: value } }) })),
|
|
785
|
+
),
|
|
786
|
+
h('div', { style: S.row },
|
|
787
|
+
h(Toggle, { checked: memoryCfg.enabled !== false, onChange: (value) => update({ memory: { enabled: value } }), label: '開啟個人記憶', hint: '關掉後角色不再記新的事情,也不會讀到舊的' }),
|
|
788
|
+
h(Toggle, { checked: memoryCfg.reviewDaily !== false, onChange: (value) => update({ memory: { reviewDaily: value } }), label: '每天請角色整理一次', hint: '清掉過期或不再成立的記憶' }),
|
|
789
|
+
),
|
|
790
|
+
h('div', { style: S.row },
|
|
791
|
+
h(Btn, { onClick: () => runMemory('memory-prune'), disabled: !!busy }, busy === 'memory-prune' ? '清理中…' : '清理到期記憶'),
|
|
792
|
+
h(Btn, { onClick: () => runMemory('memory-review'), disabled: !!busy }, busy === 'memory-review' ? '整理中…' : '現在請角色整理'),
|
|
793
|
+
),
|
|
794
|
+
memoryData
|
|
795
|
+
? (memoryUsers.length === 0
|
|
796
|
+
? h('div', { style: S.note }, '目前沒有任何記憶。角色在對話中覺得重要時,會自己記下來。')
|
|
797
|
+
: memoryUsers.map((row) => h('div', { key: row.userId, style: S.panelBox },
|
|
798
|
+
h('div', { style: S.fieldTitle }, (row.displayName || row.userId) + '(' + row.entries.length + ' 筆)'),
|
|
799
|
+
row.entries.map((entry) => h('div', { key: entry.id, style: { display: 'flex', gap: 8, alignItems: 'flex-start', padding: '6px 0', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
800
|
+
h('span', { style: { flex: '0 0 auto', fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', paddingTop: 2 } }, ({ fact: '事實', event: '事件', preference: '喜好', inside: '默契' })[entry.kind] || entry.kind),
|
|
801
|
+
h('span', { style: { flex: 1, fontSize: 13, lineHeight: 1.5 } }, entry.text, entry.expiresText ? h('span', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, '(到 ' + String(entry.expiresText).slice(0, 10) + ')') : null),
|
|
802
|
+
h(Btn, { tone: 'danger', onClick: () => removeMemory(row.userId, entry), disabled: !!busy }, '刪除'),
|
|
803
|
+
)),
|
|
804
|
+
)))
|
|
805
|
+
: h('div', { style: S.note }, '按「重新整理」讀取目前記住的內容。'),
|
|
806
|
+
),
|
|
807
|
+
|
|
708
808
|
// 2. LINE 憑證
|
|
709
809
|
h(
|
|
710
810
|
Card,
|
package/lib/agent-runner.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
80
80
|
|
|
81
81
|
// 把「插件自己的人設」與「貼吧動態」掛進這個 agent 的 system prompt。
|
|
82
82
|
// 自訂人設只有選了才注入;大肥魚的人設走它自己的 session 指定。
|
|
83
|
-
function agentSetup({ personaText, contextText }) {
|
|
83
|
+
function agentSetup({ personaText, contextText, tools = [] }) {
|
|
84
84
|
return (agentCtx) => {
|
|
85
85
|
try {
|
|
86
86
|
const prompt = agentCtx?.systemPrompt
|
|
@@ -120,6 +120,17 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
120
120
|
} catch (error) {
|
|
121
121
|
logger?.warn?.(`注入人設/貼吧動態失敗:${error?.message || error}`)
|
|
122
122
|
}
|
|
123
|
+
if (Array.isArray(tools) && tools.length > 0) {
|
|
124
|
+
try {
|
|
125
|
+
const registry = agentCtx.get?.('tools') || agentCtx.tools
|
|
126
|
+
if (!registry?.register) throw new Error('這個 profile 沒有 tools 服務')
|
|
127
|
+
for (const spec of tools) {
|
|
128
|
+
agentCtx.effect(() => registry.register(spec), `line-bot: tool ${spec.name}`)
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logger?.warn?.(`掛載記憶工具失敗:${error?.message || error}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
|
|
@@ -161,7 +172,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
161
172
|
}
|
|
162
173
|
}
|
|
163
174
|
|
|
164
|
-
async function turn({ sessionId, text, persona, personaClient, personaText, contextText, cwd: cwdOverride }) {
|
|
175
|
+
async function turn({ sessionId, text, persona, personaClient, personaText, contextText, tools = [], cwd: cwdOverride }) {
|
|
165
176
|
const cfg = getConfig()
|
|
166
177
|
const timeoutMs = cfg.agent.timeoutMs || 180000
|
|
167
178
|
const { options } = agentOptions()
|
|
@@ -175,7 +186,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
175
186
|
let offEnd = null
|
|
176
187
|
|
|
177
188
|
try {
|
|
178
|
-
const setup = personaText || contextText ? agentSetup({ personaText, contextText }) : undefined
|
|
189
|
+
const setup = personaText || contextText || tools.length > 0 ? agentSetup({ personaText, contextText, tools }) : undefined
|
|
179
190
|
const opened = await openAgent(sessionId, options, cwd, setup)
|
|
180
191
|
agent = opened.agent
|
|
181
192
|
handle = opened.handle
|
package/lib/config.mjs
CHANGED
|
@@ -100,6 +100,16 @@ export const DEFAULT_CONFIG = {
|
|
|
100
100
|
ask: true,
|
|
101
101
|
cooldownHours: 12,
|
|
102
102
|
},
|
|
103
|
+
memory: {
|
|
104
|
+
// 每位使用者各自的個人記憶:agent 覺得重要就自己記,有期限的到期自動刪。
|
|
105
|
+
enabled: true,
|
|
106
|
+
maxPerUser: 120,
|
|
107
|
+
injectMaxEntries: 20,
|
|
108
|
+
injectMaxChars: 2000,
|
|
109
|
+
// 背景每天檢查一次,請角色自己把過期/失效的記憶刪掉。
|
|
110
|
+
reviewDaily: true,
|
|
111
|
+
reviewHour: 4,
|
|
112
|
+
},
|
|
103
113
|
log: {
|
|
104
114
|
maxLines: 400,
|
|
105
115
|
},
|
|
@@ -212,6 +222,14 @@ export function sanitizePatch(current, patch) {
|
|
|
212
222
|
balance.threshold = clampFloat(balance.threshold, 0, 100000, 10)
|
|
213
223
|
balance.cooldownHours = clampInt(balance.cooldownHours, 1, 168, 12)
|
|
214
224
|
|
|
225
|
+
const memory = next.memory || (next.memory = {})
|
|
226
|
+
memory.enabled = Boolean(memory.enabled)
|
|
227
|
+
memory.reviewDaily = Boolean(memory.reviewDaily)
|
|
228
|
+
memory.maxPerUser = clampInt(memory.maxPerUser, 10, 500, 120)
|
|
229
|
+
memory.injectMaxEntries = clampInt(memory.injectMaxEntries, 1, 80, 20)
|
|
230
|
+
memory.injectMaxChars = clampInt(memory.injectMaxChars, 200, 8000, 2000)
|
|
231
|
+
memory.reviewHour = clampInt(memory.reviewHour, 0, 23, 4)
|
|
232
|
+
|
|
215
233
|
const nudge = next.nudge
|
|
216
234
|
nudge.enabled = Boolean(nudge.enabled)
|
|
217
235
|
nudge.chance = clampFloat(nudge.chance, 0, 1, 0.18)
|
|
@@ -291,6 +309,7 @@ export function normalizeState(raw) {
|
|
|
291
309
|
users: isPlainObject(state.users) ? state.users : {},
|
|
292
310
|
nudge: isPlainObject(state.nudge) ? state.nudge : {},
|
|
293
311
|
balance: isPlainObject(state.balance) ? state.balance : {},
|
|
312
|
+
memory: isPlainObject(state.memory) ? state.memory : {},
|
|
294
313
|
sync: isPlainObject(state.sync) ? state.sync : {},
|
|
295
314
|
webhook: isPlainObject(state.webhook) ? state.webhook : null,
|
|
296
315
|
lastEventAt: Number(state.lastEventAt) || 0,
|
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
|
|
34
|
+
const { createMemoryStore } = memoryMod
|
|
33
35
|
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody } = utilMod
|
|
34
36
|
|
|
35
|
-
export const VERSION = '0.
|
|
37
|
+
export const VERSION = '0.3.0'
|
|
36
38
|
|
|
37
39
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
38
40
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -168,6 +170,8 @@ 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
|
+
const memory = createMemoryStore({ dir, logger, getConfig: () => store.get() })
|
|
174
|
+
let memoryTimer = null
|
|
171
175
|
|
|
172
176
|
function balanceView() {
|
|
173
177
|
const cached = balance.get()
|
|
@@ -193,8 +197,38 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
193
197
|
return lines.join('\n')
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
function
|
|
197
|
-
|
|
200
|
+
function memoryRulesText() {
|
|
201
|
+
if (store.get().memory?.enabled === false) return ''
|
|
202
|
+
return [
|
|
203
|
+
'【個人記憶】你有一份自己的記憶(每位使用者分開)。',
|
|
204
|
+
'對話中若出現你覺得重要、之後還會用到的資訊(喜好、近況、承諾、重要事件),馬上用 line_memory_add 記下來,不用等對方交代。',
|
|
205
|
+
'一次只記一件事;如果同時有兩三件重要的事,就分開呼叫兩三次,不要合併成一段。',
|
|
206
|
+
'有期限的事(考試、旅行、短期的狀況)請填 ttlDays,到期會自動刪掉;一次性閒聊不要記。',
|
|
207
|
+
'使用者要你忘掉某件事時,用 line_memory_forget 刪掉;想確認自己記了什麼就用 line_memory_list。',
|
|
208
|
+
'很重要:只有真的呼叫了工具才算記住。沒呼叫就不要說「記好了」;使用者明確要你記住時,先呼叫工具再回覆。',
|
|
209
|
+
'不要記密碼、token、證件號等敏感資料,也不要在回覆裡提到這些工具或「記憶系統」。',
|
|
210
|
+
].join('\n')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function memoryContextText(userId) {
|
|
214
|
+
if (!userId) return ''
|
|
215
|
+
const rules = memoryRulesText()
|
|
216
|
+
const remembered = memory.formatForContext(userId)
|
|
217
|
+
return [rules, remembered].filter(Boolean).join('\n\n')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function memoryToolsFor(userId) {
|
|
221
|
+
if (!userId || store.get().memory?.enabled === false) return []
|
|
222
|
+
try {
|
|
223
|
+
return memory.toolsFor(userId)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
logger.warn('準備記憶工具失敗:' + errorText(error))
|
|
226
|
+
return []
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function agentContextText(userId) {
|
|
231
|
+
return [tiebaContextText(), balanceContextText(), memoryContextText(userId)].filter(Boolean).join('\n\n')
|
|
198
232
|
}
|
|
199
233
|
|
|
200
234
|
async function refreshBalance() {
|
|
@@ -269,6 +303,94 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
269
303
|
}
|
|
270
304
|
}
|
|
271
305
|
|
|
306
|
+
// ── 個人記憶:清理與每日整理 ────────────────────────────────────────────
|
|
307
|
+
function pruneMemories() {
|
|
308
|
+
try {
|
|
309
|
+
const out = memory.prune()
|
|
310
|
+
if (out.removed > 0) logger.info('個人記憶:清理 ' + out.removed + ' 筆到期項目')
|
|
311
|
+
return out
|
|
312
|
+
} catch (error) {
|
|
313
|
+
logger.warn('清理個人記憶失敗:' + errorText(error))
|
|
314
|
+
return { removed: 0, error: errorText(error) }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function reviewMemories({ userId = '' } = {}) {
|
|
319
|
+
const cfg = store.get()
|
|
320
|
+
if (cfg.memory?.enabled === false) return { skipped: 'disabled' }
|
|
321
|
+
const state = store.getState()
|
|
322
|
+
const targets = userId
|
|
323
|
+
? [[userId, state.users?.[userId]]]
|
|
324
|
+
: Object.entries(state.users || {})
|
|
325
|
+
const reviewed = []
|
|
326
|
+
for (const [id, user] of targets) {
|
|
327
|
+
if (!user?.sessionId) continue
|
|
328
|
+
const entries = memory.list(id)
|
|
329
|
+
if (entries.length === 0) continue
|
|
330
|
+
const selection = cfg.persona.enforce ? user.personaId || cfg.persona.id : ''
|
|
331
|
+
const applied = await applyPersonaSelection(selection, user.sessionId)
|
|
332
|
+
const lines = entries.map((entry) => {
|
|
333
|
+
const created = new Date(entry.createdAt).toISOString().slice(0, 10)
|
|
334
|
+
const until = entry.expiresAt ? ',' + new Date(entry.expiresAt).toISOString().slice(0, 10) + ' 失效' : ''
|
|
335
|
+
return '- ' + entry.id + ' [' + entry.kind + '] ' + entry.text + '(' + created + until + ')'
|
|
336
|
+
})
|
|
337
|
+
const promptText = [
|
|
338
|
+
'(這是系統提供的背景整理,不是主人說的話)請你檢查自己對這位使用者的記憶。',
|
|
339
|
+
'目前的記憶:',
|
|
340
|
+
...lines,
|
|
341
|
+
'把已經過期、一次事件已結束、或明顯不再成立的,用 line_memory_forget 刪掉;其餘保留。',
|
|
342
|
+
'不要輸出給主人看的訊息,只呼叫工具,最後回一個 OK。',
|
|
343
|
+
].join('\n')
|
|
344
|
+
try {
|
|
345
|
+
await runner.turn({
|
|
346
|
+
sessionId: user.sessionId,
|
|
347
|
+
text: promptText,
|
|
348
|
+
persona: applied.persona,
|
|
349
|
+
personaText: applied.personaText,
|
|
350
|
+
personaClient: persona(),
|
|
351
|
+
contextText: '',
|
|
352
|
+
tools: memoryToolsFor(id),
|
|
353
|
+
cwd: ensureUserWorkspace(id) || undefined,
|
|
354
|
+
})
|
|
355
|
+
reviewed.push({ userId: id, entries: entries.length })
|
|
356
|
+
} catch (error) {
|
|
357
|
+
logger.warn('整理 ' + id + ' 的記憶失敗:' + errorText(error))
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
store.updateState((current) => ({ ...current, memory: { ...(current.memory || {}), lastReviewAt: Date.now() } }))
|
|
361
|
+
logger.info('個人記憶:整理完成,檢查 ' + reviewed.length + ' 位使用者')
|
|
362
|
+
return { reviewed: reviewed.length, users: reviewed }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function maybeDailyMemoryReview() {
|
|
366
|
+
const cfg = store.get()
|
|
367
|
+
if (cfg.memory?.enabled === false || cfg.memory?.reviewDaily === false) return
|
|
368
|
+
const hour = Number(cfg.memory?.reviewHour)
|
|
369
|
+
const wantedHour = Number.isFinite(hour) ? Math.max(0, Math.min(23, Math.round(hour))) : 4
|
|
370
|
+
const now = new Date()
|
|
371
|
+
if (now.getHours() !== wantedHour) return
|
|
372
|
+
const lastReviewAt = Number(store.getState().memory?.lastReviewAt) || 0
|
|
373
|
+
if (lastReviewAt && now.getTime() - lastReviewAt < 20 * 3600_000) return
|
|
374
|
+
reviewMemories().catch((error) => logger.warn('每日記憶整理失敗:' + errorText(error)))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function startMemoryWatch() {
|
|
378
|
+
stopMemoryWatch()
|
|
379
|
+
pruneMemories()
|
|
380
|
+
memoryTimer = setInterval(() => {
|
|
381
|
+
pruneMemories()
|
|
382
|
+
maybeDailyMemoryReview()
|
|
383
|
+
}, 10 * 60_000)
|
|
384
|
+
memoryTimer.unref?.()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function stopMemoryWatch() {
|
|
388
|
+
if (memoryTimer) {
|
|
389
|
+
clearInterval(memoryTimer)
|
|
390
|
+
memoryTimer = null
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
272
394
|
// ── LINE 憑證 ────────────────────────────────────────────────────────────
|
|
273
395
|
async function getToken({ force = false } = {}) {
|
|
274
396
|
const cfg = store.get()
|
|
@@ -684,7 +806,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
684
806
|
persona: applied.persona,
|
|
685
807
|
personaText: applied.personaText,
|
|
686
808
|
personaClient: persona(),
|
|
687
|
-
contextText: agentContextText(),
|
|
809
|
+
contextText: agentContextText(userId),
|
|
810
|
+
tools: memoryToolsFor(userId),
|
|
688
811
|
cwd: ensureUserWorkspace(userId) || cfg.agent.cwd || undefined,
|
|
689
812
|
})
|
|
690
813
|
let replyText = String(result?.text || '').trim()
|
|
@@ -759,7 +882,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
759
882
|
persona: applied.persona,
|
|
760
883
|
personaText: applied.personaText,
|
|
761
884
|
personaClient: persona(),
|
|
762
|
-
contextText: agentContextText(),
|
|
885
|
+
contextText: agentContextText(owner?.[0]),
|
|
886
|
+
tools: memoryToolsFor(owner?.[0]),
|
|
763
887
|
cwd: owner ? ensureUserWorkspace(owner[0]) : undefined,
|
|
764
888
|
})
|
|
765
889
|
},
|
|
@@ -848,6 +972,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
848
972
|
nudge.start()
|
|
849
973
|
tiebaSync.start()
|
|
850
974
|
startBalanceWatch()
|
|
975
|
+
startMemoryWatch()
|
|
851
976
|
store.updateState((state) => ({ ...state, lastStartAt: Date.now() }))
|
|
852
977
|
if (cfg.line.channelId && cfg.line.channelSecret) refreshBotInfo().catch(() => {})
|
|
853
978
|
return buildStatus()
|
|
@@ -856,6 +981,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
856
981
|
async function stopAll() {
|
|
857
982
|
stopWebhookGuard()
|
|
858
983
|
stopBalanceWatch()
|
|
984
|
+
stopMemoryWatch()
|
|
859
985
|
nudge.stop()
|
|
860
986
|
tiebaSync.stop()
|
|
861
987
|
await tunnel.stop({ silent: true }).catch(() => {})
|
|
@@ -936,6 +1062,16 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
936
1062
|
lastAskText: state.balance?.lastAskText || '',
|
|
937
1063
|
low: Boolean(balanceView() && Number(cfg.balance.threshold) > 0 && balanceView().value.total < Number(cfg.balance.threshold)),
|
|
938
1064
|
},
|
|
1065
|
+
memory: {
|
|
1066
|
+
...memory.stats(),
|
|
1067
|
+
enabled: cfg.memory?.enabled !== false,
|
|
1068
|
+
reviewDaily: cfg.memory?.reviewDaily !== false,
|
|
1069
|
+
reviewHour: Number(cfg.memory?.reviewHour) || 0,
|
|
1070
|
+
maxPerUser: Number(cfg.memory?.maxPerUser) || 120,
|
|
1071
|
+
injectMaxEntries: Number(cfg.memory?.injectMaxEntries) || 20,
|
|
1072
|
+
injectMaxChars: Number(cfg.memory?.injectMaxChars) || 2000,
|
|
1073
|
+
lastReviewAt: Number(state.memory?.lastReviewAt) || 0,
|
|
1074
|
+
},
|
|
939
1075
|
sync: { ...tiebaSync.stats(), config: cfg.sync },
|
|
940
1076
|
users,
|
|
941
1077
|
logs: logger.lines(),
|
|
@@ -1096,6 +1232,37 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1096
1232
|
return refreshBalance()
|
|
1097
1233
|
case 'balance-ask':
|
|
1098
1234
|
return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
|
|
1235
|
+
case 'memory-list': {
|
|
1236
|
+
const only = String(payload.userId || '').trim()
|
|
1237
|
+
const state = store.getState()
|
|
1238
|
+
const rows = Object.entries(state.users || {})
|
|
1239
|
+
.filter(([userId]) => !only || userId === only)
|
|
1240
|
+
.map(([userId, user]) => ({
|
|
1241
|
+
userId,
|
|
1242
|
+
displayName: user?.displayName || '',
|
|
1243
|
+
entries: memory.list(userId).map((entry) => ({
|
|
1244
|
+
...entry,
|
|
1245
|
+
createdText: new Date(entry.createdAt).toISOString(),
|
|
1246
|
+
expiresText: entry.expiresAt ? new Date(entry.expiresAt).toISOString() : '',
|
|
1247
|
+
})),
|
|
1248
|
+
}))
|
|
1249
|
+
.filter((row) => row.entries.length > 0)
|
|
1250
|
+
return { users: rows, total: rows.reduce((sum, row) => sum + row.entries.length, 0) }
|
|
1251
|
+
}
|
|
1252
|
+
case 'memory-add': {
|
|
1253
|
+
const userId = String(payload.userId || '').trim()
|
|
1254
|
+
if (!userId) throw new Error('缺少 userId')
|
|
1255
|
+
return memory.add(userId, payload)
|
|
1256
|
+
}
|
|
1257
|
+
case 'memory-delete': {
|
|
1258
|
+
const userId = String(payload.userId || '').trim()
|
|
1259
|
+
if (!userId) throw new Error('缺少 userId')
|
|
1260
|
+
return memory.forget(userId, { ids: payload.id ? [payload.id] : payload.ids })
|
|
1261
|
+
}
|
|
1262
|
+
case 'memory-prune':
|
|
1263
|
+
return pruneMemories()
|
|
1264
|
+
case 'memory-review':
|
|
1265
|
+
return reviewMemories({ userId: String(payload.userId || '').trim() })
|
|
1099
1266
|
case 'nudge-test':
|
|
1100
1267
|
return nudge.tick({ force: true })
|
|
1101
1268
|
case 'agent-test': {
|
|
@@ -1103,6 +1270,9 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1103
1270
|
const sessionId = String(payload.sessionId || `session-line-selftest-${Date.now()}`)
|
|
1104
1271
|
const cwd = String(payload.cwd || '').trim() || undefined
|
|
1105
1272
|
const selection = String(payload.personaId || '').trim() || (cfg.persona.enforce ? cfg.persona.id : '')
|
|
1273
|
+
const testUserId = String(payload.userId || '').trim()
|
|
1274
|
+
|| Object.entries(store.getState().users || {}).find(([, item]) => item.sessionId === sessionId)?.[0]
|
|
1275
|
+
|| ''
|
|
1106
1276
|
const applied = await applyPersonaSelection(selection, sessionId)
|
|
1107
1277
|
const result = await runner.turn({
|
|
1108
1278
|
sessionId,
|
|
@@ -1110,7 +1280,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1110
1280
|
persona: applied.persona,
|
|
1111
1281
|
personaText: applied.personaText,
|
|
1112
1282
|
personaClient: persona(),
|
|
1113
|
-
contextText: agentContextText(),
|
|
1283
|
+
contextText: agentContextText(testUserId),
|
|
1284
|
+
tools: memoryToolsFor(testUserId),
|
|
1114
1285
|
cwd,
|
|
1115
1286
|
})
|
|
1116
1287
|
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/toolkit.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// 把工具定義補成宿主要求的形狀(output schema + render),並把參數映射轉成 JSON Schema。
|
|
2
|
+
// 做法與 dsh-tieba-dive 的 lib/toolkit.mjs 相同:宿主有 dsh-tools 就用它的 defineTool,
|
|
3
|
+
// 沒有就自己補;這樣外掛不必把 dsh-tools 宣告成依賴。
|
|
4
|
+
let base = (spec) => spec
|
|
5
|
+
let usingHost = false
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const mod = await import('dsh-tools')
|
|
9
|
+
if (typeof mod.defineTool === 'function') {
|
|
10
|
+
base = mod.defineTool
|
|
11
|
+
usingHost = true
|
|
12
|
+
}
|
|
13
|
+
} catch {
|
|
14
|
+
/* 宿主沒給,用本地兜底 */
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const textOutput = {
|
|
18
|
+
schema: { type: 'object', properties: {}, additionalProperties: true },
|
|
19
|
+
render: (_args, value) => [{
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
|
|
22
|
+
}],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toJsonSchema(params) {
|
|
26
|
+
if (!params) return { type: 'object', properties: {}, required: [] }
|
|
27
|
+
if (params.type === 'object') return params
|
|
28
|
+
const properties = {}
|
|
29
|
+
const required = []
|
|
30
|
+
for (const [key, value] of Object.entries(params)) {
|
|
31
|
+
if (!value || typeof value !== 'object') continue
|
|
32
|
+
const { required: isRequired, ...schema } = value
|
|
33
|
+
properties[key] = schema
|
|
34
|
+
if (isRequired === true) required.push(key)
|
|
35
|
+
}
|
|
36
|
+
return { type: 'object', properties, required, additionalProperties: false }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function defineTool(spec) {
|
|
40
|
+
if (usingHost) return base({ output: textOutput, ...spec })
|
|
41
|
+
return base({ output: textOutput, ...spec, parameters: toJsonSchema(spec.parameters) })
|
|
42
|
+
}
|