@lotuseslunarland/dsh-lotuses-memory 0.0.0-stage → 0.1.3

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/README.md CHANGED
@@ -1,3 +1,85 @@
1
- # Temporary Holding Version
1
+ # Lotuses'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
+ 跨会话长期记忆插件,给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)用。
4
+
5
+ 对话型 agent 通常"每个群/每个线程一个 session",换个地方就忘了之前认识谁。
6
+ 这个插件把"认识的人、待过的群、发生过的事"存成普通 Markdown 档案,**每轮请求自动注入**,
7
+ 并提供两个工具让模型自己读写。
8
+
9
+ ## 特性
10
+
11
+ - **档案是普通文件**:`<memoryDir>/people/<QQ>.md`、`<memoryDir>/groups/<群号>.md`,随时能手工编辑
12
+ - **自动注入**:走 `systemPrompt.context`,不污染用户消息正文;按「当前发言人 + 当前群」挑档案
13
+ - **工具**:`memory_note`(记一条)、`memory_recall`(翻全文)
14
+ - **自动压缩**:档案超过 4000 字符时把旧记录折叠成一行计数,保留最近 40 条
15
+ - **通道无关**:聊天对象从会话 ID 解析(`<prefix>-<kind>-<id>`),发言人以消息前缀里的 `(QQ)` 识别;
16
+ 解析不到时只注入群档案(或退回上一轮记住的发言人)
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ # 从 npm
22
+ dsh plugin --profile web add @lotuseslunarland/dsh-lotuses-memory
23
+
24
+ # 或从本地目录 / tgz
25
+ cd ~/.dsh/profiles/web
26
+ pnpm add file:/path/to/Lotuses_Memory
27
+ ```
28
+
29
+ 然后在 profile 的 `cordis.patch.yml` 里**用 insert 块**启用(新插件不能用顶层 `- id:`):
30
+
31
+ ```yaml
32
+ - insert:
33
+ - id: lotuses-memory
34
+ name: '@lotuseslunarland/dsh-lotuses-memory'
35
+ config:
36
+ memoryDir: '/path/to/Memory'
37
+ sessionsDir: '/root/.dsh/sessions' # 可选,默认 <DSH_HOME>/sessions
38
+ maxPersonChars: 900
39
+ maxGroupChars: 600
40
+ ```
41
+
42
+ ## 配置
43
+
44
+ | 键 | 默认 | 说明 |
45
+ | --- | --- | --- |
46
+ | `memoryDir` | `<cwd>/Memory` | 记忆库目录 |
47
+ | `sessionsDir` | `<DSH_HOME>/sessions` | 会话文件根目录(用于识别发言人) |
48
+ | `maxPersonChars` | `900` | 单个人物档案注入上限 |
49
+ | `maxGroupChars` | `600` | 单个群档案注入上限 |
50
+ | `injectOrder` | `2500` | 注入在系统提示中的排序位置 |
51
+
52
+ ## 档案格式
53
+
54
+ > 下面的人名、QQ、群号全是虚构示例,只为说明结构。
55
+
56
+ ```markdown
57
+ # 示例昵称(100000001)
58
+
59
+ ## 名字
60
+ - **账号昵称**:`示例昵称`
61
+ - **各群群名片**:1000000001 → 小示
62
+
63
+ ## 基本信息
64
+ - **身份**:管理员
65
+
66
+ ## 称呼与代词
67
+
68
+ - 我叫「示例昵称」;性别未确认时用「对方」
69
+
70
+ ## 记录
71
+ - 2026-01-01 00:00|喜欢在深夜写代码
72
+ ```
73
+
74
+ 写入是**追加式**的,不会覆盖手写内容;文件头(名字/基本信息等)由人维护,`## 记录` 段由工具追加。
75
+
76
+ ## 设计取舍
77
+
78
+ - **为什么读会话文件而不是订阅事件**:这样插件不依赖任何聊天通道插件,也不碰宿主内部状态。
79
+ 代价是"同一轮里消息可能还没落盘",因此保留了「上次成功发言人」兜底。
80
+ - **为什么不用向量检索**:长期记忆的核心需求是"认识人和群",结构化档案足够;向量检索成本高且难以人工校正。
81
+ - **为什么档案是文件**:可审计、可备份、可手改。黑盒记忆出了问题没法查。
82
+
83
+ ## License
84
+
85
+ MIT
package/lib/core.js ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Lotuses'Memory 核心逻辑(纯 Node,不依赖 dsh 运行时,可独立单测)。
3
+ *
4
+ * 负责:记忆档案读写/截断/压缩、会话 ID 解析、从会话文件里找最近发言人。
5
+ * 插件壳(index.js)只负责把这里的东西接到 systemPrompt 与工具注册上。
6
+ * @module dsh-lotuses-memory/core
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import zlib from 'node:zlib';
11
+
12
+
13
+ /** 档案读取缓存:路径 → { mtimeMs, text }。 */
14
+ const cache = new Map();
15
+
16
+ /** 会话文件解析缓存:路径 → { mtimeMs, speaker }。 */
17
+ const speakerCache = new Map();
18
+ /**
19
+ * 每个会话「上次成功解析出来的发言人」。
20
+ *
21
+ * 为什么需要它:插件在组装请求时读会话文件,而那条 user/message 往往还没落盘
22
+ * (同一轮里先投递、后组装),当场解析会得到 undefined。此时退回上一轮记住的人 ——
23
+ * 比直接丢掉个人档案好,只有全新会话的第一轮才会真的没有。
24
+ */
25
+ const lastKnownSpeaker = new Map();
26
+
27
+ /**
28
+ * 解析记忆库根目录。
29
+ * @param config - 插件配置。
30
+ * @returns 绝对或相对路径。
31
+ */
32
+ export function memoryRoot(config) {
33
+ const dir = typeof config.memoryDir === 'string' ? config.memoryDir.trim() : '';
34
+ if (dir !== '')
35
+ return dir.replace(/[\\/]+$/, '');
36
+ return path.resolve('Memory');
37
+ }
38
+
39
+ /**
40
+ * 解析会话根目录。
41
+ * @param config - 插件配置。
42
+ * @returns 目录路径。
43
+ */
44
+ function sessionsRoot(config) {
45
+ const dir = typeof config.sessionsDir === 'string' ? config.sessionsDir.trim() : '';
46
+ if (dir !== '')
47
+ return dir.replace(/[\\/]+$/, '');
48
+ const home = process.env.DSH_HOME ?? path.join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.dsh');
49
+ return path.join(home, 'sessions');
50
+ }
51
+
52
+ /**
53
+ * 读一份档案(按 mtime 缓存)。
54
+ * @param root - 记忆库根目录。
55
+ * @param folder - people / groups。
56
+ * @param key - QQ 号或群号。
57
+ * @returns 文本或 null。
58
+ */
59
+ export function readDoc(root, folder, key) {
60
+ if (typeof key !== 'string' || key === '')
61
+ return null;
62
+ const full = path.join(root, folder, key + '.md');
63
+ try {
64
+ const mtime = fs.statSync(full).mtimeMs;
65
+ const hit = cache.get(full);
66
+ if (hit !== undefined && hit.mtimeMs === mtime)
67
+ return hit.text;
68
+ const text = fs.readFileSync(full, 'utf8');
69
+ cache.set(full, { mtimeMs: mtime, text });
70
+ return text;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 截断到指定长度(按行切)。
79
+ * @param text - 原文。
80
+ * @param max - 上限。
81
+ * @returns 截断后的文本。
82
+ */
83
+ function trim(text, max) {
84
+ if (text.length <= max)
85
+ return text;
86
+ let cut = text.slice(0, max);
87
+ const nl = cut.lastIndexOf('\n');
88
+ if (nl > max * 0.6)
89
+ cut = cut.slice(0, nl);
90
+ return cut + '\n…(档案还有更多,需要细节可调 memory_recall)';
91
+ }
92
+
93
+ /**
94
+ * 从会话 ID 解析聊天对象。
95
+ *
96
+ * 约定(QQ 通道的会话命名):`onebot-group-<群号>[-后缀]` / `onebot-private-<QQ>[-后缀]`。
97
+ * 其他通道解析不出来时返回 null,此时只注入"全局记忆"(不注入个人档案)。
98
+ * @param sessionId - 会话 id。
99
+ * @returns `{ kind, target }` 或 null。
100
+ */
101
+ export function chatOfSession(sessionId) {
102
+ if (typeof sessionId !== 'string')
103
+ return null;
104
+ const m = /^onebot-(group|private)-(\d{5,12})/.exec(sessionId);
105
+ if (m === null)
106
+ return null;
107
+ return { kind: m[1], target: m[2] };
108
+ }
109
+
110
+ /**
111
+ * 解压 zstd 多帧并返回 JSONL 事件数组。
112
+ * @param file - 会话文件路径。
113
+ * @returns 事件数组(解析失败的跳过)。
114
+ */
115
+ function readSessionEvents(file) {
116
+ const buf = fs.readFileSync(file);
117
+ const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
118
+ const offsets = [];
119
+ let i = 0;
120
+ while ((i = buf.indexOf(magic, i)) !== -1) {
121
+ offsets.push(i);
122
+ i += 4;
123
+ }
124
+ offsets.push(buf.length);
125
+ const chunks = [];
126
+ for (let k = 0; k < offsets.length - 1; k++) {
127
+ try {
128
+ chunks.push(zlib.zstdDecompressSync(buf.subarray(offsets[k], offsets[k + 1])).toString('utf8'));
129
+ }
130
+ catch { /* 跳过坏帧 */ }
131
+ }
132
+ const events = [];
133
+ for (const line of chunks.join('').split('\n')) {
134
+ if (line === '')
135
+ continue;
136
+ try {
137
+ events.push(JSON.parse(line));
138
+ }
139
+ catch { /* 跳过坏行 */ }
140
+ }
141
+ return events;
142
+ }
143
+
144
+ /**
145
+ * 找出某个会话最近一条用户消息的发送者 QQ。
146
+ *
147
+ * 为什么读文件而不是订阅事件:这样插件不依赖任何通道插件,也不碰宿主内部状态。
148
+ * 从消息正文的 `[hh:mm 昵称(QQ)]` 前缀解析;解析不到就返回 undefined(只注入群档案)。
149
+ * @param config - 插件配置。
150
+ * @param sessionId - 会话 id。
151
+ * @returns QQ 号或 undefined。
152
+ */
153
+ export function lastSpeaker(config, sessionId) {
154
+ if (typeof sessionId !== 'string' || sessionId === '')
155
+ return undefined;
156
+ const root = sessionsRoot(config);
157
+ let dir = null;
158
+ try {
159
+ for (const workspace of fs.readdirSync(root)) {
160
+ const candidate = path.join(root, workspace, sessionId);
161
+ if (fs.existsSync(candidate)) {
162
+ dir = candidate;
163
+ break;
164
+ }
165
+ }
166
+ }
167
+ catch {
168
+ return lastKnownSpeaker.get(sessionId);
169
+ }
170
+ if (dir === null)
171
+ return lastKnownSpeaker.get(sessionId);
172
+ const file = path.join(dir, 'session.v4.jsonl.zstd');
173
+ try {
174
+ const mtime = fs.statSync(file).mtimeMs;
175
+ const hit = speakerCache.get(file);
176
+ if (hit !== undefined && hit.mtimeMs === mtime) {
177
+ if (hit.speaker !== undefined)
178
+ lastKnownSpeaker.set(sessionId, hit.speaker);
179
+ return hit.speaker ?? lastKnownSpeaker.get(sessionId);
180
+ }
181
+ const events = readSessionEvents(file);
182
+ let found;
183
+ for (let k = events.length - 1; k >= 0; k--) {
184
+ const e = events[k];
185
+ if (e?.type !== 'user/message')
186
+ continue;
187
+ const text = (e.data?.content ?? []).map((b) => (typeof b.text === 'string' ? b.text : '')).join('');
188
+ // `[hh:mm 昵称(QQ)]` 或 `[hh:mm 昵称(QQ,账号昵称:xx)]`
189
+ const m = /\[\d{1,2}:\d{2}\s[^\]]*?\((\d{5,12})[)\]]/.exec(text);
190
+ if (m !== null) {
191
+ found = m[1];
192
+ break;
193
+ }
194
+ }
195
+ speakerCache.set(file, { mtimeMs: mtime, speaker: found });
196
+ if (found !== undefined) {
197
+ lastKnownSpeaker.set(sessionId, found);
198
+ return found;
199
+ }
200
+ // 这一轮的 user/message 可能还没落盘 → 退回上一轮记住的人
201
+ return lastKnownSpeaker.get(sessionId);
202
+ }
203
+ catch {
204
+ return lastKnownSpeaker.get(sessionId);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 组装注入文本。
210
+ * @param root - 记忆库根目录。
211
+ * @param opts - `{ personId, groupId }`。
212
+ * @param config - 插件配置(取上限)。
213
+ * @returns 注入文本(空串表示没有可注入的内容)。
214
+ */
215
+ export function buildHint(root, opts, config) {
216
+ const parts = [];
217
+ if (typeof opts.personId === 'string' && opts.personId !== '') {
218
+ const person = readDoc(root, 'people', opts.personId);
219
+ if (person !== null && person.trim() !== '')
220
+ parts.push('[记忆·这个人]\n' + trim(person, Number(config.maxPersonChars ?? 900)));
221
+ else
222
+ parts.push('[记忆] QQ ' + opts.personId + ' 是第一次见的新面孔,可以自然认识一下。');
223
+ }
224
+ if (typeof opts.groupId === 'string' && opts.groupId !== '') {
225
+ const group = readDoc(root, 'groups', opts.groupId);
226
+ if (group !== null && group.trim() !== '')
227
+ parts.push('[记忆·这个群]\n' + trim(group, Number(config.maxGroupChars ?? 600)));
228
+ }
229
+ if (parts.length === 0)
230
+ return '';
231
+ return parts.join('\n') + '\n(以上是可信的长期记忆,自然运用即可;不要把这些档案原文念给别人听)\n';
232
+ }
233
+
234
+ /**
235
+ * 追加一条事实到档案(不存在就建)。
236
+ * @param root - 记忆库根目录。
237
+ * @param kind - person / group。
238
+ * @param key - QQ 号或群号。
239
+ * @param text - 要记的内容。
240
+ * @returns 写入的文件路径。
241
+ */
242
+ export function noteFact(root, kind, key, text) {
243
+ const folder = kind === 'group' ? 'groups' : 'people';
244
+ fs.mkdirSync(path.join(root, folder), { recursive: true });
245
+ const full = path.join(root, folder, key + '.md');
246
+ const now = new Date();
247
+ const pad = (n) => String(n).padStart(2, '0');
248
+ const stamp = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
249
+ + ' ' + pad(now.getHours()) + ':' + pad(now.getMinutes());
250
+ const line = '- ' + stamp + '|' + String(text).trim().replace(/\n+/g, ' ');
251
+ if (!fs.existsSync(full))
252
+ fs.writeFileSync(full, (kind === 'group' ? '# 群 ' : '# QQ ') + key + '\n\n## 记录\n\n', 'utf8');
253
+ fs.appendFileSync(full, line + '\n', 'utf8');
254
+ cache.delete(full);
255
+ try {
256
+ compactIfNeeded(root, kind, key);
257
+ }
258
+ catch { /* 压缩失败不影响写入 */ }
259
+ return full;
260
+ }
261
+
262
+ /** 档案超过该长度就折叠旧记录。 */
263
+ export const COMPACT_THRESHOLD = 4000;
264
+ /** 折叠后保留的最近记录条数。 */
265
+ export const COMPACT_KEEP = 40;
266
+
267
+ /**
268
+ * 档案自动压缩:保留头部与最近 N 条记录,更早的折叠成一行计数。
269
+ * @param root - 记忆库根目录。
270
+ * @param kind - person / group。
271
+ * @param key - QQ 号或群号。
272
+ * @returns 是否发生折叠。
273
+ */
274
+ export function compactIfNeeded(root, kind, key) {
275
+ const folder = kind === 'group' ? 'groups' : 'people';
276
+ const full = path.join(root, folder, key + '.md');
277
+ if (!fs.existsSync(full))
278
+ return false;
279
+ const text = fs.readFileSync(full, 'utf8');
280
+ if (text.length <= COMPACT_THRESHOLD)
281
+ return false;
282
+ const at = text.indexOf('## 记录');
283
+ if (at < 0)
284
+ return false;
285
+ const head = text.slice(0, at) + '## 记录\n\n';
286
+ const lines = text.slice(at).split('\n');
287
+ const recordIdx = [];
288
+ for (let i = 0; i < lines.length; i++) {
289
+ if (lines[i].startsWith('- ') && !lines[i].startsWith('- (更早的'))
290
+ recordIdx.push(i);
291
+ }
292
+ if (recordIdx.length <= COMPACT_KEEP)
293
+ return false;
294
+ let prevFolded = 0;
295
+ for (const l of lines) {
296
+ const m = /^- (更早的 (\d+) 条记录已折叠/.exec(l);
297
+ if (m !== null) {
298
+ prevFolded = Number(m[1]) || 0;
299
+ break;
300
+ }
301
+ }
302
+ const folded = recordIdx.length - COMPACT_KEEP;
303
+ const keepFrom = recordIdx[recordIdx.length - COMPACT_KEEP];
304
+ const rest = lines.slice(keepFrom).filter((l) => !/^- (更早的 \d+ 条记录已折叠/.test(l));
305
+ fs.writeFileSync(full, head + '- (更早的 ' + (folded + prevFolded) + ' 条记录已折叠;如需细节可翻会话历史)\n' + rest.join('\n'), 'utf8');
306
+ cache.delete(full);
307
+ return true;
308
+ }
package/lib/index.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Lotuses'Memory(dsh-lotuses-memory)— 跨会话长期记忆(独立插件)
3
+ *
4
+ * 只做三件事,逻辑都在 core.js 里:
5
+ * 1. 用 systemPrompt.context 每轮注入「这个人 / 这个群」的档案摘要
6
+ * 2. 注册 memory_note(写事实)与 memory_recall(翻全文)
7
+ * 3. 写档案时自动压缩(core.noteFact 内部完成)
8
+ *
9
+ * 与聊天通道完全解耦:从会话 ID 解析聊天对象、从会话文件解析发言人,
10
+ * 不依赖任何通道插件,也不改用户消息正文。
11
+ * @module dsh-lotuses-memory
12
+ */
13
+ import { defineTool } from '@deepseek-ai/dsh-tools';
14
+ import { z } from 'zod';
15
+ import {
16
+ buildHint,
17
+ chatOfSession,
18
+ lastSpeaker,
19
+ memoryRoot,
20
+ noteFact,
21
+ readDoc,
22
+ } from './core.js';
23
+
24
+ export { compactIfNeeded, chatOfSession, lastSpeaker, buildHint, noteFact } from './core.js';
25
+ export { COMPACT_KEEP, COMPACT_THRESHOLD } from './core.js';
26
+
27
+ /** Cordis 插件名。 */
28
+ export const name = 'lotuses-memory';
29
+
30
+ /** 依赖的服务:系统提示组装 + 工具注册。 */
31
+ export const inject = ['systemPrompt', 'tools'];
32
+
33
+ /** 配置 schema(cordis 会对它调用 .validate,必须用 zod/schemastery 这类 schema 对象,不能是普通 JSON)。 */
34
+ export const Config = z.object({
35
+ memoryDir: z.string().default('').describe('记忆库目录;留空则用 <工作目录>/Memory'),
36
+ sessionsDir: z.string().default('').describe('会话文件根目录;留空则用 <DSH_HOME>/sessions'),
37
+ maxPersonChars: z.number().default(900).describe('单个人物档案注入上限(字符)'),
38
+ maxGroupChars: z.number().default(600).describe('单个群档案注入上限(字符)'),
39
+ injectOrder: z.number().default(2500).describe('注入在系统提示里的排序位置'),
40
+ });
41
+ /**
42
+ * 插件入口。
43
+ * @param ctx - cordis 插件上下文。
44
+ * @param rawConfig - 插件配置。
45
+ */
46
+ export function apply(ctx, rawConfig) {
47
+ const config = { ...rawConfig };
48
+ const root = memoryRoot(config);
49
+ console.log('[lotuses-memory] 已加载:记忆库 ' + root);
50
+
51
+ // ① 每轮请求自动注入
52
+ ctx.effect(() => ctx.systemPrompt.context({
53
+ name: 'memory:hint',
54
+ order: Number(config.injectOrder ?? 2500),
55
+ interpolate: false,
56
+ text: (context) => {
57
+ try {
58
+ const sessionId = context?.agent?.session?.id;
59
+ const chat = chatOfSession(sessionId);
60
+ const speaker = lastSpeaker(config, sessionId);
61
+ const hint = buildHint(root, {
62
+ personId: speaker,
63
+ groupId: chat?.kind === 'group' ? chat.target : undefined,
64
+ }, config);
65
+ console.log('[lotuses-memory] 注入 session=' + String(sessionId)
66
+ + ' chat=' + JSON.stringify(chat)
67
+ + ' speaker=' + String(speaker)
68
+ + ' 长度=' + hint.length
69
+ + ' contextKeys=' + Object.keys(context ?? {}).join(','));
70
+ return hint;
71
+ }
72
+ catch (error) {
73
+ console.log('[lotuses-memory] 注入失败: ' + String(error && error.message));
74
+ return '';
75
+ }
76
+ },
77
+ }), 'dsh-memory.inject');
78
+
79
+ // ② 工具:写记忆
80
+ ctx.effect(() => ctx.tools.register(defineTool({
81
+ name: 'memory_note',
82
+ description: '把值得长期记住的事实写进记忆库(跨会话共享)。'
83
+ + '\n适合记:这个人是谁 / 喜欢什么 / 说过什么承诺、这个群是干什么的 / 有哪些常驻成员、自己的重要经历。'
84
+ + '\n不要记:一次性闲聊、临时状态,以及密码 / 密钥 / 住址这类敏感信息。'
85
+ + '\n一句一条,写清「谁 / 什么事」,不要抄大段原话。',
86
+ parameters: {
87
+ kind: { type: 'string', description: 'person=记人,group=记群' },
88
+ key: { type: 'string', description: 'QQ 号(记人)或群号(记群);省略则按当前会话推断' },
89
+ text: { type: 'string', description: '要记的事实(一句话)' },
90
+ },
91
+ output: {
92
+ schema: { type: 'json' },
93
+ render: (_args, value) => [{
94
+ type: 'text',
95
+ text: value !== null && typeof value === 'object' && value.ok === true
96
+ ? '(记下了:' + value.kind + ' ' + value.key + ')'
97
+ : '(没记成)',
98
+ }],
99
+ },
100
+ isConcurrencySafe: () => true,
101
+ execute: async (args, exec) => {
102
+ const kind = args.kind === 'group' ? 'group' : 'person';
103
+ const text = String(args.text ?? '').trim();
104
+ let key = String(args.key ?? '').trim().replace(/\D/g, '');
105
+ if (key === '') {
106
+ const sessionId = exec?.agent?.session?.id;
107
+ const chat = chatOfSession(sessionId);
108
+ if (kind === 'group')
109
+ key = chat?.kind === 'group' ? chat.target : '';
110
+ else
111
+ key = lastSpeaker(config, sessionId) ?? '';
112
+ }
113
+ if (key === '' || text === '')
114
+ throw new Error('memory_note: 缺少 key 或 text');
115
+ noteFact(root, kind, key, text);
116
+ return { ok: true, kind, key };
117
+ },
118
+ })), 'dsh-memory.note');
119
+
120
+ // ③ 工具:翻细节
121
+ ctx.effect(() => ctx.tools.register(defineTool({
122
+ name: 'memory_recall',
123
+ description: '读一份记忆档案的全文(人物或群)。需要了解某人/某群的完整记录时用。',
124
+ parameters: {
125
+ kind: { type: 'string', description: 'person=人,group=群' },
126
+ key: { type: 'string', description: 'QQ 号或群号' },
127
+ },
128
+ output: {
129
+ schema: { type: 'json' },
130
+ render: (_args, value) => [{
131
+ type: 'text',
132
+ text: typeof value?.text === 'string' && value.text !== '' ? value.text : '(没有找到这份档案)',
133
+ }],
134
+ },
135
+ isConcurrencySafe: () => true,
136
+ execute: async (args, exec) => {
137
+ const kind = args.kind === 'group' ? 'group' : 'person';
138
+ const key = String(args.key ?? '').trim().replace(/\D/g, '');
139
+ if (key === '')
140
+ throw new Error('memory_recall: 缺少 key');
141
+ const text = readDoc(root, kind === 'group' ? 'groups' : 'people', key);
142
+ return { text: text ?? '' };
143
+ },
144
+ })), 'dsh-memory.recall');
145
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@lotuseslunarland/dsh-lotuses-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.3",
4
+ "description": "Lotuses'Memory — 跨会话长期记忆:按聊天对象自动把人物/群档案注入 systemPrompt,并提供 memory_note / memory_recall 工具",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "lib/index.js",
13
+ "lib/core.js",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "dsh",
18
+ "deepseek-harness",
19
+ "plugin",
20
+ "memory",
21
+ "long-term-memory"
22
+ ],
23
+ "author": "lotuseslunarland",
24
+ "license": "MIT",
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "peerDependencies": {
29
+ "@deepseek-ai/cordis": "~4.0.5",
30
+ "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
31
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.2"
32
+ }
33
+ }