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 +21 -0
- package/README.md +92 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +427 -0
- package/lib/index.js +541 -0
- package/package.json +59 -0
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` 低。
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|