dsh-lite-memory 0.2.6 → 0.2.8
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 +2 -2
- package/TRIGGERS.md +17 -0
- package/index.js +2 -1
- package/package.json +1 -1
- package/store.js +74 -14
- package/test/store.test.mjs +38 -0
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
|
|
|
19
19
|
| **L1** 它发现有跨会话价值的经验 | 一行灰字 `memory · keep? "X" — 要记的内容` + 一个 `keep` 按钮;标题加一句话,看得懂才点,不点就永远不写,48 小时内不重复 |
|
|
20
20
|
| **L2** 常识、一次性参数、凭据的值 | 永不写入 |
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
这些行只属于产生它的那一轮:重进会话就没了,历史里只留正文。同一件事再记一次会合并进原条目(旧要点保留,同键的新值覆盖);前置条件不同的同一主题是两条,不合并。
|
|
23
23
|
|
|
24
24
|
触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
|
|
25
25
|
|
|
@@ -29,7 +29,7 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
|
|
|
29
29
|
| **L1** it spots something worth keeping | one muted line `memory · keep? "X" — what would be remembered` with a single `keep` button — readable before you click, nothing written unless you click, at most once per two days |
|
|
30
30
|
| **L2** common sense, one-off parameters, credential values | never written |
|
|
31
31
|
|
|
32
|
-
Those lines belong to the turn that made them: reopening the session drops them, leaving only the prose in history.
|
|
32
|
+
Those lines belong to the turn that made them: reopening the session drops them, leaving only the prose in history. Recording the same subject again merges into the existing entry — earlier points are kept and only conflicting keys are updated. The same subject under different preconditions stays two entries.
|
|
33
33
|
|
|
34
34
|
## 点一下就存 / One click, no turn
|
|
35
35
|
|
package/TRIGGERS.md
CHANGED
|
@@ -34,6 +34,23 @@
|
|
|
34
34
|
|
|
35
35
|
这些行只属于产生它的那一轮:重进会话时不再显示,历史里只留正文。行出现在调用本身的位置——一轮正在跑时,那就是对话的最下面,不会悬浮在输入框上方。
|
|
36
36
|
|
|
37
|
+
## 什么时候合并,什么时候新建
|
|
38
|
+
|
|
39
|
+
一条约束只留一条,但**前置条件不一样就是两条**。同一条记忆挂在「什么情况下」上:换机器、换环境、换前提,内容往往互相矛盾,硬合并等于丢掉其中一种情况。
|
|
40
|
+
|
|
41
|
+
判断顺序:
|
|
42
|
+
|
|
43
|
+
1. 前置条件不同(含一边写了自己没写)→ 新建,标题后面跟场景,如「构建耗时长|CI 容器」。
|
|
44
|
+
2. 前置条件一致,且标题相同、标题字片重合 ≥ 0.6、或摘要重合 ≥ 0.75 → 合并。
|
|
45
|
+
3. 只共用项目名不算合并(「插件市场条目字段」对「插件市场踩坑清单」重合约 0.4)。
|
|
46
|
+
4. 判不准先 `memory_search`:命中且场景一致,就按那条的标题和场景写;不一致就新建。
|
|
47
|
+
|
|
48
|
+
合并只做增添,绝不整篇覆盖:
|
|
49
|
+
|
|
50
|
+
- 旧正文全留下;这次只写新增的要点。
|
|
51
|
+
- `- 键:值` 形式的行,键相同则以新的为准(端口改了、路径改了)。
|
|
52
|
+
- 真要把一条记忆作废重写(不是补充),先 `memory_forget` 再 `memory_save`,别用合并去覆盖。
|
|
53
|
+
|
|
37
54
|
## 频率与退让
|
|
38
55
|
|
|
39
56
|
- 建议:同一项目 48 小时一条、一次对话最多一条;同一主题存过就不再建议。
|
package/index.js
CHANGED
|
@@ -114,6 +114,7 @@ const SAVE_PARAMS = {
|
|
|
114
114
|
title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字` },
|
|
115
115
|
summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语,不超过 ${LIMITS.summaryChars} 字` },
|
|
116
116
|
body: { type: 'string', description: '正文 markdown。只写指针与要点,不抄录长文档' },
|
|
117
|
+
when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一主题但前置条件不同,会存成两条,不合并` },
|
|
117
118
|
kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型' },
|
|
118
119
|
tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签' },
|
|
119
120
|
},
|
|
@@ -156,7 +157,7 @@ export function apply(ctx) {
|
|
|
156
157
|
|
|
157
158
|
ctx.effect(() => tools.register({
|
|
158
159
|
name: 'memory_save',
|
|
159
|
-
description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。用户点了「保留」或发「保留记忆「X
|
|
160
|
+
description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。用户点了「保留」或发「保留记忆「X」」时,用那条建议的内容调用。更新已知主题时先 memory_read 旧正文再写:标题相同、或标题与摘要高度相近、且前置条件一致时,会合并进原来那条(merges+1),旧要点保留,同键的新值覆盖。前置条件不同的同一主题是两条,不要硬合并。正文只写这次新增的要点。${NOTES}`,
|
|
160
161
|
parameters: SAVE_PARAMS,
|
|
161
162
|
output: {
|
|
162
163
|
schema: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-lite-memory",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"description": "On-demand memory for the DeepSeek Harness: nothing in the system prompt, one muted line only when something is saved, hit or suggested.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
package/store.js
CHANGED
|
@@ -12,6 +12,7 @@ export const LIMITS = {
|
|
|
12
12
|
entryBytes: 4096,
|
|
13
13
|
totalBytes: 512 * 1024,
|
|
14
14
|
titleChars: 40,
|
|
15
|
+
whenChars: 60,
|
|
15
16
|
summaryChars: 60,
|
|
16
17
|
suggestionWindowMs: 48 * 60 * 60 * 1000,
|
|
17
18
|
};
|
|
@@ -125,20 +126,73 @@ export function trimIndex(scope, index) {
|
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/** 同一主题的判定线。跨过就合并成一条,跨不过就新建。 */
|
|
130
|
+
export const MERGE = { title: 0.6, summary: 0.75 };
|
|
131
|
+
|
|
132
|
+
/** 相邻两字为一组:中文没有空格,按词切分永远对不上,按字片才认得出改写的同一件事。 */
|
|
133
|
+
function bigrams(text) {
|
|
134
|
+
const plain = normalizeText(text, 400).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
135
|
+
if (!plain) return new Set();
|
|
136
|
+
if (plain.length < 2) return new Set([plain]);
|
|
137
|
+
const out = new Set();
|
|
138
|
+
for (let i = 0; i + 1 < plain.length; i += 1) out.add(plain.slice(i, i + 2));
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
128
142
|
function similarity(a, b) {
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const right = words(b);
|
|
143
|
+
const left = bigrams(a);
|
|
144
|
+
const right = bigrams(b);
|
|
132
145
|
if (left.size === 0 || right.size === 0) return 0;
|
|
133
146
|
let shared = 0;
|
|
134
|
-
for (const
|
|
135
|
-
return shared /
|
|
147
|
+
for (const gram of left) if (right.has(gram)) shared += 1;
|
|
148
|
+
return shared / (left.size + right.size - shared);
|
|
136
149
|
}
|
|
137
150
|
|
|
138
151
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
152
|
+
* 同一主题的判定。前置条件必须一致:同一件事在不同场景下是两条记忆,
|
|
153
|
+
* 哪怕正文互相看不上眼,也不能合并成一条。
|
|
141
154
|
*/
|
|
155
|
+
function mergeCandidate(entries, title, summary, when) {
|
|
156
|
+
let best = null;
|
|
157
|
+
let bestScore = 0;
|
|
158
|
+
for (const item of entries) {
|
|
159
|
+
if ((item.when || '') !== when) continue;
|
|
160
|
+
if (item.title === title) return item;
|
|
161
|
+
const titleScore = similarity(item.title, title);
|
|
162
|
+
const summaryScore = similarity(item.summary, summary);
|
|
163
|
+
if (titleScore < MERGE.title && summaryScore < MERGE.summary) continue;
|
|
164
|
+
const score = Math.max(titleScore, summaryScore);
|
|
165
|
+
if (score > bestScore) {
|
|
166
|
+
best = item;
|
|
167
|
+
bestScore = score;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return best;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 合并只做增添:旧要点全留下;同一个键(`- 键:值`)的新值覆盖旧值。 */
|
|
174
|
+
export function mergeBody(oldBody, incoming) {
|
|
175
|
+
const keyOf = (line) => {
|
|
176
|
+
const match = /^\s*[-*]\s*([^::]{1,20})[::]/.exec(line);
|
|
177
|
+
return match ? match[1].trim() : null;
|
|
178
|
+
};
|
|
179
|
+
const flat = (line) => line.replace(/\s+/g, ' ').trim();
|
|
180
|
+
const out = String(oldBody || '').split('\n').filter((line) => line.trim());
|
|
181
|
+
for (const raw of String(incoming || '').split('\n')) {
|
|
182
|
+
const line = raw.trim();
|
|
183
|
+
if (!line) continue;
|
|
184
|
+
const key = keyOf(line);
|
|
185
|
+
if (key) {
|
|
186
|
+
const at = out.findIndex((item) => keyOf(item) === key);
|
|
187
|
+
if (at >= 0) out[at] = line;
|
|
188
|
+
else out.push(line);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!out.some((item) => flat(item) === flat(line))) out.push(line);
|
|
192
|
+
}
|
|
193
|
+
return out.join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
142
196
|
export function upsert(scope, draft) {
|
|
143
197
|
const title = normalizeText(draft.title, LIMITS.titleChars);
|
|
144
198
|
if (!title) throw new Error('标题为空');
|
|
@@ -149,13 +203,18 @@ export function upsert(scope, draft) {
|
|
|
149
203
|
const asSecret = detectSecret(`${title}\n${summary.text}\n${body}`);
|
|
150
204
|
if (asSecret) throw new Error(`检出疑似凭据,未保存(${asSecret})。凭据只记位置与调用方式`);
|
|
151
205
|
|
|
206
|
+
const when = normalizeText(draft.when ?? '', LIMITS.whenChars);
|
|
152
207
|
const index = loadIndex(scope);
|
|
153
208
|
const now = new Date().toISOString();
|
|
154
|
-
const existing = index.entries
|
|
209
|
+
const existing = mergeCandidate(index.entries, title, summary.text, when);
|
|
155
210
|
const id = existing ? existing.id : `mem_${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}`;
|
|
211
|
+
const stated = existing ? fs.readFileSync(entryFile(scope, id), 'utf8') : '';
|
|
212
|
+
const mergedBody = existing ? mergeBody(stated, body) : body;
|
|
213
|
+
if (Buffer.byteLength(mergedBody, 'utf8') > LIMITS.entryBytes) throw new Error(`合并后超过 ${LIMITS.entryBytes} 字:只写这次新增的要点,或先归档旧条目`);
|
|
156
214
|
const record = {
|
|
157
215
|
id,
|
|
158
|
-
title,
|
|
216
|
+
title: existing ? existing.title : when ? `${title}|${when}`.slice(0, LIMITS.titleChars) : title,
|
|
217
|
+
when,
|
|
159
218
|
summary: summary.text,
|
|
160
219
|
kind: draft.kind || 'note',
|
|
161
220
|
tags: Array.isArray(draft.tags) ? draft.tags.map((tag) => normalizeText(tag, 20)).filter(Boolean).slice(0, 5) : [],
|
|
@@ -164,15 +223,15 @@ export function upsert(scope, draft) {
|
|
|
164
223
|
lastUsed: now,
|
|
165
224
|
hits: existing ? existing.hits || 0 : 0,
|
|
166
225
|
merges: existing ? (existing.merges || 0) + 1 : 0,
|
|
167
|
-
bytes: Buffer.byteLength(
|
|
226
|
+
bytes: Buffer.byteLength(mergedBody, 'utf8'),
|
|
168
227
|
};
|
|
169
|
-
writeAtomic(entryFile(scope, id),
|
|
228
|
+
writeAtomic(entryFile(scope, id), mergedBody);
|
|
170
229
|
if (existing) index.entries = index.entries.map((item) => (item.id === id ? record : item));
|
|
171
230
|
else index.entries.push(record);
|
|
172
231
|
index.suggestions = (index.suggestions || []).filter((item) => item.title !== title).slice(-19);
|
|
173
232
|
trimIndex(scope, index);
|
|
174
233
|
saveIndex(scope, index);
|
|
175
|
-
return { record, merged: Boolean(existing) };
|
|
234
|
+
return { record, merged: Boolean(existing), mergedFrom: existing && existing.title !== title ? existing.title : null };
|
|
176
235
|
}
|
|
177
236
|
|
|
178
237
|
export function search(scope, query, limit = 4) {
|
|
@@ -182,7 +241,7 @@ export function search(scope, query, limit = 4) {
|
|
|
182
241
|
.split(/[^\p{L}\p{N}]+/u)
|
|
183
242
|
.filter((word) => word.length > 1);
|
|
184
243
|
const scored = index.entries.map((item) => {
|
|
185
|
-
const hay = `${item.title} ${item.summary} ${(item.tags || []).join(' ')}`.toLowerCase();
|
|
244
|
+
const hay = `${item.title} ${item.summary} ${item.when || ''} ${(item.tags || []).join(' ')}`.toLowerCase();
|
|
186
245
|
let score = 0;
|
|
187
246
|
for (const term of terms) if (hay.includes(term)) score += term.length > 2 ? 2 : 1;
|
|
188
247
|
if (terms.length > 0 && score === 0) score = similarity(item.title, query) > 0.5 ? 1 : 0;
|
|
@@ -267,7 +326,8 @@ export function renderIndex(entries, budget = 400) {
|
|
|
267
326
|
for (const item of entries) {
|
|
268
327
|
const last = Date.parse(item.lastUsed || item.updated || item.created || '') || now;
|
|
269
328
|
const stale = now - last > staleMs;
|
|
270
|
-
const
|
|
329
|
+
const head = item.when ? `${item.title}(${item.when})` : item.title;
|
|
330
|
+
const line = stale ? `- ${item.id} ${head}` : `- ${item.id} ${head}|${item.summary}`;
|
|
271
331
|
if (used + line.length > budget) break;
|
|
272
332
|
lines.push(line);
|
|
273
333
|
used += line.length + 1;
|
package/test/store.test.mjs
CHANGED
|
@@ -63,3 +63,41 @@ assert.ok(trimmed.totalBytes <= store.LIMITS.totalBytes);
|
|
|
63
63
|
|
|
64
64
|
fs.rmSync(home, { recursive: true, force: true });
|
|
65
65
|
console.log('store: 16 checks passed');
|
|
66
|
+
|
|
67
|
+
// 同一主题换个说法:合并,不新增。
|
|
68
|
+
const mergeScope = store.scopeOf('/tmp/project-merge');
|
|
69
|
+
store.upsert(mergeScope, { title: '插件市场条目规范', summary: '市场上传指南的位置与要点', body: '见 UPLOAD-GUIDE.md' });
|
|
70
|
+
const paraphrase = store.upsert(mergeScope, { title: '插件市场条目规范(改写)', summary: '市场上传指南的位置与要点', body: '见 UPLOAD-GUIDE.md#第二节' });
|
|
71
|
+
assert.equal(paraphrase.merged, true);
|
|
72
|
+
assert.equal(paraphrase.mergedFrom, '插件市场条目规范');
|
|
73
|
+
assert.equal(store.loadIndex(mergeScope).entries.length, 1);
|
|
74
|
+
|
|
75
|
+
// 同一项目下的另一件事:不合并。
|
|
76
|
+
const sibling = store.upsert(mergeScope, { title: '插件市场踩坑清单', summary: '卡片不刷新与抓取失败的已知原因', body: 'y' });
|
|
77
|
+
assert.equal(sibling.merged, false);
|
|
78
|
+
assert.equal(store.loadIndex(mergeScope).entries.length, 2);
|
|
79
|
+
|
|
80
|
+
console.log('store: merge rules ok');
|
|
81
|
+
|
|
82
|
+
// 前置条件不同 → 两条,哪怕标题一样。
|
|
83
|
+
const mac = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'macOS 本地', body: '- 原因:磁盘加密' });
|
|
84
|
+
const ci = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'CI 容器', body: '- 原因:冷缓存' });
|
|
85
|
+
assert.equal(mac.merged, false);
|
|
86
|
+
assert.equal(ci.merged, false);
|
|
87
|
+
assert.notEqual(ci.record.id, mac.record.id);
|
|
88
|
+
assert.equal(store.loadIndex(mergeScope).entries.filter((item) => item.title.startsWith('构建耗时长')).length, 2);
|
|
89
|
+
|
|
90
|
+
// 同一前置条件下合并:旧要点保留,同键覆盖。
|
|
91
|
+
const plus = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'macOS 本地', body: '- 绕过:先跑 cache\n- 原因:磁盘加密拖慢' });
|
|
92
|
+
assert.equal(plus.merged, true);
|
|
93
|
+
assert.equal(plus.record.id, mac.record.id);
|
|
94
|
+
const body = store.readEntry(mergeScope, mac.record.id).body;
|
|
95
|
+
assert.match(body, /绕过/);
|
|
96
|
+
assert.match(body, /磁盘加密拖慢/);
|
|
97
|
+
assert.equal(body.includes('磁盘加密\n'), false);
|
|
98
|
+
|
|
99
|
+
// 前置条件参与检索。
|
|
100
|
+
const hit = store.search(mergeScope, 'CI 容器');
|
|
101
|
+
assert.ok(hit.some((item) => item.id === ci.record.id));
|
|
102
|
+
|
|
103
|
+
console.log('store: precondition rules ok');
|