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.
- package/AGENTS.md +8 -6
- package/README.md +21 -5
- package/README.zh.md +21 -5
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +32 -5
- package/docs/troubleshooting.md +36 -7
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +697 -49
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +131 -30
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- 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
|
-
|
|
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
|
-
|
|
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 &&
|
|
772
|
-
const anchor = buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 700), Math.floor(
|
|
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(
|
|
778
|
-
|
|
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
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
|
862
|
-
if (!
|
|
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
|
-
|
|
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 =
|
|
888
|
-
|
|
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
|
|
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()
|