dsh-lite-memory 0.2.5 → 0.2.7
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 +4 -0
- package/TRIGGERS.md +13 -2
- package/client.js +12 -17
- package/index.js +2 -2
- package/package.json +1 -1
- package/store.js +37 -7
- package/test/store.test.mjs +15 -0
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ 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
|
+
这些行只属于产生它的那一轮:重进会话就没了,历史里只留正文。同一件事再记一次会合并进原条目,不会长成两条。
|
|
23
|
+
|
|
22
24
|
触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
|
|
23
25
|
|
|
24
26
|
| Case | Behaviour |
|
|
@@ -27,6 +29,8 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
|
|
|
27
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 |
|
|
28
30
|
| **L2** common sense, one-off parameters, credential values | never written |
|
|
29
31
|
|
|
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 instead of adding a second one.
|
|
33
|
+
|
|
30
34
|
## 点一下就存 / One click, no turn
|
|
31
35
|
|
|
32
36
|
`keep` 是同源直写:不产生消息、不动输入框、不打断当前回复。按钮变 `kept` 就结束了。自动写入的行尾有个 `✕`,点它就把刚才那条移入归档。
|
package/TRIGGERS.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
每次调用只占一行、灰色、12px:
|
|
23
23
|
|
|
24
24
|
```
|
|
25
|
-
· memory · 2 hits
|
|
25
|
+
· memory · 2 hits
|
|
26
26
|
· memory · no hits
|
|
27
27
|
· memory · saved "插件市场条目字段" — 目录条目要写应用名、一句话作用、图标地址 ✕
|
|
28
28
|
· memory · keep? "发布前先跑文案检查" — 界面文字只留标签、值和动作,交付前跑脚本清零 [keep]
|
|
@@ -30,7 +30,18 @@
|
|
|
30
30
|
· memory · dropped "插件市场条目字段"
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
`keep` 按钮只在建议行出现。点一下直接写盘:不产生消息、不动输入框、不占用下一轮对话,按钮变 `kept` 并禁用。同源写盘通道不通时才退回旧路——插入 `保留记忆「X」` 并排队,至少不丢这条建议。自动写入的行尾带
|
|
33
|
+
`keep` 按钮只在建议行出现。点一下直接写盘:不产生消息、不动输入框、不占用下一轮对话,按钮变 `kept` 并禁用。同源写盘通道不通时才退回旧路——插入 `保留记忆「X」` 并排队,至少不丢这条建议。自动写入的行尾带 `✕`,点了把那一条移入归档(不删文件,可从归档取回)。
|
|
34
|
+
|
|
35
|
+
这些行只属于产生它的那一轮:重进会话时不再显示,历史里只留正文。行出现在调用本身的位置——一轮正在跑时,那就是对话的最下面,不会悬浮在输入框上方。
|
|
36
|
+
|
|
37
|
+
## 什么时候合并,什么时候新建
|
|
38
|
+
|
|
39
|
+
一条约束只留一条。写入前先拿标题和摘要比一遍:够近就合并进原条目(`merges+1`),正文整篇替换;不够近才新建。
|
|
40
|
+
|
|
41
|
+
- 合并:同一件事换措辞、补细节、纠正旧值。标题相邻两字重合 ≥ 0.6,或摘要重合 ≥ 0.75。
|
|
42
|
+
- 新建:同一项目下的另一件事,哪怕标签一样。只共用项目名(「插件市场条目字段」对「插件市场踩坑清单」)不算合并。
|
|
43
|
+
- 合并前先 `memory_read` 旧正文:正文是替换,不是追加,漏读就丢细节。
|
|
44
|
+
- 判不准先 `memory_search`:命中就是同一件事,按命中那条的标题写;没命中再新建。
|
|
34
45
|
|
|
35
46
|
## 频率与退让
|
|
36
47
|
|
package/client.js
CHANGED
|
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({
|
|
|
13
13
|
memory_forget: 'memory · dropping',
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const KEEP = { idle: 'keep', saving: 'saving', saved: 'kept'
|
|
16
|
+
const KEEP = { idle: 'keep', saving: 'saving', saved: 'kept' };
|
|
17
17
|
const ROW = { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 0', fontSize: '12px', lineHeight: '1.6', opacity: 0.7 };
|
|
18
18
|
const DOT = { flex: '0 0 auto', opacity: 0.8 };
|
|
19
19
|
const LABEL = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
|
@@ -41,6 +41,13 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
return match ? match[1] : null;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
/** 只在产生这行的那一轮显示。 */
|
|
45
|
+
const useLive = (phase) => {
|
|
46
|
+
const live = React.useRef(phase !== 'result');
|
|
47
|
+
if (phase !== 'result') live.current = true;
|
|
48
|
+
return live.current;
|
|
49
|
+
};
|
|
50
|
+
|
|
44
51
|
const post = (payload) => {
|
|
45
52
|
if (typeof fetch !== 'function') return Promise.resolve(null);
|
|
46
53
|
return fetch('/lite-memory/keep', {
|
|
@@ -53,37 +60,24 @@ window.__ModuleLoader__.load({
|
|
|
53
60
|
};
|
|
54
61
|
|
|
55
62
|
const Row = (props) => {
|
|
63
|
+
if (!useLive(props.phase)) return null;
|
|
56
64
|
const line = props.phase === 'preparing' ? null : firstLine(settledText(props.block));
|
|
57
65
|
return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, line ?? RUNNING[props.toolName] ?? 'memory'));
|
|
58
66
|
};
|
|
59
67
|
|
|
60
68
|
/** 建议行:一个「keep」按钮,点了直接写盘。 */
|
|
61
69
|
const SuggestRow = (props) => {
|
|
70
|
+
if (!useLive(props.phase)) return null;
|
|
62
71
|
const [state, setState] = React.useState('idle');
|
|
63
72
|
const text = props.phase === 'preparing' ? null : settledText(props.block);
|
|
64
73
|
const label = firstLine(text) ?? RUNNING.memory_suggest;
|
|
65
74
|
const title = quoted(text);
|
|
66
75
|
const cwd = typeof props.cwd === 'string' ? props.cwd : '';
|
|
67
|
-
const actions = props.inputActions;
|
|
68
76
|
const canKeep = Boolean(title) && Boolean(cwd) && state === 'idle';
|
|
69
77
|
const keep = () => {
|
|
70
78
|
if (!canKeep) return;
|
|
71
79
|
setState('saving');
|
|
72
|
-
|
|
73
|
-
const fallback = () => {
|
|
74
|
-
try {
|
|
75
|
-
const message = `保留记忆「${title}」`;
|
|
76
|
-
const span = actions && typeof actions.captureInsertion === 'function' ? actions.captureInsertion() : null;
|
|
77
|
-
const inserted = span && actions && typeof actions.insertText === 'function' ? actions.insertText(message, span) : false;
|
|
78
|
-
if (inserted === false && actions && typeof actions.setDraft === 'function') actions.setDraft(message);
|
|
79
|
-
if (actions && typeof actions.submit === 'function') actions.submit();
|
|
80
|
-
setState('kept');
|
|
81
|
-
} catch {
|
|
82
|
-
setState('idle');
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
if (typeof fetch !== 'function') return fallback();
|
|
86
|
-
post({ op: 'keep', title, cwd }).then((value) => setState(value && value.ok ? 'saved' : 'kept'));
|
|
80
|
+
post({ op: 'keep', title, cwd }).then((value) => setState(value && value.ok ? 'saved' : 'idle'));
|
|
87
81
|
};
|
|
88
82
|
const button = title
|
|
89
83
|
? h('button', { type: 'button', onClick: keep, disabled: !canKeep, 'aria-label': '保留这条记忆', style: canKeep ? KEEP_ON : KEEP_OFF }, KEEP[state] ?? KEEP.idle)
|
|
@@ -93,6 +87,7 @@ window.__ModuleLoader__.load({
|
|
|
93
87
|
|
|
94
88
|
/** 自动写入行:一个「✕」,点了把它移入归档。 */
|
|
95
89
|
const SavedRow = (props) => {
|
|
90
|
+
if (!useLive(props.phase)) return null;
|
|
96
91
|
const [state, setState] = React.useState('idle');
|
|
97
92
|
const text = props.phase === 'preparing' ? null : settledText(props.block);
|
|
98
93
|
const title = quoted(text);
|
package/index.js
CHANGED
|
@@ -156,7 +156,7 @@ export function apply(ctx) {
|
|
|
156
156
|
|
|
157
157
|
ctx.effect(() => tools.register({
|
|
158
158
|
name: 'memory_save',
|
|
159
|
-
description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。用户点了「保留」或发「保留记忆「X
|
|
159
|
+
description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。用户点了「保留」或发「保留记忆「X」」时,用那条建议的内容调用。更新已知主题时先 memory_read 旧正文再写:标题相同、或标题与摘要高度相近时,会合并进原来那条(merges+1),正文整篇替换,不新增条目。${NOTES}`,
|
|
160
160
|
parameters: SAVE_PARAMS,
|
|
161
161
|
output: {
|
|
162
162
|
schema: {
|
|
@@ -232,7 +232,7 @@ export function apply(ctx) {
|
|
|
232
232
|
},
|
|
233
233
|
render: (value) => [{
|
|
234
234
|
type: 'text',
|
|
235
|
-
text: value.count === 0 ? 'memory · no hits' : `memory · ${value.count} hits
|
|
235
|
+
text: value.count === 0 ? 'memory · no hits' : `memory · ${value.count} hits\n${value.index}`,
|
|
236
236
|
}],
|
|
237
237
|
},
|
|
238
238
|
execute: async (args, execution) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-lite-memory",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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
|
@@ -125,14 +125,44 @@ export function trimIndex(scope, index) {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/** 同一主题的判定线。跨过就合并成一条,跨不过就新建。 */
|
|
129
|
+
export const MERGE = { title: 0.6, summary: 0.75 };
|
|
130
|
+
|
|
131
|
+
/** 相邻两字为一组:中文没有空格,按词切分永远对不上,按字片才认得出改写的同一件事。 */
|
|
132
|
+
function bigrams(text) {
|
|
133
|
+
const plain = normalizeText(text, 400).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
134
|
+
if (!plain) return new Set();
|
|
135
|
+
if (plain.length < 2) return new Set([plain]);
|
|
136
|
+
const out = new Set();
|
|
137
|
+
for (let i = 0; i + 1 < plain.length; i += 1) out.add(plain.slice(i, i + 2));
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
function similarity(a, b) {
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const right = words(b);
|
|
142
|
+
const left = bigrams(a);
|
|
143
|
+
const right = bigrams(b);
|
|
132
144
|
if (left.size === 0 || right.size === 0) return 0;
|
|
133
145
|
let shared = 0;
|
|
134
|
-
for (const
|
|
135
|
-
return shared /
|
|
146
|
+
for (const gram of left) if (right.has(gram)) shared += 1;
|
|
147
|
+
return shared / (left.size + right.size - shared);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 同标题必合并;否则标题或摘要够像,也当作同一主题的又一次记录。 */
|
|
151
|
+
function mergeCandidate(entries, title, summary) {
|
|
152
|
+
let best = null;
|
|
153
|
+
let bestScore = 0;
|
|
154
|
+
for (const item of entries) {
|
|
155
|
+
if (item.title === title) return item;
|
|
156
|
+
const titleScore = similarity(item.title, title);
|
|
157
|
+
const summaryScore = similarity(item.summary, summary);
|
|
158
|
+
if (titleScore < MERGE.title && summaryScore < MERGE.summary) continue;
|
|
159
|
+
const score = Math.max(titleScore, summaryScore);
|
|
160
|
+
if (score > bestScore) {
|
|
161
|
+
best = item;
|
|
162
|
+
bestScore = score;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return best;
|
|
136
166
|
}
|
|
137
167
|
|
|
138
168
|
/**
|
|
@@ -151,7 +181,7 @@ export function upsert(scope, draft) {
|
|
|
151
181
|
|
|
152
182
|
const index = loadIndex(scope);
|
|
153
183
|
const now = new Date().toISOString();
|
|
154
|
-
const existing = index.entries
|
|
184
|
+
const existing = mergeCandidate(index.entries, title, summary.text);
|
|
155
185
|
const id = existing ? existing.id : `mem_${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}`;
|
|
156
186
|
const record = {
|
|
157
187
|
id,
|
|
@@ -172,7 +202,7 @@ export function upsert(scope, draft) {
|
|
|
172
202
|
index.suggestions = (index.suggestions || []).filter((item) => item.title !== title).slice(-19);
|
|
173
203
|
trimIndex(scope, index);
|
|
174
204
|
saveIndex(scope, index);
|
|
175
|
-
return { record, merged: Boolean(existing) };
|
|
205
|
+
return { record, merged: Boolean(existing), mergedFrom: existing && existing.title !== title ? existing.title : null };
|
|
176
206
|
}
|
|
177
207
|
|
|
178
208
|
export function search(scope, query, limit = 4) {
|
package/test/store.test.mjs
CHANGED
|
@@ -63,3 +63,18 @@ 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');
|