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.
- package/AGENTS.md +8 -5
- package/README.md +22 -5
- package/README.zh.md +21 -4
- 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 +41 -1
- package/docs/troubleshooting.md +395 -350
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +703 -50
- 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 +61 -4
- package/lib/inject.js +195 -49
- 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/preset-scope.js +121 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
739
|
-
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)
|
|
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(
|
|
745
|
-
|
|
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
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
829
|
-
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
|
|
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
|
-
|
|
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 =
|
|
855
|
-
|
|
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
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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
|
-
|
|
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 {}
|