dsh-lite-memory 0.0.0-stage → 0.2.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 regrevia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # dsh-lite-memory
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **零常驻记忆。命中才读,没命中一分上下文都不花。**
4
+ **Memory that costs nothing until the moment you need it.**
5
+
6
+ ---
7
+
8
+ ## 它是什么 / What it is
9
+
10
+ 给 DeepSeek Harness 的轻量记忆:不写系统提示、不逐轮注入、不扫描文件。记忆只在两种时刻出现——你说「记一下」,或者它命中了一条查过的内容。
11
+
12
+ Lightweight memory for the DeepSeek Harness. Nothing in the system prompt, nothing injected per turn. Memory appears at exactly two moments: you ask it to remember, or a query matches something already stored.
13
+
14
+ ## 触发方式 / Triggers
15
+
16
+ | 场景 | 行为 |
17
+ | --- | --- |
18
+ | **L0** 你明确要求「记住这个」 | 立即写盘,一条确认行 |
19
+ | **L1** 它发现有跨会话价值的经验 | 一行灰字 `可记「X」` + 一个 `保留` 按钮;不点就永远不写 |
20
+ | **L2** 常识、一次性参数、凭据的值 | 永不写入 |
21
+
22
+ 触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
23
+
24
+ | Case | Behaviour |
25
+ | --- | --- |
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 |
28
+ | **L2** common sense, one-off parameters, credential values | never written |
29
+
30
+ ## 点一下就存 / One click, no turn
31
+
32
+ 「保留」是同源直写:不产生消息、不动输入框、不打断当前回复。按钮变 `已保存` 就结束了。
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.
35
+
36
+ ## 为什么轻 / Why it stays light
37
+
38
+ - **0 依赖**,存储层无定时器、无监听、无网络,只有同步单文件写。
39
+ - 搜索最多 4 条、400 字;正文只在 `memory_read` 里出现,全文永不进上下文。
40
+ - 同标题自动合并;90 天未用只留 `id + 标题`;超 200 条或 512 KB 转 `archive/`,文件从不删除。
41
+ - 一个项目一个目录,项目之间互相看不见。
42
+
43
+ - **Zero dependencies.** The store has no timers, no listeners, no network — just synchronous single-file writes.
44
+ - Search returns at most 4 hits and 400 characters. Full text only appears in `memory_read`.
45
+ - Same title merges. Unused for 90 days drops to `id + title`. Over 200 entries or 512 KB archives instead of piling up. Files are never deleted.
46
+ - One directory per project, so projects never see each other's notes.
47
+
48
+ ## 安装 / Install
49
+
50
+ Plugin Manager 里搜 `dsh-lite-memory` 直接装;或 `npm pack` 后用 Plugin Manager 安装生成的 `.tgz`。
51
+
52
+ Install from the DSH Plugin Manager (search `dsh-lite-memory`), or `npm pack` and install the resulting `.tgz` through it.
53
+
54
+ ## 工具 / Tools
55
+
56
+ `memory_save` · `memory_suggest` · `memory_search` · `memory_read` · `memory_forget`
57
+
58
+ 完整触发规则见 [TRIGGERS.md](TRIGGERS.md)。 / Full trigger rules: [TRIGGERS.md](TRIGGERS.md).
59
+
60
+ ## License
61
+
62
+ MIT
package/TRIGGERS.md ADDED
@@ -0,0 +1,47 @@
1
+ # 触发与展示
2
+
3
+ 三档。档位决定「谁说了算」和「什么时候出现在对话里」。
4
+
5
+ | 档 | 谁发起 | 什么时候 | 存不存 | 用户看到什么 |
6
+ | --- | --- | --- | --- | --- |
7
+ | L0 | 用户 | 说「记住 / 以后 / 别用 / 下次」这类话 | 直接存 | `记忆 · 已存「X」` |
8
+ | L1 | 模型 | 用户没说,但这经验跨会话有用 | 点了才存 | `可记「X」` + 「保留」按钮 |
9
+ | L2 | — | 常识、一次性参数、临时路径 | 不存 | 无 |
10
+
11
+ ## 什么时候触发
12
+
13
+ 模型不会常驻读记忆。触发全靠工具描述里的触发词,命中才调。
14
+
15
+ - 查:用户说「上次 / 之前 / 记住过 / 规范 / 约定 / 路径 / 凭据怎么取」,或要做的事像是在这个项目里做过。
16
+ - 写:用户明确要求;或用户对某条建议点了「保留」。
17
+ - 建议:一条经验能跨会话复用、又只存在于用户嘴里(偏好、惯例、踩过的坑),且同一项目 24 小时内没建议过。
18
+ - 不写:`1+1=2` 这种常识、一次性任务参数、能从仓库直接读到的正文;凭据只记位置与调用方式。
19
+
20
+ ## 触发后长什么样
21
+
22
+ 每次调用只占一行、灰色、12px:
23
+
24
+ ```
25
+ · 记忆 · 命中 2 条 · 引用 96 字
26
+ · 记忆 · 无匹配
27
+ · 记忆 · 已存「插件市场条目规范」
28
+ · 可记「提交前先跑文案 lint」 [保留]
29
+ · 记忆 · 未建议:今天已经建议过「插件市场条目规范」
30
+ · 记忆 · 已归档「插件市场条目规范」
31
+ ```
32
+
33
+ 「保留」按钮只在 `可记` 那一行出现。点一下直接写盘:不产生消息、不动输入框、不占用下一轮对话,按钮变 `已保存` 并禁用。同源写盘通道不通时才退回旧路——插入 `保留记忆「X」` 并排队,至少不丢这条建议,按钮显示 `已保留`。不点就什么都不发生,那行过一会儿只是一段灰色历史。
34
+
35
+ ## 频率与退让
36
+
37
+ - 建议:同一项目 24 小时一条;同一主题存过就不再建议。
38
+ - 结果:搜索最多 4 条、400 字;全文只在 `memory_read` 里出现。
39
+ - 用户说「别记」→ `memory_forget` 归档,不删文件。
40
+ - 拒绝三次后仍反复建议,说明触发词太宽:收窄 `memory_suggest` 的描述,而不是加状态。
41
+
42
+ ## 新会话为什么不会乱
43
+
44
+ - 记忆只在工具结果里出现,从不进 system prompt,也从不伪造用户消息。
45
+ - 结果行永远是「记忆 · …」,模型能分清这是历史笔记,不是用户本轮要求。
46
+ - 摘要必须是名词短语;「你要…」「请…」这类指令式摘要会被拒绝写入。
47
+ - 存储按项目目录哈希隔离,别的项目看不到。
package/client.js ADDED
@@ -0,0 +1,95 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-lite-memory',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+
7
+ /** One muted line per memory call: a summary, never the payload. */
8
+ const RUNNING = {
9
+ memory_save: '记忆 · 写入中',
10
+ memory_suggest: '记忆 · 判断中',
11
+ memory_search: '记忆 · 检索中',
12
+ memory_read: '记忆 · 读取中',
13
+ memory_forget: '记忆 · 归档中',
14
+ };
15
+
16
+ const LABELS = { idle: '保留', saving: '保存中', saved: '已保存', kept: '已保留' };
17
+ const ROW = { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 0', fontSize: '12px', lineHeight: '1.6', opacity: 0.7 };
18
+ const DOT = { flex: '0 0 auto', opacity: 0.8 };
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' };
23
+
24
+ /** 结果文本已经是中文,这里只取第一行。 */
25
+ const settledText = (block) => {
26
+ if (block === undefined || block === null || !('kind' in block)) return null;
27
+ const content = Array.isArray(block.content) ? block.content : [];
28
+ for (const item of content) {
29
+ if (item !== null && item.type === 'text' && typeof item.text === 'string' && item.text.length > 0) return item.text;
30
+ }
31
+ if (block.error !== undefined) return '记忆 · 已中止';
32
+ if (block.isError === true) return '记忆 · 失败';
33
+ return null;
34
+ };
35
+
36
+ const firstLine = (value) => (typeof value === 'string' ? value.split('\n')[0] : null);
37
+
38
+ const Row = (props) => {
39
+ 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] ?? '记忆'));
41
+ };
42
+
43
+ /** 建议行只有一个「保留」:点击直接写盘。 */
44
+ const SuggestRow = (props) => {
45
+ const [state, setState] = React.useState('idle');
46
+ 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;
50
+ const cwd = typeof props.cwd === 'string' ? props.cwd : '';
51
+ const actions = props.inputActions;
52
+ const canKeep = Boolean(title) && Boolean(cwd) && state === 'idle';
53
+ const keep = () => {
54
+ if (!canKeep) return;
55
+ setState('saving');
56
+ /** 写盘通道不通时退回一条普通消息,至少不丢这条建议。 */
57
+ const fallback = () => {
58
+ try {
59
+ const message = `保留记忆「${title}」`;
60
+ const span = actions && typeof actions.captureInsertion === 'function' ? actions.captureInsertion() : null;
61
+ const inserted = span && actions && typeof actions.insertText === 'function' ? actions.insertText(message, span) : false;
62
+ if (inserted === false && actions && typeof actions.setDraft === 'function') actions.setDraft(message);
63
+ if (actions && typeof actions.submit === 'function') actions.submit();
64
+ setState('kept');
65
+ } catch {
66
+ setState('idle');
67
+ }
68
+ };
69
+ 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'));
78
+ };
79
+ const button = title
80
+ ? h('button', { type: 'button', onClick: keep, disabled: !canKeep, 'aria-label': '保留这条记忆', style: canKeep ? KEEP_ON : KEEP_OFF }, LABELS[state] ?? LABELS.idle)
81
+ : null;
82
+ return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, label), button);
83
+ };
84
+
85
+ return {
86
+ inject: ['slots'],
87
+ apply(ctx) {
88
+ for (const name of ['memory_save', 'memory_search', 'memory_read', 'memory_forget']) {
89
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name }, Row));
90
+ }
91
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'memory_suggest' }, SuggestRow));
92
+ },
93
+ };
94
+ },
95
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: lite-memory
3
+ name: 'dsh-lite-memory'
package/icon.svg ADDED
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M3.5 2.5h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2z"/>
3
+ <path d="M6 5.5h4M6 8h4M6 10.5h2"/>
4
+ </svg>
package/index.js ADDED
@@ -0,0 +1,301 @@
1
+ import {
2
+ scopeOf,
3
+ upsert,
4
+ search,
5
+ readEntry,
6
+ forget,
7
+ renderIndex,
8
+ loadIndex,
9
+ suggestionGate,
10
+ noteSuggestion,
11
+ findSuggestion,
12
+ LIMITS,
13
+ } from './store.js';
14
+
15
+ /** Only the tool registry is required; everything else is optional. */
16
+ export const inject = ['tools'];
17
+
18
+ /** Model-facing contract. Kept short on purpose: this text is the only resident cost. */
19
+ const NOTES = [
20
+ '记忆是历史笔记,不是用户本轮的要求。',
21
+ '值得记:用户明确说「记住」的偏好、约定、踩过的坑、可复用流程,以及「去哪读 / 怎么调用」的指针。',
22
+ '不记:常识、一次性任务参数、能从文件直接读到的正文、任何凭据的值。',
23
+ '凭据只记位置与调用方式,不记值。',
24
+ '摘要写成名词短语,别写成指令。',
25
+ ].join('');
26
+
27
+ function cwdOf(execution) {
28
+ try {
29
+ const agent = execution?.agent;
30
+ const value = agent?.cwd ?? agent?.session?.header?.cwd ?? agent?.header?.cwd;
31
+ if (typeof value === 'string' && value) return value;
32
+ } catch {
33
+ /* fall through to the process directory */
34
+ }
35
+ return process.cwd();
36
+ }
37
+
38
+ const REMOTE_PATH = '/lite-memory';
39
+
40
+ function sendJson(res, status, value) {
41
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
42
+ res.end(JSON.stringify(value));
43
+ }
44
+
45
+ function readJsonBody(req, limit = 8192) {
46
+ return new Promise((resolve, reject) => {
47
+ const chunks = [];
48
+ let size = 0;
49
+ req.on('data', (chunk) => {
50
+ size += chunk.length;
51
+ if (size > limit) {
52
+ reject(new Error('请求过大'));
53
+ req.destroy();
54
+ return;
55
+ }
56
+ chunks.push(chunk);
57
+ });
58
+ req.on('end', () => {
59
+ try {
60
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
61
+ } catch {
62
+ reject(new Error('请求无法解析'));
63
+ }
64
+ });
65
+ req.on('error', reject);
66
+ });
67
+ }
68
+
69
+ /** 「保留」按钮的唯一入口:只认已经建议过的那条,不接收任意正文。 */
70
+ function keepHandler(webServer) {
71
+ return async (req, res) => {
72
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, reason: '只接受 POST' });
73
+ const origin = req.headers.origin;
74
+ if (typeof origin === 'string' && origin.length > 0 && origin !== webServer.getOrigin()) {
75
+ return sendJson(res, 403, { ok: false, reason: '来源不符' });
76
+ }
77
+ const pathname = String(req.url || '').split('?')[0];
78
+ if (pathname !== REMOTE_PATH + '/keep') return sendJson(res, 404, { ok: false, reason: '未知操作' });
79
+ let body;
80
+ try {
81
+ body = await readJsonBody(req);
82
+ } catch (error) {
83
+ return sendJson(res, 400, { ok: false, reason: error instanceof Error ? error.message : '请求无法解析' });
84
+ }
85
+ const cwd = typeof body.cwd === 'string' ? body.cwd : '';
86
+ const title = typeof body.title === 'string' ? body.title : '';
87
+ if (cwd.length === 0 || title.length === 0) return sendJson(res, 400, { ok: false, reason: '请求不完整' });
88
+ const scope = scopeOf(cwd);
89
+ const draft = findSuggestion(scope, title);
90
+ if (!draft) return sendJson(res, 404, { ok: false, reason: '没有待保留的建议' });
91
+ try {
92
+ const saved = upsert(scope, draft);
93
+ return sendJson(res, 200, { ok: true, title: saved.record.title, id: saved.record.id });
94
+ } catch (error) {
95
+ return sendJson(res, 400, { ok: false, reason: error instanceof Error ? error.message : '保存失败' });
96
+ }
97
+ };
98
+ }
99
+
100
+ const SAVE_PARAMS = {
101
+ type: 'object',
102
+ properties: {
103
+ title: { type: 'string', description: `标题,名词短语,不超过 ${LIMITS.titleChars} 字` },
104
+ summary: { type: 'string', description: `一句话摘要,名词短语,不超过 ${LIMITS.summaryChars} 字` },
105
+ body: { type: 'string', description: '正文 markdown。只写指针与要点,不抄录长文档' },
106
+ kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型' },
107
+ tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签' },
108
+ },
109
+ required: ['title', 'summary'],
110
+ additionalProperties: false,
111
+ };
112
+
113
+ const SUGGEST_PARAMS = SAVE_PARAMS;
114
+
115
+ const SEARCH_PARAMS = {
116
+ type: 'object',
117
+ properties: {
118
+ query: { type: 'string', description: '要查的主题;用用户原话里的词' },
119
+ limit: { type: 'integer', minimum: 1, maximum: 6, description: '最多返回几条,默认 4' },
120
+ },
121
+ required: ['query'],
122
+ additionalProperties: false,
123
+ };
124
+
125
+ const READ_PARAMS = {
126
+ type: 'object',
127
+ properties: { id: { type: 'string', description: '记忆 id,来自搜索结果' } },
128
+ required: ['id'],
129
+ additionalProperties: false,
130
+ };
131
+
132
+ const FORGET_PARAMS = {
133
+ type: 'object',
134
+ properties: { id: { type: 'string', description: '要移入归档的记忆 id' } },
135
+ required: ['id'],
136
+ additionalProperties: false,
137
+ };
138
+
139
+ function text(blocks) {
140
+ return blocks.map((block) => (block.type === 'text' ? block.text : JSON.stringify(block)));
141
+ }
142
+
143
+ export function apply(ctx) {
144
+ const tools = ctx.tools;
145
+
146
+ ctx.effect(() => tools.register({
147
+ name: 'memory_save',
148
+ description: `保存一条记忆。仅在用户明确要求记住,或用户确认保留时才调用。用户发「保留记忆「X」」时,用当时那条建议的内容调用本工具。${NOTES}`,
149
+ parameters: SAVE_PARAMS,
150
+ output: {
151
+ schema: {
152
+ type: 'object',
153
+ properties: {
154
+ id: { type: 'string' },
155
+ title: { type: 'string' },
156
+ merged: { type: 'boolean' },
157
+ },
158
+ required: ['id', 'title', 'merged'],
159
+ additionalProperties: false,
160
+ },
161
+ render: (value) => [{
162
+ type: 'text',
163
+ text: `记忆 · 已存「${value.title}」${value.merged ? '(并入了同主题条目)' : ''}`,
164
+ }],
165
+ },
166
+ execute: async (args, execution) => {
167
+ const scope = scopeOf(cwdOf(execution));
168
+ const saved = upsert(scope, args);
169
+ return { id: saved.record.id, title: saved.record.title, merged: saved.merged };
170
+ },
171
+ }), 'lite-memory: memory_save');
172
+
173
+ ctx.effect(() => tools.register({
174
+ name: 'memory_suggest',
175
+ description: `对一条值得长期保留、但用户没有明说的经验提出保存建议。用户点「保留」才保存,不点就不保存。每次会话最多用一次;同一项目 24 小时内不会重复建议。${NOTES}`,
176
+ parameters: SUGGEST_PARAMS,
177
+ output: {
178
+ schema: {
179
+ type: 'object',
180
+ properties: {
181
+ asked: { type: 'boolean' },
182
+ title: { type: 'string' },
183
+ reason: { type: 'string' },
184
+ },
185
+ required: ['asked', 'title'],
186
+ additionalProperties: false,
187
+ },
188
+ render: (value) => [{
189
+ type: 'text',
190
+ text: value.asked ? `可记「${value.title}」` : `记忆 · 未建议:${value.reason ?? ''}`,
191
+ }],
192
+ },
193
+ execute: async (args, execution) => {
194
+ const scope = scopeOf(cwdOf(execution));
195
+ const gate = suggestionGate(scope, args.title);
196
+ if (!gate.ok) return { asked: false, title: String(args.title ?? ''), reason: gate.reason };
197
+ noteSuggestion(scope, args, 'asked');
198
+ return { asked: true, title: String(args.title ?? '') };
199
+ },
200
+ }), 'lite-memory: memory_suggest');
201
+
202
+ ctx.effect(() => tools.register({
203
+ name: 'memory_search',
204
+ description: `按关键词检索已有记忆,返回标题与一句话摘要(最多 4 条)。用户提到「记住过 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」时先查这里。命中后再用 memory_read 取正文。${NOTES}`,
205
+ parameters: SEARCH_PARAMS,
206
+ output: {
207
+ schema: {
208
+ type: 'object',
209
+ properties: {
210
+ count: { type: 'integer' },
211
+ chars: { type: 'integer' },
212
+ index: { type: 'string' },
213
+ },
214
+ required: ['count', 'chars', 'index'],
215
+ additionalProperties: false,
216
+ },
217
+ render: (value) => [{
218
+ type: 'text',
219
+ text: value.count === 0 ? '记忆 · 无匹配' : `记忆 · 命中 ${value.count} 条 · 引用 ${value.chars} 字\n${value.index}`,
220
+ }],
221
+ },
222
+ execute: async (args, execution) => {
223
+ const scope = scopeOf(cwdOf(execution));
224
+ const hits = search(scope, args.query, args.limit ?? 4);
225
+ const rendered = renderIndex(hits);
226
+ return { count: hits.length, chars: rendered.chars, index: rendered.text };
227
+ },
228
+ }), 'lite-memory: memory_search');
229
+
230
+ ctx.effect(() => tools.register({
231
+ name: 'memory_read',
232
+ description: '读取一条记忆的正文。只在摘要不足以回答时才调用。',
233
+ parameters: READ_PARAMS,
234
+ output: {
235
+ schema: {
236
+ type: 'object',
237
+ properties: {
238
+ id: { type: 'string' },
239
+ title: { type: 'string' },
240
+ body: { type: 'string' },
241
+ },
242
+ required: ['id', 'title', 'body'],
243
+ additionalProperties: false,
244
+ },
245
+ render: (value) => text([{ type: 'text', text: value.body }]),
246
+ },
247
+ execute: async (args, execution) => {
248
+ const scope = scopeOf(cwdOf(execution));
249
+ const { record, body } = readEntry(scope, args.id);
250
+ return { id: record.id, title: record.title, body };
251
+ },
252
+ }), 'lite-memory: memory_read');
253
+
254
+ ctx.effect(() => tools.register({
255
+ name: 'memory_forget',
256
+ description: '把一条记忆移入归档。用户说「别记这个 / 删掉那条」时调用;归档不删除文件。',
257
+ parameters: FORGET_PARAMS,
258
+ output: {
259
+ schema: {
260
+ type: 'object',
261
+ properties: { id: { type: 'string' }, title: { type: 'string' } },
262
+ required: ['id', 'title'],
263
+ additionalProperties: false,
264
+ },
265
+ render: (value) => [{
266
+ type: 'text',
267
+ text: `记忆 · 已归档「${value.title}」`,
268
+ }],
269
+ },
270
+ execute: async (args, execution) => {
271
+ const scope = scopeOf(cwdOf(execution));
272
+ const record = forget(scope, args.id);
273
+ return { id: record.id, title: record.title };
274
+ },
275
+ }), 'lite-memory: memory_forget');
276
+
277
+ /** 「保留」走同源路由:点击只写盘,不产生消息,也不打断对话。 */
278
+ const mountRoute = (holder) => {
279
+ const webServer = typeof holder.get === 'function' ? holder.get('webServer', false) : holder.webServer;
280
+ if (!webServer || typeof webServer.register !== 'function' || typeof webServer.getOrigin !== 'function') return;
281
+ holder.effect(() => webServer.register({ kind: 'prefix', path: REMOTE_PATH, handler: keepHandler(webServer) }), 'lite-memory: keep route');
282
+ };
283
+ if (typeof ctx.inject === 'function') ctx.inject(['webServer'], (scope) => mountRoute(scope));
284
+ else mountRoute(ctx);
285
+
286
+ /** Read-only summary for the Client row: how big the store already is. */
287
+ ctx.effect(() => {
288
+ globalThis.__liteMemoryStats = (cwd) => {
289
+ try {
290
+ const scope = scopeOf(cwd ?? process.cwd());
291
+ const index = loadIndex(scope);
292
+ return { scope: scope.key, count: index.entries.length, bytes: index.totalBytes ?? 0 };
293
+ } catch {
294
+ return { scope: '', count: 0, bytes: 0 };
295
+ }
296
+ };
297
+ return () => {
298
+ delete globalThis.__liteMemoryStats;
299
+ };
300
+ }, 'lite-memory: stats');
301
+ }
package/locale/en.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "meta.title": "Lite Memory",
3
+ "meta.description": "Zero resident storage. Reads on a hit; costs nothing when nothing matches."
4
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "meta.title": "轻记忆",
3
+ "meta.description": "零常驻存储。命中才读,未命中不占上下文。"
4
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.2",
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
+ "type": "module",
6
+ "main": "index.js",
7
+ "engines": {
8
+ "dsh": ">=0.1.5"
9
+ },
10
+ "scripts": {
11
+ "test": "node test/store.test.mjs"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./client": "./client.js",
16
+ "./locale/*.json": "./locale/*.json",
17
+ "./icon": "./icon.svg",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "store.js",
23
+ "client.js",
24
+ "cordis.patch.yml",
25
+ "locale",
26
+ "icon.svg",
27
+ "README.md",
28
+ "TRIGGERS.md",
29
+ "LICENSE",
30
+ "test"
31
+ ],
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "immediately": true,
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-ui-conversation"
41
+ ]
42
+ }
43
+ },
44
+ "keywords": [
45
+ "dsh",
46
+ "dsh-plugin",
47
+ "memory",
48
+ "deepseek-harness"
49
+ ],
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/regrevia/dsh-lite-memory.git"
53
+ },
54
+ "homepage": "https://github.com/regrevia/dsh-lite-memory#readme",
55
+ "bugs": {
56
+ "url": "https://github.com/regrevia/dsh-lite-memory/issues"
57
+ },
58
+ "author": "regrevia",
59
+ "license": "MIT"
60
+ }
package/store.js ADDED
@@ -0,0 +1,276 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ /**
7
+ * Hard limits. They are the whole reason this plugin stays small for years:
8
+ * nothing is ever allowed to grow without pushing the oldest entry to archive/.
9
+ */
10
+ export const LIMITS = {
11
+ entries: 200,
12
+ entryBytes: 4096,
13
+ totalBytes: 512 * 1024,
14
+ titleChars: 40,
15
+ summaryChars: 60,
16
+ suggestionWindowMs: 24 * 60 * 60 * 1000,
17
+ };
18
+
19
+ /** Strings that mean "this is a credential", never a memory. */
20
+ const SECRET_PATTERNS = [
21
+ /sk-[A-Za-z0-9_-]{16,}/,
22
+ /gh[pousr]_[A-Za-z0-9]{20,}/,
23
+ /AKIA[0-9A-Z]{16}/,
24
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
25
+ /(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]\s*\S{6,}/i,
26
+ /[A-Za-z0-9+/]{60,}={0,2}/,
27
+ ];
28
+
29
+ /** A summary is a noun phrase. An imperative turns a note into an instruction. */
30
+ const IMPERATIVE = /^(?:你要|你得|你必须|请|务必|必须|记得|记住|别忘了|do not|don't|please|you (?:must|should|need))/i;
31
+
32
+ export function memoryRoot() {
33
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
34
+ return path.join(home, 'lite-memory');
35
+ }
36
+
37
+ /** One store per project directory, so projects never see each other. */
38
+ export function scopeOf(cwd) {
39
+ const abs = path.resolve(cwd || process.cwd());
40
+ const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 8);
41
+ const label = path.basename(abs).replace(/[^\w.-]+/g, '-').slice(0, 32) || 'root';
42
+ return { key: `${label}-${hash}`, dir: path.join(memoryRoot(), `${label}-${hash}`), cwd: abs };
43
+ }
44
+
45
+ function readJson(file, fallback) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ function writeAtomic(file, text) {
54
+ fs.mkdirSync(path.dirname(file), { recursive: true });
55
+ const tmp = `${file}.${process.pid}.tmp`;
56
+ fs.writeFileSync(tmp, text);
57
+ fs.renameSync(tmp, file);
58
+ }
59
+
60
+ export function detectSecret(text) {
61
+ const hit = SECRET_PATTERNS.find((re) => re.test(String(text || '')));
62
+ return hit ? hit.source : null;
63
+ }
64
+
65
+ export function normalizeText(value, max) {
66
+ return String(value ?? '')
67
+ .replace(/\s+/g, ' ')
68
+ .trim()
69
+ .slice(0, max);
70
+ }
71
+
72
+ export function validSummary(summary) {
73
+ const text = normalizeText(summary, LIMITS.summaryChars);
74
+ if (!text) return { ok: false, reason: '摘要为空' };
75
+ if (IMPERATIVE.test(text)) return { ok: false, reason: '摘要必须写成名词短语,不能写成指令' };
76
+ return { ok: true, text };
77
+ }
78
+
79
+ function entryFile(scope, id) {
80
+ return path.join(scope.dir, 'entries', `${id}.md`);
81
+ }
82
+
83
+ export function loadIndex(scope) {
84
+ const file = path.join(scope.dir, 'index.json');
85
+ const data = readJson(file, null);
86
+ if (!data || !Array.isArray(data.entries)) return { version: 1, scope: scope.key, entries: [], suggestions: [] };
87
+ if (!Array.isArray(data.suggestions)) data.suggestions = [];
88
+ return data;
89
+ }
90
+
91
+ export function saveIndex(scope, index) {
92
+ index.version = 1;
93
+ index.scope = scope.key;
94
+ index.cwd = scope.cwd;
95
+ index.updated = new Date().toISOString();
96
+ index.totalBytes = index.entries.reduce((sum, item) => sum + (item.bytes || 0), 0);
97
+ writeAtomic(path.join(scope.dir, 'index.json'), JSON.stringify(index, null, 2));
98
+ }
99
+
100
+ /** Oldest and least used first; archive keeps the file, it never deletes. */
101
+ function archiveEntry(scope, index, entry) {
102
+ try {
103
+ const from = entryFile(scope, entry.id);
104
+ const to = path.join(scope.dir, 'archive', `${entry.id}.md`);
105
+ fs.mkdirSync(path.dirname(to), { recursive: true });
106
+ if (fs.existsSync(from)) fs.renameSync(from, to);
107
+ } catch {
108
+ /* an unreadable entry is still dropped from the index */
109
+ }
110
+ index.entries = index.entries.filter((item) => item.id !== entry.id);
111
+ }
112
+
113
+ export function trimIndex(scope, index) {
114
+ const byStaleness = [...index.entries].sort((a, b) => {
115
+ const rank = (item) => (item.lastUsed || item.updated || item.created || '');
116
+ return rank(a).localeCompare(rank(b));
117
+ });
118
+ while (index.entries.length > LIMITS.entries) archiveEntry(scope, index, byStaleness.shift());
119
+ let total = index.entries.reduce((sum, item) => sum + (item.bytes || 0), 0);
120
+ while (total > LIMITS.totalBytes && byStaleness.length > 0) {
121
+ const victim = byStaleness.shift();
122
+ if (!index.entries.some((item) => item.id === victim.id)) continue;
123
+ total -= victim.bytes || 0;
124
+ archiveEntry(scope, index, victim);
125
+ }
126
+ }
127
+
128
+ function similarity(a, b) {
129
+ const words = (text) => new Set(normalizeText(text, 400).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 1));
130
+ const left = words(a);
131
+ const right = words(b);
132
+ if (left.size === 0 || right.size === 0) return 0;
133
+ let shared = 0;
134
+ for (const word of left) if (right.has(word)) shared += 1;
135
+ return shared / Math.max(left.size, right.size);
136
+ }
137
+
138
+ /**
139
+ * Save or merge one memory. A repeat of an existing topic updates it and counts
140
+ * the merge instead of adding a row, which is what keeps a long-lived store tidy.
141
+ */
142
+ export function upsert(scope, draft) {
143
+ const title = normalizeText(draft.title, LIMITS.titleChars);
144
+ if (!title) throw new Error('标题为空');
145
+ const summary = validSummary(draft.summary);
146
+ if (!summary.ok) throw new Error(summary.reason);
147
+ const body = String(draft.body ?? '').trim();
148
+ if (body.length > LIMITS.entryBytes) throw new Error(`正文超过 ${LIMITS.entryBytes} 字,请精简或拆成指针`);
149
+ const asSecret = detectSecret(`${title}\n${summary.text}\n${body}`);
150
+ if (asSecret) throw new Error(`检出疑似凭据,未保存(${asSecret})。凭据只记位置与调用方式`);
151
+
152
+ const index = loadIndex(scope);
153
+ const now = new Date().toISOString();
154
+ const existing = index.entries.find((item) => item.title === title) || index.entries.find((item) => similarity(item.summary, summary.text) > 0.8);
155
+ const id = existing ? existing.id : `mem_${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}`;
156
+ const record = {
157
+ id,
158
+ title,
159
+ summary: summary.text,
160
+ kind: draft.kind || 'note',
161
+ tags: Array.isArray(draft.tags) ? draft.tags.map((tag) => normalizeText(tag, 20)).filter(Boolean).slice(0, 5) : [],
162
+ created: existing ? existing.created : now,
163
+ updated: now,
164
+ lastUsed: now,
165
+ hits: existing ? existing.hits || 0 : 0,
166
+ merges: existing ? (existing.merges || 0) + 1 : 0,
167
+ bytes: Buffer.byteLength(body, 'utf8'),
168
+ };
169
+ writeAtomic(entryFile(scope, id), body);
170
+ if (existing) index.entries = index.entries.map((item) => (item.id === id ? record : item));
171
+ else index.entries.push(record);
172
+ index.suggestions = (index.suggestions || []).filter((item) => item.title !== title).slice(-19);
173
+ trimIndex(scope, index);
174
+ saveIndex(scope, index);
175
+ return { record, merged: Boolean(existing) };
176
+ }
177
+
178
+ export function search(scope, query, limit = 4) {
179
+ const index = loadIndex(scope);
180
+ const terms = normalizeText(query, 200)
181
+ .toLowerCase()
182
+ .split(/[^\p{L}\p{N}]+/u)
183
+ .filter((word) => word.length > 1);
184
+ const scored = index.entries.map((item) => {
185
+ const hay = `${item.title} ${item.summary} ${(item.tags || []).join(' ')}`.toLowerCase();
186
+ let score = 0;
187
+ for (const term of terms) if (hay.includes(term)) score += term.length > 2 ? 2 : 1;
188
+ if (terms.length > 0 && score === 0) score = similarity(item.title, query) > 0.5 ? 1 : 0;
189
+ return { item, score };
190
+ });
191
+ const hits = scored.filter((row) => row.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
192
+ if (hits.length > 0) {
193
+ const now = new Date().toISOString();
194
+ for (const hit of hits) {
195
+ hit.item.hits = (hit.item.hits || 0) + 1;
196
+ hit.item.lastUsed = now;
197
+ }
198
+ saveIndex(scope, index);
199
+ }
200
+ return hits.map((row) => row.item);
201
+ }
202
+
203
+ export function readEntry(scope, id) {
204
+ const index = loadIndex(scope);
205
+ const record = index.entries.find((item) => item.id === id);
206
+ if (!record) throw new Error(`没有这条记忆:${id}`);
207
+ const body = fs.readFileSync(entryFile(scope, record.id), 'utf8');
208
+ record.lastUsed = new Date().toISOString();
209
+ record.hits = (record.hits || 0) + 1;
210
+ saveIndex(scope, index);
211
+ return { record, body };
212
+ }
213
+
214
+ export function forget(scope, id) {
215
+ const index = loadIndex(scope);
216
+ const record = index.entries.find((item) => item.id === id);
217
+ if (!record) throw new Error(`没有这条记忆:${id}`);
218
+ archiveEntry(scope, index, record);
219
+ saveIndex(scope, index);
220
+ return record;
221
+ }
222
+
223
+ /** Why a suggestion is refused: one per day per project, and never for a saved topic. */
224
+ export function suggestionGate(scope, title) {
225
+ const index = loadIndex(scope);
226
+ const text = normalizeText(title, LIMITS.titleChars);
227
+ if (index.entries.some((item) => item.title === text)) return { ok: false, reason: '这条已经存过' };
228
+ const now = Date.now();
229
+ const recent = index.suggestions.find((item) => now - Date.parse(item.at) < LIMITS.suggestionWindowMs);
230
+ if (recent) return { ok: false, reason: `今天已经建议过「${recent.title}」` };
231
+ return { ok: true };
232
+ }
233
+
234
+ export function noteSuggestion(scope, draft, decision = 'asked') {
235
+ const source = typeof draft === 'string' ? { title: draft } : draft;
236
+ const title = normalizeText(source.title, LIMITS.titleChars);
237
+ const payload = {
238
+ title,
239
+ summary: normalizeText(source.summary, LIMITS.summaryChars),
240
+ body: String(source.body ?? ''),
241
+ kind: source.kind || 'note',
242
+ tags: Array.isArray(source.tags) ? source.tags.map((tag) => normalizeText(tag, 20)).filter(Boolean).slice(0, 5) : [],
243
+ };
244
+ const index = loadIndex(scope);
245
+ index.suggestions = [
246
+ ...(index.suggestions || []),
247
+ { title, at: new Date().toISOString(), decision, draft: payload },
248
+ ].slice(-20);
249
+ saveIndex(scope, index);
250
+ }
251
+
252
+ /** 待保留的那条建议;没有就返回 null(已过期、已存下或从未建议过)。 */
253
+ export function findSuggestion(scope, title) {
254
+ const index = loadIndex(scope);
255
+ const text = normalizeText(title, LIMITS.titleChars);
256
+ const item = (index.suggestions || []).find((entry) => entry.title === text && entry.decision === 'asked');
257
+ if (!item) return null;
258
+ return item.draft ?? { title: text };
259
+ }
260
+
261
+ /** The compact index handed to the model: one noun-phrase line per entry, hard capped. */
262
+ export function renderIndex(entries, budget = 400) {
263
+ const staleMs = 90 * 24 * 60 * 60 * 1000;
264
+ const now = Date.now();
265
+ const lines = [];
266
+ let used = 0;
267
+ for (const item of entries) {
268
+ const last = Date.parse(item.lastUsed || item.updated || item.created || '') || now;
269
+ const stale = now - last > staleMs;
270
+ const line = stale ? `- ${item.id} ${item.title}` : `- ${item.id} ${item.title}|${item.summary}`;
271
+ if (used + line.length > budget) break;
272
+ lines.push(line);
273
+ used += line.length + 1;
274
+ }
275
+ return { text: lines.join('\n'), chars: used };
276
+ }
@@ -0,0 +1,65 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import * as store from '../store.js';
6
+
7
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-'));
8
+ process.env.DSH_HOME = home;
9
+
10
+ const scope = store.scopeOf('/tmp/project-a');
11
+ const other = store.scopeOf('/tmp/project-b');
12
+ const draft = { title: '插件市场条目规范', summary: '市场上传指南的位置与要点', body: '见 UPLOAD-GUIDE.md', kind: 'pointer', tags: ['marketplace'] };
13
+
14
+ const first = store.upsert(scope, draft);
15
+ assert.equal(first.merged, false);
16
+ assert.equal(store.loadIndex(scope).entries.length, 1);
17
+
18
+ const second = store.upsert(scope, { ...draft, summary: '市场上传指南的位置与要点(更新)' });
19
+ assert.equal(second.merged, true);
20
+ assert.equal(second.record.id, first.record.id);
21
+ assert.equal(store.loadIndex(scope).entries.length, 1);
22
+ assert.equal(store.loadIndex(scope).entries[0].merges, 1);
23
+
24
+ assert.deepEqual(store.search(scope, '插件市场').map((item) => item.id), [first.record.id]);
25
+ assert.deepEqual(store.search(scope, '量子力学'), []);
26
+ assert.deepEqual(store.loadIndex(other).entries, []);
27
+
28
+ assert.throws(() => store.upsert(scope, { title: 'x', summary: '你要记得用 tab 缩进' }), /名词短语/);
29
+ assert.throws(() => store.upsert(scope, { title: 'y', summary: '凭据位置', body: 'ghp_abcdefghijklmnopqrstuvwxyz0123456789' }), /凭据/);
30
+
31
+ const read = store.readEntry(scope, first.record.id);
32
+ assert.match(read.body, /UPLOAD-GUIDE/);
33
+ assert.ok(store.loadIndex(scope).entries[0].hits >= 2);
34
+
35
+ assert.equal(store.suggestionGate(scope, '插件市场条目规范').ok, false);
36
+ store.noteSuggestion(scope, '提交前先跑 lint');
37
+ assert.equal(store.suggestionGate(scope, '另一条').ok, false);
38
+ store.upsert(scope, { title: '提交前先跑 lint', summary: '交付前的文案检查步骤' });
39
+ assert.equal(store.suggestionGate(scope, '新的建议').ok, true);
40
+
41
+ const old = store.upsert(scope, { title: '很久没用到的条目', summary: '三个月没命中', body: 'x' });
42
+ const index = store.loadIndex(scope);
43
+ index.entries = index.entries.map((item) =>
44
+ item.id === old.record.id ? { ...item, lastUsed: new Date(Date.now() - 400 * 86400000).toISOString() } : item,
45
+ );
46
+ store.saveIndex(scope, index);
47
+ const stale = store.loadIndex(scope).entries.find((item) => item.id === old.record.id);
48
+ const rendered = store.renderIndex([stale]);
49
+ assert.equal(rendered.text.includes('|'), false);
50
+ assert.ok(rendered.text.includes(old.record.title));
51
+
52
+ const forgotten = store.forget(scope, first.record.id);
53
+ assert.equal(forgotten.title, draft.title);
54
+ assert.equal(store.loadIndex(scope).entries.some((item) => item.id === first.record.id), false);
55
+ assert.ok(fs.existsSync(path.join(scope.dir, 'archive', `${first.record.id}.md`)));
56
+
57
+ for (let i = 0; i < 240; i += 1) {
58
+ store.upsert(scope, { title: `条目 ${i}`, summary: `第 ${i} 条`, body: 'x' });
59
+ }
60
+ const trimmed = store.loadIndex(scope);
61
+ assert.ok(trimmed.entries.length <= store.LIMITS.entries);
62
+ assert.ok(trimmed.totalBytes <= store.LIMITS.totalBytes);
63
+
64
+ fs.rmSync(home, { recursive: true, force: true });
65
+ console.log('store: 16 checks passed');