dsh-memu-memory 1.1.2 → 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 (49) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +21 -5
  3. package/README.zh.md +21 -5
  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 +32 -5
  8. package/docs/troubleshooting.md +36 -7
  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 +697 -49
  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 +36 -0
  20. package/lib/inject.js +131 -30
  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/private.js +139 -13
  31. package/lib/rpc.js +45 -4
  32. package/lib/state.js +109 -161
  33. package/lib/tools.js +9 -5
  34. package/lib/vector-migration.js +878 -0
  35. package/lib/vector-profile.js +941 -0
  36. package/package.json +3 -2
  37. package/scripts/acceptance-local.mjs +5 -4
  38. package/scripts/chain-matrix.mjs +527 -0
  39. package/scripts/check-all.mjs +19 -0
  40. package/scripts/check-doc-drift.mjs +353 -0
  41. package/scripts/diagnose-bindings.mjs +176 -0
  42. package/scripts/local-runtime-install.mjs +142 -0
  43. package/scripts/local-runtime-verify.mjs +56 -0
  44. package/scripts/local-vector-verify.mjs +93 -0
  45. package/scripts/recall-bench.mjs +813 -0
  46. package/scripts/repetition-ab-scenarios.mjs +93 -0
  47. package/scripts/repetition-detector-snapshot.mjs +314 -0
  48. package/scripts/repetition-eval-live.mjs +360 -0
  49. 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
@@ -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,6 +723,46 @@ 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
+
694
766
  /**
695
767
  * Deliver the memory rules as a message when the system prompt cannot hold them.
696
768
  *
@@ -708,6 +780,7 @@ function withGuideMessage(ctx, payload, decision) {
708
780
  if (typeof payload?.step === 'number' && payload.step !== 1) return decision
709
781
  const binding = getSessionBinding(sessionId)
710
782
  if (!binding || binding.guideMessageAt) return decision
783
+ if (!admissionFor(binding)) return decision
711
784
  if (!decision || !Array.isArray(decision.messages)) return decision
712
785
  const rules = sectionText(ctx, 'system-guide', payload)
713
786
  const text = [
@@ -758,33 +831,38 @@ async function injectInboundForStep({ agent, decision, turn, signal, log, ctx })
758
831
  const imageCount = images.length
759
832
  setSessionBinding(sessionId, { lastImageCount: imageCount })
760
833
  const anchorNeeded = effective.reinjectPending || !(effective.injectedIds || []).includes(ANCHOR_SENTINEL)
761
- 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))
762
840
  const appendBounded = (text, candidateIds = []) => {
763
841
  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
842
+ if (estTokens(text) > totalRoom()) return
767
843
  const clipped = text
768
844
  parts.push(clipped)
769
845
  ids.push(...candidateIds.filter(id => clipped.includes(`[${id}]`)))
770
846
  }
771
- if (anchorNeeded && budget >= 160) {
772
- 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)
773
849
  appendBounded(anchor.text, anchor.ids)
774
850
  }
775
851
  if (media.length) {
776
852
  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)
853
+ const text = ['<memory-media>', truncateToTokens(lines, Math.min(140, Math.floor(totalRoom() / 3))), '图片引用不是视觉证据;attachments 只接受 sha256。', '</memory-media>'].join('\n')
854
+ appendBounded(text)
779
855
  }
780
856
  let outcome = 'anchor-only'
781
857
  let errors = []
782
858
  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'
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
+ }
788
866
  }
789
867
  if (signal?.aborted) return decision
790
868
  const current = getSessionBinding(sessionId)
@@ -798,6 +876,13 @@ async function injectInboundForStep({ agent, decision, turn, signal, log, ctx })
798
876
  return decision
799
877
  } else setRecallError(sessionId, null)
800
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
+ }
801
886
  if (text) {
802
887
  const pushed = [...new Set(ids)]
803
888
  markInjected(sessionId, pushed, clock)
@@ -854,13 +939,14 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
854
939
  // Advance the idle clock even when injection is off, so sleep consolidation
855
940
  // still knows when this conversation last did anything.
856
941
  touchSession(sessionId)
857
- const settings = getSettings()
858
- if (settings.autoInject === 'off') return decision
859
942
  // 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
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
863
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)
864
950
 
865
951
  const query = contextualQuery(decision.messages, lastUser)
866
952
  const imageCount = imageTurn(lastUser).imageCount
@@ -874,18 +960,27 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
874
960
 
875
961
  const parts = []
876
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))
877
969
  if (imageCount && effective.useLibrary) {
878
970
  const current = getSessionBinding(sessionId)
879
971
  const pending = (current?.pendingMedia || []).slice(-8)
880
972
  if (pending.length) {
881
973
  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'))
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)
883
976
  }
884
977
  }
885
978
 
886
979
  if (anchorNeeded) {
887
- const anchor = buildAnchor(effective, settings, sessionId)
888
- 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()) {
889
984
  parts.push(anchor.text)
890
985
  ids.push(ANCHOR_SENTINEL, ...anchor.ids)
891
986
  }
@@ -893,7 +988,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
893
988
  // the only moment the pre-compaction memories are missing from the window.
894
989
  if (reinject) {
895
990
  const replay = buildReplay(effective, settings, sessionId)
896
- if (replay.text) {
991
+ if (replay.text && estTokens(replay.text) <= totalRoom()) {
897
992
  parts.push(replay.text)
898
993
  ids.push(...replay.ids)
899
994
  }
@@ -902,7 +997,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
902
997
 
903
998
  if (!reinject) {
904
999
  const anniversary = buildAnniversary(sessionId, effective)
905
- if (anniversary.text) {
1000
+ if (anniversary.text && estTokens(anniversary.text) <= totalRoom()) {
906
1001
  parts.push(anniversary.text)
907
1002
  ids.push(...anniversary.ids)
908
1003
  for (const id of anniversary.fired) markAnniversaryShown(sessionId, id, anniversary.dayKey)
@@ -913,7 +1008,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
913
1008
  // Independent of retrieval: a loop can be worth raising in a conversation that
914
1009
  // shares no vocabulary with it, which is exactly the case lexical recall misses.
915
1010
  const nudge = buildOpenLoopNudge(effective, settings, sessionId)
916
- if (nudge.text) {
1011
+ if (nudge.text && estTokens(nudge.text) <= totalRoom()) {
917
1012
  parts.push(nudge.text)
918
1013
  ids.push(...nudge.ids)
919
1014
  }
@@ -923,7 +1018,8 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
923
1018
  // The recall path takes the host's exec shape, where the session lives under
924
1019
  // `.agent`; passing the Agent itself made sessionIdOf return null and turned
925
1020
  // every per-turn recall into a silent no-op (P0-1).
926
- 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)
927
1023
  setSessionBinding(sessionId, { lastRecallOutcome: { reason: recall.outcome || 'no-match', candidateCount: recall.candidateCount || 0, injectedCount: recall.ids.length, at: new Date().toISOString() } })
928
1024
  if (recall.text) {
929
1025
  parts.push(recall.text)
@@ -939,14 +1035,14 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
939
1035
 
940
1036
  if (!reinject) {
941
1037
  const cue = await maybeCue({ sessionId, turn, settings, binding: effective, exec: { agent } })
942
- if (cue) {
1038
+ if (cue && estTokens(cue.text) <= totalRoom()) {
943
1039
  parts.push(cue.text)
944
1040
  // The related pointers reached the window, so they count towards habituation and
945
1041
  // dedupe exactly like the recall block's ids.
946
1042
  ids.push(...cue.ids)
947
1043
  }
948
1044
  const texture = maybeTexture(sessionId, settings)
949
- if (texture) parts.push(texture)
1045
+ if (texture && estTokens(texture) <= totalRoom()) parts.push(texture)
950
1046
  }
951
1047
 
952
1048
  if (parts.length === 0) {
@@ -954,6 +1050,11 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
954
1050
  return decision
955
1051
  }
956
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
+
957
1058
  const text = parts.join('\n\n')
958
1059
  const message = createMemoryMessage(text)
959
1060
  const rewritten = [...decision.messages]
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { startEmbeddingWorker } from './local-embedding-worker.js'
5
+ import { LocalEmbeddingError } from './local-embedding-protocol.js'
6
+
7
+ // §14.5: the worker runs in its own child process so a native crash cannot take
8
+ // the host down. The port and the random token travel over the process
9
+ // environment (never argv, never a shared config file, never a log line), and the
10
+ // parent learns the real port from a single ready line on stdout.
11
+ export const ENTRY_ENV = Object.freeze({
12
+ modelDir: 'DSH_MEMU_EMBED_MODEL_DIR',
13
+ manifest: 'DSH_MEMU_EMBED_MANIFEST',
14
+ manifestFile: 'DSH_MEMU_EMBED_MANIFEST_FILE',
15
+ runtimeDir: 'DSH_MEMU_EMBED_RUNTIME_DIR',
16
+ token: 'DSH_MEMU_EMBED_TOKEN',
17
+ host: 'DSH_MEMU_EMBED_HOST',
18
+ port: 'DSH_MEMU_EMBED_PORT',
19
+ maxBodyBytes: 'DSH_MEMU_EMBED_MAX_BODY_BYTES',
20
+ maxBatch: 'DSH_MEMU_EMBED_MAX_BATCH',
21
+ })
22
+
23
+ function readManifest(env) {
24
+ const inline = String(env[ENTRY_ENV.manifest] || '').trim()
25
+ if (inline.startsWith('{')) {
26
+ try {
27
+ return JSON.parse(inline)
28
+ } catch (error) {
29
+ throw new LocalEmbeddingError('manifest env is not valid JSON', 'LOCAL_VECTOR_MANIFEST_INVALID', 503, String((error && error.message) || error))
30
+ }
31
+ }
32
+ const file = inline || String(env[ENTRY_ENV.manifestFile] || '').trim()
33
+ if (!file) throw new LocalEmbeddingError('a manifest path or inline manifest is required', 'LOCAL_VECTOR_MANIFEST_INVALID', 503)
34
+ let raw = ''
35
+ try {
36
+ raw = fs.readFileSync(path.resolve(file), 'utf8')
37
+ } catch (error) {
38
+ throw new LocalEmbeddingError('manifest file ' + path.basename(file) + ' could not be read', 'LOCAL_VECTOR_MANIFEST_INVALID', 503, String((error && error.code) || error))
39
+ }
40
+ try {
41
+ return JSON.parse(raw)
42
+ } catch (error) {
43
+ throw new LocalEmbeddingError('manifest file ' + path.basename(file) + ' is not valid JSON', 'LOCAL_VECTOR_MANIFEST_INVALID', 503, String((error && error.message) || error))
44
+ }
45
+ }
46
+
47
+ function readInteger(value, label, fallback) {
48
+ const text = String(value || '').trim()
49
+ if (!text) return fallback
50
+ const number = Number(text)
51
+ if (!Number.isInteger(number) || number < 0) {
52
+ throw new LocalEmbeddingError(label + ' must be a non-negative integer', 'LOCAL_VECTOR_BAD_CONFIG', 400)
53
+ }
54
+ return number
55
+ }
56
+
57
+ export function parseEmbeddingEntryConfig(env = process.env) {
58
+ const token = String(env[ENTRY_ENV.token] || '')
59
+ if (!token) throw new LocalEmbeddingError('a worker token is required', 'LOCAL_VECTOR_TOKEN_INVALID', 400)
60
+ const modelDir = String(env[ENTRY_ENV.modelDir] || '').trim()
61
+ if (!modelDir) throw new LocalEmbeddingError('modelDir is required', 'LOCAL_VECTOR_MODEL_DIR_MISSING', 503)
62
+ const runtimeDir = String(env[ENTRY_ENV.runtimeDir] || '').trim()
63
+ return {
64
+ modelDir,
65
+ manifest: readManifest(env),
66
+ runtimeDir: runtimeDir || undefined,
67
+ token,
68
+ host: String(env[ENTRY_ENV.host] || '127.0.0.1').trim() || '127.0.0.1',
69
+ port: readInteger(env[ENTRY_ENV.port], 'port', 0),
70
+ maxBodyBytes: readInteger(env[ENTRY_ENV.maxBodyBytes], 'maxBodyBytes', undefined),
71
+ maxBatch: readInteger(env[ENTRY_ENV.maxBatch], 'maxBatch', undefined),
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Starts the worker and emits exactly one ready line. Kept separate from main()
77
+ * so a test can drive it with a stub worker and never listen on a socket.
78
+ */
79
+ export async function runEmbeddingEntry({ env = process.env, stdout = process.stdout, logger = console, startWorker = startEmbeddingWorker } = {}) {
80
+ const config = parseEmbeddingEntryConfig(env)
81
+ const worker = await startWorker({ ...config, logger })
82
+ const ready = {
83
+ type: 'ready',
84
+ baseUrl: worker.baseUrl,
85
+ url: worker.url,
86
+ model: worker.model,
87
+ dimension: worker.dimension,
88
+ pid: process.pid,
89
+ }
90
+ stdout.write(JSON.stringify(ready) + '\n')
91
+ return { worker, ready }
92
+ }
93
+
94
+ function stderrLogger(stderr) {
95
+ const write = (level) => (message) => {
96
+ try { stderr.write('[local-embedding ' + level + '] ' + String(message) + '\n') } catch {}
97
+ }
98
+ return { info: write('info'), warn: write('warn'), error: write('error'), log: write('info') }
99
+ }
100
+
101
+ function isEntryPoint() {
102
+ const invoked = process.argv[1]
103
+ if (!invoked) return false
104
+ try {
105
+ return pathToFileURL(fs.realpathSync(invoked)).href === pathToFileURL(fs.realpathSync(new URL(import.meta.url))).href
106
+ } catch {
107
+ return false
108
+ }
109
+ }
110
+
111
+ async function main() {
112
+ const stdout = process.stdout
113
+ const stderr = process.stderr
114
+ let worker = null
115
+ let stopping = false
116
+ const stop = async (code) => {
117
+ if (stopping) return
118
+ stopping = true
119
+ try { await worker?.close() } catch {}
120
+ process.exit(code)
121
+ }
122
+ const failAndExit = (error) => {
123
+ const code = String((error && error.code) || 'LOCAL_VECTOR_ERROR')
124
+ const message = String((error && error.message) || error || 'embedding worker failed')
125
+ try { stderr.write(JSON.stringify({ type: 'error', code, message }) + '\n') } catch {}
126
+ void stop(1)
127
+ }
128
+
129
+ // The parent owns the lifetime: stdin EOF, SIGTERM/SIGINT or an IPC disconnect
130
+ // all mean "release the native session now" (§14.5: no leaked processes).
131
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { void stop(0) })
132
+ process.on('disconnect', () => { void stop(0) })
133
+ process.on('uncaughtException', failAndExit)
134
+ process.on('unhandledRejection', failAndExit)
135
+ try { process.stdin.on('end', () => { void stop(0) }); process.stdin.on('close', () => { void stop(0) }); process.stdin.resume() } catch {}
136
+
137
+ try {
138
+ const result = await runEmbeddingEntry({ stdout, logger: stderrLogger(stderr) })
139
+ worker = result.worker
140
+ } catch (error) {
141
+ failAndExit(error)
142
+ }
143
+ }
144
+
145
+ if (isEntryPoint()) main()