dsh-memory-ledger 0.0.0-stage → 1.0.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/ledger.js ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Multi-scope ledger for the `dsh-memory-ledger` bundle.
3
+ *
4
+ * Two scopes exist:
5
+ *
6
+ * - `user` — `<dataDir>/memory.json`: settings and lessons that hold
7
+ * everywhere (the person, not the project).
8
+ * - `project` — `<dataDir>/projects/<slug>.json`: one file per working
9
+ * directory, keyed by its path so two checkouts never mix.
10
+ *
11
+ * The hub loads the files lazily, keeps the per-file stores alive for the
12
+ * process, and combines them for the prompt section, the tools, and the UI.
13
+ */
14
+
15
+ import { createHash } from 'node:crypto'
16
+ import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
17
+ import { basename, join } from 'node:path'
18
+ import { MemoryStore, entryMatches, rankEntries, renderConstraints, renderPromptSection } from './store.js'
19
+
20
+ /** Scopes a caller may address explicitly. */
21
+ export const SCOPES = ['user', 'project']
22
+
23
+ /**
24
+ * Stable, human-scannable file name for one working directory.
25
+ * @param {string} cwd Absolute working directory.
26
+ * @returns {string} `<readable-basename>-<hash8>`.
27
+ */
28
+ export function projectSlug(cwd) {
29
+ const text = String(cwd ?? '').trim()
30
+ const readable = basename(text.replace(/[\\/]+$/u, ''))
31
+ .replace(/[^A-Za-z0-9._-]+/gu, '-')
32
+ .replace(/^-+|-+$/gu, '')
33
+ .slice(0, 32)
34
+ const hash = createHash('sha1').update(text.toLowerCase()).digest('hex').slice(0, 8)
35
+ return `${readable === '' ? 'project' : readable}-${hash}`
36
+ }
37
+
38
+ /** Merge two incident lists by signature, summing repeats. */
39
+ function mergeIncidents(lists) {
40
+ const merged = new Map()
41
+ for (const list of lists) {
42
+ for (const incident of list) {
43
+ const existing = merged.get(incident.signature)
44
+ if (existing === undefined) {
45
+ merged.set(incident.signature, { ...incident, handled: incident.handled === true })
46
+ continue
47
+ }
48
+ existing.count += incident.count
49
+ existing.handled = existing.handled && incident.handled === true
50
+ if (String(incident.lastSeenAt) > String(existing.lastSeenAt)) existing.lastSeenAt = incident.lastSeenAt
51
+ }
52
+ }
53
+ return [...merged.values()]
54
+ }
55
+
56
+ /**
57
+ * All ledgers for one data directory.
58
+ */
59
+ export class LedgerHub {
60
+ /**
61
+ * @param {object} options
62
+ * @param {string} options.dir Data directory.
63
+ * @param {{ warn?: (message: string) => void }} [options.logger]
64
+ */
65
+ constructor({ dir, logger = null }) {
66
+ this.dir = dir
67
+ this.logger = logger
68
+ this.stores = new Map()
69
+ }
70
+
71
+ /** @returns {string} Path of the user-scope ledger. */
72
+ get userPath() {
73
+ return join(this.dir, 'memory.json')
74
+ }
75
+
76
+ /** @returns {string} Directory holding per-project ledgers. */
77
+ get projectsDir() {
78
+ return join(this.dir, 'projects')
79
+ }
80
+
81
+ /** @returns {MemoryStore} The user-scope ledger. */
82
+ user() {
83
+ let store = this.stores.get('user')
84
+ if (store === undefined) {
85
+ store = new MemoryStore({ file: this.userPath, scope: 'user', logger: this.logger })
86
+ this.stores.set('user', store)
87
+ }
88
+ return store
89
+ }
90
+
91
+ /**
92
+ * The ledger for one working directory.
93
+ * @param {string} cwd Absolute working directory.
94
+ * @returns {MemoryStore}
95
+ */
96
+ project(cwd) {
97
+ const slug = projectSlug(cwd)
98
+ let store = this.stores.get(slug)
99
+ if (store === undefined) {
100
+ store = new MemoryStore({
101
+ file: join(this.projectsDir, `${slug}.json`),
102
+ scope: 'project',
103
+ slug,
104
+ label: cwd,
105
+ logger: this.logger,
106
+ })
107
+ this.stores.set(slug, store)
108
+ }
109
+ return store
110
+ }
111
+
112
+ /**
113
+ * Every project ledger on disk, for the UI.
114
+ * @returns {Array<{ slug: string, label: string, file: string, facts: number, lessons: number }>}
115
+ */
116
+ projects() {
117
+ const rows = []
118
+ try {
119
+ if (!existsSync(this.projectsDir)) return rows
120
+ for (const name of readdirSync(this.projectsDir)) {
121
+ if (!name.endsWith('.json')) continue
122
+ const file = join(this.projectsDir, name)
123
+ try {
124
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
125
+ const entries = Array.isArray(parsed?.entries) ? parsed.entries : []
126
+ rows.push({
127
+ slug: typeof parsed?.slug === 'string' ? parsed.slug : name.replace(/\.json$/u, ''),
128
+ label: typeof parsed?.label === 'string' ? parsed.label : name.replace(/\.json$/u, ''),
129
+ file,
130
+ facts: entries.filter(entry => entry.kind === 'fact' && entry.archived !== true).length,
131
+ lessons: entries.filter(entry => entry.kind === 'lesson' && entry.archived !== true).length,
132
+ })
133
+ } catch {
134
+ /* a corrupt project file shows up as unreadable, not fatal */
135
+ }
136
+ }
137
+ } catch {
138
+ /* no projects directory yet */
139
+ }
140
+ return rows.sort((a, b) => a.label.localeCompare(b.label))
141
+ }
142
+
143
+ /**
144
+ * Combine the user ledger with one project ledger for the prompt section.
145
+ * @param {string | undefined} cwd The session's working directory.
146
+ * @param {object} [options] Render options forwarded to the store.
147
+ * @returns {string}
148
+ */
149
+ renderFor(cwd, options = {}) {
150
+ const user = this.user()
151
+ const scopes = [{ store: user, scope: 'user' }]
152
+ if (typeof cwd === 'string' && cwd.trim() !== '') {
153
+ scopes.push({ store: this.project(cwd), scope: 'project' })
154
+ }
155
+ const entries = scopes.flatMap(({ store, scope }) =>
156
+ store.entries().map(entry => ({ ...entry, scope })))
157
+ const incidents = mergeIncidents(scopes.map(({ store }) => store.incidents()))
158
+ return renderPromptSection({ entries, incidents }, options)
159
+ }
160
+
161
+ /**
162
+ * Combine the user ledger with one project ledger for the hard-constraint
163
+ * section.
164
+ * @param {string | undefined} cwd The session's working directory.
165
+ * @param {object} [options] Render options forwarded to the store.
166
+ * @returns {string}
167
+ */
168
+ renderConstraintsFor(cwd, options = {}) {
169
+ const user = this.user()
170
+ const entries = user.entries().map(entry => ({ ...entry, scope: 'user' }))
171
+ if (typeof cwd === 'string' && cwd.trim() !== '') {
172
+ for (const entry of this.project(cwd).entries()) entries.push({ ...entry, scope: 'project' })
173
+ }
174
+ return renderConstraints({ entries }, options)
175
+ }
176
+
177
+ /**
178
+ * Search across scopes.
179
+ * @param {object} query
180
+ * @param {string} [query.text]
181
+ * @param {'fact'|'lesson'} [query.kind]
182
+ * @param {string} [query.tags]
183
+ * @param {number} [query.limit]
184
+ * @param {string} [query.mode] `hybrid` | `keyword` | `vector`.
185
+ * @param {string} [query.scope] `auto` | `user` | `project` | `all`.
186
+ * @param {string} [query.cwd] Session working directory, for `project` and `auto`.
187
+ * @param {boolean} [query.includeArchived]
188
+ * @returns {Array<Record<string, unknown>>} Ranked entries with `scope` and `_score`.
189
+ */
190
+ search(query = {}) {
191
+ const scope = ['auto', 'user', 'project', 'all'].includes(String(query.scope))
192
+ ? String(query.scope)
193
+ : 'auto'
194
+ const limit = Math.min(Math.max(Math.trunc(query.limit ?? 10) || 10, 1), 50)
195
+ const cwd = typeof query.cwd === 'string' ? query.cwd.trim() : ''
196
+ const targets = []
197
+ if (scope === 'user' || scope === 'auto' || scope === 'all') {
198
+ targets.push({ store: this.user(), scope: 'user', label: '' })
199
+ }
200
+ if ((scope === 'project' || scope === 'auto' || scope === 'all') && cwd !== '') {
201
+ const store = this.project(cwd)
202
+ targets.push({ store, scope: 'project', label: cwd })
203
+ }
204
+ if (scope === 'all' && cwd === '') {
205
+ for (const row of this.projects()) {
206
+ const store = this.stores.get(row.slug)
207
+ ?? new MemoryStore({ file: row.file, scope: 'project', slug: row.slug, label: row.label, logger: this.logger })
208
+ this.stores.set(row.slug, store)
209
+ targets.push({ store, scope: 'project', label: row.label })
210
+ }
211
+ }
212
+ if (targets.length === 0) return []
213
+
214
+ // One candidate pool, one corpus: scores stay comparable across ledgers.
215
+ const candidates = []
216
+ for (const target of targets) {
217
+ for (const entry of target.store.entries()) {
218
+ if (!entryMatches(entry, query)) continue
219
+ candidates.push({ ...entry, scope: target.scope, project: target.scope === 'project' ? target.label : '' })
220
+ }
221
+ }
222
+ return rankEntries(candidates, { text: query.text, mode: query.mode, limit: query.limit ?? limit })
223
+ }
224
+
225
+ /**
226
+ * Write one entry into a scope.
227
+ * @param {object} input
228
+ * @param {'user'|'project'} [input.scope]
229
+ * @param {string} [input.cwd]
230
+ * @returns {{ entry: Record<string, unknown>, updated: boolean, scope: string }}
231
+ */
232
+ upsert(input) {
233
+ const cwd = typeof input.cwd === 'string' ? input.cwd.trim() : ''
234
+ let scope = SCOPES.includes(String(input.scope)) ? String(input.scope) : ''
235
+ if (scope === '') scope = input.kind === 'lesson' && cwd !== '' ? 'project' : 'user'
236
+ if (scope === 'project' && cwd === '') scope = 'user'
237
+ const store = scope === 'project' ? this.project(cwd) : this.user()
238
+ const result = store.upsert(input)
239
+ return { ...result, scope }
240
+ }
241
+
242
+ /**
243
+ * Find the store that owns one entry id, loading project ledgers as needed.
244
+ * @param {string} id
245
+ * @returns {{ store: MemoryStore, scope: string } | undefined}
246
+ */
247
+ locate(id) {
248
+ for (const store of [this.user(), ...this.liveProjects()]) {
249
+ if (store.get(id) !== undefined) return { store, scope: store.scope }
250
+ }
251
+ for (const row of this.projects()) {
252
+ let store = this.stores.get(row.slug)
253
+ if (store === undefined) {
254
+ store = new MemoryStore({ file: row.file, scope: 'project', slug: row.slug, label: row.label, logger: this.logger })
255
+ this.stores.set(row.slug, store)
256
+ }
257
+ if (store.get(id) !== undefined) return { store, scope: 'project' }
258
+ }
259
+ return undefined
260
+ }
261
+
262
+ /**
263
+ * Edit one entry in place, wherever it lives.
264
+ * @param {string} id Entry id.
265
+ * @param {object} [fields] Partial fields, see {@link MemoryStore.patch}.
266
+ * @returns {{ entry: Record<string, unknown>, scope: string }}
267
+ */
268
+ patch(id, fields = {}) {
269
+ const found = this.locate(id)
270
+ if (found === undefined) throw new Error(`no memory entry with id "${id}" in any scope`)
271
+ return { entry: found.store.patch(id, fields), scope: found.scope }
272
+ }
273
+
274
+ /**
275
+ * Archive or delete one entry wherever it lives.
276
+ * @param {string} id Entry id.
277
+ * @param {boolean} [hard]
278
+ * @returns {{ id: string, hard: boolean, title: string, scope: string }}
279
+ */
280
+ forget(id, hard = false) {
281
+ const found = this.locate(id)
282
+ if (found === undefined) throw new Error(`no memory entry with id "${id}" in any scope`)
283
+ const removed = found.store.forget(id, hard)
284
+ return { ...removed, scope: found.scope }
285
+ }
286
+
287
+ /** @returns {MemoryStore[]} Project stores already loaded in this process. */
288
+ liveProjects() {
289
+ return [...this.stores.entries()].filter(([key]) => key !== 'user').map(([, store]) => store)
290
+ }
291
+
292
+ /**
293
+ * The complete view the UI renders. Without a working directory it shows the
294
+ * user ledger plus every project ledger on disk.
295
+ * @param {string} [cwd] Session working directory.
296
+ * @param {object} [query] Extra search options (`text`, `kind`, `mode`, `scope`, `includeArchived`).
297
+ * @returns {Record<string, unknown>}
298
+ */
299
+ snapshot(cwd = '', query = {}) {
300
+ const user = this.user()
301
+ const current = typeof cwd === 'string' && cwd.trim() !== '' ? this.project(cwd) : undefined
302
+ const projects = this.projects()
303
+ const projectStores = []
304
+ if (current !== undefined) {
305
+ projectStores.push(current)
306
+ } else {
307
+ for (const row of projects) {
308
+ const store = this.stores.get(row.slug)
309
+ ?? new MemoryStore({ file: row.file, scope: 'project', slug: row.slug, label: row.label, logger: this.logger })
310
+ this.stores.set(row.slug, store)
311
+ projectStores.push(store)
312
+ }
313
+ }
314
+
315
+ const useSearch = typeof query.text === 'string' && query.text.trim() !== ''
316
+ const includeArchived = query.includeArchived === true
317
+ const visible = (entry) => includeArchived || entry.archived !== true
318
+ const entries = useSearch
319
+ ? this.search({ ...query, cwd, scope: query.scope ?? (cwd === '' ? 'all' : 'auto'), limit: query.limit ?? 50 })
320
+ : [
321
+ ...user.entries().filter(visible)
322
+ .map(entry => ({ ...entry, scope: 'user', project: '', _score: 0 })),
323
+ ...projectStores.flatMap(store => store.entries()
324
+ .filter(visible)
325
+ .map(entry => ({ ...entry, scope: 'project', project: store.label, _score: 0 }))),
326
+ ].sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
327
+
328
+ return {
329
+ version: 1,
330
+ generatedAt: new Date().toISOString(),
331
+ mode: query.mode ?? 'hybrid',
332
+ filtered: useSearch,
333
+ cwd: cwd || '',
334
+ user: {
335
+ path: user.path,
336
+ stats: user.stats(),
337
+ incidents: user.incidents().filter(incident => !incident.handled),
338
+ },
339
+ project: current === undefined
340
+ ? null
341
+ : {
342
+ slug: current.slug,
343
+ label: current.label,
344
+ path: current.path,
345
+ stats: current.stats(),
346
+ incidents: current.incidents().filter(incident => !incident.handled),
347
+ },
348
+ projects,
349
+ entries,
350
+ }
351
+ }
352
+ }
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Memory ledger",
4
+ "description": "Keeps standing user settings (identity, pen name, preferences) and verified problem-to-solution lessons on two levels — user-wide and per project — injects a bounded summary into every session, mixes BM25 with TF-IDF vector retrieval, and counts repeated tool failures mechanically. The left-sidebar Memory panel lists, searches, and archives the same ledger.",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "记忆册",
4
+ "description": "把用户的长期设定(身份、笔名、偏好)与已验证的问题→解法分「用户 / 本项目」两级记在本地 JSON 里,每轮注入一份有字数上限的摘要;检索用 BM25 + TF-IDF 向量混合,机械统计重复出现的工具失败。左侧栏「记忆册」面板可浏览、搜索、归档。",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,74 @@
1
1
  {
2
2
  "name": "dsh-memory-ledger",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "description": "Persistent memory for DeepSeek Harness: standing settings and verified problem→solution lessons, separated per project, injected into every session, with a left-sidebar panel, hybrid BM25+vector search, provenance, and an unrecorded-setting reminder.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "icon": "./icon.svg",
8
+ "author": "巴西鲜橙",
9
+ "license": "MIT",
10
+ "keywords": [
11
+ "deepseek-harness",
12
+ "dsh",
13
+ "dsh-plugin",
14
+ "dsh-bundle",
15
+ "memory",
16
+ "agent-memory",
17
+ "context",
18
+ "lessons",
19
+ "provenance",
20
+ "hybrid-search",
21
+ "vector-search",
22
+ "sidebar"
23
+ ],
24
+ "exports": {
25
+ ".": "./index.js",
26
+ "./client": "./client.js",
27
+ "./package.json": "./package.json",
28
+ "./locale/*.json": "./locale/*.json",
29
+ "./icon": "./icon.svg"
30
+ },
31
+ "files": [
32
+ "index.js",
33
+ "store.js",
34
+ "ledger.js",
35
+ "reminder.js",
36
+ "client.js",
37
+ "cordis.patch.yml",
38
+ "locale/*.json",
39
+ "docs",
40
+ "icon.svg",
41
+ "README.md",
42
+ "README-zh.md",
43
+ "LICENSE"
44
+ ],
45
+ "engines": {
46
+ "node": "^22.19.0 || >=24.0.0",
47
+ "dsh": ">=0.2.2-rc.1 <0.3.0-0"
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/dsh-llm": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0 || >=0.2.0-0 <0.3.0 || >=0.2.1-0 <0.3.0",
51
+ "@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-0 <0.2.0 || >=0.1.7-0 <0.2.0 || >=0.2.0-0 <0.3.0 || >=0.2.1-0 <0.3.0",
52
+ "@deepseek-ai/schemastery": "^3.18.2"
53
+ },
54
+ "dsh": {
55
+ "bundle": {
56
+ "patch": "./cordis.patch.yml"
57
+ },
58
+ "client": {
59
+ "platform": "web",
60
+ "immediately": true,
61
+ "inject": [
62
+ "@deepseek-ai/dsh-client-ui-sidebar",
63
+ "@deepseek-ai/dsh-client-ui-layout"
64
+ ]
65
+ }
66
+ },
67
+ "publishConfig": {
68
+ "access": "public",
69
+ "registry": "https://registry.npmjs.org/"
70
+ },
71
+ "scripts": {
72
+ "test": "node --test test/store.test.mjs"
73
+ }
6
74
  }
package/reminder.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * "Did the user just state a standing setting?" — a rule-based detector with
3
+ * no model call, so the reminder costs nothing until it actually fires.
4
+ *
5
+ * The detector is deliberately generous: a false positive only produces one
6
+ * short reminder line asking the agent to consider recording it, while a false
7
+ * negative silently loses the whole point of the plugin.
8
+ */
9
+
10
+ /** Chinese markers of a standing statement (identity, pen name, policy). */
11
+ const ZH_PATTERNS = [
12
+ /我(是|叫|的)(名字|笔名|署名|昵称|身份|职业|工作|习惯|偏好)/u,
13
+ /我的?(笔名|署名|名字|昵称|身份|职业|单位|公司|项目|团队)/u,
14
+ /(以后|今后|从此|之后)(都)?(用|叫|署|按|写|要)/u,
15
+ /(记住|记一下|记下来|记一下|别忘了|请记|帮我记)/u,
16
+ /(统一|一律|全部)(用|署|按|改名)/u,
17
+ /(不要|别再|禁止|不许)(用|叫|写|按|再)/u,
18
+ /(默认|习惯|偏好)(用|是|按|为)/u,
19
+ /(署名|落款|笔名)/u,
20
+ ]
21
+
22
+ /** English markers of the same. */
23
+ const EN_PATTERN = /\b(i am|i'm|my name is|my pen name|call me|remember that|remember this|from now on|always use|never use|we use|our project uses|default to)\b/iu
24
+
25
+ /** Phrases that keep a question-with-a-marker in scope. */
26
+ const IMPERATIVE = /(记住|记一下|记下来|别忘了|请记|帮我记|from now on|remember that|remember this)/u
27
+
28
+ /**
29
+ * Whether one inbox message is a genuine user statement rather than injected
30
+ * context. Plugin-injected reminders contain words like 笔名/署名 themselves,
31
+ * so without this guard the reminder would re-trigger itself forever.
32
+ * @param {{ role?: string, source?: { kind?: string } }} message
33
+ * @returns {boolean}
34
+ */
35
+ export function isUserStatement(message) {
36
+ if (message?.role !== 'user') return false
37
+ const kind = message?.source?.kind
38
+ if (typeof kind !== 'string' || kind === '') return true
39
+ // Every producer-owned kind (plugin, plugin:<name>, tool, time-context, …)
40
+ // is synthetic context, not something the human typed.
41
+ return kind === 'user'
42
+ }
43
+
44
+ /**
45
+ * Whether one user message looks like a statement of a standing setting.
46
+ * @param {string} text The message text.
47
+ * @returns {boolean}
48
+ */
49
+ export function looksLikeSetting(text) {
50
+ const value = String(text ?? '').trim()
51
+ if (value.length < 4) return false
52
+ const sample = value.slice(0, 2000)
53
+ const hit = ZH_PATTERNS.some(pattern => pattern.test(sample)) || EN_PATTERN.test(sample)
54
+ if (!hit) return false
55
+ // A pure question is not a statement: "我的笔名是什么?" must not trigger.
56
+ const endsAsQuestion = /[??]\s*$/u.test(sample)
57
+ return !(endsAsQuestion && !IMPERATIVE.test(sample))
58
+ }
59
+
60
+ /**
61
+ * Whether one user message commands recording explicitly. Such a message is
62
+ * still worth reminding about, but the reminder says less.
63
+ * @param {string} text
64
+ * @returns {boolean}
65
+ */
66
+ export function asksToRemember(text) {
67
+ return IMPERATIVE.test(String(text ?? ''))
68
+ }
69
+
70
+ /**
71
+ * Build the reminder injected into the agent's inbox.
72
+ * @param {string} quote The user sentence that looked like a setting.
73
+ * @returns {string}
74
+ */
75
+ export function reminderText(quote) {
76
+ const sample = String(quote ?? '').replace(/\s+/gu, ' ').trim().slice(0, 90)
77
+ return [
78
+ '【记忆提醒 · dsh-memory-ledger】',
79
+ `上一轮用户说过一句可能是长期设定的话:「${sample}」`,
80
+ '如果它确实是稳定设定(身份、笔名、署名、语言、技术栈、格式偏好),现在就调用 memory_save 记录:能归到固定槽位的传 key(如 pen_name),并在 reason 里写明为什么记。',
81
+ '如果只是这一轮的临时要求,忽略本提示;不要因为这条提示回复用户,也不要反复提醒。',
82
+ ].join('\n')
83
+ }