dsh-lite-memory 0.2.2 → 0.2.4

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
@@ -16,7 +16,7 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
16
16
  | 场景 | 行为 |
17
17
  | --- | --- |
18
18
  | **L0** 你明确要求「记住这个」 | 立即写盘,一条确认行 |
19
- | **L1** 它发现有跨会话价值的经验 | 一行灰字 `可记「X」` + 一个 `保留` 按钮;不点就永远不写 |
19
+ | **L1** 它发现有跨会话价值的经验 | 一行灰字 `memory · keep "X"?` + 一个 `keep` 按钮;不写问句、不用你打字,不点就永远不写,48 小时内不重复 |
20
20
  | **L2** 常识、一次性参数、凭据的值 | 永不写入 |
21
21
 
22
22
  触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
@@ -24,14 +24,14 @@ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothi
24
24
  | Case | Behaviour |
25
25
  | --- | --- |
26
26
  | **L0** you ask it to remember | written immediately, one confirmation line |
27
- | **L1** it spots something worth keeping | one muted line `可记「X」` with a single `保留` button — nothing is written unless you click |
27
+ | **L1** it spots something worth keeping | one muted line `memory · keep "X"?` with a single `keep` button — never a question, no typing, nothing written unless you click, at most once per two days |
28
28
  | **L2** common sense, one-off parameters, credential values | never written |
29
29
 
30
30
  ## 点一下就存 / One click, no turn
31
31
 
32
- 「保留」是同源直写:不产生消息、不动输入框、不打断当前回复。按钮变 `已保存` 就结束了。
32
+ `keep` 是同源直写:不产生消息、不动输入框、不打断当前回复。按钮变 `kept` 就结束了。自动写入的行尾有个 `✕`,点它就把刚才那条移入归档。
33
33
 
34
- `保留` writes straight to disk from the page: no message, no extra model turn, the input box untouched. The button turns into `已保存` and that is the whole interaction.
34
+ `keep` writes straight to disk from the page: no message, no extra model turn, the input box untouched. The button turns into `kept` and that is the whole interaction. An auto-saved line ends with `✕`: click it and that entry moves to the archive.
35
35
 
36
36
  ## 为什么轻 / Why it stays light
37
37
 
package/TRIGGERS.md CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  | 档 | 谁发起 | 什么时候 | 存不存 | 用户看到什么 |
6
6
  | --- | --- | --- | --- | --- |
7
- | L0 | 用户 | 说「记住 / 以后 / 别用 / 下次」这类话 | 直接存 | `记忆 · 已存「X」` |
8
- | L1 | 模型 | 用户没说,但这经验跨会话有用 | 点了才存 | `可记「X」` + 「保留」按钮 |
7
+ | L0 | 用户 | 说「记住 / 以后 / 别用 / 下次」这类话 | 直接存 | `memory · saved "X"` + `✕` |
8
+ | L1 | 模型 | 用户没说,但这经验跨会话有用 | 点了才存 | `memory · keep "X"?` + `keep` 按钮 |
9
9
  | L2 | — | 常识、一次性参数、临时路径 | 不存 | 无 |
10
10
 
11
11
  ## 什么时候触发
@@ -13,8 +13,8 @@
13
13
  模型不会常驻读记忆。触发全靠工具描述里的触发词,命中才调。
14
14
 
15
15
  - 查:用户说「上次 / 之前 / 记住过 / 规范 / 约定 / 路径 / 凭据怎么取」,或要做的事像是在这个项目里做过。
16
- - 写:用户明确要求;或用户对某条建议点了「保留」。
17
- - 建议:一条经验能跨会话复用、又只存在于用户嘴里(偏好、惯例、踩过的坑),且同一项目 24 小时内没建议过。
16
+ - 写:用户明确要求;用户自己的话已经是长期规则(「以后都这样 / 别再 X」)时直接存,不先问;或用户对某条建议点了「保留」。
17
+ - 建议:一条经验能跨会话复用、又只存在于用户嘴里(偏好、惯例、踩过的坑),且同一项目 48 小时内没建议过。只出按钮,不写「要不要记」这种问句——问句要用户打字,按钮不要。
18
18
  - 不写:`1+1=2` 这种常识、一次性任务参数、能从仓库直接读到的正文;凭据只记位置与调用方式。
19
19
 
20
20
  ## 触发后长什么样
@@ -22,19 +22,19 @@
22
22
  每次调用只占一行、灰色、12px:
23
23
 
24
24
  ```
25
- · 记忆 · 命中 2 条 · 引用 96 字
26
- · 记忆 · 无匹配
27
- · 记忆 · 已存「插件市场条目规范」
28
- · 可记「提交前先跑文案 lint」 [保留]
29
- · 记忆 · 未建议:今天已经建议过「插件市场条目规范」
30
- · 记忆 · 已归档「插件市场条目规范」
25
+ · memory · 2 hits · 96 chars
26
+ · memory · no hits
27
+ · memory · saved "插件市场条目规范" ✕
28
+ · memory · keep "提交前先跑文案 lint" [keep]
29
+ · memory · skipped
30
+ · memory · dropped "插件市场条目规范"
31
31
  ```
32
32
 
33
- 「保留」按钮只在 `可记` 那一行出现。点一下直接写盘:不产生消息、不动输入框、不占用下一轮对话,按钮变 `已保存` 并禁用。同源写盘通道不通时才退回旧路——插入 `保留记忆「X」` 并排队,至少不丢这条建议,按钮显示 `已保留`。不点就什么都不发生,那行过一会儿只是一段灰色历史。
33
+ `keep` 按钮只在建议行出现。点一下直接写盘:不产生消息、不动输入框、不占用下一轮对话,按钮变 `kept` 并禁用。同源写盘通道不通时才退回旧路——插入 `保留记忆「X」` 并排队,至少不丢这条建议。自动写入的行尾带 `✕`,点了把那一条移入归档(不删文件,可从归档取回)。不点就什么都不发生,那行过一会儿只是一段灰色历史。
34
34
 
35
35
  ## 频率与退让
36
36
 
37
- - 建议:同一项目 24 小时一条;同一主题存过就不再建议。
37
+ - 建议:同一项目 48 小时一条、一次对话最多一条;同一主题存过就不再建议。
38
38
  - 结果:搜索最多 4 条、400 字;全文只在 `memory_read` 里出现。
39
39
  - 用户说「别记」→ `memory_forget` 归档,不删文件。
40
40
  - 拒绝三次后仍反复建议,说明触发词太宽:收窄 `memory_suggest` 的描述,而不是加状态。
package/client.js CHANGED
@@ -6,47 +6,63 @@ window.__ModuleLoader__.load({
6
6
 
7
7
  /** One muted line per memory call: a summary, never the payload. */
8
8
  const RUNNING = {
9
- memory_save: '记忆 · 写入中',
10
- memory_suggest: '记忆 · 判断中',
11
- memory_search: '记忆 · 检索中',
12
- memory_read: '记忆 · 读取中',
13
- memory_forget: '记忆 · 归档中',
9
+ memory_save: 'memory · saving',
10
+ memory_suggest: 'memory · checking',
11
+ memory_search: 'memory · searching',
12
+ memory_read: 'memory · reading',
13
+ memory_forget: 'memory · dropping',
14
14
  };
15
15
 
16
- const LABELS = { idle: '保留', saving: '保存中', saved: '已保存', kept: '已保留' };
16
+ const KEEP = { idle: 'keep', saving: 'saving', saved: 'kept', kept: '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' };
20
- const KEEP = { flex: '0 0 auto', border: '1px solid currentColor', background: 'transparent', color: 'inherit', borderRadius: '999px', padding: '0 8px', fontSize: '12px', lineHeight: '18px' };
21
- const KEEP_ON = { ...KEEP, opacity: 0.9, cursor: 'pointer' };
22
- const KEEP_OFF = { ...KEEP, opacity: 0.45, cursor: 'default' };
20
+ const KEEP_STYLE = { flex: '0 0 auto', border: '1px solid currentColor', background: 'transparent', color: 'inherit', borderRadius: '999px', padding: '0 8px', fontSize: '12px', lineHeight: '18px' };
21
+ const KEEP_ON = { ...KEEP_STYLE, opacity: 0.9, cursor: 'pointer' };
22
+ const KEEP_OFF = { ...KEEP_STYLE, opacity: 0.45, cursor: 'default' };
23
+ const DROP = { flex: '0 0 auto', border: 'none', background: 'transparent', color: 'inherit', padding: '0 2px', fontSize: '12px', lineHeight: '18px', opacity: 0.55, cursor: 'pointer' };
23
24
 
24
- /** 结果文本已经是中文,这里只取第一行。 */
25
25
  const settledText = (block) => {
26
26
  if (block === undefined || block === null || !('kind' in block)) return null;
27
27
  const content = Array.isArray(block.content) ? block.content : [];
28
28
  for (const item of content) {
29
29
  if (item !== null && item.type === 'text' && typeof item.text === 'string' && item.text.length > 0) return item.text;
30
30
  }
31
- if (block.error !== undefined) return '记忆 · 已中止';
32
- if (block.isError === true) return '记忆 · 失败';
31
+ if (block.error !== undefined) return 'memory · stopped';
32
+ if (block.isError === true) return 'memory · failed';
33
33
  return null;
34
34
  };
35
35
 
36
36
  const firstLine = (value) => (typeof value === 'string' ? value.split('\n')[0] : null);
37
37
 
38
+ /** 结果行里带标题时把它取出来,用于点按钮后原路回传。 */
39
+ const quoted = (value) => {
40
+ const match = typeof value === 'string' ? /"([^"]+)"/.exec(value) : null;
41
+ return match ? match[1] : null;
42
+ };
43
+
44
+ const post = (payload) => {
45
+ if (typeof fetch !== 'function') return Promise.resolve(null);
46
+ return fetch('/lite-memory/keep', {
47
+ method: 'POST',
48
+ headers: { 'content-type': 'application/json' },
49
+ body: JSON.stringify(payload),
50
+ })
51
+ .then((response) => (response.ok ? response.json() : null))
52
+ .catch(() => null);
53
+ };
54
+
38
55
  const Row = (props) => {
39
56
  const line = props.phase === 'preparing' ? null : firstLine(settledText(props.block));
40
- return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, line ?? RUNNING[props.toolName] ?? '记忆'));
57
+ return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, line ?? RUNNING[props.toolName] ?? 'memory'));
41
58
  };
42
59
 
43
- /** 建议行只有一个「保留」:点击直接写盘。 */
60
+ /** 建议行:一个「keep」按钮,点了直接写盘。 */
44
61
  const SuggestRow = (props) => {
45
62
  const [state, setState] = React.useState('idle');
46
63
  const text = props.phase === 'preparing' ? null : settledText(props.block);
47
- const label = firstLine(text) ?? RUNNING.memory_suggest ?? '记忆';
48
- const match = typeof text === 'string' && text.startsWith('可记「') ? /「([^」]+)」/.exec(text) : null;
49
- const title = match ? match[1] : null;
64
+ const label = firstLine(text) ?? RUNNING.memory_suggest;
65
+ const title = quoted(text);
50
66
  const cwd = typeof props.cwd === 'string' ? props.cwd : '';
51
67
  const actions = props.inputActions;
52
68
  const canKeep = Boolean(title) && Boolean(cwd) && state === 'idle';
@@ -67,28 +83,39 @@ window.__ModuleLoader__.load({
67
83
  }
68
84
  };
69
85
  if (typeof fetch !== 'function') return fallback();
70
- fetch('/lite-memory/keep', {
71
- method: 'POST',
72
- headers: { 'content-type': 'application/json' },
73
- body: JSON.stringify({ title, cwd }),
74
- })
75
- .then((response) => (response.ok ? response.json() : null))
76
- .then((value) => setState(value && value.ok ? 'saved' : 'kept'))
77
- .catch(() => setState('kept'));
86
+ post({ op: 'keep', title, cwd }).then((value) => setState(value && value.ok ? 'saved' : 'kept'));
78
87
  };
79
88
  const button = title
80
- ? h('button', { type: 'button', onClick: keep, disabled: !canKeep, 'aria-label': '保留这条记忆', style: canKeep ? KEEP_ON : KEEP_OFF }, LABELS[state] ?? LABELS.idle)
89
+ ? h('button', { type: 'button', onClick: keep, disabled: !canKeep, 'aria-label': '保留这条记忆', style: canKeep ? KEEP_ON : KEEP_OFF }, KEEP[state] ?? KEEP.idle)
81
90
  : null;
82
91
  return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, label), button);
83
92
  };
84
93
 
94
+ /** 自动写入行:一个「✕」,点了把它移入归档。 */
95
+ const SavedRow = (props) => {
96
+ const [state, setState] = React.useState('idle');
97
+ const text = props.phase === 'preparing' ? null : settledText(props.block);
98
+ const title = quoted(text);
99
+ const cwd = typeof props.cwd === 'string' ? props.cwd : '';
100
+ const canDrop = Boolean(title) && Boolean(cwd) && state === 'idle';
101
+ const drop = () => {
102
+ if (!canDrop) return;
103
+ setState('saving');
104
+ post({ op: 'undo', title, cwd }).then((value) => setState(value && value.ok ? 'dropped' : 'idle'));
105
+ };
106
+ const label = state === 'dropped' ? 'memory · dropped' : firstLine(text) ?? RUNNING.memory_save;
107
+ const button = title && state !== 'dropped' ? h('button', { type: 'button', onClick: drop, disabled: !canDrop, 'aria-label': '删除这条记忆', style: DROP }, '✕') : null;
108
+ return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, label), button);
109
+ };
110
+
85
111
  return {
86
112
  inject: ['slots'],
87
113
  apply(ctx) {
88
- for (const name of ['memory_save', 'memory_search', 'memory_read', 'memory_forget']) {
114
+ for (const name of ['memory_search', 'memory_read', 'memory_forget']) {
89
115
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name }, Row));
90
116
  }
91
117
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'memory_suggest' }, SuggestRow));
118
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'memory_save' }, SavedRow));
92
119
  },
93
120
  };
94
121
  },
package/index.js CHANGED
@@ -66,7 +66,7 @@ function readJsonBody(req, limit = 8192) {
66
66
  });
67
67
  }
68
68
 
69
- /** 「保留」按钮的唯一入口:只认已经建议过的那条,不接收任意正文。 */
69
+ /** 「保留」和「✕」的唯一入口:保留只认已经建议过的那条,删除只按标题找已有条目。 */
70
70
  function keepHandler(webServer) {
71
71
  return async (req, res) => {
72
72
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, reason: '只接受 POST' });
@@ -75,7 +75,8 @@ function keepHandler(webServer) {
75
75
  return sendJson(res, 403, { ok: false, reason: '来源不符' });
76
76
  }
77
77
  const pathname = String(req.url || '').split('?')[0];
78
- if (pathname !== REMOTE_PATH + '/keep') return sendJson(res, 404, { ok: false, reason: '未知操作' });
78
+ const known = pathname === REMOTE_PATH + '/keep' || pathname === REMOTE_PATH + '/undo';
79
+ if (!known) return sendJson(res, 404, { ok: false, reason: '未知操作' });
79
80
  let body;
80
81
  try {
81
82
  body = await readJsonBody(req);
@@ -86,6 +87,16 @@ function keepHandler(webServer) {
86
87
  const title = typeof body.title === 'string' ? body.title : '';
87
88
  if (cwd.length === 0 || title.length === 0) return sendJson(res, 400, { ok: false, reason: '请求不完整' });
88
89
  const scope = scopeOf(cwd);
90
+ if (body.op === 'undo' || pathname === REMOTE_PATH + '/undo') {
91
+ const record = loadIndex(scope).entries.find((item) => item.title === title);
92
+ if (!record) return sendJson(res, 404, { ok: false, reason: '没有这条记忆' });
93
+ try {
94
+ forget(scope, record.id);
95
+ return sendJson(res, 200, { ok: true, title: record.title, id: record.id });
96
+ } catch (error) {
97
+ return sendJson(res, 400, { ok: false, reason: error instanceof Error ? error.message : '删除失败' });
98
+ }
99
+ }
89
100
  const draft = findSuggestion(scope, title);
90
101
  if (!draft) return sendJson(res, 404, { ok: false, reason: '没有待保留的建议' });
91
102
  try {
@@ -145,7 +156,7 @@ export function apply(ctx) {
145
156
 
146
157
  ctx.effect(() => tools.register({
147
158
  name: 'memory_save',
148
- description: `保存一条记忆。仅在用户明确要求记住,或用户确认保留时才调用。用户发「保留记忆「X」」时,用当时那条建议的内容调用本工具。${NOTES}`,
159
+ description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。用户点了「保留」或发「保留记忆「X」」时,用那条建议的内容调用。${NOTES}`,
149
160
  parameters: SAVE_PARAMS,
150
161
  output: {
151
162
  schema: {
@@ -160,7 +171,7 @@ export function apply(ctx) {
160
171
  },
161
172
  render: (value) => [{
162
173
  type: 'text',
163
- text: `记忆 · 已存「${value.title}」${value.merged ? '(并入了同主题条目)' : ''}`,
174
+ text: value.merged ? `memory · merged "${value.title}"` : `memory · saved "${value.title}"`,
164
175
  }],
165
176
  },
166
177
  execute: async (args, execution) => {
@@ -172,7 +183,7 @@ export function apply(ctx) {
172
183
 
173
184
  ctx.effect(() => tools.register({
174
185
  name: 'memory_suggest',
175
- description: `对一条值得长期保留、但用户没有明说的经验提出保存建议。用户点「保留」才保存,不点就不保存。每次会话最多用一次;同一项目 24 小时内不会重复建议。${NOTES}`,
186
+ description: `对一条用户没说、但跨会话还有用的经验提出保存建议。调用后对话里出现一个「保留」按钮,用户点了才保存。不要用问句征询「要不要记」,那要用户打字;按钮不打扰对话。一次对话最多建议一条,问自己「下次会话还得靠它吗」,不确定就别建议。${NOTES}`,
176
187
  parameters: SUGGEST_PARAMS,
177
188
  output: {
178
189
  schema: {
@@ -187,7 +198,7 @@ export function apply(ctx) {
187
198
  },
188
199
  render: (value) => [{
189
200
  type: 'text',
190
- text: value.asked ? `可记「${value.title}」` : `记忆 · 未建议:${value.reason ?? ''}`,
201
+ text: value.asked ? `memory · keep "${value.title}"?` : 'memory · skipped',
191
202
  }],
192
203
  },
193
204
  execute: async (args, execution) => {
@@ -216,7 +227,7 @@ export function apply(ctx) {
216
227
  },
217
228
  render: (value) => [{
218
229
  type: 'text',
219
- text: value.count === 0 ? '记忆 · 无匹配' : `记忆 · 命中 ${value.count} 条 · 引用 ${value.chars} 字\n${value.index}`,
230
+ text: value.count === 0 ? 'memory · no hits' : `memory · ${value.count} hits · ${value.chars} chars\n${value.index}`,
220
231
  }],
221
232
  },
222
233
  execute: async (args, execution) => {
@@ -264,7 +275,7 @@ export function apply(ctx) {
264
275
  },
265
276
  render: (value) => [{
266
277
  type: 'text',
267
- text: `记忆 · 已归档「${value.title}」`,
278
+ text: `memory · dropped "${value.title}"`,
268
279
  }],
269
280
  },
270
281
  execute: async (args, execution) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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
@@ -13,7 +13,7 @@ export const LIMITS = {
13
13
  totalBytes: 512 * 1024,
14
14
  titleChars: 40,
15
15
  summaryChars: 60,
16
- suggestionWindowMs: 24 * 60 * 60 * 1000,
16
+ suggestionWindowMs: 48 * 60 * 60 * 1000,
17
17
  };
18
18
 
19
19
  /** Strings that mean "this is a credential", never a memory. */
@@ -220,14 +220,14 @@ export function forget(scope, id) {
220
220
  return record;
221
221
  }
222
222
 
223
- /** Why a suggestion is refused: one per day per project, and never for a saved topic. */
223
+ /** Why a suggestion is refused: one per two days per project, and never for a saved topic. */
224
224
  export function suggestionGate(scope, title) {
225
225
  const index = loadIndex(scope);
226
226
  const text = normalizeText(title, LIMITS.titleChars);
227
227
  if (index.entries.some((item) => item.title === text)) return { ok: false, reason: '这条已经存过' };
228
228
  const now = Date.now();
229
229
  const recent = index.suggestions.find((item) => now - Date.parse(item.at) < LIMITS.suggestionWindowMs);
230
- if (recent) return { ok: false, reason: `今天已经建议过「${recent.title}」` };
230
+ if (recent) return { ok: false, reason: `48 小时内已建议过「${recent.title}」` };
231
231
  return { ok: true };
232
232
  }
233
233