dsh-foxy-memory 0.0.0-stage → 0.2.9
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/LICENSE +21 -0
- package/README.md +117 -2
- package/cordis.patch.yml +3 -0
- package/lib/index.js +380 -0
- package/package.json +42 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kun-Fox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-foxy-memory
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`dsh-foxy-memory` 是 [`dsh-foxy`](../dsh-foxy) 的可选记忆子插件。它把 Foxy 会话写成本地 JSONL,并在下一轮把最近记忆注入 Foxy 的 persona。
|
|
4
|
+
|
|
5
|
+
它不依赖 ChatLuna/LivingMemory;这样任何人都可以只装 `dsh-foxy` + `dsh-foxy-memory` 就得到能记住对话的 Foxy。
|
|
6
|
+
|
|
7
|
+
## 记录格式
|
|
8
|
+
|
|
9
|
+
默认文件:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
%USERPROFILE%\.dsh\foxy\memory\entries.jsonl
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
每条记录:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"id": "uuid",
|
|
20
|
+
"presetId": "foxy3",
|
|
21
|
+
"sessionId": "session-id",
|
|
22
|
+
"platform": "dsh",
|
|
23
|
+
"sourceLabel": "DSH",
|
|
24
|
+
"speaker": "kunfox",
|
|
25
|
+
"role": "user",
|
|
26
|
+
"type": "conversation",
|
|
27
|
+
"status": "active",
|
|
28
|
+
"content": "今天在 DSH 做了……",
|
|
29
|
+
"keywords": [],
|
|
30
|
+
"summary": "",
|
|
31
|
+
"importance": 0.5,
|
|
32
|
+
"createdAt": 1790000000000,
|
|
33
|
+
"updatedAt": 1790000000000
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
注入时会格式化成:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
- [2026-11-06 20:00] [DSH] kunfox: 今天在 DSH 做了……
|
|
41
|
+
- [2026-11-06 20:00] [DSH] Foxy: 记住啦。
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`platform` 统一小写(`dsh`),显示时用 `sourceLabel`(如 `DSH`);`presetId` 允许 Foxy 主预设和 Character 预设分开存档。
|
|
45
|
+
|
|
46
|
+
## 配置
|
|
47
|
+
|
|
48
|
+
| 字段 | 默认 | 说明 |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| `dataDir` | `$DSH_HOME/foxy/memory` | 数据目录 |
|
|
51
|
+
| `entriesFile` | `entries.jsonl` | 文件名 |
|
|
52
|
+
| `maxEntries` | `5000` | 超过后丢弃最旧记录 |
|
|
53
|
+
| `maxContentChars` | `4000` | 单条内容截断 |
|
|
54
|
+
| `maxContextEntries` | `20` | 最多注入条数 |
|
|
55
|
+
| `maxContextChars` | `6000` | 注入内容总字符上限 |
|
|
56
|
+
| `userLabel` | `kunfox` | 用户显示名 |
|
|
57
|
+
| `assistantLabel` | `Foxy` | 助手显示名 |
|
|
58
|
+
| `provider` | `livingmemory`(配置 bridgeUrl 时)/ `local` | 记忆写到哪个 provider |
|
|
59
|
+
| `bridgeUrl` | 空 | Koishi 桥地址,如 `http://127.0.0.1:5140/foxy-memory` |
|
|
60
|
+
| `bridgeToken` | 空 | 与桥的 `token` 一致;公网部署必须设置 |
|
|
61
|
+
| `bridgePresetId` | `foxy3(Character)` | 写入 LivingMemory 的 preset |
|
|
62
|
+
| `bridgeTimeoutMs` | `15000` | 桥请求超时 |
|
|
63
|
+
| `batchSize` | `8` | 累积多少条消息后调用 `/ingest` |
|
|
64
|
+
| `flushIntervalMs` | `30000` | 未满一批时的定时冲刷间隔 |
|
|
65
|
+
| `extractModel` | `deepseek/deepseek-flash` | 桥提取记忆使用的模型 |
|
|
66
|
+
|
|
67
|
+
DSH 侧不再逐条写记忆:`record` 只把消息放进 pending,满 `batchSize` 或到 `flushIntervalMs` 时调用桥的 `/ingest`,由模型提炼成中文第一人称记忆。`stats()` 会显示 `pending`。
|
|
68
|
+
|
|
69
|
+
Foxy 面板的「记忆桥设置」可以修改这些值,保存到 `$DSH_HOME/foxy/settings.json`;插件启动时读取,保存后尝试热重载。`/api/dsh-foxy/settings` 在 DSH host 内提供。
|
|
70
|
+
|
|
71
|
+
## 服务
|
|
72
|
+
|
|
73
|
+
加载后提供:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
ctx.foxyMemory.record({ role, text, presetId, sessionId, platform, speaker })
|
|
77
|
+
ctx.foxyMemory.context({ presetId, limit })
|
|
78
|
+
ctx.foxyMemory.list({ presetId, status, limit })
|
|
79
|
+
ctx.foxyMemory.search(query, { presetId })
|
|
80
|
+
ctx.foxyMemory.archive(id)
|
|
81
|
+
ctx.foxyMemory.stats()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
同时通过 `ctx.foxy.registerMemoryProvider('local', ...)` 和(配置了桥时)`registerMemoryProvider('livingmemory', ...)` 注册到 Foxy 的 provider 注册表。
|
|
85
|
+
|
|
86
|
+
## 与 LivingMemory 的关系
|
|
87
|
+
|
|
88
|
+
本地 JSONL 是默认实现,不是 `chatluna-livingmemory` 的替代品,也不直接写它的 `koishi.db`/PGlite 索引。
|
|
89
|
+
|
|
90
|
+
如果要把 DSH 记忆写回 QQ 侧的 `foxy3(Character)` 池,安装并配置 [`koishi-plugin-foxy-memory-bridge`](../koishi-plugin-foxy-memory-bridge):
|
|
91
|
+
|
|
92
|
+
```yaml
|
|
93
|
+
# DSH 侧 dsh-foxy-memory 配置
|
|
94
|
+
provider: livingmemory
|
|
95
|
+
bridgeUrl: http://127.0.0.1:5140/foxy-memory
|
|
96
|
+
bridgeToken: 与桥一致的随机串
|
|
97
|
+
bridgePresetId: foxy3(Character)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
DSH 侧只向桥发送 `record`/`context` 请求;桥内调用:
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
ctx.chatluna_living_memory.createMemory(scope, input, speakerKeys)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
不要直接 INSERT SQLite,否则会绕过 speaker key、profile 和向量索引。桥不可用时会自动回退本地 JSONL,不会丢消息。
|
|
107
|
+
|
|
108
|
+
## 安装
|
|
109
|
+
|
|
110
|
+
```powershell
|
|
111
|
+
dsh plugin --profile desktop add "file:D:\path\to\dsh-foxy-memory"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
需要同时安装 `dsh-foxy`;重启 DSH 后,Foxy 会话会使用当前 provider。默认是本地 JSONL;配置 `bridgeUrl` 后默认切到 LivingMemory 桥(也可显式 `provider: local` 保持本地)。
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const name = 'dsh-foxy-memory'
|
|
8
|
+
export const inject = ['foxy']
|
|
9
|
+
|
|
10
|
+
function resolveDataDir(config) {
|
|
11
|
+
if (typeof config.dataDir === 'string' && config.dataDir.trim()) return config.dataDir.trim()
|
|
12
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
13
|
+
return join(home, 'foxy', 'memory')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clip(text, max) {
|
|
17
|
+
const value = String(text || '').trim()
|
|
18
|
+
return value.length > max ? `${value.slice(0, max)}…` : value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function roleSpeaker(entry, config) {
|
|
22
|
+
const speaker = String(entry.speaker || '').trim()
|
|
23
|
+
if (speaker && speaker !== 'user' && speaker !== 'assistant') return speaker
|
|
24
|
+
if (entry.role === 'assistant') return config.assistantLabel || 'Foxy'
|
|
25
|
+
return config.userLabel || 'kunfox'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizePlatform(value) {
|
|
29
|
+
const platform = String(value || 'dsh').trim().toLowerCase()
|
|
30
|
+
return platform || 'dsh'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatEntry(entry, config) {
|
|
34
|
+
const date = new Date(entry.createdAt).toISOString().slice(0, 16).replace('T', ' ')
|
|
35
|
+
const platform = entry.sourceLabel || (entry.platform === 'dsh' ? 'DSH' : entry.platform) || 'DSH'
|
|
36
|
+
return `- [${date}] [${platform} 平台] ${roleSpeaker(entry, config)}: ${entry.content}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function apply(ctx, config = {}) {
|
|
40
|
+
try {
|
|
41
|
+
const settingsFile = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'foxy', 'settings.json')
|
|
42
|
+
const saved = JSON.parse(readFileSync(settingsFile, 'utf8'))
|
|
43
|
+
for (const key of ['provider', 'bridgeUrl', 'bridgeToken', 'bridgePresetId', 'batchSize', 'flushIntervalMs', 'extractModel']) {
|
|
44
|
+
if (saved[key] !== undefined) config[key] = saved[key]
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
config = {
|
|
48
|
+
...config,
|
|
49
|
+
userLabel: config.userLabel || 'kunfox',
|
|
50
|
+
assistantLabel: config.assistantLabel || 'Foxy',
|
|
51
|
+
}
|
|
52
|
+
const dataDir = resolveDataDir(config)
|
|
53
|
+
const entriesFile = join(dataDir, config.entriesFile || 'entries.jsonl')
|
|
54
|
+
const maxEntries = Math.max(1, Number(config.maxEntries) || 5000)
|
|
55
|
+
const maxContentChars = Math.max(200, Number(config.maxContentChars) || 4000)
|
|
56
|
+
const maxContextEntries = Math.max(1, Number(config.maxContextEntries) || 20)
|
|
57
|
+
const maxContextChars = Math.max(500, Number(config.maxContextChars) || 6000)
|
|
58
|
+
|
|
59
|
+
const entries = new Map()
|
|
60
|
+
let ready = null
|
|
61
|
+
|
|
62
|
+
async function rewrite() {
|
|
63
|
+
const temp = `${entriesFile}.tmp`
|
|
64
|
+
const body = [...entries.values()].map((entry) => JSON.stringify(entry)).join('\n')
|
|
65
|
+
await writeFile(temp, body ? `${body}\n` : '', 'utf8')
|
|
66
|
+
await rename(temp, entriesFile)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalize(entry) {
|
|
70
|
+
if (!entry || !entry.id || !entry.content) return null
|
|
71
|
+
entry.type ||= 'conversation'
|
|
72
|
+
entry.status ||= 'active'
|
|
73
|
+
entry.platform = normalizePlatform(entry.platform)
|
|
74
|
+
entry.sourceLabel ||= entry.platform === 'dsh' ? 'DSH' : entry.platform
|
|
75
|
+
entry.keywords = Array.isArray(entry.keywords) ? entry.keywords : []
|
|
76
|
+
entry.summary ||= ''
|
|
77
|
+
entry.importance = Number(entry.importance) || 0.5
|
|
78
|
+
entry.createdAt = Number(entry.createdAt) || Date.now()
|
|
79
|
+
entry.updatedAt = Number(entry.updatedAt) || entry.createdAt
|
|
80
|
+
if (!entry.speaker) entry.speaker = entry.role === 'assistant' ? config.assistantLabel || 'Foxy' : config.userLabel || 'kunfox'
|
|
81
|
+
return entry
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function init() {
|
|
85
|
+
await mkdir(dataDir, { recursive: true })
|
|
86
|
+
try {
|
|
87
|
+
const text = await readFile(entriesFile, 'utf8')
|
|
88
|
+
for (const line of text.split(/\r?\n/)) {
|
|
89
|
+
if (!line.trim()) continue
|
|
90
|
+
try {
|
|
91
|
+
const entry = normalize(JSON.parse(line))
|
|
92
|
+
if (entry) entries.set(entry.id, entry)
|
|
93
|
+
} catch {
|
|
94
|
+
// Skip a damaged line rather than losing the whole archive.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error?.code !== 'ENOENT') throw error
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ready = init().catch((error) => {
|
|
103
|
+
ctx.logger?.warn?.(`[dsh-foxy-memory] failed to load ${entriesFile}: ${error instanceof Error ? error.message : String(error)}`)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
async function record(input = {}) {
|
|
107
|
+
await ready
|
|
108
|
+
const role = input.role === 'assistant' ? 'assistant' : input.role === 'user' ? 'user' : ''
|
|
109
|
+
if (!role) throw new TypeError('memory role must be user or assistant')
|
|
110
|
+
const content = clip(input.text, maxContentChars)
|
|
111
|
+
if (!content) return null
|
|
112
|
+
|
|
113
|
+
const presetId = String(input.presetId || 'foxy3')
|
|
114
|
+
const sessionId = String(input.sessionId || '')
|
|
115
|
+
const platform = normalizePlatform(input.platform || input.sourceLabel)
|
|
116
|
+
const sourceLabel = input.sourceLabel || (platform === 'dsh' ? 'DSH' : platform)
|
|
117
|
+
const speaker = String(
|
|
118
|
+
input.speaker || (role === 'assistant' ? config.assistantLabel || 'Foxy' : config.userLabel || 'kunfox'),
|
|
119
|
+
)
|
|
120
|
+
const last = [...entries.values()].at(-1)
|
|
121
|
+
if (
|
|
122
|
+
last &&
|
|
123
|
+
last.role === role &&
|
|
124
|
+
last.presetId === presetId &&
|
|
125
|
+
last.sessionId === sessionId &&
|
|
126
|
+
last.platform === platform &&
|
|
127
|
+
last.content === content &&
|
|
128
|
+
Math.abs(Number(input.at || Date.now()) - last.createdAt) < 1000
|
|
129
|
+
) {
|
|
130
|
+
return last
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const now = Number(input.at) || Date.now()
|
|
134
|
+
const entry = normalize({
|
|
135
|
+
id: randomUUID(),
|
|
136
|
+
presetId,
|
|
137
|
+
sessionId,
|
|
138
|
+
platform,
|
|
139
|
+
sourceLabel,
|
|
140
|
+
speaker,
|
|
141
|
+
role,
|
|
142
|
+
type: 'conversation',
|
|
143
|
+
status: 'active',
|
|
144
|
+
content,
|
|
145
|
+
keywords: Array.isArray(input.keywords) ? input.keywords : [],
|
|
146
|
+
summary: '',
|
|
147
|
+
importance: role === 'assistant' ? 0.4 : 0.5,
|
|
148
|
+
createdAt: now,
|
|
149
|
+
updatedAt: now,
|
|
150
|
+
})
|
|
151
|
+
entries.set(entry.id, entry)
|
|
152
|
+
await appendFile(entriesFile, `${JSON.stringify(entry)}\n`, 'utf8')
|
|
153
|
+
|
|
154
|
+
let trimmed = false
|
|
155
|
+
while (entries.size > maxEntries) {
|
|
156
|
+
const oldest = entries.keys().next().value
|
|
157
|
+
if (oldest === undefined) break
|
|
158
|
+
entries.delete(oldest)
|
|
159
|
+
trimmed = true
|
|
160
|
+
}
|
|
161
|
+
if (trimmed) await rewrite()
|
|
162
|
+
return entry
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function context(input = {}) {
|
|
166
|
+
await ready
|
|
167
|
+
const presetId = String(input.presetId || 'foxy3')
|
|
168
|
+
const limit = Math.min(maxContextEntries, Math.max(1, Number(input.limit) || maxContextEntries))
|
|
169
|
+
const picked = [...entries.values()]
|
|
170
|
+
.filter((entry) => entry.presetId === presetId && entry.status === 'active')
|
|
171
|
+
.slice(-limit)
|
|
172
|
+
if (!picked.length) return ''
|
|
173
|
+
|
|
174
|
+
const lines = []
|
|
175
|
+
let size = 0
|
|
176
|
+
for (const entry of picked.reverse()) {
|
|
177
|
+
const line = formatEntry(entry, config)
|
|
178
|
+
if (size + line.length > maxContextChars) break
|
|
179
|
+
lines.push(line)
|
|
180
|
+
size += line.length + 1
|
|
181
|
+
}
|
|
182
|
+
return lines.reverse().join('\n')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function list(input = {}) {
|
|
186
|
+
await ready
|
|
187
|
+
const presetId = input.presetId === undefined ? undefined : String(input.presetId)
|
|
188
|
+
const status = input.status === undefined ? 'active' : String(input.status)
|
|
189
|
+
const limit = Math.min(500, Math.max(1, Number(input.limit) || 50))
|
|
190
|
+
return [...entries.values()]
|
|
191
|
+
.filter((entry) => (presetId === undefined || entry.presetId === presetId) && (status === '*' || entry.status === status))
|
|
192
|
+
.slice(-limit)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function search(query, input = {}) {
|
|
196
|
+
await ready
|
|
197
|
+
const needle = String(query || '').trim().toLowerCase()
|
|
198
|
+
if (!needle) return []
|
|
199
|
+
const presetId = input.presetId === undefined ? undefined : String(input.presetId)
|
|
200
|
+
const limit = Math.min(200, Math.max(1, Number(input.limit) || 20))
|
|
201
|
+
return [...entries.values()]
|
|
202
|
+
.filter((entry) => entry.status === 'active')
|
|
203
|
+
.filter((entry) => presetId === undefined || entry.presetId === presetId)
|
|
204
|
+
.filter((entry) =>
|
|
205
|
+
`${entry.content}\n${entry.keywords.join(' ')}\n${entry.platform}\n${entry.speaker}`.toLowerCase().includes(needle),
|
|
206
|
+
)
|
|
207
|
+
.slice(-limit)
|
|
208
|
+
.reverse()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function archive(id) {
|
|
212
|
+
await ready
|
|
213
|
+
const entry = entries.get(String(id))
|
|
214
|
+
if (!entry) return null
|
|
215
|
+
entry.status = 'archived'
|
|
216
|
+
entry.updatedAt = Date.now()
|
|
217
|
+
await rewrite()
|
|
218
|
+
return entry
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function stats() {
|
|
222
|
+
const byPreset = {}
|
|
223
|
+
const byPlatform = {}
|
|
224
|
+
for (const entry of entries.values()) {
|
|
225
|
+
byPreset[entry.presetId] ??= { active: 0, archived: 0 }
|
|
226
|
+
byPreset[entry.presetId][entry.status === 'archived' ? 'archived' : 'active'] += 1
|
|
227
|
+
byPlatform[entry.platform] ??= { active: 0, archived: 0 }
|
|
228
|
+
byPlatform[entry.platform][entry.status === 'archived' ? 'archived' : 'active'] += 1
|
|
229
|
+
}
|
|
230
|
+
return { dataDir, entriesFile, total: entries.size, byPreset, byPlatform }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const provider = Object.freeze({
|
|
234
|
+
name: 'local',
|
|
235
|
+
version: 1,
|
|
236
|
+
record,
|
|
237
|
+
context,
|
|
238
|
+
list,
|
|
239
|
+
search,
|
|
240
|
+
archive,
|
|
241
|
+
stats,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const bridgeUrl = String(config.bridgeUrl || '').trim().replace(/\/+$/, '')
|
|
245
|
+
const bridgeToken = String(config.bridgeToken || '').trim()
|
|
246
|
+
const bridgePresetId = String(config.bridgePresetId || config.presetId || 'foxy3(Character)')
|
|
247
|
+
const bridgeTimeoutMs = Math.max(1000, Number(config.bridgeTimeoutMs) || 15000)
|
|
248
|
+
|
|
249
|
+
const batchSize = Math.max(1, Number(config.batchSize) || 8)
|
|
250
|
+
const flushIntervalMs = Math.max(1000, Number(config.flushIntervalMs) || 30000)
|
|
251
|
+
const pendingBySession = new Map()
|
|
252
|
+
let flushTimer
|
|
253
|
+
|
|
254
|
+
function pendingFor(sessionId) {
|
|
255
|
+
const key = String(sessionId || 'default')
|
|
256
|
+
if (!pendingBySession.has(key)) pendingBySession.set(key, [])
|
|
257
|
+
return pendingBySession.get(key)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function flushSession(sessionId) {
|
|
261
|
+
const key = String(sessionId || 'default')
|
|
262
|
+
const messages = pendingBySession.get(key)
|
|
263
|
+
if (!messages?.length) return { created: 0 }
|
|
264
|
+
try {
|
|
265
|
+
const result = await bridgePost('/ingest', {
|
|
266
|
+
presetId: bridgePresetId,
|
|
267
|
+
sessionId: key,
|
|
268
|
+
messages,
|
|
269
|
+
})
|
|
270
|
+
pendingBySession.delete(key)
|
|
271
|
+
ctx.logger?.info?.(`[dsh-foxy-memory] flushed ${messages.length} messages -> ${result.created || 0} memories`)
|
|
272
|
+
return result
|
|
273
|
+
} catch (error) {
|
|
274
|
+
ctx.logger?.warn?.(`[dsh-foxy-memory] ingest failed; keeping ${messages.length} pending: ${error instanceof Error ? error.message : String(error)}`)
|
|
275
|
+
if (messages.length > 200) messages.splice(0, messages.length - 200)
|
|
276
|
+
return { created: 0, pending: messages.length }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function flushAll() {
|
|
281
|
+
for (const key of [...pendingBySession.keys()]) await flushSession(key)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function bridgePost(path, body = {}) {
|
|
285
|
+
if (!bridgeUrl) throw new Error('foxy-memory bridgeUrl is not configured')
|
|
286
|
+
const url = new URL(`${bridgeUrl}${path}`)
|
|
287
|
+
for (const [key, value] of Object.entries(body)) {
|
|
288
|
+
if (value === undefined || value === null) continue
|
|
289
|
+
if (typeof value === 'object') continue
|
|
290
|
+
if (String(value).trim()) url.searchParams.set(key, String(value))
|
|
291
|
+
}
|
|
292
|
+
const response = await fetch(url, {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: {
|
|
295
|
+
'content-type': 'application/json',
|
|
296
|
+
...(bridgeToken ? { authorization: `Bearer ${bridgeToken}` } : {}),
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify(body),
|
|
299
|
+
signal: AbortSignal.timeout(bridgeTimeoutMs),
|
|
300
|
+
})
|
|
301
|
+
const data = await response.json().catch(() => ({}))
|
|
302
|
+
if (!response.ok || data?.ok === false) {
|
|
303
|
+
throw new Error(data?.error || `bridge HTTP ${response.status}`)
|
|
304
|
+
}
|
|
305
|
+
return data
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const bridgeProvider = bridgeUrl
|
|
309
|
+
? Object.freeze({
|
|
310
|
+
name: 'livingmemory',
|
|
311
|
+
version: 1,
|
|
312
|
+
async record(input = {}) {
|
|
313
|
+
const sessionId = String(input.sessionId || 'default')
|
|
314
|
+
const messages = pendingFor(sessionId)
|
|
315
|
+
messages.push({
|
|
316
|
+
role: input.role,
|
|
317
|
+
text: input.text,
|
|
318
|
+
at: input.at || Date.now(),
|
|
319
|
+
})
|
|
320
|
+
if (messages.length >= batchSize) await flushSession(sessionId)
|
|
321
|
+
return {
|
|
322
|
+
id: `pending:${sessionId}`,
|
|
323
|
+
presetId: bridgePresetId,
|
|
324
|
+
platform: input.platform || 'dsh',
|
|
325
|
+
sourceLabel: input.sourceLabel || 'DSH',
|
|
326
|
+
role: input.role,
|
|
327
|
+
content: input.text,
|
|
328
|
+
createdAt: Number(new Date(input.at)) || Date.now(),
|
|
329
|
+
pending: messages.length,
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
async context(input = {}) {
|
|
333
|
+
try {
|
|
334
|
+
const result = await bridgePost('/context', {
|
|
335
|
+
presetId: bridgePresetId,
|
|
336
|
+
sessionId: input.sessionId,
|
|
337
|
+
query: input.query || 'DSH',
|
|
338
|
+
limit: input.limit,
|
|
339
|
+
})
|
|
340
|
+
if (result.text) return result.text
|
|
341
|
+
} catch (error) {
|
|
342
|
+
ctx.logger?.warn?.(`[dsh-foxy-memory] bridge context failed, falling back to local: ${error instanceof Error ? error.message : String(error)}`)
|
|
343
|
+
}
|
|
344
|
+
return context(input)
|
|
345
|
+
},
|
|
346
|
+
list,
|
|
347
|
+
search,
|
|
348
|
+
archive,
|
|
349
|
+
stats() {
|
|
350
|
+
const pending = [...pendingBySession.values()].reduce((sum, list) => sum + list.length, 0)
|
|
351
|
+
return { ...stats(), provider: 'livingmemory', bridgeUrl, bridgePresetId, pending }
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
: null
|
|
355
|
+
|
|
356
|
+
const requested = String(config.provider || (bridgeProvider ? 'livingmemory' : 'local'))
|
|
357
|
+
const active = requested === 'livingmemory' && bridgeProvider ? bridgeProvider : provider
|
|
358
|
+
|
|
359
|
+
const disposeLocal = ctx.foxy.registerMemoryProvider('local', async () => provider)
|
|
360
|
+
const disposeBridge = bridgeProvider
|
|
361
|
+
? ctx.foxy.registerMemoryProvider('livingmemory', async () => bridgeProvider)
|
|
362
|
+
: null
|
|
363
|
+
flushTimer = setInterval(() => { void flushAll() }, flushIntervalMs)
|
|
364
|
+
flushTimer.unref?.()
|
|
365
|
+
|
|
366
|
+
if (typeof ctx.provide === 'function') ctx.provide('foxyMemory', active)
|
|
367
|
+
ctx.logger?.info?.(`[dsh-foxy-memory] ready; provider=${active.name}; file=${entriesFile}${bridgeUrl ? `; bridge=${bridgeUrl}` : ''}`)
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
provider: active,
|
|
371
|
+
dispose: () => {
|
|
372
|
+
clearInterval(flushTimer)
|
|
373
|
+
void flushAll()
|
|
374
|
+
disposeLocal()
|
|
375
|
+
disposeBridge?.()
|
|
376
|
+
},
|
|
377
|
+
reload: async () => { entries.clear(); await init() },
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-foxy-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.9",
|
|
4
|
+
"description": "Memory subplugin for dsh-foxy: local JSONL archive plus optional ChatLuna LivingMemory bridge, recall and search.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"cordis.patch.yml",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"author": "Kun-Fox",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/Kun-Fox/dsh-foxy-memory.git"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/Kun-Fox/dsh-foxy-memory#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/Kun-Fox/dsh-foxy-memory/issues"
|
|
23
|
+
},
|
|
24
|
+
"dsh": {
|
|
25
|
+
"bundle": {
|
|
26
|
+
"patch": "./cordis.patch.yml"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"dsh",
|
|
31
|
+
"deepseek-harness",
|
|
32
|
+
"foxy",
|
|
33
|
+
"memory",
|
|
34
|
+
"livingmemory"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|