dsh-memu-memory 1.1.1 → 1.1.3

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 (50) hide show
  1. package/AGENTS.md +8 -5
  2. package/README.md +22 -5
  3. package/README.zh.md +21 -4
  4. package/docs/contributing.md +5 -3
  5. package/docs/env-and-commands.md +13 -0
  6. package/docs/host-api.md +35 -3
  7. package/docs/memory-semantics.md +41 -1
  8. package/docs/troubleshooting.md +395 -350
  9. package/lib/activity-log.js +190 -0
  10. package/lib/backup.js +1103 -0
  11. package/lib/binding-diagnostics.js +630 -0
  12. package/lib/client.js +703 -50
  13. package/lib/curator.js +81 -37
  14. package/lib/cycle.js +3 -0
  15. package/lib/embedding-catalog.js +763 -0
  16. package/lib/embedding-download.js +407 -0
  17. package/lib/embedding-runtime.js +710 -0
  18. package/lib/engine.js +2 -2
  19. package/lib/index.js +61 -4
  20. package/lib/inject.js +195 -49
  21. package/lib/local-embedding-entry.js +145 -0
  22. package/lib/local-embedding-manager.js +249 -0
  23. package/lib/local-embedding-protocol.js +173 -0
  24. package/lib/local-embedding-worker.js +652 -0
  25. package/lib/local-vector-service.js +283 -0
  26. package/lib/memory.js +385 -45
  27. package/lib/memu-config.js +10 -22
  28. package/lib/memu.js +53 -19
  29. package/lib/paths.js +24 -0
  30. package/lib/preset-scope.js +121 -0
  31. package/lib/private.js +139 -13
  32. package/lib/rpc.js +45 -4
  33. package/lib/state.js +109 -161
  34. package/lib/tools.js +9 -5
  35. package/lib/vector-migration.js +878 -0
  36. package/lib/vector-profile.js +941 -0
  37. package/package.json +3 -2
  38. package/scripts/acceptance-local.mjs +5 -4
  39. package/scripts/chain-matrix.mjs +527 -0
  40. package/scripts/check-all.mjs +19 -0
  41. package/scripts/check-doc-drift.mjs +353 -0
  42. package/scripts/diagnose-bindings.mjs +176 -0
  43. package/scripts/local-runtime-install.mjs +142 -0
  44. package/scripts/local-runtime-verify.mjs +56 -0
  45. package/scripts/local-vector-verify.mjs +93 -0
  46. package/scripts/recall-bench.mjs +813 -0
  47. package/scripts/repetition-ab-scenarios.mjs +93 -0
  48. package/scripts/repetition-detector-snapshot.mjs +314 -0
  49. package/scripts/repetition-eval-live.mjs +360 -0
  50. package/scripts/repetition-eval.mjs +702 -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
@@ -1,10 +1,14 @@
1
1
  import { registerTools } from './tools.js'
2
2
  import { registerInjection } from './inject.js'
3
+ import { registerPresetAttachment } from './preset-scope.js'
3
4
  import { registerRpc } from './rpc.js'
4
5
  import { registerCycle } from './cycle.js'
5
6
  import { syncPromptDefaults } from './prompts.js'
6
7
  import { ensureEngine, engineStatus, ENGINE_VERSION } from './engine.js'
7
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'
8
12
  import { setWarnSink } from './paths.js'
9
13
 
10
14
  export const name = 'dsh-memu-memory'
@@ -14,6 +18,29 @@ export const inject = ['tools', 'timer']
14
18
 
15
19
  const applied = new WeakSet()
16
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
+
17
44
  function startEngineBootstrap(ctx) {
18
45
  try {
19
46
  const settings = getSettings()
@@ -36,6 +63,17 @@ function startEngineBootstrap(ctx) {
36
63
  }
37
64
 
38
65
  export function apply(ctx, config) {
66
+ // A conversation that names an agent preset composes its model-facing plugin set from
67
+ // that preset's cordis.yml alone, and that subtree never delivers `agent/pre-step` to
68
+ // this instance — so nothing here runs for those conversations. `lib/preset-scope.js`
69
+ // walks the live agent registry and attaches the injection listener on each such
70
+ // agent's own context instead. No preset file is touched.
71
+ //
72
+ // `config.preset === true` remains for the opposite case: a user who *does* list this
73
+ // plugin inside a preset gets a second instance in the same process, and that instance
74
+ // must not re-register the process-global surfaces (RPC prefix, idle sweep, engine
75
+ // installer). The tools and the pre-step hook are what such a mount needs.
76
+ const inPreset = config?.preset === true
39
77
  if (applied.has(ctx)) {
40
78
  try { ctx.logger?.warn?.('dsh-memu-memory: apply() already ran on this context; skipping') } catch {}
41
79
  return
@@ -72,13 +110,32 @@ export function apply(ctx, config) {
72
110
  ctx.effect(() => disposeInjection, 'dsh-memu-memory: injection')
73
111
  }
74
112
 
75
- registerRpc(ctx)
76
- startEngineBootstrap(ctx)
77
-
78
- const disposeCycle = registerCycle(ctx, {
113
+ const disposeAttach = registerPresetAttachment(ctx, {
79
114
  log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
80
115
  })
81
116
  if (typeof ctx.effect === 'function') {
117
+ ctx.effect(() => disposeAttach, 'dsh-memu-memory: preset attachment')
118
+ }
119
+
120
+ if (!inPreset) {
121
+ registerRpc(ctx)
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')
133
+ }
134
+
135
+ const disposeCycle = inPreset ? null : registerCycle(ctx, {
136
+ log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
137
+ })
138
+ if (disposeCycle && typeof ctx.effect === 'function') {
82
139
  ctx.effect(() => disposeCycle, 'dsh-memu-memory: cycle')
83
140
  }
84
141
 
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,6 +723,80 @@ 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 guide 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 the once-only flag 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
+ /**
767
+ * Deliver the memory rules as a message when the system prompt cannot hold them.
768
+ *
769
+ * A preset persona with `complete: true` is restored as the *only* system prompt
770
+ * section after assembly, so `systemPrompt.section()` from this plugin is dropped and
771
+ * the model never learns it may write memories — it sees the tools but not the habit.
772
+ * A plugin message survives that, so send it once per conversation (binding flag), on
773
+ * the first step, before the model answers.
774
+ */
775
+ function withGuideMessage(ctx, payload, decision) {
776
+ try {
777
+ const header = payload?.agent?.session?.header
778
+ const sessionId = header?.id
779
+ if (!sessionId || header.origin === 'subagent') return decision
780
+ if (typeof payload?.step === 'number' && payload.step !== 1) return decision
781
+ const binding = getSessionBinding(sessionId)
782
+ if (!binding || binding.guideMessageAt) return decision
783
+ if (!admissionFor(binding)) return decision
784
+ if (!decision || !Array.isArray(decision.messages)) return decision
785
+ const rules = sectionText(ctx, 'system-guide', payload)
786
+ const text = [
787
+ '<memory-guide>',
788
+ rules,
789
+ '</memory-guide>',
790
+ '以上是本插件的记忆规则,不是用户的新指令。若本会话的工具列表里没有 memu_* 工具,请忽略本段,也不要声称自己能写记忆。',
791
+ ].join('\n')
792
+ setSessionBinding(sessionId, { guideMessageAt: new Date().toISOString() })
793
+ return { ...decision, messages: [...decision.messages, createMemoryMessage(text)] }
794
+ } catch (error) {
795
+ try { ctx.logger?.warn?.(`dsh-memu-memory: guide message skipped: ${error?.message || error}`) } catch {}
796
+ return decision
797
+ }
798
+ }
799
+
694
800
  async function injectInboundForStep({ agent, decision, turn, signal, log, ctx }) {
695
801
  const sessionId = agent.session.header.id
696
802
  const before = getSessionBinding(sessionId)
@@ -725,33 +831,38 @@ async function injectInboundForStep({ agent, decision, turn, signal, log, ctx })
725
831
  const imageCount = images.length
726
832
  setSessionBinding(sessionId, { lastImageCount: imageCount })
727
833
  const anchorNeeded = effective.reinjectPending || !(effective.injectedIds || []).includes(ANCHOR_SENTINEL)
728
- const budget = Math.max(0, Number(settings.turnTokens ?? 640))
834
+ // turnTokens remains the recall block's own ceiling, but injectionTotalTokens is
835
+ // the ceiling for the whole plugin message. The two must be applied together:
836
+ // otherwise a 160-token total with an 800-token recall budget injects ~786.
837
+ const turnBudget = Math.max(0, Number(settings.turnTokens ?? 640))
838
+ const totalBudget = Math.max(0, Number(settings.injectionTotalTokens ?? 2000))
839
+ const totalRoom = () => Math.max(0, totalBudget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0))
729
840
  const appendBounded = (text, candidateIds = []) => {
730
841
  if (!text) return
731
- const room = budget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0)
732
- if (room <= 0) return
733
- if (estTokens(text) > room) return
842
+ if (estTokens(text) > totalRoom()) return
734
843
  const clipped = text
735
844
  parts.push(clipped)
736
845
  ids.push(...candidateIds.filter(id => clipped.includes(`[${id}]`)))
737
846
  }
738
- if (anchorNeeded && budget >= 160) {
739
- const anchor = buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 700), Math.floor(budget / 2)) }, sessionId)
847
+ if (anchorNeeded && totalRoom() >= 160) {
848
+ const anchor = buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 700), Math.floor(turnBudget / 2), totalRoom()) }, sessionId)
740
849
  appendBounded(anchor.text, anchor.ids)
741
850
  }
742
851
  if (media.length) {
743
852
  const lines = media.map(item => `${item.sha256} · ${item.captionBy}: ${clip(item.caption || '[图片]', 35)}${item.missing ? ' · missing' : ''}`).join('\n')
744
- const text = ['<memory-media>', truncateToTokens(lines, Math.min(140, Math.floor(budget / 3))), '图片引用不是视觉证据;attachments 只接受 sha256。', '</memory-media>'].join('\n')
745
- if (estTokens(text) + estTokens(parts.join('\n\n')) + 2 <= budget) parts.push(text)
853
+ const text = ['<memory-media>', truncateToTokens(lines, Math.min(140, Math.floor(totalRoom() / 3))), '图片引用不是视觉证据;attachments 只接受 sha256。', '</memory-media>'].join('\n')
854
+ appendBounded(text)
746
855
  }
747
856
  let outcome = 'anchor-only'
748
857
  let errors = []
749
858
  if (settings.autoInject !== 'anchor-only' && (imageCount || query.length >= Number(settings.minUserChars ?? 6))) {
750
- const remaining = Math.max(0, budget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0))
751
- const recall = await buildRecall(effective, query, clock, { ...settings, turnTokens: remaining }, { agent }, sessionId)
752
- if (recall.text) { parts.push(recall.text); ids.push(...recall.ids) }
753
- errors = recall.errors
754
- outcome = recall.outcome || 'no-match'
859
+ const recallBudget = Math.max(0, Math.min(turnBudget, totalRoom()))
860
+ if (recallBudget > 0) {
861
+ const recall = await buildRecall(effective, query, clock, { ...settings, turnTokens: recallBudget }, { agent }, sessionId)
862
+ appendBounded(recall.text, recall.ids)
863
+ errors = recall.errors
864
+ outcome = recall.outcome || 'no-match'
865
+ }
755
866
  }
756
867
  if (signal?.aborted) return decision
757
868
  const current = getSessionBinding(sessionId)
@@ -765,6 +876,13 @@ async function injectInboundForStep({ agent, decision, turn, signal, log, ctx })
765
876
  return decision
766
877
  } else setRecallError(sessionId, null)
767
878
  const text = parts.join('\n\n')
879
+ // Final assertion: every block above is individually bounded, so exceeding the
880
+ // whole-message ceiling here means the accounting drifted. Drop the batch rather
881
+ // than publish an over-budget message.
882
+ if (estTokens(text) > totalBudget) {
883
+ setRecallError(sessionId, 'inbound injection exceeded injectionTotalTokens')
884
+ return decision
885
+ }
768
886
  if (text) {
769
887
  const pushed = [...new Set(ids)]
770
888
  markInjected(sessionId, pushed, clock)
@@ -821,13 +939,14 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
821
939
  // Advance the idle clock even when injection is off, so sleep consolidation
822
940
  // still knows when this conversation last did anything.
823
941
  touchSession(sessionId)
824
- const settings = getSettings()
825
- if (settings.autoInject === 'off') return decision
826
942
  // The folder policy is the ceiling: a conversation cannot opt into a memory
827
- // layer its folder has switched off.
828
- const scope = effectiveScope(binding)
829
- if (!scope.enabled) return decision
943
+ // layer its folder has switched off. The guide message shares this admission.
944
+ const admitted = admissionFor(binding)
945
+ if (!admitted) return decision
946
+ const { settings, scope } = admitted
830
947
  const effective = { ...binding, useLibrary: scope.useLibrary, useGlobal: scope.useGlobal }
948
+ // Captured before any await below; re-checked immediately before the splice.
949
+ const snapshot = injectionSnapshot(effective)
831
950
 
832
951
  const query = contextualQuery(decision.messages, lastUser)
833
952
  const imageCount = imageTurn(lastUser).imageCount
@@ -841,18 +960,27 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
841
960
 
842
961
  const parts = []
843
962
  const ids = []
963
+ // The sub-budgets (anchorTokens, turnTokens, the hint's own cap) are ceilings for
964
+ // their own blocks; injectionTotalTokens is the ceiling for the whole plugin
965
+ // message. Without this outer gate the sub-budgets can add up past the configured
966
+ // total even when each one individually looks fine.
967
+ const injectionBudget = Math.max(0, Number(settings.injectionTotalTokens ?? 2000))
968
+ const totalRoom = () => Math.max(0, injectionBudget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0))
844
969
  if (imageCount && effective.useLibrary) {
845
970
  const current = getSessionBinding(sessionId)
846
971
  const pending = (current?.pendingMedia || []).slice(-8)
847
972
  if (pending.length) {
848
973
  const details = pending.map(item => `${item.sha256} · ${item.captionBy}: ${clip(item.caption || '[图片]', 35)}${item.missing ? ' · missing' : ''}`).join('\n')
849
- parts.push(['<memory-media>', truncateToTokens(details, 150), current.stickerHint ? '同图反复出现,可以用 track=habit 聚合表达习惯,不必逐次写记忆。' : '', '只知道描述与引用,不能凭空声称看过像素。attachments 只接受 sha256。', '</memory-media>'].filter(Boolean).join('\n'))
974
+ const mediaText = ['<memory-media>', truncateToTokens(details, 150), current.stickerHint ? '同图反复出现,可以用 track=habit 聚合表达习惯,不必逐次写记忆。' : '', '只知道描述与引用,不能凭空声称看过像素。attachments 只接受 sha256。', '</memory-media>'].filter(Boolean).join('\n')
975
+ if (estTokens(mediaText) <= totalRoom()) parts.push(mediaText)
850
976
  }
851
977
  }
852
978
 
853
979
  if (anchorNeeded) {
854
- const anchor = buildAnchor(effective, settings, sessionId)
855
- if (anchor.text) {
980
+ const anchor = totalRoom() >= 160
981
+ ? buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 800), totalRoom()) }, sessionId)
982
+ : { text: '', ids: [] }
983
+ if (anchor.text && estTokens(anchor.text) <= totalRoom()) {
856
984
  parts.push(anchor.text)
857
985
  ids.push(ANCHOR_SENTINEL, ...anchor.ids)
858
986
  }
@@ -860,7 +988,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
860
988
  // the only moment the pre-compaction memories are missing from the window.
861
989
  if (reinject) {
862
990
  const replay = buildReplay(effective, settings, sessionId)
863
- if (replay.text) {
991
+ if (replay.text && estTokens(replay.text) <= totalRoom()) {
864
992
  parts.push(replay.text)
865
993
  ids.push(...replay.ids)
866
994
  }
@@ -869,7 +997,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
869
997
 
870
998
  if (!reinject) {
871
999
  const anniversary = buildAnniversary(sessionId, effective)
872
- if (anniversary.text) {
1000
+ if (anniversary.text && estTokens(anniversary.text) <= totalRoom()) {
873
1001
  parts.push(anniversary.text)
874
1002
  ids.push(...anniversary.ids)
875
1003
  for (const id of anniversary.fired) markAnniversaryShown(sessionId, id, anniversary.dayKey)
@@ -880,7 +1008,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
880
1008
  // Independent of retrieval: a loop can be worth raising in a conversation that
881
1009
  // shares no vocabulary with it, which is exactly the case lexical recall misses.
882
1010
  const nudge = buildOpenLoopNudge(effective, settings, sessionId)
883
- if (nudge.text) {
1011
+ if (nudge.text && estTokens(nudge.text) <= totalRoom()) {
884
1012
  parts.push(nudge.text)
885
1013
  ids.push(...nudge.ids)
886
1014
  }
@@ -890,7 +1018,8 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
890
1018
  // The recall path takes the host's exec shape, where the session lives under
891
1019
  // `.agent`; passing the Agent itself made sessionIdOf return null and turned
892
1020
  // every per-turn recall into a silent no-op (P0-1).
893
- const recall = await buildRecall(effective, query, memoryClock, settings, { agent }, sessionId)
1021
+ const recallBudget = Math.max(0, Math.min(Number(settings.turnTokens ?? 800), totalRoom()))
1022
+ const recall = await buildRecall(effective, query, memoryClock, { ...settings, turnTokens: recallBudget }, { agent }, sessionId)
894
1023
  setSessionBinding(sessionId, { lastRecallOutcome: { reason: recall.outcome || 'no-match', candidateCount: recall.candidateCount || 0, injectedCount: recall.ids.length, at: new Date().toISOString() } })
895
1024
  if (recall.text) {
896
1025
  parts.push(recall.text)
@@ -906,14 +1035,14 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
906
1035
 
907
1036
  if (!reinject) {
908
1037
  const cue = await maybeCue({ sessionId, turn, settings, binding: effective, exec: { agent } })
909
- if (cue) {
1038
+ if (cue && estTokens(cue.text) <= totalRoom()) {
910
1039
  parts.push(cue.text)
911
1040
  // The related pointers reached the window, so they count towards habituation and
912
1041
  // dedupe exactly like the recall block's ids.
913
1042
  ids.push(...cue.ids)
914
1043
  }
915
1044
  const texture = maybeTexture(sessionId, settings)
916
- if (texture) parts.push(texture)
1045
+ if (texture && estTokens(texture) <= totalRoom()) parts.push(texture)
917
1046
  }
918
1047
 
919
1048
  if (parts.length === 0) {
@@ -921,6 +1050,11 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
921
1050
  return decision
922
1051
  }
923
1052
 
1053
+ // This is the last point before the message enters decision.messages. Recall,
1054
+ // cue and texture all awaited above; a library/context/switch change during any
1055
+ // of them invalidates every part built from the old snapshot, not just recall.
1056
+ if (!injectionSnapshotIsCurrent(sessionId, snapshot, settings.autoInject)) return decision
1057
+
924
1058
  const text = parts.join('\n\n')
925
1059
  const message = createMemoryMessage(text)
926
1060
  const rewritten = [...decision.messages]
@@ -974,36 +1108,48 @@ function sectionText(ctx, key, context) {
974
1108
  }
975
1109
  }
976
1110
 
977
- export function registerInjection(ctx) {
1111
+ export function registerInjection(ctx, options = {}) {
978
1112
  const disposers = []
979
- try {
980
- ctx.inject(['systemPrompt'], (sctx) => {
981
- sctx.effect(() => sctx.systemPrompt.section({
982
- name: 'dsh-memu-memory:guide',
983
- order: 320,
984
- interpolate: false,
985
- text: (context) => sectionText(sctx, 'system-guide', context),
986
- }), 'dsh-memu-memory: guide prompt')
987
- sctx.effect(() => sctx.systemPrompt.section({
988
- name: 'dsh-memu-memory:private-policy',
989
- order: 321,
990
- interpolate: false,
991
- text: (context) => sectionText(sctx, 'private-policy', context),
992
- }), 'dsh-memu-memory: private policy prompt')
993
- })
994
- } catch (e) {
995
- try { ctx.logger?.warn?.(`dsh-memu-memory: systemPrompt guide unavailable: ${e?.message || e}`) } catch {}
1113
+ // `guide: false` is how a *preset* conversation gets the memory anchor without being
1114
+ // told about tools its preset never mounted — the guide text references memu_* tools,
1115
+ // and a preset that does not list this plugin has no such tools.
1116
+ const wantGuide = options.guide !== false
1117
+ // `guideMessage` exists for conversations whose preset persona uses `complete: true`:
1118
+ // that flag makes the persona the only system prompt section, so a section injected
1119
+ // here is discarded before the model ever sees it. A plugin message still arrives, so
1120
+ // the rules are delivered once per conversation as text instead of as a section.
1121
+ const guideInMessage = options.guideMessage === true
1122
+ if (wantGuide) {
1123
+ try {
1124
+ ctx.inject(['systemPrompt'], (sctx) => {
1125
+ sctx.effect(() => sctx.systemPrompt.section({
1126
+ name: 'dsh-memu-memory:guide',
1127
+ order: 320,
1128
+ interpolate: false,
1129
+ text: (context) => sectionText(sctx, 'system-guide', context),
1130
+ }), 'dsh-memu-memory: guide prompt')
1131
+ sctx.effect(() => sctx.systemPrompt.section({
1132
+ name: 'dsh-memu-memory:private-policy',
1133
+ order: 321,
1134
+ interpolate: false,
1135
+ text: (context) => sectionText(sctx, 'private-policy', context),
1136
+ }), 'dsh-memu-memory: private policy prompt')
1137
+ })
1138
+ } catch (e) {
1139
+ try { ctx.logger?.warn?.(`dsh-memu-memory: systemPrompt guide unavailable: ${e?.message || e}`) } catch {}
1140
+ }
996
1141
  }
997
1142
 
998
1143
  const offPre = ctx.on('agent/pre-step', async (payload, next) => {
999
1144
  const decision = await next()
1000
1145
  try {
1001
- return await injectForStep({
1146
+ const injected = await injectForStep({
1002
1147
  ...payload,
1003
1148
  decision,
1004
1149
  ctx,
1005
1150
  log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
1006
1151
  })
1152
+ return guideInMessage ? withGuideMessage(ctx, payload, injected) : injected
1007
1153
  } catch (error) {
1008
1154
  try { setRecallError(payload?.agent?.session?.header?.id, `injection failed: ${error?.message || error}`) } catch {}
1009
1155
  try { ctx.logger?.warn?.(`dsh-memu-memory: pre-step injection failed (fail-open): ${error?.stack || error}`) } catch {}