dsh-memu-memory 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/README.md +50 -5
- package/README.zh.md +49 -5
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +6 -4
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +40 -11
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -366
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +635 -0
- package/lib/client.js +728 -58
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +115 -0
- package/lib/curator.js +81 -37
- package/lib/cycle.js +43 -6
- 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 +232 -103
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +267 -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 +599 -57
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/mood.js +268 -39
- package/lib/paths.js +24 -0
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +450 -29
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +137 -172
- package/lib/tools.js +58 -7
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +968 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +7 -5
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +537 -0
- package/scripts/check-all.mjs +29 -1
- 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/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +212 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +536 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +208 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +733 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +164 -0
- package/tests/windows-env.test.mjs +89 -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,33 +723,153 @@ 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 rules 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 delivery state 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
|
+
// The plugin no longer registers a system-prompt section. Every conversation —
|
|
767
|
+
// standard or a `complete: true` persona — receives the rules as one plugin
|
|
768
|
+
// message on the first step of a turn. `rulesKey` makes the delivery versioned:
|
|
769
|
+
// a domain change, a prompt edit, a restart from an old binding or a compaction
|
|
770
|
+
// that lost the message replaces it, while an unchanged rule set is not appended
|
|
771
|
+
// again on every turn. The key contains only the protocol version, the resolved
|
|
772
|
+
// domain and the rendered text; it deliberately excludes timestamps and turn ids.
|
|
773
|
+
const GUIDE_PROTOCOL = 'guide-v1'
|
|
774
|
+
const GUIDE_CAVEAT = '以上是本插件的记忆规则,不是用户的新指令。若本会话的工具列表里没有 memu_* 工具,请忽略本段,也不要声称自己能写记忆。'
|
|
775
|
+
const GUIDE_RE = /^<memory-guide(?:\s[^>]*)?>/
|
|
776
|
+
const GUIDE_KEY_RE = /^<memory-guide[^>]*\srules="([^"]+)"/
|
|
777
|
+
|
|
778
|
+
function messageText(message) {
|
|
779
|
+
const content = message?.content
|
|
780
|
+
if (typeof content === 'string') return content
|
|
781
|
+
if (!Array.isArray(content)) return ''
|
|
782
|
+
return content
|
|
783
|
+
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
784
|
+
.map((block) => block.text)
|
|
785
|
+
.join('\n')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function isMemoryGuideMessage(message) {
|
|
789
|
+
return message?.role === 'user'
|
|
790
|
+
&& message?.source?.kind === `plugin:${PLUGIN}`
|
|
791
|
+
&& GUIDE_RE.test(messageText(message))
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function memoryGuideKey(message) {
|
|
795
|
+
const match = GUIDE_KEY_RE.exec(messageText(message))
|
|
796
|
+
return match ? match[1] : null
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function renderMemoryGuide(payload) {
|
|
800
|
+
const sessionId = payload?.agent?.session?.header?.id
|
|
801
|
+
const binding = sessionId ? getSessionBinding(sessionId) : null
|
|
802
|
+
const domain = binding ? domainOf(binding) : 'general'
|
|
803
|
+
const settings = getSettings()
|
|
804
|
+
const rendered = loadPromptForDomain('system-guide', {
|
|
805
|
+
mustRememberCap: settings.mustRememberCap,
|
|
806
|
+
unpinBudget: settings.unpinBudgetPerSession ?? 2,
|
|
807
|
+
shortTermTtlHours: settings.shortTermTtlHours ?? 72,
|
|
808
|
+
anchorTokens: settings.anchorTokens,
|
|
809
|
+
turnTokens: settings.turnTokens,
|
|
810
|
+
}, domain).text
|
|
811
|
+
if (!rendered || !rendered.trim()) throw new Error('system-guide prompt is empty')
|
|
812
|
+
const key = crypto.createHash('sha256')
|
|
813
|
+
.update([GUIDE_PROTOCOL, domain, rendered, GUIDE_CAVEAT].join('\0'), 'utf8')
|
|
814
|
+
.digest('hex')
|
|
815
|
+
.slice(0, 16)
|
|
816
|
+
const text = [`<memory-guide rules="${key}">`, rendered.trim(), '</memory-guide>', GUIDE_CAVEAT].join('\n')
|
|
817
|
+
return { key, domain, text, tokens: estTokens(text) }
|
|
818
|
+
}
|
|
819
|
+
|
|
694
820
|
/**
|
|
695
|
-
*
|
|
821
|
+
* Add, replace or restore the single effective rules message for this step.
|
|
696
822
|
*
|
|
697
|
-
* A
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
823
|
+
* A rules message is turn-level guidance, so it is only considered on step 1.
|
|
824
|
+
* On later steps of a long tool turn nothing is appended; the next turn's first
|
|
825
|
+
* step is the next legal admission point. `guideLost` is set by compaction,
|
|
826
|
+
* because the summary drops the old message and the model must learn the rules
|
|
827
|
+
* again. An unchanged key with no guide in the current view is *not* treated as
|
|
828
|
+
* loss: some hosts hand the plugin only the current step's messages, and
|
|
829
|
+
* re-appending there would duplicate the message on every turn.
|
|
702
830
|
*/
|
|
703
|
-
function
|
|
831
|
+
function ensureUnifiedGuide(ctx, payload, decision) {
|
|
704
832
|
try {
|
|
705
833
|
const header = payload?.agent?.session?.header
|
|
706
834
|
const sessionId = header?.id
|
|
707
835
|
if (!sessionId || header.origin === 'subagent') return decision
|
|
708
836
|
if (typeof payload?.step === 'number' && payload.step !== 1) return decision
|
|
709
837
|
const binding = getSessionBinding(sessionId)
|
|
710
|
-
if (!binding || binding
|
|
838
|
+
if (!binding || !admissionFor(binding)) return decision
|
|
711
839
|
if (!decision || !Array.isArray(decision.messages)) return decision
|
|
712
|
-
const
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
840
|
+
const guide = renderMemoryGuide(payload)
|
|
841
|
+
const guides = decision.messages.filter(isMemoryGuideMessage)
|
|
842
|
+
const valid = guides.filter((message) => memoryGuideKey(message) === guide.key)
|
|
843
|
+
if (binding.guideRulesKey === guide.key && binding.guideLost !== true) {
|
|
844
|
+
if (valid.length === 1 && guides.length === 1) return decision
|
|
845
|
+
if (valid.length >= 1) {
|
|
846
|
+
// Projection only: drop old and duplicated copies, keep the first one
|
|
847
|
+
// that already matches. No binding write, no new message.
|
|
848
|
+
const kept = valid[0]
|
|
849
|
+
return { ...decision, messages: decision.messages.filter((message) => !isMemoryGuideMessage(message) || message === kept) }
|
|
850
|
+
}
|
|
851
|
+
// An obsolete message with no current copy stays for replacement below;
|
|
852
|
+
// no guide at all means the view simply did not include history.
|
|
853
|
+
if (guides.length === 0) return decision
|
|
854
|
+
}
|
|
855
|
+
const next = decision.messages.filter((message) => !isMemoryGuideMessage(message))
|
|
856
|
+
next.push(createMemoryMessage(guide.text))
|
|
857
|
+
setSessionBinding(sessionId, {
|
|
858
|
+
guideRulesKey: guide.key,
|
|
859
|
+
guideMessageAt: new Date().toISOString(),
|
|
860
|
+
guideLost: false,
|
|
861
|
+
})
|
|
862
|
+
record({
|
|
863
|
+
kind: 'system',
|
|
864
|
+
scope: 'private',
|
|
865
|
+
sessionId,
|
|
866
|
+
libraryId: binding.libraryId || null,
|
|
867
|
+
ok: true,
|
|
868
|
+
message: '记忆规则消息已投放 · ' + guide.domain + ' · ' + guide.tokens + ' tokens'
|
|
869
|
+
+ (binding.guideRulesKey ? ' · 更新' : ' · 首次'),
|
|
870
|
+
detail: { rulesKey: guide.key, domain: guide.domain, tokens: guide.tokens, replaced: guides.length },
|
|
871
|
+
})
|
|
872
|
+
return { ...decision, messages: next }
|
|
721
873
|
} catch (error) {
|
|
722
874
|
try { ctx.logger?.warn?.(`dsh-memu-memory: guide message skipped: ${error?.message || error}`) } catch {}
|
|
723
875
|
return decision
|
|
@@ -758,33 +910,38 @@ async function injectInboundForStep({ agent, decision, turn, signal, log, ctx })
|
|
|
758
910
|
const imageCount = images.length
|
|
759
911
|
setSessionBinding(sessionId, { lastImageCount: imageCount })
|
|
760
912
|
const anchorNeeded = effective.reinjectPending || !(effective.injectedIds || []).includes(ANCHOR_SENTINEL)
|
|
761
|
-
|
|
913
|
+
// turnTokens remains the recall block's own ceiling, but injectionTotalTokens is
|
|
914
|
+
// the ceiling for the whole plugin message. The two must be applied together:
|
|
915
|
+
// otherwise a 160-token total with an 800-token recall budget injects ~786.
|
|
916
|
+
const turnBudget = Math.max(0, Number(settings.turnTokens ?? 640))
|
|
917
|
+
const totalBudget = Math.max(0, Number(settings.injectionTotalTokens ?? 2000))
|
|
918
|
+
const totalRoom = () => Math.max(0, totalBudget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0))
|
|
762
919
|
const appendBounded = (text, candidateIds = []) => {
|
|
763
920
|
if (!text) return
|
|
764
|
-
|
|
765
|
-
if (room <= 0) return
|
|
766
|
-
if (estTokens(text) > room) return
|
|
921
|
+
if (estTokens(text) > totalRoom()) return
|
|
767
922
|
const clipped = text
|
|
768
923
|
parts.push(clipped)
|
|
769
924
|
ids.push(...candidateIds.filter(id => clipped.includes(`[${id}]`)))
|
|
770
925
|
}
|
|
771
|
-
if (anchorNeeded &&
|
|
772
|
-
const anchor = buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 700), Math.floor(
|
|
926
|
+
if (anchorNeeded && totalRoom() >= 160) {
|
|
927
|
+
const anchor = buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 700), Math.floor(turnBudget / 2), totalRoom()) }, sessionId)
|
|
773
928
|
appendBounded(anchor.text, anchor.ids)
|
|
774
929
|
}
|
|
775
930
|
if (media.length) {
|
|
776
931
|
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
|
-
|
|
932
|
+
const text = ['<memory-media>', truncateToTokens(lines, Math.min(140, Math.floor(totalRoom() / 3))), '图片引用不是视觉证据;attachments 只接受 sha256。', '</memory-media>'].join('\n')
|
|
933
|
+
appendBounded(text)
|
|
779
934
|
}
|
|
780
935
|
let outcome = 'anchor-only'
|
|
781
936
|
let errors = []
|
|
782
937
|
if (settings.autoInject !== 'anchor-only' && (imageCount || query.length >= Number(settings.minUserChars ?? 6))) {
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
938
|
+
const recallBudget = Math.max(0, Math.min(turnBudget, totalRoom()))
|
|
939
|
+
if (recallBudget > 0) {
|
|
940
|
+
const recall = await buildRecall(effective, query, clock, { ...settings, turnTokens: recallBudget }, { agent }, sessionId)
|
|
941
|
+
appendBounded(recall.text, recall.ids)
|
|
942
|
+
errors = recall.errors
|
|
943
|
+
outcome = recall.outcome || 'no-match'
|
|
944
|
+
}
|
|
788
945
|
}
|
|
789
946
|
if (signal?.aborted) return decision
|
|
790
947
|
const current = getSessionBinding(sessionId)
|
|
@@ -798,6 +955,13 @@ async function injectInboundForStep({ agent, decision, turn, signal, log, ctx })
|
|
|
798
955
|
return decision
|
|
799
956
|
} else setRecallError(sessionId, null)
|
|
800
957
|
const text = parts.join('\n\n')
|
|
958
|
+
// Final assertion: every block above is individually bounded, so exceeding the
|
|
959
|
+
// whole-message ceiling here means the accounting drifted. Drop the batch rather
|
|
960
|
+
// than publish an over-budget message.
|
|
961
|
+
if (estTokens(text) > totalBudget) {
|
|
962
|
+
setRecallError(sessionId, 'inbound injection exceeded injectionTotalTokens')
|
|
963
|
+
return decision
|
|
964
|
+
}
|
|
801
965
|
if (text) {
|
|
802
966
|
const pushed = [...new Set(ids)]
|
|
803
967
|
markInjected(sessionId, pushed, clock)
|
|
@@ -854,13 +1018,14 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
854
1018
|
// Advance the idle clock even when injection is off, so sleep consolidation
|
|
855
1019
|
// still knows when this conversation last did anything.
|
|
856
1020
|
touchSession(sessionId)
|
|
857
|
-
const settings = getSettings()
|
|
858
|
-
if (settings.autoInject === 'off') return decision
|
|
859
1021
|
// The folder policy is the ceiling: a conversation cannot opt into a memory
|
|
860
|
-
// layer its folder has switched off.
|
|
861
|
-
const
|
|
862
|
-
if (!
|
|
1022
|
+
// layer its folder has switched off. The guide message shares this admission.
|
|
1023
|
+
const admitted = admissionFor(binding)
|
|
1024
|
+
if (!admitted) return decision
|
|
1025
|
+
const { settings, scope } = admitted
|
|
863
1026
|
const effective = { ...binding, useLibrary: scope.useLibrary, useGlobal: scope.useGlobal }
|
|
1027
|
+
// Captured before any await below; re-checked immediately before the splice.
|
|
1028
|
+
const snapshot = injectionSnapshot(effective)
|
|
864
1029
|
|
|
865
1030
|
const query = contextualQuery(decision.messages, lastUser)
|
|
866
1031
|
const imageCount = imageTurn(lastUser).imageCount
|
|
@@ -874,18 +1039,27 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
874
1039
|
|
|
875
1040
|
const parts = []
|
|
876
1041
|
const ids = []
|
|
1042
|
+
// The sub-budgets (anchorTokens, turnTokens, the hint's own cap) are ceilings for
|
|
1043
|
+
// their own blocks; injectionTotalTokens is the ceiling for the whole plugin
|
|
1044
|
+
// message. Without this outer gate the sub-budgets can add up past the configured
|
|
1045
|
+
// total even when each one individually looks fine.
|
|
1046
|
+
const injectionBudget = Math.max(0, Number(settings.injectionTotalTokens ?? 2000))
|
|
1047
|
+
const totalRoom = () => Math.max(0, injectionBudget - estTokens(parts.join('\n\n')) - (parts.length ? 2 : 0))
|
|
877
1048
|
if (imageCount && effective.useLibrary) {
|
|
878
1049
|
const current = getSessionBinding(sessionId)
|
|
879
1050
|
const pending = (current?.pendingMedia || []).slice(-8)
|
|
880
1051
|
if (pending.length) {
|
|
881
1052
|
const details = pending.map(item => `${item.sha256} · ${item.captionBy}: ${clip(item.caption || '[图片]', 35)}${item.missing ? ' · missing' : ''}`).join('\n')
|
|
882
|
-
|
|
1053
|
+
const mediaText = ['<memory-media>', truncateToTokens(details, 150), current.stickerHint ? '同图反复出现,可以用 track=habit 聚合表达习惯,不必逐次写记忆。' : '', '只知道描述与引用,不能凭空声称看过像素。attachments 只接受 sha256。', '</memory-media>'].filter(Boolean).join('\n')
|
|
1054
|
+
if (estTokens(mediaText) <= totalRoom()) parts.push(mediaText)
|
|
883
1055
|
}
|
|
884
1056
|
}
|
|
885
1057
|
|
|
886
1058
|
if (anchorNeeded) {
|
|
887
|
-
const anchor =
|
|
888
|
-
|
|
1059
|
+
const anchor = totalRoom() >= 160
|
|
1060
|
+
? buildAnchor(effective, { ...settings, anchorTokens: Math.min(Number(settings.anchorTokens || 800), totalRoom()) }, sessionId)
|
|
1061
|
+
: { text: '', ids: [] }
|
|
1062
|
+
if (anchor.text && estTokens(anchor.text) <= totalRoom()) {
|
|
889
1063
|
parts.push(anchor.text)
|
|
890
1064
|
ids.push(ANCHOR_SENTINEL, ...anchor.ids)
|
|
891
1065
|
}
|
|
@@ -893,7 +1067,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
893
1067
|
// the only moment the pre-compaction memories are missing from the window.
|
|
894
1068
|
if (reinject) {
|
|
895
1069
|
const replay = buildReplay(effective, settings, sessionId)
|
|
896
|
-
if (replay.text) {
|
|
1070
|
+
if (replay.text && estTokens(replay.text) <= totalRoom()) {
|
|
897
1071
|
parts.push(replay.text)
|
|
898
1072
|
ids.push(...replay.ids)
|
|
899
1073
|
}
|
|
@@ -902,7 +1076,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
902
1076
|
|
|
903
1077
|
if (!reinject) {
|
|
904
1078
|
const anniversary = buildAnniversary(sessionId, effective)
|
|
905
|
-
if (anniversary.text) {
|
|
1079
|
+
if (anniversary.text && estTokens(anniversary.text) <= totalRoom()) {
|
|
906
1080
|
parts.push(anniversary.text)
|
|
907
1081
|
ids.push(...anniversary.ids)
|
|
908
1082
|
for (const id of anniversary.fired) markAnniversaryShown(sessionId, id, anniversary.dayKey)
|
|
@@ -913,7 +1087,7 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
913
1087
|
// Independent of retrieval: a loop can be worth raising in a conversation that
|
|
914
1088
|
// shares no vocabulary with it, which is exactly the case lexical recall misses.
|
|
915
1089
|
const nudge = buildOpenLoopNudge(effective, settings, sessionId)
|
|
916
|
-
if (nudge.text) {
|
|
1090
|
+
if (nudge.text && estTokens(nudge.text) <= totalRoom()) {
|
|
917
1091
|
parts.push(nudge.text)
|
|
918
1092
|
ids.push(...nudge.ids)
|
|
919
1093
|
}
|
|
@@ -923,7 +1097,8 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
923
1097
|
// The recall path takes the host's exec shape, where the session lives under
|
|
924
1098
|
// `.agent`; passing the Agent itself made sessionIdOf return null and turned
|
|
925
1099
|
// every per-turn recall into a silent no-op (P0-1).
|
|
926
|
-
const
|
|
1100
|
+
const recallBudget = Math.max(0, Math.min(Number(settings.turnTokens ?? 800), totalRoom()))
|
|
1101
|
+
const recall = await buildRecall(effective, query, memoryClock, { ...settings, turnTokens: recallBudget }, { agent }, sessionId)
|
|
927
1102
|
setSessionBinding(sessionId, { lastRecallOutcome: { reason: recall.outcome || 'no-match', candidateCount: recall.candidateCount || 0, injectedCount: recall.ids.length, at: new Date().toISOString() } })
|
|
928
1103
|
if (recall.text) {
|
|
929
1104
|
parts.push(recall.text)
|
|
@@ -939,14 +1114,14 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
939
1114
|
|
|
940
1115
|
if (!reinject) {
|
|
941
1116
|
const cue = await maybeCue({ sessionId, turn, settings, binding: effective, exec: { agent } })
|
|
942
|
-
if (cue) {
|
|
1117
|
+
if (cue && estTokens(cue.text) <= totalRoom()) {
|
|
943
1118
|
parts.push(cue.text)
|
|
944
1119
|
// The related pointers reached the window, so they count towards habituation and
|
|
945
1120
|
// dedupe exactly like the recall block's ids.
|
|
946
1121
|
ids.push(...cue.ids)
|
|
947
1122
|
}
|
|
948
1123
|
const texture = maybeTexture(sessionId, settings)
|
|
949
|
-
if (texture) parts.push(texture)
|
|
1124
|
+
if (texture && estTokens(texture) <= totalRoom()) parts.push(texture)
|
|
950
1125
|
}
|
|
951
1126
|
|
|
952
1127
|
if (parts.length === 0) {
|
|
@@ -954,6 +1129,11 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
954
1129
|
return decision
|
|
955
1130
|
}
|
|
956
1131
|
|
|
1132
|
+
// This is the last point before the message enters decision.messages. Recall,
|
|
1133
|
+
// cue and texture all awaited above; a library/context/switch change during any
|
|
1134
|
+
// of them invalidates every part built from the old snapshot, not just recall.
|
|
1135
|
+
if (!injectionSnapshotIsCurrent(sessionId, snapshot, settings.autoInject)) return decision
|
|
1136
|
+
|
|
957
1137
|
const text = parts.join('\n\n')
|
|
958
1138
|
const message = createMemoryMessage(text)
|
|
959
1139
|
const rewritten = [...decision.messages]
|
|
@@ -982,62 +1162,8 @@ export async function injectForStep({ agent, decision, turn, step, signal, log =
|
|
|
982
1162
|
return { ...decision, messages: rewritten }
|
|
983
1163
|
}
|
|
984
1164
|
|
|
985
|
-
|
|
986
|
-
* Section text must never throw. A missing default prompt file or an unknown key
|
|
987
|
-
* would otherwise break setPrompt assembly for *every* conversation, which takes
|
|
988
|
-
* the whole harness down rather than just memory. Fall back variant -> base ->
|
|
989
|
-
* one line, and log.
|
|
990
|
-
*/
|
|
991
|
-
function sectionText(ctx, key, context) {
|
|
992
|
-
try {
|
|
993
|
-
const sessionId = context?.agent?.session?.header?.id || context?.session?.header?.id || null
|
|
994
|
-
const binding = sessionId ? getSessionBinding(sessionId) : null
|
|
995
|
-
return loadPromptForDomain(key, {
|
|
996
|
-
mustRememberCap: getSettings().mustRememberCap,
|
|
997
|
-
unpinBudget: getSettings().unpinBudgetPerSession ?? 2,
|
|
998
|
-
shortTermTtlHours: getSettings().shortTermTtlHours ?? 72,
|
|
999
|
-
// the coding variants talk about the anchor budget in tokens, so the number
|
|
1000
|
-
// has to come from the live settings and not from a literal in the prompt
|
|
1001
|
-
anchorTokens: getSettings().anchorTokens,
|
|
1002
|
-
turnTokens: getSettings().turnTokens,
|
|
1003
|
-
}, binding ? domainOf(binding) : 'general').text
|
|
1004
|
-
} catch (error) {
|
|
1005
|
-
try { ctx.logger?.warn?.(`dsh-memu-memory: ${key} prompt unavailable: ${error?.message || error}`) } catch {}
|
|
1006
|
-
return '(记忆规则暂时不可用:插件内部错误,不要向用户提及这一段。)'
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
export function registerInjection(ctx, options = {}) {
|
|
1165
|
+
export function registerInjection(ctx) {
|
|
1011
1166
|
const disposers = []
|
|
1012
|
-
// `guide: false` is how a *preset* conversation gets the memory anchor without being
|
|
1013
|
-
// told about tools its preset never mounted — the guide text references memu_* tools,
|
|
1014
|
-
// and a preset that does not list this plugin has no such tools.
|
|
1015
|
-
const wantGuide = options.guide !== false
|
|
1016
|
-
// `guideMessage` exists for conversations whose preset persona uses `complete: true`:
|
|
1017
|
-
// that flag makes the persona the only system prompt section, so a section injected
|
|
1018
|
-
// here is discarded before the model ever sees it. A plugin message still arrives, so
|
|
1019
|
-
// the rules are delivered once per conversation as text instead of as a section.
|
|
1020
|
-
const guideInMessage = options.guideMessage === true
|
|
1021
|
-
if (wantGuide) {
|
|
1022
|
-
try {
|
|
1023
|
-
ctx.inject(['systemPrompt'], (sctx) => {
|
|
1024
|
-
sctx.effect(() => sctx.systemPrompt.section({
|
|
1025
|
-
name: 'dsh-memu-memory:guide',
|
|
1026
|
-
order: 320,
|
|
1027
|
-
interpolate: false,
|
|
1028
|
-
text: (context) => sectionText(sctx, 'system-guide', context),
|
|
1029
|
-
}), 'dsh-memu-memory: guide prompt')
|
|
1030
|
-
sctx.effect(() => sctx.systemPrompt.section({
|
|
1031
|
-
name: 'dsh-memu-memory:private-policy',
|
|
1032
|
-
order: 321,
|
|
1033
|
-
interpolate: false,
|
|
1034
|
-
text: (context) => sectionText(sctx, 'private-policy', context),
|
|
1035
|
-
}), 'dsh-memu-memory: private policy prompt')
|
|
1036
|
-
})
|
|
1037
|
-
} catch (e) {
|
|
1038
|
-
try { ctx.logger?.warn?.(`dsh-memu-memory: systemPrompt guide unavailable: ${e?.message || e}`) } catch {}
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
1167
|
|
|
1042
1168
|
const offPre = ctx.on('agent/pre-step', async (payload, next) => {
|
|
1043
1169
|
const decision = await next()
|
|
@@ -1048,7 +1174,7 @@ export function registerInjection(ctx, options = {}) {
|
|
|
1048
1174
|
ctx,
|
|
1049
1175
|
log: (message) => { try { ctx.logger?.warn?.(`dsh-memu-memory: ${message}`) } catch {} },
|
|
1050
1176
|
})
|
|
1051
|
-
return
|
|
1177
|
+
return ensureUnifiedGuide(ctx, payload, injected)
|
|
1052
1178
|
} catch (error) {
|
|
1053
1179
|
try { setRecallError(payload?.agent?.session?.header?.id, `injection failed: ${error?.message || error}`) } catch {}
|
|
1054
1180
|
try { ctx.logger?.warn?.(`dsh-memu-memory: pre-step injection failed (fail-open): ${error?.stack || error}`) } catch {}
|
|
@@ -1072,6 +1198,9 @@ export function registerInjection(ctx, options = {}) {
|
|
|
1072
1198
|
.slice(-REPLAY_MAX)
|
|
1073
1199
|
releaseInjected(sessionId)
|
|
1074
1200
|
markReinjectPending(sessionId)
|
|
1201
|
+
// The compaction summary drops the plugin's rules message. Flag it so the
|
|
1202
|
+
// next turn's first step replaces it even though guideRulesKey still matches.
|
|
1203
|
+
if (before) setSessionBinding(sessionId, { guideLost: true })
|
|
1075
1204
|
if (recent.length > 0) {
|
|
1076
1205
|
setSessionBinding(sessionId, { replayIds: recent, replayAt: new Date().toISOString() })
|
|
1077
1206
|
}
|