dsh-plugin-memory-share 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/store.js ADDED
@@ -0,0 +1,306 @@
1
+ // dsh-plugin-MemoryShare 存储层:一个进程内可同步读取、磁盘上原子写入的 JSON 记忆库。
2
+ // 设计约束(见 README「设计取舍」):
3
+ // 1. 渲染发生在系统提示词组装里,是同步调用 —— 所以读走内存快照,写才碰磁盘;
4
+ // 2. 多个 DSH 进程可能同时开同一个库 —— 所以写入串行化 + 临时文件 rename;
5
+ // 3. 记忆是长期资产,损坏不能静默丢失 —— 坏文件改名备份后再从空库开始。
6
+ // License: MIT
7
+ import { promises as fs } from 'node:fs'
8
+ import { join, resolve } from 'node:path'
9
+ import { homedir } from 'node:os'
10
+ import { randomBytes } from 'node:crypto'
11
+
12
+ export const STORE_FILE = 'memories.json'
13
+ export const STORE_VERSION = 1
14
+ export const SCOPES = ['user', 'workspace', 'session']
15
+
16
+ /** 淘汰顺序:user 最该保留,session 最先走。 */
17
+ const SCOPE_PRIORITY = { user: 0, workspace: 1, session: 2 }
18
+ const DAY_MS = 86400000
19
+
20
+ /** 默认数据目录:$DSH_HOME/memory-share(DSH 自身的 home)。 */
21
+ export function defaultDataDir() {
22
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
23
+ return join(home, 'memory-share')
24
+ }
25
+
26
+ /** 作用域键归一化:Windows 下大小写不敏感,避免同一目录两条记忆。 */
27
+ export function normalizeKey(value) {
28
+ if (typeof value !== 'string' || value.trim() === '') return ''
29
+ const resolved = resolve(value)
30
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
31
+ }
32
+
33
+ /** 记忆文本归一化:折叠空白、去首尾。 */
34
+ export function normalizeText(value) {
35
+ return String(value ?? '').replace(/\s+/g, ' ').trim()
36
+ }
37
+
38
+ /**
39
+ * 作用域键归一化:只有 workspace 是路径,需要 resolve + 大小写归一;
40
+ * session 键是会话 id 字符串,绝不能当路径处理(否则会被 resolve 成磁盘路径)。
41
+ */
42
+ export function scopeKey(scope, value) {
43
+ if (scope === 'user') return ''
44
+ if (scope === 'workspace') return normalizeKey(value)
45
+ return normalizeText(value)
46
+ }
47
+
48
+ function fingerprintOf(text) {
49
+ return normalizeText(text).toLowerCase()
50
+ }
51
+
52
+ /** TTL 过期判定(未设置 ttlDays 视为永不过期)。 */
53
+ export function isExpired(entry, now = Date.now()) {
54
+ const ttl = Number(entry?.ttlDays)
55
+ if (!Number.isFinite(ttl) || ttl <= 0) return false
56
+ return now >= (Number(entry.createdAt) || 0) + ttl * DAY_MS
57
+ }
58
+
59
+ function newId(entries) {
60
+ const taken = new Set(entries.map((entry) => entry.id))
61
+ // 短 id 让模型更容易原样回引(forget 靠它定位),同时保留时间序 + 随机后缀。
62
+ const stamp = Date.now().toString(36).slice(-6)
63
+ for (let attempt = 0; attempt < 8; attempt += 1) {
64
+ const id = `m${stamp}${randomBytes(2).toString('hex')}`
65
+ if (!taken.has(id)) return id
66
+ }
67
+ return `m${stamp}${randomBytes(4).toString('hex')}`
68
+ }
69
+
70
+ export class MemoryStore {
71
+ /**
72
+ * @param {object} options
73
+ * @param {string} [options.dir] 数据目录
74
+ * @param {number} [options.maxEntries] 条目上限,超出按作用域优先级淘汰
75
+ * @param {{warn?: Function}} [options.logger]
76
+ */
77
+ constructor({ dir = defaultDataDir(), maxEntries = 500, logger } = {}) {
78
+ this.dir = dir
79
+ this.file = join(dir, STORE_FILE)
80
+ this.maxEntries = Math.max(10, Number(maxEntries) || 500)
81
+ this.logger = logger
82
+ this.entries = []
83
+ this.loaded = false
84
+ this.mtimeMs = -1
85
+ this.chain = Promise.resolve()
86
+ }
87
+
88
+ warn(message) {
89
+ try {
90
+ (this.logger?.warn ?? console.warn)(`[memory-share] ${message}`)
91
+ } catch {
92
+ /* 日志失败不能影响主流程 */
93
+ }
94
+ }
95
+
96
+ /** 同步快照:供系统提示词渲染使用。 */
97
+ snapshot() {
98
+ return this.entries
99
+ }
100
+
101
+ /** 读取磁盘;mtime 未变则复用内存。force 时强制重读。 */
102
+ async load(force = false) {
103
+ let stat
104
+ try {
105
+ stat = await fs.stat(this.file)
106
+ } catch (error) {
107
+ if (error?.code === 'ENOENT') {
108
+ this.entries = []
109
+ this.loaded = true
110
+ this.mtimeMs = 0
111
+ return this.entries
112
+ }
113
+ throw error
114
+ }
115
+ if (!force && this.loaded && stat.mtimeMs === this.mtimeMs) return this.entries
116
+
117
+ const raw = await fs.readFile(this.file, 'utf8')
118
+ let parsed
119
+ try {
120
+ parsed = JSON.parse(raw)
121
+ } catch {
122
+ const backup = `${this.file}.corrupt-${Date.now()}`
123
+ await fs.rename(this.file, backup).catch(() => {})
124
+ this.warn(`记忆库无法解析,已备份为 ${backup} 并从空库开始`)
125
+ this.entries = []
126
+ this.loaded = true
127
+ this.mtimeMs = 0
128
+ return this.entries
129
+ }
130
+ const list = Array.isArray(parsed?.entries) ? parsed.entries : []
131
+ this.entries = list.filter((entry) => entry && typeof entry.id === 'string' && typeof entry.text === 'string')
132
+ this.loaded = true
133
+ this.mtimeMs = stat.mtimeMs
134
+ return this.entries
135
+ }
136
+
137
+ /** 原子落盘:先写临时文件再 rename,避免读到半个 JSON。 */
138
+ async persist() {
139
+ await fs.mkdir(this.dir, { recursive: true })
140
+ const payload = JSON.stringify({
141
+ version: STORE_VERSION,
142
+ updatedAt: new Date().toISOString(),
143
+ entries: this.entries,
144
+ }, null, 2)
145
+ const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
146
+ await fs.writeFile(tmp, payload, 'utf8')
147
+ await fs.rename(tmp, this.file)
148
+ const stat = await fs.stat(this.file)
149
+ this.mtimeMs = stat.mtimeMs
150
+ }
151
+
152
+ /** 串行化的读-改-写。fn 返回 { entries, value }。 */
153
+ async mutate(fn) {
154
+ const run = this.chain.then(async () => {
155
+ await this.load(true)
156
+ const outcome = await fn(this.entries)
157
+ this.entries = outcome.entries
158
+ await this.persist()
159
+ return outcome.value
160
+ })
161
+ this.chain = run.then(() => undefined, () => undefined)
162
+ return run
163
+ }
164
+
165
+ /** 超出上限时淘汰:先 session,再 workspace,最后 user;同作用域内旧的先走。 */
166
+ evict(entries) {
167
+ if (entries.length <= this.maxEntries) return entries
168
+ const sorted = [...entries].sort((a, b) => {
169
+ const byScope = (SCOPE_PRIORITY[a.scope] ?? 9) - (SCOPE_PRIORITY[b.scope] ?? 9)
170
+ if (byScope !== 0) return byScope
171
+ return (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0)
172
+ })
173
+ const keep = new Set(sorted.slice(0, this.maxEntries).map((entry) => entry.id))
174
+ return entries.filter((entry) => keep.has(entry.id))
175
+ }
176
+
177
+ /**
178
+ * 保存一条记忆。同一 (文本指纹, scope, key) 视为同一条,更新而不新增。
179
+ * @returns {Promise<{entry: object, duplicate: boolean, total: number}>}
180
+ */
181
+ async save(input) {
182
+ const text = normalizeText(input?.text)
183
+ if (text === '') throw new Error('memory-share: text 不能为空')
184
+ const scope = SCOPES.includes(input?.scope) ? input.scope : 'user'
185
+ const key = scopeKey(scope, input?.key)
186
+ if (scope !== 'user' && key === '') {
187
+ throw new Error(`memory-share: scope=${scope} 需要一个可用的键(工作区路径或会话 id)`)
188
+ }
189
+ const tags = Array.isArray(input?.tags)
190
+ ? [...new Set(input.tags.map((tag) => normalizeText(tag)).filter(Boolean))].slice(0, 12)
191
+ : []
192
+ const ttlDays = Number.isFinite(Number(input?.ttlDays)) && Number(input.ttlDays) > 0
193
+ ? Math.floor(Number(input.ttlDays))
194
+ : undefined
195
+ const fingerprint = fingerprintOf(text)
196
+
197
+ return this.mutate((entries) => {
198
+ const now = Date.now()
199
+ const existing = entries.find((entry) => entry.fingerprint === fingerprint
200
+ && entry.scope === scope
201
+ && (entry.key ?? '') === key)
202
+ if (existing !== undefined) {
203
+ existing.updatedAt = now
204
+ if (tags.length > 0) existing.tags = [...new Set([...(existing.tags ?? []), ...tags])]
205
+ if (ttlDays !== undefined) existing.ttlDays = ttlDays
206
+ return { entries, value: { entry: existing, duplicate: true, total: entries.length } }
207
+ }
208
+ const entry = {
209
+ id: newId(entries),
210
+ text,
211
+ scope,
212
+ key,
213
+ fingerprint,
214
+ tags,
215
+ createdAt: now,
216
+ updatedAt: now,
217
+ source: input?.source ?? {},
218
+ }
219
+ if (ttlDays !== undefined) entry.ttlDays = ttlDays
220
+ entries.push(entry)
221
+ return { entries: this.evict(entries), value: { entry, duplicate: false, total: entries.length } }
222
+ })
223
+ }
224
+
225
+ /**
226
+ * 检索。query 为空时返回最近的条目;否则按命中词数排序。
227
+ * @returns {Promise<object[]>}
228
+ */
229
+ async list({ scope, key, query, limit = 20, includeExpired = false } = {}) {
230
+ await this.load()
231
+ const now = Date.now()
232
+ const max = Math.max(1, Math.min(200, Number(limit) || 20))
233
+ const tokens = normalizeText(query).toLowerCase().split(/\s+/).filter(Boolean)
234
+ const wantedKey = scope === undefined ? '' : scopeKey(scope, key)
235
+
236
+ let rows = this.entries.filter((entry) => {
237
+ if (!includeExpired && isExpired(entry, now)) return false
238
+ if (scope !== undefined && entry.scope !== scope) return false
239
+ if (scope && scope !== 'user' && (entry.key ?? '') !== wantedKey) return false
240
+ return true
241
+ })
242
+
243
+ if (tokens.length > 0) {
244
+ const scored = []
245
+ for (const entry of rows) {
246
+ const haystack = `${entry.text} ${(entry.tags ?? []).join(' ')}`.toLowerCase()
247
+ const score = tokens.reduce((sum, token) => (haystack.includes(token) ? sum + 1 : sum), 0)
248
+ if (score > 0) scored.push({ entry, score })
249
+ }
250
+ scored.sort((a, b) => b.score - a.score || (Number(b.entry.updatedAt) || 0) - (Number(a.entry.updatedAt) || 0))
251
+ return scored.slice(0, max).map((item) => item.entry)
252
+ }
253
+
254
+ rows = rows.sort((a, b) => (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0))
255
+ return rows.slice(0, max)
256
+ }
257
+
258
+ /** 删除一条。返回是否真的删掉了。 */
259
+ async remove(id) {
260
+ const target = normalizeText(id)
261
+ if (target === '') throw new Error('memory-share: id 不能为空')
262
+ return this.mutate((entries) => {
263
+ const next = entries.filter((entry) => entry.id !== target)
264
+ return { entries: next, value: next.length !== entries.length }
265
+ })
266
+ }
267
+
268
+ /** 修改一条的正文。指纹同步更新,避免改完还能被旧指纹命中去重。 */
269
+ async update(id, text) {
270
+ const target = normalizeText(id)
271
+ if (target === '') throw new Error('memory-share: id 不能为空')
272
+ const nextText = normalizeText(text)
273
+ if (nextText === '') throw new Error('memory-share: text 不能为空')
274
+ return this.mutate((entries) => {
275
+ const entry = entries.find((item) => item.id === target)
276
+ if (entry === undefined) return { entries, value: false }
277
+ entry.text = nextText
278
+ entry.fingerprint = fingerprintOf(nextText)
279
+ entry.updatedAt = Date.now()
280
+ return { entries, value: true }
281
+ })
282
+ }
283
+
284
+ /** 清空某个作用域(user 即清空全部 user 级记忆)。返回删除条数。 */
285
+ async clear(scope, key) {
286
+ if (!SCOPES.includes(scope)) throw new Error(`memory-share: 未知 scope ${scope}`)
287
+ const wantedKey = scopeKey(scope, key)
288
+ return this.mutate((entries) => {
289
+ const next = entries.filter((entry) => !(entry.scope === scope && (entry.key ?? '') === wantedKey))
290
+ return { entries: next, value: entries.length - next.length }
291
+ })
292
+ }
293
+
294
+ /** 同步筛选出对某个 agent 可见的记忆(渲染热路径)。 */
295
+ selectFor({ cwd, sessionId, now = Date.now() } = {}) {
296
+ const workspaceKey = normalizeKey(cwd)
297
+ const sessionKey = normalizeText(sessionId)
298
+ return this.entries.filter((entry) => {
299
+ if (isExpired(entry, now)) return false
300
+ if (entry.scope === 'user') return true
301
+ if (entry.scope === 'workspace') return workspaceKey !== '' && entry.key === workspaceKey
302
+ if (entry.scope === 'session') return sessionKey !== '' && entry.key === sessionKey
303
+ return false
304
+ })
305
+ }
306
+ }
@@ -0,0 +1,29 @@
1
+ // 严格反射清单,由 DSH Typert 加载器校验。结构对齐 dsh-plugin-mind-tree。
2
+ // License: MIT
3
+ import { PACKAGE, NAMESPACE, LIST_DESCRIPTOR, UPDATE_DESCRIPTOR, REMOVE_DESCRIPTOR } from './remote.js'
4
+
5
+ export const TYPERT = {
6
+ package: PACKAGE,
7
+ face: 'host',
8
+ schemas: [],
9
+ invocations: [LIST_DESCRIPTOR, UPDATE_DESCRIPTOR, REMOVE_DESCRIPTOR],
10
+ model: {
11
+ services: [{
12
+ key: NAMESPACE,
13
+ exportName: 'MemoryShareService',
14
+ description: '只读为主的管理面:列出全部长期记忆条目、修改单条正文、删除单条。写入记忆仍走 memory_save 工具,不在本服务上暴露。',
15
+ summary: '跨会话长期记忆的查看与维护',
16
+ tags: ['memory', 'long-term-memory', 'settings'],
17
+ members: [
18
+ { kind: 'method', name: 'list', signature: 'list(): Promise<MemoryList>' },
19
+ { kind: 'method', name: 'update', signature: 'update(id: string, text: string): Promise<MemoryMutation>' },
20
+ { kind: 'method', name: 'forget', signature: 'forget(id: string): Promise<MemoryMutation>' },
21
+ ],
22
+ types: [],
23
+ }],
24
+ events: [],
25
+ objects: [],
26
+ },
27
+ }
28
+
29
+ export default TYPERT
package/package.json CHANGED
@@ -1,6 +1,83 @@
1
1
  {
2
2
  "name": "dsh-plugin-memory-share",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "description": "dsh-plugin-MemoryShare — 跨会话共享的长期记忆:被动注入系统提示词,配 memory_save / memory_recall / memory_forget 三个工具,以及「设置 → 插件 → 记忆」管理页。",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "ShOpenMind",
9
+ "url": "https://github.com/ShOpenMind"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ShOpenMind/dsh-plugin-MemoryShare.git"
14
+ },
15
+ "homepage": "https://github.com/ShOpenMind/dsh-plugin-MemoryShare#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/ShOpenMind/dsh-plugin-MemoryShare/issues"
18
+ },
19
+ "main": "lib/index.js",
20
+ "exports": {
21
+ ".": "./lib/index.js",
22
+ "./remote": "./lib/remote.js",
23
+ "./typert": "./lib/typert.host.js",
24
+ "./client": "./lib/client.js",
25
+ "./cordis.patch.yml": "./cordis.patch.yml",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib",
30
+ "bin",
31
+ "cordis.patch.yml",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "keywords": [
36
+ "dsh",
37
+ "deepseek-harness",
38
+ "plugin",
39
+ "memory",
40
+ "long-term-memory",
41
+ "dsh-plugin",
42
+ "dsh-bundle",
43
+ "dsh-market",
44
+ "agent-memory"
45
+ ],
46
+ "scripts": {
47
+ "build": "node tools/build.mjs",
48
+ "test": "node tools/host-test.mjs && node tools/cordis-mount-test.mjs",
49
+ "test:host": "node tools/host-test.mjs",
50
+ "test:cordis": "node tools/cordis-mount-test.mjs",
51
+ "demo": "node tools/demo.mjs"
52
+ },
53
+ "engines": {
54
+ "node": ">=22"
55
+ },
56
+ "dependencies": {
57
+ "zod": "^4.4.0"
58
+ },
59
+ "devDependencies": {
60
+ "esbuild": "^0.25.0"
61
+ },
62
+ "dsh": {
63
+ "bundle": {
64
+ "patch": "./cordis.patch.yml"
65
+ },
66
+ "client": {
67
+ "platform": "web",
68
+ "inject": [
69
+ "@deepseek-ai/dsh-client-ui-slots",
70
+ "@deepseek-ai/dsh-client-ui-settings",
71
+ "@deepseek-ai/dsh-api-gateway"
72
+ ],
73
+ "external": [
74
+ "@deepseek-ai/dsh-client-ui-slots",
75
+ "@deepseek-ai/dsh-client-ui-settings",
76
+ "@deepseek-ai/dsh-api-gateway"
77
+ ]
78
+ }
79
+ },
80
+ "bin": {
81
+ "dsh-plugin-memory-share": "bin/status.mjs"
82
+ }
83
+ }