dsh-foxy-memory 0.0.0-stage → 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/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
- # Temporary Holding Version
1
+ # dsh-foxy-memory
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-foxy-memory
3
+ name: dsh-foxy-memory
package/lib/index.js ADDED
@@ -0,0 +1,395 @@
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 first = messages[0] || {}
266
+ const result = await bridgePost('/ingest', {
267
+ presetId: bridgePresetId,
268
+ sessionId: key,
269
+ scene: first.scene || 'dsh',
270
+ platform: first.platform || 'dsh',
271
+ channelType: first.channelType || 'private',
272
+ channelId: first.channelId || key,
273
+ eventType: first.eventType || 'conversation',
274
+ sourceLabel: first.sourceLabel || 'DSH',
275
+ messages,
276
+ })
277
+ pendingBySession.delete(key)
278
+ ctx.logger?.info?.(`[dsh-foxy-memory] flushed ${messages.length} messages -> ${result.created || 0} memories`)
279
+ return result
280
+ } catch (error) {
281
+ ctx.logger?.warn?.(`[dsh-foxy-memory] ingest failed; keeping ${messages.length} pending: ${error instanceof Error ? error.message : String(error)}`)
282
+ if (messages.length > 200) messages.splice(0, messages.length - 200)
283
+ return { created: 0, pending: messages.length }
284
+ }
285
+ }
286
+
287
+ async function flushAll() {
288
+ for (const key of [...pendingBySession.keys()]) await flushSession(key)
289
+ }
290
+
291
+ async function bridgePost(path, body = {}) {
292
+ if (!bridgeUrl) throw new Error('foxy-memory bridgeUrl is not configured')
293
+ const url = new URL(`${bridgeUrl}${path}`)
294
+ for (const [key, value] of Object.entries(body)) {
295
+ if (value === undefined || value === null) continue
296
+ if (typeof value === 'object') continue
297
+ if (String(value).trim()) url.searchParams.set(key, String(value))
298
+ }
299
+ const response = await fetch(url, {
300
+ method: 'POST',
301
+ headers: {
302
+ 'content-type': 'application/json',
303
+ ...(bridgeToken ? { authorization: `Bearer ${bridgeToken}` } : {}),
304
+ },
305
+ body: JSON.stringify(body),
306
+ signal: AbortSignal.timeout(bridgeTimeoutMs),
307
+ })
308
+ const data = await response.json().catch(() => ({}))
309
+ if (!response.ok || data?.ok === false) {
310
+ throw new Error(data?.error || `bridge HTTP ${response.status}`)
311
+ }
312
+ return data
313
+ }
314
+
315
+ const bridgeProvider = bridgeUrl
316
+ ? Object.freeze({
317
+ name: 'livingmemory',
318
+ version: 1,
319
+ async record(input = {}) {
320
+ const sessionId = String(input.sessionId || 'default')
321
+ const messages = pendingFor(sessionId)
322
+ messages.push({
323
+ role: input.role,
324
+ text: input.text,
325
+ at: input.at || Date.now(),
326
+ scene: input.scene || 'dsh',
327
+ platform: input.platform || 'dsh',
328
+ sourceLabel: input.sourceLabel || 'DSH',
329
+ eventType: input.eventType || 'conversation',
330
+ channelType: input.channelType || 'private',
331
+ channelId: input.channelId || sessionId,
332
+ title: input.title || '',
333
+ url: input.url || '',
334
+ })
335
+ if (messages.length >= batchSize) await flushSession(sessionId)
336
+ return {
337
+ id: `pending:${sessionId}`,
338
+ presetId: bridgePresetId,
339
+ platform: input.platform || 'dsh',
340
+ sourceLabel: input.sourceLabel || 'DSH',
341
+ role: input.role,
342
+ content: input.text,
343
+ createdAt: Number(new Date(input.at)) || Date.now(),
344
+ pending: messages.length,
345
+ }
346
+ },
347
+ async context(input = {}) {
348
+ try {
349
+ const result = await bridgePost('/context', {
350
+ presetId: bridgePresetId,
351
+ sessionId: input.sessionId,
352
+ query: input.query || 'DSH',
353
+ limit: input.limit,
354
+ })
355
+ if (result.text) return result.text
356
+ } catch (error) {
357
+ ctx.logger?.warn?.(`[dsh-foxy-memory] bridge context failed, falling back to local: ${error instanceof Error ? error.message : String(error)}`)
358
+ }
359
+ return context(input)
360
+ },
361
+ list,
362
+ search,
363
+ archive,
364
+ stats() {
365
+ const pending = [...pendingBySession.values()].reduce((sum, list) => sum + list.length, 0)
366
+ return { ...stats(), provider: 'livingmemory', bridgeUrl, bridgePresetId, pending }
367
+ },
368
+ })
369
+ : null
370
+
371
+ const requested = String(config.provider || (bridgeProvider ? 'livingmemory' : 'local'))
372
+ const active = requested === 'livingmemory' && bridgeProvider ? bridgeProvider : provider
373
+
374
+ const disposeLocal = ctx.foxy.registerMemoryProvider('local', async () => provider)
375
+ const disposeBridge = bridgeProvider
376
+ ? ctx.foxy.registerMemoryProvider('livingmemory', async () => bridgeProvider)
377
+ : null
378
+ flushTimer = setInterval(() => { void flushAll() }, flushIntervalMs)
379
+ flushTimer.unref?.()
380
+
381
+ if (typeof ctx.provide === 'function') ctx.provide('foxyMemory', active)
382
+ ctx.logger?.info?.(`[dsh-foxy-memory] ready; provider=${active.name}; file=${entriesFile}${bridgeUrl ? `; bridge=${bridgeUrl}` : ''}`)
383
+
384
+ return {
385
+ provider: active,
386
+ dispose: () => {
387
+ clearInterval(flushTimer)
388
+ void flushAll()
389
+ disposeLocal()
390
+ disposeBridge?.()
391
+ },
392
+ reload: async () => { entries.clear(); await init() },
393
+ }
394
+ }
395
+
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "dsh-foxy-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
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
+
45
+