dsh-memu-memory 1.1.3 → 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 (173) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.md +31 -2
  3. package/README.zh.md +30 -2
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +1 -1
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/host-api.md +2 -2
  9. package/docs/memory-semantics.md +12 -10
  10. package/docs/recall-benchmark.md +238 -0
  11. package/docs/troubleshooting.md +395 -395
  12. package/lib/active-memory.js +165 -0
  13. package/lib/activity-log.js +1 -1
  14. package/lib/binding-diagnostics.js +8 -3
  15. package/lib/client.js +33 -11
  16. package/lib/consolidation-ledger.js +347 -0
  17. package/lib/consolidation-receipt.js +284 -0
  18. package/lib/consolidation-snapshot.js +274 -0
  19. package/lib/consolidation-worker.js +115 -0
  20. package/lib/cycle.js +40 -6
  21. package/lib/inject.js +104 -76
  22. package/lib/local-embedding-manager.js +24 -6
  23. package/lib/memory.js +221 -19
  24. package/lib/mood.js +268 -39
  25. package/lib/preset-scope.js +1 -1
  26. package/lib/private.js +335 -40
  27. package/lib/prompts/private-consolidation.coding.md +1 -0
  28. package/lib/prompts/private-consolidation.md +1 -0
  29. package/lib/prompts/private-consolidation.mixed.md +1 -0
  30. package/lib/prompts/private-consolidation.rp.md +1 -0
  31. package/lib/prompts.js +4 -4
  32. package/lib/remote-summary.js +51 -25
  33. package/lib/rpc.js +9 -1
  34. package/lib/state.js +29 -12
  35. package/lib/tools.js +49 -2
  36. package/lib/vector-profile.js +27 -0
  37. package/package.json +2 -1
  38. package/scripts/acceptance-local.mjs +2 -1
  39. package/scripts/active-memory-ab-live.mjs +655 -0
  40. package/scripts/active-memory-ab.mjs +1302 -0
  41. package/scripts/chain-matrix.mjs +11 -1
  42. package/scripts/check-all.mjs +10 -1
  43. package/tests/activation.test.mjs +89 -0
  44. package/tests/active-memory-ab.test.mjs +745 -0
  45. package/tests/active-memory.test.mjs +129 -0
  46. package/tests/activity-log.test.mjs +126 -0
  47. package/tests/activity-wiring.test.mjs +103 -0
  48. package/tests/appendix-b.test.mjs +212 -0
  49. package/tests/appendix-b2.test.mjs +166 -0
  50. package/tests/audit-regression.test.mjs +272 -0
  51. package/tests/audit-safety.test.mjs +227 -0
  52. package/tests/backend-compensation.test.mjs +272 -0
  53. package/tests/backup.test.mjs +793 -0
  54. package/tests/binding-diagnostics.test.mjs +720 -0
  55. package/tests/browser.test.mjs +366 -0
  56. package/tests/client-render.test.mjs +892 -0
  57. package/tests/coalesce.test.mjs +113 -0
  58. package/tests/config.test.mjs +159 -0
  59. package/tests/consolidation-credential.test.mjs +320 -0
  60. package/tests/consolidation-fork.test.mjs +357 -0
  61. package/tests/consolidation-ledger.test.mjs +298 -0
  62. package/tests/consolidation-snapshot.test.mjs +321 -0
  63. package/tests/consolidation-value.test.mjs +89 -0
  64. package/tests/context-scope.test.mjs +40 -0
  65. package/tests/cue.test.mjs +44 -0
  66. package/tests/curator-probe.test.mjs +433 -0
  67. package/tests/curator.test.mjs +150 -0
  68. package/tests/cycle.test.mjs +411 -0
  69. package/tests/disclosure.test.mjs +231 -0
  70. package/tests/doc-drift.test.mjs +222 -0
  71. package/tests/embedding-catalog.test.mjs +266 -0
  72. package/tests/embedding-download.test.mjs +391 -0
  73. package/tests/embedding-runtime.test.mjs +389 -0
  74. package/tests/emotion-chain.test.mjs +84 -0
  75. package/tests/emotion-contract.test.mjs +445 -0
  76. package/tests/engine.test.mjs +35 -0
  77. package/tests/fixtures/active-memory-ab/README.md +56 -0
  78. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  79. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  80. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  81. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  82. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  83. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  84. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  85. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  86. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  87. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  88. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  89. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  90. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  91. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  92. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  93. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  94. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  95. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  96. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  97. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  98. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  99. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  100. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  101. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  102. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  103. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  104. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  105. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  106. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  107. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  108. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  109. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  110. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  111. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  112. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  113. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  114. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  115. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  116. package/tests/graph-view.test.mjs +187 -0
  117. package/tests/graph.test.mjs +77 -0
  118. package/tests/hook-order.test.mjs +72 -0
  119. package/tests/humanlike.test.mjs +232 -0
  120. package/tests/hybrid-recall.test.mjs +92 -0
  121. package/tests/inbound.test.mjs +393 -0
  122. package/tests/inject-guide.test.mjs +364 -0
  123. package/tests/inject.test.mjs +222 -0
  124. package/tests/injection-budget-settings.test.mjs +35 -0
  125. package/tests/injection-budget.test.mjs +64 -0
  126. package/tests/injection-chain.test.mjs +191 -0
  127. package/tests/loader-register.mjs +3 -0
  128. package/tests/loader.mjs +12 -0
  129. package/tests/local-embedding-entry.test.mjs +95 -0
  130. package/tests/local-embedding-manager.test.mjs +476 -0
  131. package/tests/local-embedding-protocol.test.mjs +95 -0
  132. package/tests/local-embedding-worker.test.mjs +346 -0
  133. package/tests/managed-memory.test.mjs +536 -0
  134. package/tests/managed-runtime.test.mjs +40 -0
  135. package/tests/media.test.mjs +132 -0
  136. package/tests/migration-payload.test.mjs +42 -0
  137. package/tests/mood.test.mjs +109 -0
  138. package/tests/multiprocess.test.mjs +29 -0
  139. package/tests/new-session-gate.test.mjs +361 -0
  140. package/tests/package-shipping.test.mjs +84 -0
  141. package/tests/preset-scope.test.mjs +212 -0
  142. package/tests/prompts.test.mjs +250 -0
  143. package/tests/react-snapshot.test.mjs +23 -0
  144. package/tests/recall-bench.test.mjs +306 -0
  145. package/tests/remote.test.mjs +140 -0
  146. package/tests/rename.test.mjs +30 -0
  147. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  148. package/tests/repetition-eval-live.test.mjs +177 -0
  149. package/tests/repetition-eval.test.mjs +373 -0
  150. package/tests/salient-date.test.mjs +142 -0
  151. package/tests/settings-beat.test.mjs +221 -0
  152. package/tests/short-term.test.mjs +166 -0
  153. package/tests/sidebar-beat.test.mjs +208 -0
  154. package/tests/sidebar-refresh.test.mjs +123 -0
  155. package/tests/sidebar.test.mjs +70 -0
  156. package/tests/snippet.test.mjs +47 -0
  157. package/tests/source-kind.test.mjs +52 -0
  158. package/tests/state.test.mjs +150 -0
  159. package/tests/stats.test.mjs +112 -0
  160. package/tests/storage.test.mjs +125 -0
  161. package/tests/stubs/dsh-llm.js +5 -0
  162. package/tests/stubs/dsh-session.js +7 -0
  163. package/tests/stubs/dsh-tools.js +101 -0
  164. package/tests/tool-schema.test.mjs +73 -0
  165. package/tests/tools.test.mjs +184 -0
  166. package/tests/ui-defaults.test.mjs +57 -0
  167. package/tests/update-fields.test.mjs +138 -0
  168. package/tests/vector-migration.test.mjs +733 -0
  169. package/tests/vector-profile.test.mjs +425 -0
  170. package/tests/visibility-migration.test.mjs +152 -0
  171. package/tests/webui-auth.test.mjs +454 -0
  172. package/tests/webui.test.mjs +164 -0
  173. package/tests/windows-env.test.mjs +89 -0
package/lib/inject.js CHANGED
@@ -751,9 +751,9 @@ function injectionSnapshotIsCurrent(sessionId, snapshot, autoInject) {
751
751
  && scope.useGlobal === snapshot.useGlobal
752
752
  }
753
753
 
754
- // Both the ordinary first-step injection and the guide message must pass the same
754
+ // Both the ordinary first-step injection and the rules message must pass the same
755
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
756
+ // memory tools it is not supposed to use, and consumes delivery state while doing
757
757
  // it — so re-enabling later would never deliver the rules.
758
758
  function admissionFor(binding) {
759
759
  const settings = getSettings()
@@ -763,34 +763,113 @@ function admissionFor(binding) {
763
763
  return { settings, scope }
764
764
  }
765
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
+
766
820
  /**
767
- * 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.
768
822
  *
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.
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.
774
830
  */
775
- function withGuideMessage(ctx, payload, decision) {
831
+ function ensureUnifiedGuide(ctx, payload, decision) {
776
832
  try {
777
833
  const header = payload?.agent?.session?.header
778
834
  const sessionId = header?.id
779
835
  if (!sessionId || header.origin === 'subagent') return decision
780
836
  if (typeof payload?.step === 'number' && payload.step !== 1) return decision
781
837
  const binding = getSessionBinding(sessionId)
782
- if (!binding || binding.guideMessageAt) return decision
783
- if (!admissionFor(binding)) return decision
838
+ if (!binding || !admissionFor(binding)) return decision
784
839
  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)] }
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 }
794
873
  } catch (error) {
795
874
  try { ctx.logger?.warn?.(`dsh-memu-memory: guide message skipped: ${error?.message || error}`) } catch {}
796
875
  return decision
@@ -1083,62 +1162,8 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
1083
1162
  return { ...decision, messages: rewritten }
1084
1163
  }
1085
1164
 
1086
- /**
1087
- * Section text must never throw. A missing default prompt file or an unknown key
1088
- * would otherwise break setPrompt assembly for *every* conversation, which takes
1089
- * the whole harness down rather than just memory. Fall back variant -> base ->
1090
- * one line, and log.
1091
- */
1092
- function sectionText(ctx, key, context) {
1093
- try {
1094
- const sessionId = context?.agent?.session?.header?.id || context?.session?.header?.id || null
1095
- const binding = sessionId ? getSessionBinding(sessionId) : null
1096
- return loadPromptForDomain(key, {
1097
- mustRememberCap: getSettings().mustRememberCap,
1098
- unpinBudget: getSettings().unpinBudgetPerSession ?? 2,
1099
- shortTermTtlHours: getSettings().shortTermTtlHours ?? 72,
1100
- // the coding variants talk about the anchor budget in tokens, so the number
1101
- // has to come from the live settings and not from a literal in the prompt
1102
- anchorTokens: getSettings().anchorTokens,
1103
- turnTokens: getSettings().turnTokens,
1104
- }, binding ? domainOf(binding) : 'general').text
1105
- } catch (error) {
1106
- try { ctx.logger?.warn?.(`dsh-memu-memory: ${key} prompt unavailable: ${error?.message || error}`) } catch {}
1107
- return '(记忆规则暂时不可用:插件内部错误,不要向用户提及这一段。)'
1108
- }
1109
- }
1110
-
1111
- export function registerInjection(ctx, options = {}) {
1165
+ export function registerInjection(ctx) {
1112
1166
  const disposers = []
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
- }
1141
- }
1142
1167
 
1143
1168
  const offPre = ctx.on('agent/pre-step', async (payload, next) => {
1144
1169
  const decision = await next()
@@ -1149,7 +1174,7 @@ export function registerInjection(ctx, options = {}) {
1149
1174
  ctx,
1150
1175
  log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
1151
1176
  })
1152
- return guideInMessage ? withGuideMessage(ctx, payload, injected) : injected
1177
+ return ensureUnifiedGuide(ctx, payload, injected)
1153
1178
  } catch (error) {
1154
1179
  try { setRecallError(payload?.agent?.session?.header?.id, `injection failed: ${error?.message || error}`) } catch {}
1155
1180
  try { ctx.logger?.warn?.(`dsh-memu-memory: pre-step injection failed (fail-open): ${error?.stack || error}`) } catch {}
@@ -1173,6 +1198,9 @@ export function registerInjection(ctx, options = {}) {
1173
1198
  .slice(-REPLAY_MAX)
1174
1199
  releaseInjected(sessionId)
1175
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 })
1176
1204
  if (recent.length > 0) {
1177
1205
  setSessionBinding(sessionId, { replayIds: recent, replayAt: new Date().toISOString() })
1178
1206
  }
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { assertProfileVerified } from './vector-profile.js'
5
5
  import { manifestModelId } from './local-embedding-worker.js'
6
+ import { windowsSystemEnv } from './paths.js'
6
7
 
7
8
  const entry = fileURLToPath(new URL('./local-embedding-entry.js', import.meta.url))
8
9
  const failure = (code, message) => Object.assign(new Error(message), { code, fallback: 'lexical' })
@@ -21,16 +22,23 @@ const DEFAULT_WINDOW_MS = 60_000
21
22
  const DENIED_ENV_NAMES = Object.freeze(['http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'node_options'])
22
23
  const DENIED_ENV_PREFIXES = Object.freeze(['memu_', 'dsh_memu_embed_', 'openai_', 'azure_openai_', 'anthropic_', 'gemini_', 'google_', 'cohere_'])
23
24
 
24
- function childEnvironment(extra) {
25
- const env = {}
26
- for (const [name, value] of Object.entries(process.env)) {
25
+ // The system-variable overlay goes in *first* so a real host value always wins, and
26
+ // only after it does the filtered copy of the host environment. The host can hand a
27
+ // tool-spawned process an empty environment (docs/env-and-commands.md §9.1), and a
28
+ // child that then resolves `%SystemDrive%\ProgramData\...` literally writes that
29
+ // junk into its own cwd — which is how this repo once grew a "%SystemDrive%" folder.
30
+ // The worker loads ONNX/DirectML and tokenizer native libraries from exactly that
31
+ // kind of path, so it is the site that can least afford to miss the overlay.
32
+ export function childEnvironment(extra, { platform = process.platform, env = process.env } = {}) {
33
+ const inherited = {}
34
+ for (const [name, value] of Object.entries(env || {})) {
27
35
  if (typeof value !== 'string') continue
28
36
  const lower = name.toLowerCase()
29
37
  if (DENIED_ENV_NAMES.includes(lower) || lower.includes('proxy')) continue
30
38
  if (DENIED_ENV_PREFIXES.some(prefix => lower.startsWith(prefix))) continue
31
- env[name] = value
39
+ inherited[name] = value
32
40
  }
33
- return { ...env, ...extra }
41
+ return { ...windowsSystemEnv(platform, env || {}), ...inherited, ...extra }
34
42
  }
35
43
 
36
44
  // One isolated native process per immutable profile. Tokens live only in memory and
@@ -125,8 +133,13 @@ export function createEmbeddingWorkerManager({ spawnImpl = spawn, executable = p
125
133
  abort(code, message)
126
134
  stopChild(record)
127
135
  }
136
+ // This timer is the only thing that turns "never reported ready" into the typed
137
+ // lexical fallback, so nothing may depend on the event loop being held open for
138
+ // another reason: an unconditional unref let acquire() stay pending forever whenever
139
+ // the child contributed no libuv handle of its own (a spawn stand-in, N2). It is
140
+ // dereferenced below only once the spawn proves a real child holds the process open,
141
+ // which is the sole case where this timer would merely delay shutdown.
128
142
  timer = setTimeout(() => fail('LOCAL_VECTOR_START_TIMEOUT', 'the local embedding worker did not become ready in time'), timeoutMs)
129
- timer.unref?.()
130
143
  let child = null
131
144
  try {
132
145
  child = spawnImpl(executable, [entry], {
@@ -150,6 +163,11 @@ export function createEmbeddingWorkerManager({ spawnImpl = spawn, executable = p
150
163
  return
151
164
  }
152
165
  record.child = child
166
+ // A real child holds the process open by itself (stdio pipes and its own handle), so
167
+ // its boot timer need not; a stand-in without a pid holds nothing, and dereferencing
168
+ // the timer there would leave the timeout unable to fire. A spawn that fails reaches
169
+ // fail() through 'error'/'close' and clears the timer either way.
170
+ if (Number.isInteger(child?.pid) && child.pid > 0) timer.unref?.()
153
171
  // A worker that died turns the next stdin write into EPIPE. An unhandled 'error'
154
172
  // event on a child pipe is an uncaught exception in the host process, so every
155
173
  // pipe gets a sink: semantic recall degrades, the host does not go down with it.