dsh-instruction-memory 1.0.10 → 1.0.11

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.
Files changed (3) hide show
  1. package/README.md +35 -3
  2. package/lib/index.js +91 -7
  3. 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 injected=437 chars
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)` / `legacy cwd file (migrated)`
110
- / `first run (empty store created)`)、条目数、总开关状态、实际注入字符数,以及读取错误。
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` 级别由宿主日志级别过滤;
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: sectionText })
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: materialise an empty store so the path is real and the
718
- // settings page can show it.
719
- await writeToDisk()
720
- if (state.loadedFrom === null) state.loadedFrom = 'first run (empty store created)'
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
- + ' injected=' + (chars > 0 ? chars + ' chars' : 'nothing')
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.10",
3
+ "version": "1.0.11",
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",