dsh-instruction-memory 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 huangpeiyan
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 ADDED
@@ -0,0 +1,92 @@
1
+ # dsh-instruction-memory
2
+
3
+ 在 DSH 设置界面中维护**长期指令记忆**:保存后自动注入此后每一轮对话的系统提示词。
4
+
5
+ ## 只由用户本人维护
6
+
7
+ **这份记忆的内容只属于用户。** 本插件刻意**不注册任何面向模型的工具**——模型没有任何
8
+ 途径自行添加、修改或删除记忆条目。写入入口只有设置页(以及用户本人直接编辑存储文件),
9
+ 因此不会出现 AI 悄悄给用户"记下"指令的情况。
10
+
11
+ 条目内容由用户输入并被原样注入,本插件不对其做语义判断或自动生成。
12
+
13
+ ## 组成
14
+
15
+ | 文件 | 作用 |
16
+ | --- | --- |
17
+ | `lib/index.js` | Host 半:读取/保存记忆,注册 `systemPrompt` 段落,暴露同源 JSON 路由 |
18
+ | `lib/client.js` | Client 半:在 `settings.section` 注册「指令记忆」设置页(手写模块,无需构建) |
19
+ | `cordis.patch.yml` | bundle patch:把 Host 行插入宿主组合 |
20
+ | `contract-test.mjs` | 契约测试:驱动真实路由,验证两半之间的响应格式 |
21
+ | `verify.mjs` | 装载验证:按 DSH 的方式加载两半 |
22
+ | `smoke-test.mjs` | 注入渲染逻辑的单元测试 |
23
+
24
+ ## 生效方式
25
+
26
+ - **始终** 条目:无条件遵守。
27
+ - **按需** 条目:由模型按相关性自行判断是否适用,不相关时忽略;可填「适用场景」辅助判断。
28
+ - **优先级** 高 > 普通 > 低,决定注入顺序。
29
+ - **注入字数上限**:超出时按优先级保留,被略过的条目数会在注入文本末尾注明。
30
+
31
+ 条目全部停用或总开关关闭时,该提示词段落**整个注销**,系统提示词与未安装本插件时逐字节一致。
32
+
33
+ ## 存储
34
+
35
+ 记忆保存在 **DSH 数据根目录**下(设置页会显示完整绝对路径):
36
+
37
+ ```
38
+ <DSH_HOME>/instruction-memory/memory.json # DSH_HOME 默认为 ~/.dsh
39
+ ```
40
+
41
+ 路径优先级与官方 `@deepseek-ai/dsh-home-paths` 一致:显式 `$DSH_HOME` → `~/.dsh`。
42
+ **空白的 `$DSH_HOME` 视为未设置**,绝不会退回当前工作目录——否则用户换个目录启动
43
+ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
44
+
45
+ > 1.0.0 之前的构建曾把数据写在宿主进程 cwd 下(`.dsh-instruction-memory.json`)。
46
+ > 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
47
+
48
+ 该文件可手工编辑,改完点设置页的「重新载入」即可读回。
49
+
50
+ ## 兼容性
51
+
52
+ `dsh.compatibility.dsh` 声明为 `>=0.1.5-rc.1`。
53
+
54
+ **准确说明**:本插件仅在 **dsh 0.1.5-rc.1(Windows / Node 22)** 上实测通过。
55
+ 该版本下限是"已实测"的记录,不是对更高版本兼容性的验证结论。
56
+
57
+ ## 发布(维护者)
58
+
59
+ ```sh
60
+ # 1. 替换占位符:package.json 的 repository / homepage / bugs / author,以及 LICENSE 版权行
61
+ # 2. 自检(prepublishOnly 也会自动跑,占位符未替换会中止发布)
62
+ npm test
63
+ node check-metadata.mjs
64
+ # 3. 发布(需先 npm login)
65
+ npm publish
66
+ ```
67
+
68
+ 发布前自检会拒绝在 `TODO` 占位符残留时发布,避免把空仓库地址或无名版权声明发出去。
69
+
70
+ ## 数据格式
71
+
72
+ ```json
73
+ {
74
+ "version": 1,
75
+ "enabled": true,
76
+ "budgetChars": 4000,
77
+ "entries": [
78
+ {
79
+ "id": "im-xxxx",
80
+ "title": "回答语言",
81
+ "content": "所有回答使用简体中文。",
82
+ "mode": "always",
83
+ "when": "",
84
+ "priority": 1,
85
+ "enabled": true,
86
+ "updatedAt": 1700000000000
87
+ }
88
+ ]
89
+ }
90
+ ```
91
+
92
+ `mode` 为 `always` | `auto`;`priority` 为 `2` 高 / `1` 普通 / `0` 低。
@@ -0,0 +1,7 @@
1
+ # dsh-instruction-memory bundle patch: mount the Host row under its package
2
+ # name, resolved through the profile's node_modules. The Client half is picked
3
+ # up automatically from the package's `dsh.client` declaration.
4
+
5
+ - insert:
6
+ - id: instruction-memory
7
+ name: dsh-instruction-memory
package/lib/client.js ADDED
@@ -0,0 +1,427 @@
1
+ /**
2
+ * dsh-instruction-memory — Client half.
3
+ *
4
+ * Hand-authored in the browser module-loader format the web shell expects
5
+ * (`window.__ModuleLoader__.load` + `require`), so the package needs no build
6
+ * step. Contributes one settings page; all data lives host-side and is reached
7
+ * through the same-origin route the Host half registers.
8
+ */
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-instruction-memory',
11
+ factory: (require) => {
12
+ var module = { exports: {} }
13
+ var exports = module.exports
14
+
15
+ const React = require('react')
16
+ const h = React.createElement
17
+
18
+ const API = '/instruction-memory/api'
19
+
20
+ const CSS = `
21
+ .im-root { display:flex; flex-direction:column; gap:16px; padding:6px 2px 56px; max-width:860px;
22
+ color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6; }
23
+ .im-h { margin:0; font-size:14px; font-weight:600; }
24
+ .im-sub { margin:3px 0 0; font-size:12px; color:var(--dsw-alias-label-secondary,#8a9099); }
25
+ .im-card { display:flex; flex-direction:column; gap:11px; padding:15px; border-radius:12px;
26
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-layer-1,#fff); }
27
+ .im-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
28
+ .im-row-wrap { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
29
+ .im-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
30
+ .im-spacer { flex:1; }
31
+ .im-btn { border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-layer-1,#fff);
32
+ color:inherit; border-radius:8px; padding:5px 12px; font-size:12px; font-family:inherit; cursor:pointer; }
33
+ .im-btn:hover:not(:disabled) { background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.05)); }
34
+ .im-btn:disabled { opacity:.5; cursor:not-allowed; }
35
+ .im-btn-primary { background:var(--dsw-alias-brand-primary,#3b6cff); border-color:transparent; color:#fff; }
36
+ .im-btn-danger { color:var(--dsw-alias-state-error-primary,#d94838); }
37
+ .im-input, .im-textarea, .im-select { width:100%; box-sizing:border-box; border-radius:8px; padding:7px 10px;
38
+ border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.18)); background:var(--dsw-alias-bg-base,#fff);
39
+ color:inherit; font-size:13px; font-family:inherit; line-height:1.55; }
40
+ .im-textarea { resize:vertical; min-height:92px; }
41
+ .im-input:focus, .im-textarea:focus, .im-select:focus { outline:none; border-color:var(--dsw-alias-brand-primary,#3b6cff); }
42
+ .im-label { display:block; margin-bottom:5px; font-size:12px; color:var(--dsw-alias-label-secondary,#8a9099); }
43
+ .im-list { display:flex; flex-direction:column; gap:8px; }
44
+ .im-item { display:flex; flex-direction:column; gap:6px; padding:12px 14px; border-radius:10px;
45
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.1)); background:var(--dsw-alias-bg-layer-1,#fff); }
46
+ .im-item-off { opacity:.5; }
47
+ .im-item-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
48
+ .im-title { font-size:13px; font-weight:600; }
49
+ .im-body { font-size:12px; color:var(--dsw-alias-label-secondary,#6b7280);
50
+ white-space:pre-wrap; word-break:break-word; max-height:96px; overflow:hidden; }
51
+ .im-tag { padding:1px 8px; border-radius:999px; font-size:11px; white-space:nowrap;
52
+ border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.14)); color:var(--dsw-alias-label-secondary,#6b7280); }
53
+ .im-tag-always { color:var(--dsw-alias-brand-primary,#3b6cff); border-color:currentColor; }
54
+ .im-tag-auto { color:var(--dsw-alias-state-success-primary,#18a058); border-color:currentColor; }
55
+ .im-tag-high { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
56
+ .im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
57
+ border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
58
+ .im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
59
+ .im-ok { font-size:12px; color:var(--dsw-alias-state-success-primary,#18a058); }
60
+ .im-meta { font-size:11.5px; color:var(--dsw-alias-label-secondary,#8a9099); word-break:break-all; }
61
+ .im-pre { margin:0; padding:12px 14px; border-radius:8px; font-size:11.5px; line-height:1.65;
62
+ background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)); color:var(--dsw-alias-label-secondary,#4b5563);
63
+ white-space:pre-wrap; word-break:break-word; max-height:340px; overflow:auto;
64
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
65
+ .im-switch { display:inline-flex; align-items:center; gap:6px; font-size:12px; cursor:pointer; user-select:none; }
66
+ .im-switch input { width:15px; height:15px; margin:0; cursor:pointer; accent-color:var(--dsw-alias-brand-primary,#3b6cff); }
67
+ .im-divider { height:1px; background:var(--dsw-alias-border-l1,rgba(0,0,0,.08)); }
68
+ .im-status { display:flex; align-items:center; gap:7px; font-size:12px; }
69
+ .im-dot { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dsw-alias-label-secondary,#8a9099); }
70
+ .im-dot-on { background:var(--dsw-alias-state-success-primary,#18a058); }
71
+ .im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
72
+ .im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
73
+ @media (max-width:640px) { .im-grid2 { grid-template-columns:1fr; } }
74
+ `
75
+
76
+ function cloneEntry(entry) {
77
+ return {
78
+ id: entry.id,
79
+ title: entry.title,
80
+ content: entry.content,
81
+ mode: entry.mode,
82
+ when: entry.when,
83
+ priority: entry.priority,
84
+ enabled: entry.enabled,
85
+ updatedAt: entry.updatedAt,
86
+ }
87
+ }
88
+
89
+ function blankDraft() {
90
+ return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, updatedAt: 0 }
91
+ }
92
+
93
+ /**
94
+ * Accept both response shapes the Host can produce: the
95
+ * `{ ok, message, snapshot }` envelope used by the mutating methods, and a
96
+ * bare snapshot. Returns null only when no usable snapshot is present.
97
+ */
98
+ function normalizeResult(result) {
99
+ if (result === null || typeof result !== 'object') return null
100
+ const snapshot = result.snapshot !== undefined && result.snapshot !== null ? result.snapshot : result
101
+ if (snapshot === null || typeof snapshot !== 'object' || snapshot.data === undefined) return null
102
+ return {
103
+ snapshot,
104
+ ok: result.ok !== false,
105
+ message: typeof result.message === 'string' ? result.message : '',
106
+ }
107
+ }
108
+
109
+ async function callHost(method, args) {
110
+ const response = await fetch(API, {
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify({ method, args: args === undefined ? null : args }),
114
+ })
115
+ if (!response.ok) throw new Error('宿主返回 HTTP ' + response.status)
116
+ return await response.json()
117
+ }
118
+
119
+ function EntryEditor(props) {
120
+ const draft = props.draft
121
+ const set = (patch) => props.onChange(Object.assign(cloneEntry(draft), patch))
122
+ const children = [
123
+ h('h3', { className: 'im-h', key: 'title' }, draft.id === '' ? '新增指令' : '编辑指令'),
124
+ h('div', { key: 't' },
125
+ h('label', { className: 'im-label' }, '标题'),
126
+ h('input', {
127
+ className: 'im-input',
128
+ value: draft.title,
129
+ placeholder: '例如:回答语言',
130
+ onChange: (ev) => set({ title: ev.target.value }),
131
+ })),
132
+ h('div', { key: 'c' },
133
+ h('label', { className: 'im-label' }, '指令内容'),
134
+ h('textarea', {
135
+ className: 'im-textarea',
136
+ value: draft.content,
137
+ placeholder: '例如:所有回答使用简体中文;不确定需求时先提问再动手。',
138
+ onChange: (ev) => set({ content: ev.target.value }),
139
+ })),
140
+ h('div', { className: 'im-grid2', key: 'g' },
141
+ h('div', null,
142
+ h('label', { className: 'im-label' }, '生效方式'),
143
+ h('select', {
144
+ className: 'im-select',
145
+ value: draft.mode,
146
+ onChange: (ev) => set({ mode: ev.target.value }),
147
+ },
148
+ h('option', { value: 'always' }, '始终生效(无条件遵守)'),
149
+ h('option', { value: 'auto' }, '按需生效(按相关性自动判断)'))),
150
+ h('div', null,
151
+ h('label', { className: 'im-label' }, '优先级'),
152
+ h('select', {
153
+ className: 'im-select',
154
+ value: String(draft.priority),
155
+ onChange: (ev) => set({ priority: Number(ev.target.value) }),
156
+ },
157
+ h('option', { value: '2' }, '高'),
158
+ h('option', { value: '1' }, '普通'),
159
+ h('option', { value: '0' }, '低')))),
160
+ ]
161
+ if (draft.mode === 'auto') {
162
+ children.push(h('div', { key: 'w' },
163
+ h('label', { className: 'im-label' }, '适用场景(可选,帮助模型判断何时生效)'),
164
+ h('input', {
165
+ className: 'im-input',
166
+ value: draft.when,
167
+ placeholder: '例如:编写或修改代码时',
168
+ onChange: (ev) => set({ when: ev.target.value }),
169
+ })))
170
+ }
171
+ children.push(h('div', { className: 'im-row-wrap', key: 'a' },
172
+ h('button', { className: 'im-btn im-btn-primary', disabled: props.busy, onClick: props.onSave },
173
+ props.busy ? '保存中…' : '保存'),
174
+ h('button', { className: 'im-btn', disabled: props.busy, onClick: props.onCancel }, '取消'),
175
+ h('span', { className: 'im-spacer' }),
176
+ h('span', { className: 'im-meta' }, '保存后对下一轮对话立即生效')))
177
+ return h('div', { className: 'im-card' }, children)
178
+ }
179
+
180
+ function InstructionPanel() {
181
+ const [view, setView] = React.useState(null)
182
+ const [error, setError] = React.useState('')
183
+ const [notice, setNotice] = React.useState('')
184
+ const [busy, setBusy] = React.useState(false)
185
+ const [draft, setDraft] = React.useState(null)
186
+ const [budgetText, setBudgetText] = React.useState('')
187
+ const [showPreview, setShowPreview] = React.useState(false)
188
+
189
+ function absorb(result) {
190
+ const normalized = normalizeResult(result)
191
+ if (normalized === null) {
192
+ // Never fail silently: the previous version returned here, leaving the
193
+ // panel on its loading state forever with no error to act on.
194
+ setError('宿主返回了无法识别的数据')
195
+ return
196
+ }
197
+ setView(normalized.snapshot)
198
+ setBudgetText(String(normalized.snapshot.data.budgetChars))
199
+ if (!normalized.ok && normalized.message) {
200
+ setError(normalized.message)
201
+ setNotice('')
202
+ } else {
203
+ setError('')
204
+ setNotice(normalized.message)
205
+ }
206
+ }
207
+
208
+ async function send(method, args) {
209
+ setBusy(true)
210
+ try {
211
+ const result = await callHost(method, args)
212
+ absorb(result)
213
+ return result
214
+ } catch (err) {
215
+ setError(String(err && err.message ? err.message : err))
216
+ return null
217
+ } finally {
218
+ setBusy(false)
219
+ }
220
+ }
221
+
222
+ React.useEffect(() => {
223
+ let alive = true
224
+ callHost('state', null).then(
225
+ (result) => { if (alive) absorb(result) },
226
+ (err) => { if (alive) setError(String(err && err.message ? err.message : err)) },
227
+ )
228
+ return () => { alive = false }
229
+ }, [])
230
+
231
+ if (view === null) {
232
+ return h('div', { className: 'im-root' },
233
+ h('div', { className: 'im-empty' }, error === '' ? '正在读取指令记忆…' : error))
234
+ }
235
+
236
+ if (draft !== null) {
237
+ return h('div', { className: 'im-root' },
238
+ h(EntryEditor, {
239
+ draft,
240
+ busy,
241
+ onChange: setDraft,
242
+ onCancel: () => { setDraft(null); setError('') },
243
+ onSave: () => {
244
+ if (draft.title.trim() === '' && draft.content.trim() === '') {
245
+ setError('请至少填写标题或指令内容')
246
+ return
247
+ }
248
+ send('save-entry', { entry: draft }).then((result) => {
249
+ if (result && result.ok === true) { setDraft(null); setError('') }
250
+ })
251
+ },
252
+ }))
253
+ }
254
+
255
+ const data = view.data
256
+ const storage = view.storage
257
+ const injection = view.injection || { registered: false, chars: 0, error: null }
258
+ const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
259
+ const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
260
+
261
+ const dotClass = injection.error
262
+ ? 'im-dot im-dot-err'
263
+ : (injection.registered ? 'im-dot im-dot-on' : 'im-dot im-dot-off')
264
+ const dotText = injection.error
265
+ ? injection.error
266
+ : (injection.registered
267
+ ? '已挂载到系统提示词——后续每轮对话自动生效(' + injection.chars + ' 字符)'
268
+ : (activeCount === 0 ? '未挂载:当前没有启用中的指令' : '未挂载'))
269
+
270
+ const header = h('div', { className: 'im-card', key: 'head' },
271
+ h('div', { className: 'im-row' },
272
+ h('div', null,
273
+ h('h3', { className: 'im-h' }, '指令记忆'),
274
+ h('p', { className: 'im-sub' }, '保存后自动写入系统提示词,对此后所有对话、问答与任务执行生效。')),
275
+ h('label', { className: 'im-switch' },
276
+ h('input', {
277
+ type: 'checkbox',
278
+ checked: data.enabled === true,
279
+ disabled: busy,
280
+ onChange: () => send('set-options', { enabled: !data.enabled }),
281
+ }),
282
+ '总开关')),
283
+ h('div', { className: 'im-status' },
284
+ h('span', { className: dotClass }),
285
+ h('span', { className: injection.error ? 'im-err' : 'im-meta' }, dotText)),
286
+ h('div', { className: 'im-grid2' },
287
+ h('div', null,
288
+ h('label', { className: 'im-label' }, '注入字数上限'),
289
+ h('input', {
290
+ className: 'im-input',
291
+ type: 'number',
292
+ min: view.limits.minBudget,
293
+ max: view.limits.maxBudget,
294
+ step: 100,
295
+ value: budgetText,
296
+ disabled: busy,
297
+ onChange: (ev) => setBudgetText(ev.target.value),
298
+ onBlur: () => {
299
+ const parsed = Number(budgetText)
300
+ if (!Number.isFinite(parsed) || parsed < view.limits.minBudget || parsed > view.limits.maxBudget) {
301
+ setBudgetText(String(data.budgetChars))
302
+ setError('上限需在 ' + view.limits.minBudget + ' ~ ' + view.limits.maxBudget + ' 之间')
303
+ return
304
+ }
305
+ send('set-options', { budgetChars: parsed })
306
+ },
307
+ })),
308
+ h('div', null,
309
+ h('label', { className: 'im-label' }, '生效规则'),
310
+ h('div', { className: 'im-meta' },
311
+ '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。'))),
312
+ h('div', { className: 'im-row-wrap' },
313
+ h('button', {
314
+ className: 'im-btn im-btn-primary',
315
+ disabled: busy,
316
+ onClick: () => { setDraft(blankDraft()); setError('') },
317
+ }, '+ 新增指令'),
318
+ h('button', { className: 'im-btn', disabled: busy, onClick: () => send('reload', null) }, '重新载入'),
319
+ h('button', {
320
+ className: 'im-btn',
321
+ disabled: busy,
322
+ onClick: () => setShowPreview(!showPreview),
323
+ }, showPreview ? '收起注入内容' : '查看实际注入内容'),
324
+ h('span', { className: 'im-spacer' }),
325
+ h('span', { className: 'im-meta' }, '共 ' + entries.length + ' 条,生效 ' + activeCount + ' 条')),
326
+ h('div', { className: 'im-divider' }),
327
+ h('div', { className: 'im-meta' }, '存储文件:' + (storage.path ? storage.path : '(未写入磁盘)')),
328
+ h('div', { className: 'im-meta' },
329
+ storage.savedAt ? '最近保存:' + new Date(storage.savedAt).toLocaleString() : '尚未保存到磁盘'),
330
+ storage.migratedFrom
331
+ ? h('div', { className: 'im-ok' }, '已从旧位置迁移:' + storage.migratedFrom + '(原文件保留为 *.migrated)')
332
+ : null,
333
+ error !== '' ? h('div', { className: 'im-err' }, error) : null,
334
+ error === '' && notice !== '' ? h('div', { className: 'im-ok' }, notice) : null,
335
+ error === '' && notice === '' && storage.error ? h('div', { className: 'im-err' }, storage.error) : null,
336
+ showPreview ? h('div', null,
337
+ h('label', { className: 'im-label' }, '当前注入到每轮系统提示词的内容'),
338
+ h('pre', { className: 'im-pre' }, view.preview === '' ? '(当前没有生效中的指令)' : view.preview)) : null)
339
+
340
+ const items = entries.map((entry) => {
341
+ const tags = [
342
+ h('span', {
343
+ className: 'im-tag ' + (entry.mode === 'always' ? 'im-tag-always' : 'im-tag-auto'),
344
+ key: 'mode',
345
+ }, entry.mode === 'always' ? '始终' : '按需'),
346
+ ]
347
+ if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, '高'))
348
+ else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, '低'))
349
+ return h('div', {
350
+ className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
351
+ key: entry.id,
352
+ },
353
+ h('div', { className: 'im-item-head' },
354
+ h('span', { className: 'im-title' }, entry.title),
355
+ tags,
356
+ h('span', { className: 'im-spacer' }),
357
+ h('label', { className: 'im-switch' },
358
+ h('input', {
359
+ type: 'checkbox',
360
+ checked: entry.enabled === true,
361
+ disabled: busy,
362
+ onChange: () => {
363
+ const next = cloneEntry(entry)
364
+ next.enabled = !entry.enabled
365
+ send('save-entry', { entry: next })
366
+ },
367
+ }),
368
+ '启用'),
369
+ h('button', {
370
+ className: 'im-btn',
371
+ disabled: busy,
372
+ onClick: () => { setDraft(cloneEntry(entry)); setError('') },
373
+ }, '编辑'),
374
+ h('button', {
375
+ className: 'im-btn im-btn-danger',
376
+ disabled: busy,
377
+ onClick: () => send('delete-entry', { id: entry.id }),
378
+ }, '删除')),
379
+ h('div', { className: 'im-body' }, entry.content),
380
+ entry.mode === 'auto' && entry.when !== ''
381
+ ? h('div', { className: 'im-meta' }, '适用场景:' + entry.when)
382
+ : null)
383
+ })
384
+
385
+ const list = entries.length === 0
386
+ ? h('div', { className: 'im-empty', key: 'empty' },
387
+ '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。')
388
+ : h('div', { className: 'im-list', key: 'list' }, items)
389
+
390
+ return h('div', { className: 'im-root' }, header, list)
391
+ }
392
+
393
+ function apply(ctx) {
394
+ // `slots` is a declared injection (see exports.inject below), so the
395
+ // runtime holds this plugin until the slot service exists. The fallback
396
+ // covers a runtime that resolves injections lazily instead.
397
+ const slots = ctx.slots !== undefined ? ctx.slots : ctx.get('slots')
398
+ if (slots === undefined) {
399
+ console.error('[instruction-memory] slots service unavailable; settings page not registered')
400
+ return
401
+ }
402
+
403
+ // Own stylesheet: one <style> element created and removed with this fiber.
404
+ const styleEl = document.createElement('style')
405
+ styleEl.setAttribute('data-dsh-plugin', 'instruction-memory')
406
+ styleEl.textContent = CSS
407
+ document.head.appendChild(styleEl)
408
+ ctx.effect(() => () => {
409
+ if (styleEl.parentNode) styleEl.parentNode.removeChild(styleEl)
410
+ })
411
+
412
+ ctx.effect(() => slots.inject('settings.section', () => slots.register(
413
+ { name: 'settings.section', id: 'instruction-memory', order: 26, label: '指令记忆' },
414
+ () => h(InstructionPanel, null),
415
+ )))
416
+ }
417
+
418
+ exports.name = 'instruction-memory'
419
+ // Without this the runtime calls apply() immediately, before the slot
420
+ // service is published, and the settings page silently never registers.
421
+ exports.inject = ['slots']
422
+ exports.apply = apply
423
+ // Exposed for the contract test in verify.mjs.
424
+ exports.__normalizeResult = normalizeResult
425
+ return module.exports
426
+ },
427
+ })
package/lib/index.js ADDED
@@ -0,0 +1,541 @@
1
+ /**
2
+ * dsh-instruction-memory — Host half.
3
+ *
4
+ * Registers one ordered `systemPrompt` section holding the user's long-lived
5
+ * instructions, and persists them under the harness home so they survive a
6
+ * process restart. Also exposes a small same-origin JSON route that the Client
7
+ * half uses to read and edit the same store.
8
+ *
9
+ * Lifecycle: every side effect (prompt section, web route) belongs to this
10
+ * plugin's fiber, so unmounting the row removes all of them.
11
+ */
12
+
13
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
14
+ import { homedir } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+
17
+ export const name = 'instruction-memory'
18
+
19
+ /**
20
+ * Hard dependencies, declared rather than probed.
21
+ *
22
+ * This row mounts early — before the web server has published — so a one-shot
23
+ * `ctx.get('webServer')` at apply time resolves `undefined` and the route is
24
+ * lost for the life of the process. Declaring it here makes Cordis hold the row
25
+ * until the service exists.
26
+ *
27
+ * The harness `fs` service is deliberately NOT used. Its default base is the
28
+ * host process cwd, which is exactly what `@deepseek-ai/dsh-home-paths` warns
29
+ * against: a user relaunching DSH from another directory would silently get a
30
+ * second, empty store. Plugin-owned data belongs under the harness home.
31
+ */
32
+ export const inject = ['systemPrompt', 'webServer']
33
+
34
+ const STORE_DIR_NAME = 'instruction-memory'
35
+ const STORE_FILE_NAME = 'memory.json'
36
+ /** Pre-release location, kept only so existing installs can migrate off it. */
37
+ const LEGACY_FILE_NAME = '.dsh-instruction-memory.json'
38
+ const SECTION_NAME = 'instruction-memory'
39
+ const SECTION_ORDER = 900
40
+ const API_PREFIX = '/instruction-memory/api'
41
+
42
+ /**
43
+ * Resolve this plugin's data file under the harness home.
44
+ *
45
+ * Precedence mirrors `@deepseek-ai/dsh-home-paths`: an explicit `$DSH_HOME`,
46
+ * then `~/.dsh`. A blank override counts as unset and must never resolve to the
47
+ * current working directory. Exported so tests can pin it.
48
+ */
49
+ export function resolveStorePath(env = process.env, home = homedir()) {
50
+ const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
51
+ const root = configured !== '' ? configured : join(home, '.dsh')
52
+ return join(root, STORE_DIR_NAME, STORE_FILE_NAME)
53
+ }
54
+
55
+ const DEFAULT_BUDGET = 4000
56
+ const MIN_BUDGET = 800
57
+ const MAX_BUDGET = 40000
58
+ const MAX_ENTRIES = 200
59
+ const MAX_TITLE = 120
60
+ const MAX_CONTENT = 6000
61
+ const MAX_WHEN = 200
62
+
63
+ /* ------------------------------------------------------------------ *
64
+ * Pure helpers (no ctx, fully unit-testable)
65
+ * ------------------------------------------------------------------ */
66
+
67
+ function clampBudget(value) {
68
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : DEFAULT_BUDGET
69
+ if (n < MIN_BUDGET) return MIN_BUDGET
70
+ if (n > MAX_BUDGET) return MAX_BUDGET
71
+ return n
72
+ }
73
+
74
+ function cleanText(value, max) {
75
+ if (typeof value !== 'string') return ''
76
+ const trimmed = value.split('\r\n').join('\n').split('\r').join('\n').trim()
77
+ return trimmed.length > max ? trimmed.slice(0, max) : trimmed
78
+ }
79
+
80
+ /**
81
+ * Collapse a value onto a single line.
82
+ *
83
+ * A title carrying newlines breaks the injected block: the header renders as
84
+ * several lines and the title ends up duplicating the body. This bit the
85
+ * auto-title path, which took the first 40 characters of multi-line content —
86
+ * newlines and all. The full text always survives in `content`.
87
+ */
88
+ function singleLine(value, max) {
89
+ return cleanText(value, max)
90
+ .split('\n')
91
+ .map((part) => part.trim())
92
+ .filter((part) => part !== '')
93
+ .join(' ')
94
+ }
95
+
96
+ function makeId() {
97
+ return 'im-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8)
98
+ }
99
+
100
+ function priorityLabel(value) {
101
+ if (value === 2) return '高'
102
+ if (value === 0) return '低'
103
+ return '普通'
104
+ }
105
+
106
+ export function sanitizeEntry(raw) {
107
+ if (raw === null || typeof raw !== 'object') return null
108
+ const title = singleLine(raw.title, MAX_TITLE)
109
+ const content = cleanText(raw.content, MAX_CONTENT)
110
+ if (title === '' && content === '') return null
111
+ return {
112
+ id: typeof raw.id === 'string' && raw.id !== '' ? raw.id.slice(0, 64) : makeId(),
113
+ title: title === '' ? singleLine(content, 40) : title,
114
+ content,
115
+ mode: raw.mode === 'auto' ? 'auto' : 'always',
116
+ when: singleLine(raw.when, MAX_WHEN),
117
+ priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
118
+ enabled: raw.enabled !== false,
119
+ updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
120
+ }
121
+ }
122
+
123
+ function normalizeData(raw) {
124
+ const out = { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
125
+ if (raw === null || typeof raw !== 'object') return out
126
+ if (typeof raw.enabled === 'boolean') out.enabled = raw.enabled
127
+ out.budgetChars = clampBudget(raw.budgetChars)
128
+ const list = Array.isArray(raw.entries) ? raw.entries : []
129
+ const seen = new Set()
130
+ for (const candidate of list) {
131
+ if (out.entries.length >= MAX_ENTRIES) break
132
+ const entry = sanitizeEntry(candidate)
133
+ if (entry === null) continue
134
+ if (seen.has(entry.id)) entry.id = makeId()
135
+ seen.add(entry.id)
136
+ out.entries.push(entry)
137
+ }
138
+ return out
139
+ }
140
+
141
+ function formatEntry(entry, tag) {
142
+ const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
143
+ if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
144
+ lines.push(entry.content)
145
+ return lines.join('\n')
146
+ }
147
+
148
+ /**
149
+ * Render the injected block. Deliberately deterministic: identical memory
150
+ * produces byte-identical text on every step, so the prompt prefix stays
151
+ * cacheable across a session.
152
+ */
153
+ export function buildBlock(data) {
154
+ if (data === null || typeof data !== 'object' || data.enabled !== true) return ''
155
+ const active = (Array.isArray(data.entries) ? data.entries : [])
156
+ .filter((entry) => entry.enabled === true && entry.content !== '')
157
+ if (active.length === 0) return ''
158
+ active.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
159
+
160
+ const budget = clampBudget(data.budgetChars)
161
+
162
+ // Richest preamble that still leaves room for one real entry plus the
163
+ // worst-case omission footer. A tight budget degrades the preamble instead of
164
+ // silently injecting nothing.
165
+ const HEADERS = [
166
+ [
167
+ '## 用户长期指令记忆(Instruction Memory)',
168
+ '以下条目由用户在 DSH 设置 →「指令记忆」中长期保存,属于本次及后续所有对话的既有要求,请在回答、推理与执行任务时遵守;它们不覆盖系统与安全规则。',
169
+ '· [始终] 条目必须无条件遵守。',
170
+ '· [按需] 条目仅在与当前问题或任务相关时生效;不相关时直接忽略,不要强行套用。',
171
+ '· 条目之间冲突时,优先遵守优先级更高或更具体的一条;无法判断时向用户确认。',
172
+ '',
173
+ ].join('\n'),
174
+ [
175
+ '## 用户长期指令记忆(Instruction Memory)',
176
+ '[始终] 条目无条件遵守;[按需] 条目仅在与当前任务相关时生效。',
177
+ '',
178
+ ].join('\n'),
179
+ '## 用户长期指令记忆\n',
180
+ ]
181
+ const footerFor = (count) =>
182
+ count === 0
183
+ ? ''
184
+ : '\n\n(另有 ' + count + ' 条指令因注入字数上限未包含,可在「设置 → 指令记忆」中提高上限或调低其优先级。)'
185
+
186
+ const MIN_ROOM = 80
187
+ let header = HEADERS[HEADERS.length - 1]
188
+ for (const candidate of HEADERS) {
189
+ if (candidate.length + MIN_ROOM + footerFor(1).length <= budget) {
190
+ header = candidate
191
+ break
192
+ }
193
+ }
194
+
195
+ const blocks = []
196
+ let used = header.length
197
+ let omitted = 0
198
+
199
+ const consider = (entry, tag) => {
200
+ const block = formatEntry(entry, tag)
201
+ // Reserve the footer up front so a disclosure can never be squeezed out by
202
+ // the very entries that caused it.
203
+ const reserve = footerFor(omitted + 1).length
204
+ if (used + block.length + 2 + reserve <= budget) {
205
+ used += block.length + 2
206
+ blocks.push(block)
207
+ return
208
+ }
209
+ // The first entry always gets in, truncated if the budget demands it: a
210
+ // memory that loads and then injects nothing is worse than a short one.
211
+ if (blocks.length === 0) {
212
+ const room = budget - used - 2 - reserve
213
+ const note = '\n…(本条因注入字数上限被截断)'
214
+ if (room > note.length + 20) {
215
+ const body = block.slice(0, room - note.length) + note
216
+ blocks.push(body)
217
+ used += body.length + 2
218
+ return
219
+ }
220
+ }
221
+ omitted += 1
222
+ }
223
+
224
+ for (const entry of active) if (entry.mode === 'always') consider(entry, '始终')
225
+ for (const entry of active) if (entry.mode !== 'always') consider(entry, '按需')
226
+
227
+ if (blocks.length === 0) return ''
228
+ return header + blocks.join('\n\n') + footerFor(omitted)
229
+ }
230
+
231
+ /* ------------------------------------------------------------------ *
232
+ * Plugin
233
+ * ------------------------------------------------------------------ */
234
+
235
+ export function apply(ctx) {
236
+ const systemPrompt = ctx.systemPrompt
237
+ const webServer = ctx.get('webServer')
238
+
239
+ const state = {
240
+ data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] },
241
+ storagePath: null,
242
+ storageDisplay: null,
243
+ storageError: null,
244
+ savedAt: null,
245
+ migratedFrom: null,
246
+ sectionRegistered: false,
247
+ sectionError: null,
248
+ }
249
+ let sectionDispose = null
250
+
251
+ const describeError = (err) => {
252
+ if (err === null || err === undefined) return '未知错误'
253
+ if (typeof err === 'string') return err
254
+ if (typeof err.message === 'string' && err.message !== '') return err.message
255
+ return String(err)
256
+ }
257
+
258
+ const sectionText = () => {
259
+ try {
260
+ return buildBlock(state.data)
261
+ } catch (err) {
262
+ console.error('[instruction-memory] render failed:', err)
263
+ return ''
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Keep the registered section exactly in step with the stored data: present
269
+ * when something must be injected, absent otherwise. An empty memory must
270
+ * leave the prompt byte-identical to a deployment without this plugin.
271
+ */
272
+ const syncSection = () => {
273
+ const wanted = sectionText() !== ''
274
+ if (wanted && sectionDispose === null) {
275
+ try {
276
+ sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text: sectionText })
277
+ state.sectionRegistered = true
278
+ state.sectionError = null
279
+ } catch (err) {
280
+ sectionDispose = null
281
+ state.sectionRegistered = false
282
+ state.sectionError = '注入注册失败:' + describeError(err)
283
+ console.error('[instruction-memory] section registration failed:', err)
284
+ }
285
+ return
286
+ }
287
+ if (!wanted) {
288
+ if (sectionDispose !== null) {
289
+ const dispose = sectionDispose
290
+ sectionDispose = null
291
+ try {
292
+ dispose()
293
+ } catch (err) {
294
+ console.error('[instruction-memory] section dispose failed:', err)
295
+ }
296
+ }
297
+ state.sectionRegistered = false
298
+ state.sectionError = null
299
+ }
300
+ }
301
+
302
+ ctx.effect(() => () => {
303
+ if (sectionDispose !== null) {
304
+ const dispose = sectionDispose
305
+ sectionDispose = null
306
+ try {
307
+ dispose()
308
+ } catch (err) {
309
+ console.error('[instruction-memory] cleanup failed:', err)
310
+ }
311
+ }
312
+ })
313
+
314
+ /* ---------------- storage ---------------- */
315
+
316
+ const storePath = resolveStorePath()
317
+
318
+ const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
319
+
320
+ const writeToDisk = async () => {
321
+ try {
322
+ await mkdir(dirname(storePath), { recursive: true })
323
+ await writeFile(storePath, serialize(), 'utf8')
324
+ state.storagePath = storePath
325
+ state.storageDisplay = storePath
326
+ state.storageError = null
327
+ state.savedAt = Date.now()
328
+ return true
329
+ } catch (err) {
330
+ state.storageError = '保存失败:' + describeError(err)
331
+ return false
332
+ }
333
+ }
334
+
335
+ const readStoreFile = async (path) => {
336
+ const text = await readFile(path, 'utf8')
337
+ return text.trim() === '' ? null : JSON.parse(text)
338
+ }
339
+
340
+ const readFromDisk = async () => {
341
+ state.storagePath = storePath
342
+ state.storageDisplay = storePath
343
+
344
+ // 1. The canonical location under the harness home.
345
+ try {
346
+ const parsed = await readStoreFile(storePath)
347
+ if (parsed !== null) state.data = normalizeData(parsed)
348
+ state.storageError = null
349
+ return
350
+ } catch (err) {
351
+ if (err === null || err === undefined || err.code !== 'ENOENT') {
352
+ // A file we cannot understand must never be silently overwritten.
353
+ state.storageError = err instanceof SyntaxError
354
+ ? '配置文件解析失败(' + describeError(err) + '),原文件已保留;你下次保存时会覆盖它'
355
+ : '读取失败:' + describeError(err)
356
+ return
357
+ }
358
+ }
359
+
360
+ // 2. Migrate the pre-release location (host process cwd). This is the only
361
+ // reason the legacy name is still known to this plugin.
362
+ const legacyPath = join(process.cwd(), LEGACY_FILE_NAME)
363
+ try {
364
+ const parsed = await readStoreFile(legacyPath)
365
+ if (parsed !== null) {
366
+ state.data = normalizeData(parsed)
367
+ if (await writeToDisk()) {
368
+ state.migratedFrom = legacyPath
369
+ // Keep the original as evidence instead of deleting user data.
370
+ await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
371
+ }
372
+ return
373
+ }
374
+ } catch (err) {
375
+ // A missing or unreadable legacy file must never block a fresh store.
376
+ if (err instanceof SyntaxError) state.storageError = '旧位置文件解析失败,已忽略:' + describeError(err)
377
+ }
378
+
379
+ // 3. First run: materialise an empty store so the path is real and the
380
+ // settings page can show it.
381
+ await writeToDisk()
382
+ }
383
+
384
+ /* ---------------- boot ---------------- */
385
+
386
+ // Load the store, then register the section if anything must be injected.
387
+ // Every entry point awaits this, so a request that arrives before the disk
388
+ // read settles can never overwrite the file with an empty in-memory state.
389
+ const boot = readFromDisk()
390
+ .catch((err) => {
391
+ state.storageError = '读取失败:' + describeError(err)
392
+ })
393
+ .then(() => {
394
+ syncSection()
395
+ })
396
+
397
+ /* ---------------- snapshots ---------------- */
398
+
399
+ const snapshot = () => {
400
+ const preview = sectionText()
401
+ return {
402
+ data: {
403
+ version: 1,
404
+ enabled: state.data.enabled === true,
405
+ budgetChars: clampBudget(state.data.budgetChars),
406
+ entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
407
+ },
408
+ storage: {
409
+ path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
410
+ error: state.storageError,
411
+ savedAt: state.savedAt,
412
+ migratedFrom: state.migratedFrom,
413
+ },
414
+ injection: {
415
+ registered: state.sectionRegistered,
416
+ available: true,
417
+ error: state.sectionError,
418
+ chars: preview.length,
419
+ },
420
+ preview,
421
+ limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES },
422
+ }
423
+ }
424
+
425
+ const settle = (message, saved) => {
426
+ if (saved !== true && state.storageError !== null) {
427
+ return { ok: false, saved: false, message: state.storageError, snapshot: snapshot() }
428
+ }
429
+ return { ok: true, saved: saved === true, message, snapshot: snapshot() }
430
+ }
431
+
432
+ /* ---------------- commands (shared by route and callers) ---------------- */
433
+
434
+ const saveEntry = async (raw) => {
435
+ const entry = sanitizeEntry(raw)
436
+ if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
437
+ entry.updatedAt = Date.now()
438
+ const list = state.data.entries
439
+ const index = list.findIndex((candidate) => candidate.id === entry.id)
440
+ if (index >= 0) list[index] = entry
441
+ else {
442
+ if (list.length >= MAX_ENTRIES) {
443
+ return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
444
+ }
445
+ list.push(entry)
446
+ }
447
+ syncSection()
448
+ return settle(null, await writeToDisk())
449
+ }
450
+
451
+ const deleteEntry = async (id) => {
452
+ state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
453
+ syncSection()
454
+ return settle(null, await writeToDisk())
455
+ }
456
+
457
+ const setOptions = async (patch) => {
458
+ if (patch !== null && typeof patch === 'object') {
459
+ if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
460
+ if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
461
+ state.data.budgetChars = clampBudget(patch.budgetChars)
462
+ }
463
+ }
464
+ syncSection()
465
+ return settle(null, await writeToDisk())
466
+ }
467
+
468
+ const dispatch = async (method, args) => {
469
+ await boot
470
+ switch (method) {
471
+ case 'state':
472
+ // Same envelope as every other method. One response shape means the
473
+ // Client can never again be handed a payload it silently ignores.
474
+ return { ok: true, saved: true, message: null, snapshot: snapshot() }
475
+ case 'save-entry':
476
+ return saveEntry(args !== null && typeof args === 'object' ? args.entry : null)
477
+ case 'delete-entry':
478
+ return deleteEntry(args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '')
479
+ case 'set-options':
480
+ return setOptions(args)
481
+ case 'reload':
482
+ await readFromDisk()
483
+ syncSection()
484
+ return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
485
+ default:
486
+ return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
487
+ }
488
+ }
489
+
490
+ /* ---------------- same-origin JSON route for the Client half ---------------- */
491
+
492
+ let routeDispose = null
493
+ if (webServer !== undefined && typeof webServer.register === 'function') {
494
+ const handler = (req, res) => {
495
+ if (req.method !== 'POST') {
496
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
497
+ res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
498
+ return
499
+ }
500
+ let body = ''
501
+ req.setEncoding('utf8')
502
+ req.on('data', (chunk) => {
503
+ body += chunk
504
+ if (body.length > 1_000_000) req.destroy()
505
+ })
506
+ req.on('end', () => {
507
+ void (async () => {
508
+ let result
509
+ try {
510
+ const payload = body === '' ? {} : JSON.parse(body)
511
+ result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
512
+ } catch (err) {
513
+ result = { ok: false, saved: false, message: describeError(err), snapshot: snapshot() }
514
+ }
515
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
516
+ res.end(JSON.stringify(result))
517
+ })()
518
+ })
519
+ }
520
+ try {
521
+ routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
522
+ } catch (err) {
523
+ console.error('[instruction-memory] route registration failed:', err)
524
+ routeDispose = null
525
+ }
526
+ } else {
527
+ console.error('[instruction-memory] webServer unavailable: settings page will not be able to reach the host')
528
+ }
529
+
530
+ ctx.effect(() => () => {
531
+ if (routeDispose !== null) {
532
+ const dispose = routeDispose
533
+ routeDispose = null
534
+ try {
535
+ dispose()
536
+ } catch (err) {
537
+ console.error('[instruction-memory] route dispose failed:', err)
538
+ }
539
+ }
540
+ })
541
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "dsh-instruction-memory",
3
+ "version": "1.0.0",
4
+ "description": "DSH 指令记忆:在设置界面中维护可增删改的长期指令,并自动注入此后每一轮对话的系统提示词。",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "default": "./lib/index.js"
11
+ },
12
+ "./client": {
13
+ "default": "./lib/client.js"
14
+ },
15
+ "./cordis.patch.yml": "./cordis.patch.yml",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "lib",
20
+ "cordis.patch.yml",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "dsh",
26
+ "dsh-plugin",
27
+ "deepseek-harness",
28
+ "cordis",
29
+ "instruction-memory",
30
+ "system-prompt",
31
+ "settings"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "author": "huangpeiyan",
40
+ "scripts": {
41
+ "test": "node smoke-test.mjs && node contract-test.mjs && node verify.mjs",
42
+ "prepublishOnly": "node check-metadata.mjs"
43
+ },
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ },
48
+ "client": {
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-runtime",
51
+ "@deepseek-ai/dsh-client-ui-slots"
52
+ ],
53
+ "platform": "web"
54
+ },
55
+ "compatibility": {
56
+ "dsh": ">=0.1.5-rc.1"
57
+ }
58
+ }
59
+ }