dsh-instruction-memory 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -9
- package/lib/index.js +91 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,6 +85,22 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
|
|
|
85
85
|
导出的文件一定能被自己导回**——早先写死的 1,000,000 小于满配导出(200 × 6000 = 1.2 M 字符),
|
|
86
86
|
导致满配记忆导出后无法导入。
|
|
87
87
|
|
|
88
|
+
### 首次运行不写文件(惰性创建)
|
|
89
|
+
|
|
90
|
+
全新安装、尚无任何记忆时,插件**不会**在宿主启动那一刻创建空的 `memory.json`。
|
|
91
|
+
|
|
92
|
+
> 这里曾主动物化一份空存储,只为让设置页有个真实路径可显示。但那意味着插件在**进程启动、
|
|
93
|
+
> 零会话、零任务**的状态下执行了一次磁盘写入——这正是行为审计中唯一能被正当称为
|
|
94
|
+
> "没活干还在动"的副作用。现在文件由**第一个真正需要它的动作**创建:一次保存、一次导入,
|
|
95
|
+
> 或设置页读取 `state`(面板打开时就会读)。
|
|
96
|
+
|
|
97
|
+
在这之前:`storage.path` 仍会显示路径,但 `storage.onDisk` 为 `false`,设置页显示
|
|
98
|
+
「尚未写入磁盘」(该文案本就存在),`state.injection.registered === false`,`pulls === 0`。
|
|
99
|
+
挂载日志相应为 `from=first run (no store on disk yet) onDisk=no`。
|
|
100
|
+
|
|
101
|
+
**不受影响**:从 `.bak` 恢复、以及从旧位置迁移——这两条分支只有在**磁盘上已有真实数据**时才会走,
|
|
102
|
+
它们仍然会立即写回。
|
|
103
|
+
|
|
88
104
|
## 无 Web 服务器时依然生效
|
|
89
105
|
|
|
90
106
|
`systemPrompt` 是本插件唯一的硬依赖。**Web 服务器不是前置条件**:设置页那部分路由通过
|
|
@@ -103,11 +119,27 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
|
|
|
103
119
|
`info`,过程细节写 `debug`(走宿主的 `ctx.logger`):
|
|
104
120
|
|
|
105
121
|
```
|
|
106
|
-
[instruction-memory] mounted: store=<DSH_HOME>\instruction-memory\memory.json from=memory.json entries=2 enabled=true
|
|
122
|
+
[instruction-memory] mounted: store=<DSH_HOME>\instruction-memory\memory.json from=memory.json onDisk=yes entries=2 enabled=true section=437 chars (pending assembly) pulls=0
|
|
107
123
|
```
|
|
108
124
|
|
|
109
|
-
挂载行含**数据来源**(`memory.json` / `memory.json.bak (main file missing)` / `
|
|
110
|
-
/ `first run (
|
|
125
|
+
挂载行含**数据来源**(`memory.json` / `memory.json.bak (main file missing)` / `memory.json.bak (main file corrupt)`
|
|
126
|
+
/ `legacy cwd file (migrated)` / `first run (no store on disk yet)`)、**是否已在磁盘上**(`onDisk`)、
|
|
127
|
+
条目数、总开关状态、段落就绪时的字符数,以及 `pulls`。
|
|
128
|
+
|
|
129
|
+
### 为什么是 `section=… (pending assembly)` 而不是 `injected=…`
|
|
130
|
+
|
|
131
|
+
**「段落已就绪」≠「已经注入」。** 注入发生在宿主装配提示词时;插件在挂载那一刻**什么都没注入过**,
|
|
132
|
+
打印 `injected=437 chars` 会让人误以为记忆已经进入了某个提示词。这是本插件此前一处会误导排查的措辞。
|
|
133
|
+
|
|
134
|
+
真正的"到达了提示词"的信号是 **`pulls`**:宿主每次对这段做装配,插件注册的那个 thunk 就被求值一次,
|
|
135
|
+
计数器加一。因此
|
|
136
|
+
|
|
137
|
+
- `pulls=0` = 已准备、**从未装配**(进程刚起来、或会话空闲);
|
|
138
|
+
- `pulls=N` = 已被拉进提示词 N 次。
|
|
139
|
+
|
|
140
|
+
插件看不到会话或轮次,**不会**报告 `sessions=` 之类的数字——那会是编造。它只报告自己能真正观测到的东西。
|
|
141
|
+
`pulls` 同时出现在路由的 `state` 快照里(`injection.pulls`)。每次拉取另有一条 `debug` 行:
|
|
142
|
+
`section pulled into a prompt (pull #N)`。
|
|
111
143
|
|
|
112
144
|
**这是纯日志,绝不影响注入内容**:同一份存储在"有 logger"和"没有 logger"下渲染出的文本
|
|
113
145
|
**逐字节相同**(`verify.mjs` 有断言锁定这一点)。`debug` 级别由宿主日志级别过滤;
|
|
@@ -141,13 +173,21 @@ DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,
|
|
|
141
173
|
>=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0
|
|
142
174
|
```
|
|
143
175
|
|
|
144
|
-
|
|
145
|
-
|
|
176
|
+
**验证范围与证据等级**——三者不可混为一谈,下面每一行都标了它到底证明了什么:
|
|
177
|
+
|
|
178
|
+
| 等级 | 证明什么 | 覆盖 |
|
|
179
|
+
| --- | --- | --- |
|
|
180
|
+
| ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` |
|
|
181
|
+
| ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 声明范围接住的**全部 12 个已发布版本**(`0.1.3-alpha.2` → `0.1.7-rc.2`) |
|
|
182
|
+
| ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
|
|
183
|
+
|
|
184
|
+
此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2` 与 `0.1.7-rc.2`,并会**读取本机实际安装的宿主版本**做实时断言。
|
|
185
|
+
|
|
186
|
+
**关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 6 个已发布版本**(`4.0.1-rc.1` → `4.0.4`)——**每一个都具备 `ctx.inject`**,因此声明范围内不存在因它而失效的宿主版本。
|
|
146
187
|
|
|
147
|
-
|
|
188
|
+
**关于声明范围是否"超范围承诺"。** 已对全部 **27 个**已发布宿主版本穷举核验:范围内 12 个版本的 API 面**全部齐备,超范围承诺 0 项**。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
|
|
148
189
|
|
|
149
|
-
|
|
150
|
-
的固定矩阵覆盖它们,且该脚本会**读取本机实际安装的宿主版本**做实时断言)。
|
|
190
|
+
**一个运维上值得知道的事实。** 早期 DSH 用 caret 范围声明同族依赖(如 `^0.1.5-alpha.2`),范围会随时间"往前漂":**今天**安装旧版 DSH,npm 在 `includePrerelease` 语义下会把同族子包解析到**当前最新的 `0.1.x`**,而不是当年那份(实测 7/10 个采样版本会漂移,3 个因精确锁定不会)。这不影响本插件的结论——漂移目标同样在已核验范围内——但"今天装出来的树"与"当年装出来的树"可能不同。
|
|
151
191
|
|
|
152
192
|
**为什么写成分段形式**:node-semver 规定"候选版本带预发布标签时,区间里必须存在同一个
|
|
153
193
|
major.minor.patch 且自身带预发布标签的比较符"。这条规则是**集合级**的——一个 `||` 分支最多
|
|
@@ -194,7 +234,8 @@ FAIL LIVE GUARD: the installed host 0.1.8-rc.1 is admitted by both modes -> ..
|
|
|
194
234
|
两次都因为插件市场传入 `includePrerelease: true` 而侥幸放行,但任何标准 semver 判定
|
|
195
235
|
(`npm` / `pnpm` 的依赖解析)都会拒绝。现在 `host-range-test.mjs` 同时具备固定矩阵与实时守卫。
|
|
196
236
|
|
|
197
|
-
**未验证范围**:macOS / Linux、`headless` 与 `tui` profile
|
|
237
|
+
**未验证范围**:macOS / Linux、`headless` 与 `tui` profile,以及**除上表等级①那 5 个之外的任何版本的真实启动**,均未实测。
|
|
238
|
+
等级②只证明"该版本里有这些 API",**不证明端到端可用**。
|
|
198
239
|
|
|
199
240
|
**同一串范围写在两处**:`dsh.compatibility.dsh`(插件自己的字段)与 `dsh.engines.dsh`(插件市场**实际读取**的位置)。
|
|
200
241
|
市场只读 `engines.dsh` / `dsh.engines.dsh` 以及 `@deepseek-ai/dsh*` 的 `peerDependencies`,并不读 `dsh.compatibility.dsh`。
|
package/lib/index.js
CHANGED
|
@@ -437,9 +437,31 @@ export function apply(ctx) {
|
|
|
437
437
|
migratedFrom: null,
|
|
438
438
|
sectionRegistered: false,
|
|
439
439
|
sectionError: null,
|
|
440
|
+
/** Whether a store file is actually present on disk (diagnostics + the panel). */
|
|
441
|
+
storeExists: false,
|
|
440
442
|
}
|
|
441
443
|
let sectionDispose = null
|
|
442
444
|
|
|
445
|
+
/**
|
|
446
|
+
* A first run deliberately writes NOTHING (see readFromDisk step 3). This flag
|
|
447
|
+
* remembers that the store has not been materialised yet, so the file is
|
|
448
|
+
* created by the first action that genuinely needs it — a save, or the
|
|
449
|
+
* settings page asking for `state` — instead of by the bare act of the host
|
|
450
|
+
* booting with no session and no task.
|
|
451
|
+
*/
|
|
452
|
+
let needsMaterialize = false
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* How many times the host has pulled this section into a prompt, since boot.
|
|
456
|
+
*
|
|
457
|
+
* The plugin cannot see sessions or turns; it only knows that the harness
|
|
458
|
+
* evaluated the thunk it registered. That is still the honest answer to "did
|
|
459
|
+
* this actually reach a prompt?", and it is the thing the previous log line
|
|
460
|
+
* could not distinguish: a registered section with pulls=0 has been prepared
|
|
461
|
+
* but never injected.
|
|
462
|
+
*/
|
|
463
|
+
let sectionPulls = 0
|
|
464
|
+
|
|
443
465
|
const describeError = (err) => {
|
|
444
466
|
if (err === null || err === undefined) return '未知错误'
|
|
445
467
|
if (typeof err === 'string') return err
|
|
@@ -506,6 +528,21 @@ export function apply(ctx) {
|
|
|
506
528
|
}
|
|
507
529
|
}
|
|
508
530
|
|
|
531
|
+
/**
|
|
532
|
+
* The thunk handed to the harness.
|
|
533
|
+
*
|
|
534
|
+
* It is deliberately NOT `sectionText` itself: wrapping it lets the plugin
|
|
535
|
+
* count the pulls the host performs (`assembled`), which is the only
|
|
536
|
+
* first-hand evidence of "this reached a prompt" available on this side of the
|
|
537
|
+
* API. The returned text is untouched — the render stays byte-identical
|
|
538
|
+
* (verify.mjs pins that with and without a logger attached).
|
|
539
|
+
*/
|
|
540
|
+
const assembledSectionText = () => {
|
|
541
|
+
sectionPulls += 1
|
|
542
|
+
logDebug('section pulled into a prompt (pull #' + sectionPulls + ')')
|
|
543
|
+
return sectionText()
|
|
544
|
+
}
|
|
545
|
+
|
|
509
546
|
/**
|
|
510
547
|
* Keep the registered section exactly in step with the stored data: present
|
|
511
548
|
* when something must be injected, absent otherwise. An empty memory must
|
|
@@ -515,7 +552,7 @@ export function apply(ctx) {
|
|
|
515
552
|
const wanted = sectionText() !== ''
|
|
516
553
|
if (wanted && sectionDispose === null) {
|
|
517
554
|
try {
|
|
518
|
-
sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text:
|
|
555
|
+
sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text: assembledSectionText })
|
|
519
556
|
state.sectionRegistered = true
|
|
520
557
|
state.sectionError = null
|
|
521
558
|
logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
|
|
@@ -603,6 +640,10 @@ export function apply(ctx) {
|
|
|
603
640
|
state.storagePath = storePath
|
|
604
641
|
state.storageDisplay = storePath
|
|
605
642
|
state.storageError = null
|
|
643
|
+
state.storeExists = true
|
|
644
|
+
// A successful write is the one thing that makes the file real, so it is
|
|
645
|
+
// also what clears the "not materialised yet" flag set by a first run.
|
|
646
|
+
needsMaterialize = false
|
|
606
647
|
state.savedAt = Date.now()
|
|
607
648
|
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
608
649
|
+ ' entries=' + state.data.entries.length
|
|
@@ -624,6 +665,7 @@ export function apply(ctx) {
|
|
|
624
665
|
state.storageDisplay = storePath
|
|
625
666
|
state.storageWarning = null
|
|
626
667
|
state.loadedFrom = null
|
|
668
|
+
state.storeExists = false
|
|
627
669
|
|
|
628
670
|
// 1. The canonical location under the harness home.
|
|
629
671
|
try {
|
|
@@ -633,6 +675,7 @@ export function apply(ctx) {
|
|
|
633
675
|
state.data = loaded.data
|
|
634
676
|
state.storageWarning = droppedEntriesMessage(loaded.dropped)
|
|
635
677
|
state.loadedFrom = 'memory.json'
|
|
678
|
+
state.storeExists = true
|
|
636
679
|
}
|
|
637
680
|
state.storageError = null
|
|
638
681
|
return
|
|
@@ -714,10 +757,31 @@ export function apply(ctx) {
|
|
|
714
757
|
if (err instanceof SyntaxError) state.storageError = '旧位置文件解析失败,已忽略:' + describeError(err)
|
|
715
758
|
}
|
|
716
759
|
|
|
717
|
-
// 3. First run:
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
|
|
760
|
+
// 3. First run: there is no store anywhere. Deliberately write NOTHING.
|
|
761
|
+
//
|
|
762
|
+
// This used to materialise an empty memory.json so the settings page had
|
|
763
|
+
// a real path to show. That made the plugin perform a disk write at
|
|
764
|
+
// process boot — i.e. with no session, no turn and no task — which is the
|
|
765
|
+
// one side effect that a behaviour audit can legitimately call "running
|
|
766
|
+
// with nothing to do". The file is now created by the first action that
|
|
767
|
+
// genuinely needs it: a save, an import, or the settings page reading
|
|
768
|
+
// `state` (see ensureStoreMaterialized). Until then the panel shows
|
|
769
|
+
// "尚未写入磁盘", a state its UI already renders.
|
|
770
|
+
needsMaterialize = true
|
|
771
|
+
state.loadedFrom = 'first run (no store on disk yet)'
|
|
772
|
+
state.storeExists = false
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Write the empty store, once, the first time something actually needs the
|
|
777
|
+
* file to exist. A failed attempt leaves the flag set so the next caller
|
|
778
|
+
* retries instead of silently pretending the file is there.
|
|
779
|
+
*
|
|
780
|
+
* @returns whether a write was performed and succeeded.
|
|
781
|
+
*/
|
|
782
|
+
const ensureStoreMaterialized = async () => {
|
|
783
|
+
if (!needsMaterialize) return false
|
|
784
|
+
return writeToDisk()
|
|
721
785
|
}
|
|
722
786
|
|
|
723
787
|
/* ---------------- boot ---------------- */
|
|
@@ -733,12 +797,17 @@ export function apply(ctx) {
|
|
|
733
797
|
syncSection()
|
|
734
798
|
// The one always-on line: it answers "is memory mounted, where does it
|
|
735
799
|
// live, and how much is being injected?" without any further digging.
|
|
800
|
+
// 措辞很重要:`section=` 说的是"这个段落已就绪、若被装配会产出多少字符",
|
|
801
|
+
// 而不是"已经注入"。在零会话时打印 `injected=` 会让人误以为记忆已经进入
|
|
802
|
+
// 了某个提示词。真正的注入证据是 pulls:宿主每拉取一次加一(初始必为 0)。
|
|
736
803
|
const chars = sectionText().length
|
|
737
804
|
logInfo('mounted: store=' + storePath
|
|
738
805
|
+ ' from=' + String(state.loadedFrom)
|
|
806
|
+
+ ' onDisk=' + (state.storeExists ? 'yes' : 'no')
|
|
739
807
|
+ ' entries=' + state.data.entries.length
|
|
740
808
|
+ ' enabled=' + (state.data.enabled === true)
|
|
741
|
-
+ '
|
|
809
|
+
+ ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
|
|
810
|
+
+ ' pulls=' + sectionPulls
|
|
742
811
|
+ (state.storageError !== null ? ' error=' + state.storageError : ''))
|
|
743
812
|
})
|
|
744
813
|
|
|
@@ -774,12 +843,20 @@ export function apply(ctx) {
|
|
|
774
843
|
warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
|
|
775
844
|
savedAt: state.savedAt,
|
|
776
845
|
migratedFrom: state.migratedFrom,
|
|
846
|
+
/** false until the store file has actually been written (see readFromDisk step 3). */
|
|
847
|
+
onDisk: state.storeExists,
|
|
777
848
|
},
|
|
778
849
|
injection: {
|
|
779
850
|
registered: state.sectionRegistered,
|
|
780
851
|
available: true,
|
|
781
852
|
error: state.sectionError,
|
|
782
853
|
chars: preview.length,
|
|
854
|
+
/**
|
|
855
|
+
* How many times the harness has pulled this section into a prompt since
|
|
856
|
+
* boot. 0 means "prepared, never assembled" — the distinction the old
|
|
857
|
+
* `injected=` log line could not make.
|
|
858
|
+
*/
|
|
859
|
+
pulls: sectionPulls,
|
|
783
860
|
},
|
|
784
861
|
preview,
|
|
785
862
|
limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES },
|
|
@@ -954,10 +1031,17 @@ export function apply(ctx) {
|
|
|
954
1031
|
const dispatch = async (method, args) => {
|
|
955
1032
|
await boot
|
|
956
1033
|
switch (method) {
|
|
957
|
-
case 'state':
|
|
1034
|
+
case 'state': {
|
|
1035
|
+
// The settings page is the first thing that needs a real file on disk —
|
|
1036
|
+
// it displays the absolute path, and the user may then hand-edit it.
|
|
1037
|
+
// A first run writes nothing at boot (readFromDisk step 3), so the
|
|
1038
|
+
// materialisation happens here, on a deliberate user action, instead of
|
|
1039
|
+
// as a side effect of the host merely starting up.
|
|
1040
|
+
await ensureStoreMaterialized()
|
|
958
1041
|
// Same envelope as every other method. One response shape means the
|
|
959
1042
|
// Client can never again be handed a payload it silently ignores.
|
|
960
1043
|
return { ok: true, saved: true, message: null, snapshot: snapshot() }
|
|
1044
|
+
}
|
|
961
1045
|
case 'save-entry':
|
|
962
1046
|
return saveEntry(args !== null && typeof args === 'object' ? args.entry : null)
|
|
963
1047
|
case 'delete-entry':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|