dsh-memu-memory 1.1.2 → 1.2.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.
Files changed (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
package/lib/engine.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path'
5
5
  import { spawn } from 'node:child_process'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import {
8
- dataDir, engineDir, downloadsDir, logsDir, librariesDir, ensureDir, readJson, writeJson, stamp,
8
+ dataDir, engineDir, downloadsDir, logsDir, librariesDir, ensureDir, readJson, writeJson, stamp, windowsSystemEnv,
9
9
  } from './paths.js'
10
10
  import { getSettings, globalDbPath } from './state.js'
11
11
  import { ensureManagedPython } from './managed-runtime.js'
@@ -158,7 +158,7 @@ function run(cmd, args, opts = {}) {
158
158
  return new Promise((resolve) => {
159
159
  const child = spawn(cmd, args, {
160
160
  cwd: opts.cwd,
161
- env: { ...process.env, ...(opts.env || {}) },
161
+ env: { ...windowsSystemEnv(), ...process.env, ...(opts.env || {}) },
162
162
  windowsHide: true,
163
163
  stdio: ['ignore', 'pipe', 'pipe'],
164
164
  })
package/lib/index.js CHANGED
@@ -6,6 +6,9 @@ import { registerCycle } from './cycle.js'
6
6
  import { syncPromptDefaults } from './prompts.js'
7
7
  import { ensureEngine, engineStatus, ENGINE_VERSION } from './engine.js'
8
8
  import { getSettings, getMemoryBackend, backupDatabases, flushState } from './state.js'
9
+ import { closeEmbeddingWorkers } from './local-embedding-manager.js'
10
+ import { bootstrapLocalVector } from './local-vector-service.js'
11
+ import { vectorStatus } from './vector-profile.js'
9
12
  import { setWarnSink } from './paths.js'
10
13
 
11
14
  export const name = 'dsh-memu-memory'
@@ -15,6 +18,29 @@ export const inject = ['tools', 'timer']
15
18
 
16
19
  const applied = new WeakSet()
17
20
 
21
+ // 首用引导(§14.13):本地向量模式且已有(或待切换)受管模型时,启动后把受管运行时复用起来、
22
+ // 拉起 loopback worker 并健康检查 /v1/embeddings。这里不下载:没有确认的联网动作留给她在
23
+ // 设置页点按钮,启动路径只做 reuse + 健康检查。失败只 warn(召回照旧退词法)。
24
+ function startLocalVectorBootstrap(ctx) {
25
+ try {
26
+ if (getMemoryBackend() !== 'api') return
27
+ if (getSettings().vectorSource !== 'local') return
28
+ const state = vectorStatus()
29
+ const catalogId = state?.active?.profile?.catalogId || state?.requested?.catalogId || null
30
+ if (!catalogId) return
31
+ const timer = setTimeout(() => {
32
+ void bootstrapLocalVector({ confirmed: false }).then((ready) => {
33
+ try { ctx.logger?.info?.(`dsh-memu-memory: managed local embedding runtime ready (${String(ready?.model || catalogId)} ${Number(ready?.dimension) || '?'}d)`) } catch {}
34
+ }).catch((error) => {
35
+ try { ctx.logger?.warn?.(`dsh-memu-memory: local embedding bootstrap skipped: ${error?.message || error}`) } catch {}
36
+ })
37
+ }, 2500)
38
+ timer.unref?.()
39
+ } catch (error) {
40
+ try { ctx.logger?.warn?.(`dsh-memu-memory: local embedding bootstrap skipped: ${error?.message || error}`) } catch {}
41
+ }
42
+ }
43
+
18
44
  function startEngineBootstrap(ctx) {
19
45
  try {
20
46
  const settings = getSettings()
@@ -94,6 +120,16 @@ export function apply(ctx, config) {
94
120
  if (!inPreset) {
95
121
  registerRpc(ctx)
96
122
  startEngineBootstrap(ctx)
123
+ startLocalVectorBootstrap(ctx)
124
+ }
125
+
126
+ // The embedding worker is a process-global singleton, so it outlives any single
127
+ // acquire() and would keep a native session and a loopback port alive after this
128
+ // instance is unloaded. Preset mounts share that same worker, so only the instance
129
+ // that owns the process-global surfaces may close it. Recall degrades to lexical
130
+ // afterwards instead of failing.
131
+ if (!inPreset && typeof ctx.effect === 'function') {
132
+ ctx.effect(() => () => { closeEmbeddingWorkers() }, 'dsh-memu-memory: embedding workers')
97
133
  }
98
134
 
99
135
  const disposeCycle = inPreset ? null : registerCycle(ctx, {
package/lib/inject.js CHANGED
@@ -9,9 +9,10 @@ import {
9
9
  import {
10
10
  decayStrength, privateRecall, globalRecall, appliesToDomain, domainOf,
11
11
  relativeTime, daysSince, dueOpenLoops, noteInjected, noteGlobalInjected, visibleToSession,
12
- cueCandidates, isTipOfTongue, isMemoryEligible,
12
+ cueCandidates, isTipOfTongue, isMemoryEligible, sessionIdOf,
13
13
  salientDateFires, isDatedSalientDate, salientDaysAhead,
14
14
  } from './memory.js'
15
+ import { record, clipQuery } from './activity-log.js'
15
16
  import { readMood } from './mood.js'
16
17
  import { loadPromptForDomain } from './prompts.js'
17
18
  import { estTokens, packToBudget, truncateToTokens } from './budget.js'
@@ -605,6 +606,31 @@ export async function buildRecall(binding, query, turn, settings = getSettings()
605
606
  const pieces = []
606
607
  const errors = []
607
608
  let candidateCount = 0
609
+ let privateRoute = null
610
+ let globalRoute = null
611
+ // 每个回合都留一条召回记录:走了哪条路、召回/注入的是什么 id、哪些腿降级了。
612
+ // 这是"为什么这台机器上的召回从来不出来"唯一能在 UI 里自查的证据。
613
+ const finish = (payload) => {
614
+ record({
615
+ kind: 'recall',
616
+ scope: null,
617
+ sessionId: sessionId || sessionIdOf(exec) || null,
618
+ libraryId: binding.libraryId || null,
619
+ ok: (payload.errors || []).length === 0,
620
+ message: `召回 ${payload.ids.length} 条注入 / ${candidateCount} 候选` + ((payload.errors || []).length ? ` · ${payload.errors.length} 个错误` : ''),
621
+ detail: {
622
+ query: clipQuery(query),
623
+ turn,
624
+ privateRoute,
625
+ globalRoute,
626
+ ids: payload.ids,
627
+ candidateCount,
628
+ errors: payload.errors || [],
629
+ },
630
+ refs: payload.ids,
631
+ })
632
+ return payload
633
+ }
608
634
 
609
635
  // Compare against the history first, then record this message into it.
610
636
  const switched = topicShifted(binding, query, settings)
@@ -618,7 +644,12 @@ export async function buildRecall(binding, query, turn, settings = getSettings()
618
644
  // The turn's settings snapshot decides the cutoff, so a caller that runs with
619
645
  // different settings (tests, per-turn overrides) sees matching behaviour.
620
646
  const result = await privateRecall({ exec, query, topK: settings.privateTopK, relevanceThreshold: settings.relevanceThreshold, purpose: 'automatic' })
647
+ // A degraded hybrid recall is not a failed turn: the lexical leg still
648
+ // answered. The error must still reach lastRecallError so the next healthy
649
+ // recall clears it instead of the degradation disappearing.
650
+ for (const warning of result.errors || []) errors.push(warning)
621
651
  candidateCount += result.files?.length || 0
652
+ privateRoute = result.recallRoute || null
622
653
  const fresh = (result.files || [])
623
654
  .filter((f) => !seenRecently(binding, f.name, turn, settings.dedupeTurns))
624
655
  if (fresh.length > 0) {
@@ -645,6 +676,7 @@ export async function buildRecall(binding, query, turn, settings = getSettings()
645
676
  try {
646
677
  const result = await globalRecall({ exec, query, topK: settings.globalTopK, relevanceThreshold: settings.relevanceThreshold })
647
678
  candidateCount += result.files?.length || 0
679
+ globalRoute = result.recallRoute || null
648
680
  if (result.unbound) errors.push('global recall ran without a session binding; results are limited to the general domain')
649
681
  const fresh = (result.files || [])
650
682
  .filter((f) => !seenRecently(binding, f.name, turn, settings.dedupeTurns))
@@ -656,7 +688,7 @@ export async function buildRecall(binding, query, turn, settings = getSettings()
656
688
  }
657
689
  }
658
690
 
659
- if (pieces.length === 0) return { text: '', ids: [], errors, outcome: errors.length ? 'error' : candidateCount ? 'deduped' : 'no-match', candidateCount }
691
+ if (pieces.length === 0) return finish({ text: '', ids: [], errors, outcome: errors.length ? 'error' : candidateCount ? 'deduped' : 'no-match', candidateCount })
660
692
  const budget = Math.max(0, Number(settings.turnTokens ?? 640))
661
693
  const blocks = []
662
694
  for (const piece of pieces) {
@@ -681,7 +713,7 @@ export async function buildRecall(binding, query, turn, settings = getSettings()
681
713
  }
682
714
  const text = blocks.join('\n\n')
683
715
  if (!text) errors.push('recall omitted: token budget cannot fit a memory entry')
684
- return { text, ids, errors, outcome: text ? 'injected' : 'budget', candidateCount }
716
+ return finish({ text, ids, errors, outcome: text ? 'injected' : 'budget', candidateCount })
685
717
  }
686
718
 
687
719
  export function createMemoryMessage(text) {
@@ -691,33 +723,153 @@ export function createMemoryMessage(text) {
691
723
  })
692
724
  }
693
725
 
726
+ // Every part of the first-step message is built from one binding snapshot, but
727
+ // retrieval awaits an external engine. During that window the conversation can be
728
+ // switched to another library, moved to another channel/character, or have a layer
729
+ // turned off. A snapshot that no longer matches must be discarded whole: catching
730
+ // the recall error alone would still ship the pre-switch anchor and other parts.
731
+ function injectionSnapshot(binding) {
732
+ return {
733
+ libraryId: binding?.libraryId || null,
734
+ scopeKey: binding?.scopeKey || null,
735
+ memoryContext: JSON.stringify(binding?.memoryContext || {}),
736
+ useLibrary: binding?.useLibrary !== false,
737
+ useGlobal: binding?.useGlobal !== false,
738
+ }
739
+ }
740
+
741
+ function injectionSnapshotIsCurrent(sessionId, snapshot, autoInject) {
742
+ const current = getSessionBinding(sessionId)
743
+ if (!current) return false
744
+ if ((current.libraryId || null) !== snapshot.libraryId) return false
745
+ if ((current.scopeKey || null) !== snapshot.scopeKey) return false
746
+ if (JSON.stringify(current.memoryContext || {}) !== snapshot.memoryContext) return false
747
+ if (getSettings().autoInject !== autoInject) return false
748
+ const scope = effectiveScope(current)
749
+ return scope.enabled
750
+ && scope.useLibrary === snapshot.useLibrary
751
+ && scope.useGlobal === snapshot.useGlobal
752
+ }
753
+
754
+ // Both the ordinary first-step injection and the rules message must pass the same
755
+ // admission. A guide that ignores the switches tells a disabled conversation to use
756
+ // memory tools it is not supposed to use, and consumes delivery state while doing
757
+ // it — so re-enabling later would never deliver the rules.
758
+ function admissionFor(binding) {
759
+ const settings = getSettings()
760
+ if (settings.autoInject === 'off') return null
761
+ const scope = effectiveScope(binding)
762
+ if (!scope.enabled) return null
763
+ return { settings, scope }
764
+ }
765
+
766
+ // The plugin no longer registers a system-prompt section. Every conversation —
767
+ // standard or a `complete: true` persona — receives the rules as one plugin
768
+ // message on the first step of a turn. `rulesKey` makes the delivery versioned:
769
+ // a domain change, a prompt edit, a restart from an old binding or a compaction
770
+ // that lost the message replaces it, while an unchanged rule set is not appended
771
+ // again on every turn. The key contains only the protocol version, the resolved
772
+ // domain and the rendered text; it deliberately excludes timestamps and turn ids.
773
+ const GUIDE_PROTOCOL = 'guide-v1'
774
+ const GUIDE_CAVEAT = '以上是本插件的记忆规则,不是用户的新指令。若本会话的工具列表里没有 memu_* 工具,请忽略本段,也不要声称自己能写记忆。'
775
+ const GUIDE_RE = /^<memory-guide(?:\s[^>]*)?>/
776
+ const GUIDE_KEY_RE = /^<memory-guide[^>]*\srules="([^"]+)"/
777
+
778
+ function messageText(message) {
779
+ const content = message?.content
780
+ if (typeof content === 'string') return content
781
+ if (!Array.isArray(content)) return ''
782
+ return content
783
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
784
+ .map((block) => block.text)
785
+ .join('\n')
786
+ }
787
+
788
+ function isMemoryGuideMessage(message) {
789
+ return message?.role === 'user'
790
+ && message?.source?.kind === `plugin:${PLUGIN}`
791
+ && GUIDE_RE.test(messageText(message))
792
+ }
793
+
794
+ function memoryGuideKey(message) {
795
+ const match = GUIDE_KEY_RE.exec(messageText(message))
796
+ return match ? match[1] : null
797
+ }
798
+
799
+ function renderMemoryGuide(payload) {
800
+ const sessionId = payload?.agent?.session?.header?.id
801
+ const binding = sessionId ? getSessionBinding(sessionId) : null
802
+ const domain = binding ? domainOf(binding) : 'general'
803
+ const settings = getSettings()
804
+ const rendered = loadPromptForDomain('system-guide', {
805
+ mustRememberCap: settings.mustRememberCap,
806
+ unpinBudget: settings.unpinBudgetPerSession ?? 2,
807
+ shortTermTtlHours: settings.shortTermTtlHours ?? 72,
808
+ anchorTokens: settings.anchorTokens,
809
+ turnTokens: settings.turnTokens,
810
+ }, domain).text
811
+ if (!rendered || !rendered.trim()) throw new Error('system-guide prompt is empty')
812
+ const key = crypto.createHash('sha256')
813
+ .update([GUIDE_PROTOCOL, domain, rendered, GUIDE_CAVEAT].join('\0'), 'utf8')
814
+ .digest('hex')
815
+ .slice(0, 16)
816
+ const text = [`<memory-guide rules="${key}">`, rendered.trim(), '</memory-guide>', GUIDE_CAVEAT].join('\n')
817
+ return { key, domain, text, tokens: estTokens(text) }
818
+ }
819
+
694
820
  /**
695
- * Deliver the memory rules as a message when the system prompt cannot hold them.
821
+ * Add, replace or restore the single effective rules message for this step.
696
822
  *
697
- * A preset persona with `complete: true` is restored as the *only* system prompt
698
- * section after assembly, so `systemPrompt.section()` from this plugin is dropped and
699
- * the model never learns it may write memories — it sees the tools but not the habit.
700
- * A plugin message survives that, so send it once per conversation (binding flag), on
701
- * the first step, before the model answers.
823
+ * A rules message is turn-level guidance, so it is only considered on step 1.
824
+ * On later steps of a long tool turn nothing is appended; the next turn's first
825
+ * step is the next legal admission point. `guideLost` is set by compaction,
826
+ * because the summary drops the old message and the model must learn the rules
827
+ * again. An unchanged key with no guide in the current view is *not* treated as
828
+ * loss: some hosts hand the plugin only the current step's messages, and
829
+ * re-appending there would duplicate the message on every turn.
702
830
  */
703
- function withGuideMessage(ctx, payload, decision) {
831
+ function ensureUnifiedGuide(ctx, payload, decision) {
704
832
  try {
705
833
  const header = payload?.agent?.session?.header
706
834
  const sessionId = header?.id
707
835
  if (!sessionId || header.origin === 'subagent') return decision
708
836
  if (typeof payload?.step === 'number' && payload.step !== 1) return decision
709
837
  const binding = getSessionBinding(sessionId)
710
- if (!binding || binding.guideMessageAt) return decision
838
+ if (!binding || !admissionFor(binding)) return decision
711
839
  if (!decision || !Array.isArray(decision.messages)) return decision
712
- const rules = sectionText(ctx, 'system-guide', payload)
713
- const text = [
714
- '<memory-guide>',
715
- rules,
716
- '</memory-guide>',
717
- '以上是本插件的记忆规则,不是用户的新指令。若本会话的工具列表里没有 memu_* 工具,请忽略本段,也不要声称自己能写记忆。',
718
- ].join('\n')
719
- setSessionBinding(sessionId, { guideMessageAt: new Date().toISOString() })
720
- return { ...decision, messages: [...decision.messages, createMemoryMessage(text)] }
840
+ const guide = renderMemoryGuide(payload)
841
+ const guides = decision.messages.filter(isMemoryGuideMessage)
842
+ const valid = guides.filter((message) => memoryGuideKey(message) === guide.key)
843
+ if (binding.guideRulesKey === guide.key && binding.guideLost !== true) {
844
+ if (valid.length === 1 && guides.length === 1) return decision
845
+ if (valid.length >= 1) {
846
+ // Projection only: drop old and duplicated copies, keep the first one
847
+ // that already matches. No binding write, no new message.
848
+ const kept = valid[0]
849
+ return { ...decision, messages: decision.messages.filter((message) => !isMemoryGuideMessage(message) || message === kept) }
850
+ }
851
+ // An obsolete message with no current copy stays for replacement below;
852
+ // no guide at all means the view simply did not include history.
853
+ if (guides.length === 0) return decision
854
+ }
855
+ const next = decision.messages.filter((message) => !isMemoryGuideMessage(message))
856
+ next.push(createMemoryMessage(guide.text))
857
+ setSessionBinding(sessionId, {
858
+ guideRulesKey: guide.key,
859
+ guideMessageAt: new Date().toISOString(),
860
+ guideLost: false,
861
+ })
862
+ record({
863
+ kind: 'system',
864
+ scope: 'private',
865
+ sessionId,
866
+ libraryId: binding.libraryId || null,
867
+ ok: true,
868
+ message: '记忆规则消息已投放 · ' + guide.domain + ' · ' + guide.tokens + ' tokens'
869
+ + (binding.guideRulesKey ? ' · 更新' : ' · 首次'),
870
+ detail: { rulesKey: guide.key, domain: guide.domain, tokens: guide.tokens, replaced: guides.length },
871
+ })
872
+ return { ...decision, messages: next }
721
873
  } catch (error) {
722
874
  try { ctx.logger?.warn?.(`dsh-memu-memory: guide message skipped: ${error?.message || error}`) } catch {}
723
875
  return decision
@@ -758,33 +910,38 @@ async function injectInboundForStep({ agent, decision, turn, signal, log, ctx })
758
910
  const imageCount = images.length
759
911
  setSessionBinding(sessionId, { lastImageCount: imageCount })
760
912
  const anchorNeeded = effective.reinjectPending || !(effective.injectedIds || []).includes(ANCHOR_SENTINEL)
761
- const budget = Math.max(0, Number(settings.turnTokens ?? 640))
913
+ // turnTokens remains the recall block's own ceiling, but injectionTotalTokens is
914
+ // the ceiling for the whole plugin message. The two must be applied together:
915
+ // otherwise a 160-token total with an 800-token recall budget injects ~786.
916
+ const turnBudget = Math.max(0, Number(settings.turnTokens ?? 640))
917
+ const totalBudget = Math.max(0, Number(settings.injectionTotalTokens ?? 2000))
918
+ const totalRoom = () => Math.max(0, totalBudget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0))
762
919
  const appendBounded = (text, candidateIds = []) => {
763
920
  if (!text) return
764
- const room = budget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0)
765
- if (room <= 0) return
766
- if (estTokens(text) > room) return
921
+ if (estTokens(text) > totalRoom()) return
767
922
  const clipped = text
768
923
  parts.push(clipped)
769
924
  ids.push(...candidateIds.filter(id => clipped.includes(`[${id}]`)))
770
925
  }
771
- if (anchorNeeded && budget >= 160) {
772
- const anchor = buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 700), Math.floor(budget / 2)) }, sessionId)
926
+ if (anchorNeeded && totalRoom() >= 160) {
927
+ const anchor = buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 700), Math.floor(turnBudget / 2), totalRoom()) }, sessionId)
773
928
  appendBounded(anchor.text, anchor.ids)
774
929
  }
775
930
  if (media.length) {
776
931
  const lines = media.map(item => `${item.sha256} · ${item.captionBy}: ${clip(item.caption || '[图片]', 35)}${item.missing ? ' · missing' : ''}`).join('\n')
777
- const text = ['<memory-media>', truncateToTokens(lines, Math.min(140, Math.floor(budget / 3))), '图片引用不是视觉证据;attachments 只接受 sha256。', '</memory-media>'].join('\n')
778
- if (estTokens(text) + estTokens(parts.join('\n\n')) + 2 <= budget) parts.push(text)
932
+ const text = ['<memory-media>', truncateToTokens(lines, Math.min(140, Math.floor(totalRoom() / 3))), '图片引用不是视觉证据;attachments 只接受 sha256。', '</memory-media>'].join('\n')
933
+ appendBounded(text)
779
934
  }
780
935
  let outcome = 'anchor-only'
781
936
  let errors = []
782
937
  if (settings.autoInject !== 'anchor-only' && (imageCount || query.length >= Number(settings.minUserChars ?? 6))) {
783
- const remaining = Math.max(0, budget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0))
784
- const recall = await buildRecall(effective, query, clock, { ...settings, turnTokens: remaining }, { agent }, sessionId)
785
- if (recall.text) { parts.push(recall.text); ids.push(...recall.ids) }
786
- errors = recall.errors
787
- outcome = recall.outcome || 'no-match'
938
+ const recallBudget = Math.max(0, Math.min(turnBudget, totalRoom()))
939
+ if (recallBudget > 0) {
940
+ const recall = await buildRecall(effective, query, clock, { ...settings, turnTokens: recallBudget }, { agent }, sessionId)
941
+ appendBounded(recall.text, recall.ids)
942
+ errors = recall.errors
943
+ outcome = recall.outcome || 'no-match'
944
+ }
788
945
  }
789
946
  if (signal?.aborted) return decision
790
947
  const current = getSessionBinding(sessionId)
@@ -798,6 +955,13 @@ async function injectInboundForStep({ agent, decision, turn, signal, log, ctx })
798
955
  return decision
799
956
  } else setRecallError(sessionId, null)
800
957
  const text = parts.join('\n\n')
958
+ // Final assertion: every block above is individually bounded, so exceeding the
959
+ // whole-message ceiling here means the accounting drifted. Drop the batch rather
960
+ // than publish an over-budget message.
961
+ if (estTokens(text) > totalBudget) {
962
+ setRecallError(sessionId, 'inbound injection exceeded injectionTotalTokens')
963
+ return decision
964
+ }
801
965
  if (text) {
802
966
  const pushed = [...new Set(ids)]
803
967
  markInjected(sessionId, pushed, clock)
@@ -854,13 +1018,14 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
854
1018
  // Advance the idle clock even when injection is off, so sleep consolidation
855
1019
  // still knows when this conversation last did anything.
856
1020
  touchSession(sessionId)
857
- const settings = getSettings()
858
- if (settings.autoInject === 'off') return decision
859
1021
  // The folder policy is the ceiling: a conversation cannot opt into a memory
860
- // layer its folder has switched off.
861
- const scope = effectiveScope(binding)
862
- if (!scope.enabled) return decision
1022
+ // layer its folder has switched off. The guide message shares this admission.
1023
+ const admitted = admissionFor(binding)
1024
+ if (!admitted) return decision
1025
+ const { settings, scope } = admitted
863
1026
  const effective = { ...binding, useLibrary: scope.useLibrary, useGlobal: scope.useGlobal }
1027
+ // Captured before any await below; re-checked immediately before the splice.
1028
+ const snapshot = injectionSnapshot(effective)
864
1029
 
865
1030
  const query = contextualQuery(decision.messages, lastUser)
866
1031
  const imageCount = imageTurn(lastUser).imageCount
@@ -874,18 +1039,27 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
874
1039
 
875
1040
  const parts = []
876
1041
  const ids = []
1042
+ // The sub-budgets (anchorTokens, turnTokens, the hint's own cap) are ceilings for
1043
+ // their own blocks; injectionTotalTokens is the ceiling for the whole plugin
1044
+ // message. Without this outer gate the sub-budgets can add up past the configured
1045
+ // total even when each one individually looks fine.
1046
+ const injectionBudget = Math.max(0, Number(settings.injectionTotalTokens ?? 2000))
1047
+ const totalRoom = () => Math.max(0, injectionBudget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0))
877
1048
  if (imageCount && effective.useLibrary) {
878
1049
  const current = getSessionBinding(sessionId)
879
1050
  const pending = (current?.pendingMedia || []).slice(-8)
880
1051
  if (pending.length) {
881
1052
  const details = pending.map(item => `${item.sha256} · ${item.captionBy}: ${clip(item.caption || '[图片]', 35)}${item.missing ? ' · missing' : ''}`).join('\n')
882
- parts.push(['<memory-media>', truncateToTokens(details, 150), current.stickerHint ? '同图反复出现,可以用 track=habit 聚合表达习惯,不必逐次写记忆。' : '', '只知道描述与引用,不能凭空声称看过像素。attachments 只接受 sha256。', '</memory-media>'].filter(Boolean).join('\n'))
1053
+ const mediaText = ['<memory-media>', truncateToTokens(details, 150), current.stickerHint ? '同图反复出现,可以用 track=habit 聚合表达习惯,不必逐次写记忆。' : '', '只知道描述与引用,不能凭空声称看过像素。attachments 只接受 sha256。', '</memory-media>'].filter(Boolean).join('\n')
1054
+ if (estTokens(mediaText) <= totalRoom()) parts.push(mediaText)
883
1055
  }
884
1056
  }
885
1057
 
886
1058
  if (anchorNeeded) {
887
- const anchor = buildAnchor(effective, settings, sessionId)
888
- if (anchor.text) {
1059
+ const anchor = totalRoom() >= 160
1060
+ ? buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 800), totalRoom()) }, sessionId)
1061
+ : { text: '', ids: [] }
1062
+ if (anchor.text && estTokens(anchor.text) <= totalRoom()) {
889
1063
  parts.push(anchor.text)
890
1064
  ids.push(ANCHOR_SENTINEL, ...anchor.ids)
891
1065
  }
@@ -893,7 +1067,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
893
1067
  // the only moment the pre-compaction memories are missing from the window.
894
1068
  if (reinject) {
895
1069
  const replay = buildReplay(effective, settings, sessionId)
896
- if (replay.text) {
1070
+ if (replay.text && estTokens(replay.text) <= totalRoom()) {
897
1071
  parts.push(replay.text)
898
1072
  ids.push(...replay.ids)
899
1073
  }
@@ -902,7 +1076,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
902
1076
 
903
1077
  if (!reinject) {
904
1078
  const anniversary = buildAnniversary(sessionId, effective)
905
- if (anniversary.text) {
1079
+ if (anniversary.text && estTokens(anniversary.text) <= totalRoom()) {
906
1080
  parts.push(anniversary.text)
907
1081
  ids.push(...anniversary.ids)
908
1082
  for (const id of anniversary.fired) markAnniversaryShown(sessionId, id, anniversary.dayKey)
@@ -913,7 +1087,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
913
1087
  // Independent of retrieval: a loop can be worth raising in a conversation that
914
1088
  // shares no vocabulary with it, which is exactly the case lexical recall misses.
915
1089
  const nudge = buildOpenLoopNudge(effective, settings, sessionId)
916
- if (nudge.text) {
1090
+ if (nudge.text && estTokens(nudge.text) <= totalRoom()) {
917
1091
  parts.push(nudge.text)
918
1092
  ids.push(...nudge.ids)
919
1093
  }
@@ -923,7 +1097,8 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
923
1097
  // The recall path takes the host's exec shape, where the session lives under
924
1098
  // `.agent`; passing the Agent itself made sessionIdOf return null and turned
925
1099
  // every per-turn recall into a silent no-op (P0-1).
926
- const recall = await buildRecall(effective, query, memoryClock, settings, { agent }, sessionId)
1100
+ const recallBudget = Math.max(0, Math.min(Number(settings.turnTokens ?? 800), totalRoom()))
1101
+ const recall = await buildRecall(effective, query, memoryClock, { ...settings, turnTokens: recallBudget }, { agent }, sessionId)
927
1102
  setSessionBinding(sessionId, { lastRecallOutcome: { reason: recall.outcome || 'no-match', candidateCount: recall.candidateCount || 0, injectedCount: recall.ids.length, at: new Date().toISOString() } })
928
1103
  if (recall.text) {
929
1104
  parts.push(recall.text)
@@ -939,14 +1114,14 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
939
1114
 
940
1115
  if (!reinject) {
941
1116
  const cue = await maybeCue({ sessionId, turn, settings, binding: effective, exec: { agent } })
942
- if (cue) {
1117
+ if (cue && estTokens(cue.text) <= totalRoom()) {
943
1118
  parts.push(cue.text)
944
1119
  // The related pointers reached the window, so they count towards habituation and
945
1120
  // dedupe exactly like the recall block's ids.
946
1121
  ids.push(...cue.ids)
947
1122
  }
948
1123
  const texture = maybeTexture(sessionId, settings)
949
- if (texture) parts.push(texture)
1124
+ if (texture && estTokens(texture) <= totalRoom()) parts.push(texture)
950
1125
  }
951
1126
 
952
1127
  if (parts.length === 0) {
@@ -954,6 +1129,11 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
954
1129
  return decision
955
1130
  }
956
1131
 
1132
+ // This is the last point before the message enters decision.messages. Recall,
1133
+ // cue and texture all awaited above; a library/context/switch change during any
1134
+ // of them invalidates every part built from the old snapshot, not just recall.
1135
+ if (!injectionSnapshotIsCurrent(sessionId, snapshot, settings.autoInject)) return decision
1136
+
957
1137
  const text = parts.join('\n\n')
958
1138
  const message = createMemoryMessage(text)
959
1139
  const rewritten = [...decision.messages]
@@ -982,62 +1162,8 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
982
1162
  return { ...decision, messages: rewritten }
983
1163
  }
984
1164
 
985
- /**
986
- * Section text must never throw. A missing default prompt file or an unknown key
987
- * would otherwise break setPrompt assembly for *every* conversation, which takes
988
- * the whole harness down rather than just memory. Fall back variant -> base ->
989
- * one line, and log.
990
- */
991
- function sectionText(ctx, key, context) {
992
- try {
993
- const sessionId = context?.agent?.session?.header?.id || context?.session?.header?.id || null
994
- const binding = sessionId ? getSessionBinding(sessionId) : null
995
- return loadPromptForDomain(key, {
996
- mustRememberCap: getSettings().mustRememberCap,
997
- unpinBudget: getSettings().unpinBudgetPerSession ?? 2,
998
- shortTermTtlHours: getSettings().shortTermTtlHours ?? 72,
999
- // the coding variants talk about the anchor budget in tokens, so the number
1000
- // has to come from the live settings and not from a literal in the prompt
1001
- anchorTokens: getSettings().anchorTokens,
1002
- turnTokens: getSettings().turnTokens,
1003
- }, binding ? domainOf(binding) : 'general').text
1004
- } catch (error) {
1005
- try { ctx.logger?.warn?.(`dsh-memu-memory: ${key} prompt unavailable: ${error?.message || error}`) } catch {}
1006
- return '(记忆规则暂时不可用:插件内部错误,不要向用户提及这一段。)'
1007
- }
1008
- }
1009
-
1010
- export function registerInjection(ctx, options = {}) {
1165
+ export function registerInjection(ctx) {
1011
1166
  const disposers = []
1012
- // `guide: false` is how a *preset* conversation gets the memory anchor without being
1013
- // told about tools its preset never mounted — the guide text references memu_* tools,
1014
- // and a preset that does not list this plugin has no such tools.
1015
- const wantGuide = options.guide !== false
1016
- // `guideMessage` exists for conversations whose preset persona uses `complete: true`:
1017
- // that flag makes the persona the only system prompt section, so a section injected
1018
- // here is discarded before the model ever sees it. A plugin message still arrives, so
1019
- // the rules are delivered once per conversation as text instead of as a section.
1020
- const guideInMessage = options.guideMessage === true
1021
- if (wantGuide) {
1022
- try {
1023
- ctx.inject(['systemPrompt'], (sctx) => {
1024
- sctx.effect(() => sctx.systemPrompt.section({
1025
- name: 'dsh-memu-memory:guide',
1026
- order: 320,
1027
- interpolate: false,
1028
- text: (context) => sectionText(sctx, 'system-guide', context),
1029
- }), 'dsh-memu-memory: guide prompt')
1030
- sctx.effect(() => sctx.systemPrompt.section({
1031
- name: 'dsh-memu-memory:private-policy',
1032
- order: 321,
1033
- interpolate: false,
1034
- text: (context) => sectionText(sctx, 'private-policy', context),
1035
- }), 'dsh-memu-memory: private policy prompt')
1036
- })
1037
- } catch (e) {
1038
- try { ctx.logger?.warn?.(`dsh-memu-memory: systemPrompt guide unavailable: ${e?.message || e}`) } catch {}
1039
- }
1040
- }
1041
1167
 
1042
1168
  const offPre = ctx.on('agent/pre-step', async (payload, next) => {
1043
1169
  const decision = await next()
@@ -1048,7 +1174,7 @@ export function registerInjection(ctx, options = {}) {
1048
1174
  ctx,
1049
1175
  log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
1050
1176
  })
1051
- return guideInMessage ? withGuideMessage(ctx, payload, injected) : injected
1177
+ return ensureUnifiedGuide(ctx, payload, injected)
1052
1178
  } catch (error) {
1053
1179
  try { setRecallError(payload?.agent?.session?.header?.id, `injection failed: ${error?.message || error}`) } catch {}
1054
1180
  try { ctx.logger?.warn?.(`dsh-memu-memory: pre-step injection failed (fail-open): ${error?.stack || error}`) } catch {}
@@ -1072,6 +1198,9 @@ export function registerInjection(ctx, options = {}) {
1072
1198
  .slice(-REPLAY_MAX)
1073
1199
  releaseInjected(sessionId)
1074
1200
  markReinjectPending(sessionId)
1201
+ // The compaction summary drops the plugin's rules message. Flag it so the
1202
+ // next turn's first step replaces it even though guideRulesKey still matches.
1203
+ if (before) setSessionBinding(sessionId, { guideLost: true })
1075
1204
  if (recent.length > 0) {
1076
1205
  setSessionBinding(sessionId, { replayIds: recent, replayAt: new Date().toISOString() })
1077
1206
  }