@modusensus/dsh-mneme 0.8.4 → 0.8.6
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/README.md +45 -8
- package/bin/dsh-mneme-mcp.mjs +11 -3
- package/lib/api-standalone.js +11 -0
- package/lib/api.js +21 -0
- package/lib/client.js +213 -31
- package/lib/config.js +106 -5
- package/lib/document.js +221 -0
- package/lib/dream/sleep.js +128 -12
- package/lib/dream.js +46 -11
- package/lib/guide.js +44 -0
- package/lib/heat.js +2 -0
- package/lib/index.js +65 -3
- package/lib/inject.js +91 -15
- package/lib/lang.js +6 -0
- package/lib/local-embedder.js +44 -6
- package/lib/organize.js +450 -0
- package/lib/recall-stats.js +23 -1
- package/lib/runtime/layout.js +30 -0
- package/lib/runtime/loader.js +4 -1
- package/lib/runtime/verify.js +19 -6
- package/lib/search/adaptive.js +38 -0
- package/lib/service.js +165 -12
- package/lib/settings.js +35 -4
- package/lib/store.js +126 -8
- package/lib/summarize.js +253 -8
- package/lib/tools.js +136 -8
- package/package.json +6 -6
- package/runtime-manifest.json +104 -174
- package/scripts/build-runtime-manifest.mjs +8 -1
- package/scripts/sync-test-badge.mjs +18 -12
- package/src/api-standalone.js +11 -0
- package/src/api.js +21 -0
- package/src/config.js +106 -5
- package/src/document.js +221 -0
- package/src/dream/sleep.js +128 -12
- package/src/dream.js +46 -11
- package/src/guide.js +44 -0
- package/src/heat.js +2 -0
- package/src/index.js +65 -3
- package/src/inject.js +91 -15
- package/src/lang.js +6 -0
- package/src/local-embedder.js +44 -6
- package/src/organize.js +450 -0
- package/src/recall-stats.js +23 -1
- package/src/runtime/layout.js +30 -0
- package/src/runtime/loader.js +4 -1
- package/src/runtime/verify.js +19 -6
- package/src/search/adaptive.js +38 -0
- package/src/service.js +165 -12
- package/src/settings.js +35 -4
- package/src/store.js +126 -8
- package/src/summarize.js +253 -8
- package/src/tools.js +136 -8
- package/test/adaptive-inject-budget.test.js +85 -0
- package/test/api.test.js +66 -3
- package/test/client.test.js +63 -2
- package/test/document.test.js +523 -0
- package/test/dream.test.js +51 -0
- package/test/inject-content-cap.test.js +5 -3
- package/test/inject-pools.test.js +170 -0
- package/test/inject-recall.test.js +105 -0
- package/test/inject.test.js +35 -1
- package/test/lastrunat-persist.test.js +101 -0
- package/test/llm-audit.test.js +247 -0
- package/test/local-embedder.test.js +45 -2
- package/test/organize.test.js +418 -0
- package/test/recall-stats.test.js +7 -1
- package/test/runtime-loader.test.js +18 -0
- package/test/runtime-manifest.test.js +2 -1
- package/test/runtime-verify.test.js +65 -0
- package/test/sleep.test.js +154 -0
- package/test/store.test.js +13 -0
- package/test/summarize.test.js +407 -3
- package/test/tools-dto-schema-matrix.test.js +325 -0
- package/test/tools.test.js +36 -3
package/src/inject.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { createScopeResolver } from "./scope.js";
|
|
2
2
|
import { createHotMemory } from "./hot-memory.js";
|
|
3
3
|
import { STR, langOf } from "./lang.js";
|
|
4
|
+
import { MEMORY_GUIDE_SECTION } from "./guide.js";
|
|
5
|
+
import { adaptiveInjectBudget } from "./search/adaptive.js";
|
|
6
|
+
|
|
7
|
+
// Issue #179:注入预览的数据底座。systemPrompt 渲染是同步回调,面板只能事后
|
|
8
|
+
// 拉取,所以在这里旁路缓存「最近一帧组装」——快照就是本次渲染用过的同一份
|
|
9
|
+
// candidates/hotText/body,不重新检索、不二次组装,零额外开销。模块级单例:
|
|
10
|
+
// 注入器全局唯一(index.js 仅在 autoInject 开启时注册一个),面板经 api.js 的
|
|
11
|
+
// 只读 getter 读取;autoInject 关闭时注入器不存在,快照保持 null——「预览
|
|
12
|
+
// 不可用」本身就是「注入关闭」的诚实呈现。
|
|
13
|
+
let injectionSnapshot = null;
|
|
14
|
+
export function getInjectionSnapshot() {
|
|
15
|
+
return injectionSnapshot;
|
|
16
|
+
}
|
|
4
17
|
|
|
5
18
|
// Best-effort extraction of the current user's latest message text from the
|
|
6
19
|
// live session, for semantic-first injection (Bug4). The system-prompt
|
|
@@ -92,7 +105,7 @@ function extractRounds(ctx, maxRounds) {
|
|
|
92
105
|
|
|
93
106
|
export function createInjector(ctx, service, settings, config) {
|
|
94
107
|
const language = langOf(config);
|
|
95
|
-
const
|
|
108
|
+
const baseMaxItems = config.maxInjectedItems ?? 5;
|
|
96
109
|
const threshold = config.importanceThreshold ?? 3;
|
|
97
110
|
// Issue #205:注入位跨轮轮换。rotationTurns = 最近 N 个「不同用户查询」轮次
|
|
98
111
|
// 注入过的记忆本轮不再优先(0 = 关闭,保持既有行为)。历史按会话维护——
|
|
@@ -143,13 +156,18 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
143
156
|
// stale 1500) and an entry that would exceed it collapses to its title only.
|
|
144
157
|
const maxContent = config.injectContentMaxChars ?? 300;
|
|
145
158
|
const MAX_BLOCK = Math.max(1500, maxContent + 600);
|
|
159
|
+
// #249 第一批:pin 条目的正文硬顶。逐字保真不等于无界——单条上限取
|
|
160
|
+
// injectContentMaxChars 与 2000 的较大者,默认档下约束/偏好不会因常规截断
|
|
161
|
+
// 失真,同时给「一条超长约束每轮吃满常驻段」留一道闸(超顶仍带截断提示)。
|
|
162
|
+
const PINNED_CONTENT_MAX = Math.max(maxContent, 2000);
|
|
146
163
|
|
|
147
|
-
// Compressed injection (v0.5.0 2.1): a sleep-demoted row
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
164
|
+
// Compressed injection (v0.5.0 2.1): a sleep-demoted row carries its summary in
|
|
165
|
+
// `content` with the original parked in `_full_content` — inject the summary
|
|
166
|
+
// verbatim instead of re-truncating the (already short) text. 但「降级过」不等于
|
|
167
|
+
// 「天然够短」:原先那条 `_full_content` 早返回的唯一效果就是**跳过 maxLength**
|
|
168
|
+
// (两条分支取的都是 m.content),于是 pin 的硬顶在这一路上形同不存在(#266 评审)。
|
|
169
|
+
// 现在只有一条截断路径:超限照旧带提示,原文永远可通过 memory_get 取回。
|
|
151
170
|
function injectMemory(m, maxLength = maxContent) {
|
|
152
|
-
if (m?._full_content) return String(m.content ?? "");
|
|
153
171
|
const text = String(m?.content ?? "");
|
|
154
172
|
if (text.length <= maxLength) return text;
|
|
155
173
|
return `${text.slice(0, maxLength)}…${STR.truncatedHint[language](maxLength, text.length, m.id)}`;
|
|
@@ -192,26 +210,41 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
192
210
|
return escapePromptVars(STR.hotHeader[language](rounds.length, body));
|
|
193
211
|
}
|
|
194
212
|
|
|
195
|
-
function render(candidates) {
|
|
213
|
+
function render(candidates, pinnedStats = null) {
|
|
196
214
|
if (!candidates.length) return "";
|
|
197
215
|
const header = STR.memoryHeader[language];
|
|
198
216
|
const lines = [header];
|
|
199
217
|
let budget = MAX_BLOCK - header.length;
|
|
200
|
-
|
|
218
|
+
// #249 第一批:pin 池(约束/偏好)排在块头且不参与块的塌缩预算——「与情景
|
|
219
|
+
// 日志抢同一份预算后被降级」正是本议题要修的结构缺陷。单条仍有硬顶
|
|
220
|
+
// (PINNED_CONTENT_MAX),超出照旧带截断提示,不静默。
|
|
221
|
+
const pinnedCount = Math.max(0, Math.min(candidates.length, pinnedStats?.shown ?? 0));
|
|
222
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
223
|
+
const m = candidates[i];
|
|
224
|
+
const pinned = i < pinnedCount;
|
|
201
225
|
// Epistemic trust (v0.4.5): when enabled, measured observations are
|
|
202
226
|
// flagged so the agent can weigh them above guesses/opinions.
|
|
203
227
|
const verified = config.trustEpistemicWeighting === true && m.epistemic_status === "observation"
|
|
204
228
|
? STR.verified[language]
|
|
205
229
|
: "";
|
|
206
230
|
const title = STR.entryTitle[language](m.title, m.importance);
|
|
207
|
-
const content = injectMemory(m);
|
|
231
|
+
const content = injectMemory(m, pinned ? PINNED_CONTENT_MAX : maxContent);
|
|
208
232
|
const full = STR.entryLine[language](m.type, verified, title, content);
|
|
209
|
-
if (
|
|
233
|
+
if (pinned) {
|
|
234
|
+
// pin 不扣块预算(上面那条设计注释的落地):pin 一条就够击穿 MAX_BLOCK
|
|
235
|
+
// (PINNED_CONTENT_MAX 2000 > MAX_BLOCK 1500),照扣会把 budget 压成负数,
|
|
236
|
+
// 同一轮随后的普通候选全部退化成标题行——正是本议题要修的结构缺陷。
|
|
237
|
+
lines.push(full);
|
|
238
|
+
} else if (budget - full.length >= 0) {
|
|
210
239
|
lines.push(full);
|
|
211
240
|
budget -= full.length;
|
|
212
241
|
} else {
|
|
213
242
|
lines.push(`- [${m.type}] ${verified}${title}`);
|
|
214
243
|
}
|
|
244
|
+
if (pinned && i === pinnedCount - 1) {
|
|
245
|
+
const hidden = Math.max(0, pinnedStats?.suppressed ?? 0);
|
|
246
|
+
if (hidden > 0) lines.push(STR.pinnedOverflow[language](hidden));
|
|
247
|
+
}
|
|
215
248
|
}
|
|
216
249
|
return escapePromptVars(lines.join("\n"));
|
|
217
250
|
}
|
|
@@ -250,7 +283,17 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
250
283
|
return escapePromptVars(lines.join("\n"));
|
|
251
284
|
}
|
|
252
285
|
|
|
286
|
+
// #249 第一批:能力说明的系统提示段。order 150 = 插件指引段的既有惯例
|
|
287
|
+
// (ACP 的 ACP_SYSTEM_PROMPT 与 mnemon 的 routing 段都在 150)。文本是常量、
|
|
288
|
+
// 同会话内不随轮次变化,故不影响其后的前缀缓存,也不逐轮复读。宿主若不提供
|
|
289
|
+
// section seam 就静默跳过——#249 §3 的口径是「只用现成 seam,拿不到位的语义
|
|
290
|
+
// 降级处理」:能力说明仍落在工具描述上,不算失败。
|
|
291
|
+
const guideSection = config.injectGuidanceEnabled === true && typeof ctx.systemPrompt?.section === "function"
|
|
292
|
+
? ctx.systemPrompt.section({ name: "memory-guide", order: 150, text: MEMORY_GUIDE_SECTION })
|
|
293
|
+
: null;
|
|
294
|
+
|
|
253
295
|
const disposers = [
|
|
296
|
+
...(guideSection ? [guideSection] : []),
|
|
254
297
|
ctx.systemPrompt.context({
|
|
255
298
|
name: "memory",
|
|
256
299
|
order: 90,
|
|
@@ -270,16 +313,48 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
270
313
|
const rotate = recentInjectedIds(sessionId, query);
|
|
271
314
|
// rotateWindow 随集合一起传入:候选池按 maxItems×(N+1) 扩容——窗口要的
|
|
272
315
|
// 牌比既有 maxItems×2 池多时,轮换才有新鲜牌可换(#205 补测)。
|
|
273
|
-
|
|
274
|
-
|
|
316
|
+
// Issue #239(第 5 项):注入条数的查询自适应(默认关)。确定性强的话题收缩
|
|
317
|
+
// 条数、模糊话题维持上限——单向收缩,绝不越过 maxInjectedItems;判据只看查询
|
|
318
|
+
// 本身,不做额外检索(先探针检索等于白付一次 fuseRecall)。
|
|
319
|
+
const maxItems = config.injectUncertaintyAdaptive === true
|
|
320
|
+
? adaptiveInjectBudget(query, baseMaxItems)
|
|
321
|
+
: baseMaxItems;
|
|
322
|
+
// #249 第一批:pin 池统计走可选出参,不动 injectCandidates 的数组契约。
|
|
323
|
+
const pinnedStats = {};
|
|
324
|
+
const candidates = service.injectCandidates({ query, queryVector, maxItems, threshold, scope, rotate, rotateWindow: rotationTurns, pinnedStats });
|
|
325
|
+
// #249:pin 条目不进轮换历史——它们每轮固定前置,记进去只会占满轮换
|
|
326
|
+
// 窗口、挤掉情景候选的新鲜度(验收:pin 不参与跨轮轮换)。
|
|
327
|
+
recordInjection(sessionId, query, pinnedStats.shown > 0 ? candidates.slice(pinnedStats.shown) : candidates);
|
|
275
328
|
// Hot memory (v0.5.0 1.3) leads the single memory block: the agent
|
|
276
329
|
// sees the short-term rounds first, then the cross-session recall —
|
|
277
330
|
// the documented injection order 1→2. Folding it here (instead of a
|
|
278
331
|
// separate context) keeps the prompt assembly stable at two blocks.
|
|
279
332
|
const hotText = renderHotContext(ctx);
|
|
280
|
-
const body = render(candidates);
|
|
281
|
-
|
|
282
|
-
|
|
333
|
+
const body = render(candidates, pinnedStats);
|
|
334
|
+
const finalBody = !hotText ? body : body ? `${hotText}\n\n${body}` : hotText;
|
|
335
|
+
// Issue #179:旁路缓存一帧——面板「注入预览」卡据此展示构成与体积。
|
|
336
|
+
// chars 为条目内容的截断后近似值(与 render 同一函数计长),totalChars
|
|
337
|
+
// 是本次实际返回块的精确长度。maxItems 反映自适应收缩后的生效值。
|
|
338
|
+
injectionSnapshot = {
|
|
339
|
+
at: Date.now(),
|
|
340
|
+
sessionId,
|
|
341
|
+
query: query || "",
|
|
342
|
+
maxItems,
|
|
343
|
+
threshold,
|
|
344
|
+
adaptive: config.injectUncertaintyAdaptive === true,
|
|
345
|
+
scoped: scope && (scope.agent_scope || scope.workspace_scope) ? scope : null,
|
|
346
|
+
rotated: rotate ? rotate.size : 0,
|
|
347
|
+
hotChars: hotText.length,
|
|
348
|
+
entries: candidates.map((m) => ({
|
|
349
|
+
id: m.id,
|
|
350
|
+
type: m.type,
|
|
351
|
+
title: m.title,
|
|
352
|
+
importance: m.importance,
|
|
353
|
+
chars: injectMemory(m).length
|
|
354
|
+
})),
|
|
355
|
+
totalChars: finalBody.length
|
|
356
|
+
};
|
|
357
|
+
return finalBody;
|
|
283
358
|
}
|
|
284
359
|
}),
|
|
285
360
|
ctx.systemPrompt.context({
|
|
@@ -292,6 +367,7 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
292
367
|
return () => {
|
|
293
368
|
queryVectorCache.clear();
|
|
294
369
|
rotationHistory.clear();
|
|
370
|
+
injectionSnapshot = null; // 注入器卸载即失效:快照不得跨生命周期存留
|
|
295
371
|
for (const dispose of disposers) {
|
|
296
372
|
if (typeof dispose === "function") dispose();
|
|
297
373
|
}
|
package/src/lang.js
CHANGED
|
@@ -75,6 +75,12 @@ export const STR = {
|
|
|
75
75
|
zh: (max, len, id) => `〔已截断:上限 ${max},原 ${len} 字符;全文用 memory_get "${id}" 查看〕`,
|
|
76
76
|
en: (max, len, id) => `[truncated: limit ${max}, original ${len} chars; memory_get "${id}" for full text]`
|
|
77
77
|
},
|
|
78
|
+
// Issue #249(第一批):pin 池超预算时的如实标注——分层不等于丢弃,也不允许
|
|
79
|
+
// 静默省略未展示条数(agent 才知道库里的约束不止这几条,需按需 memory_search)。
|
|
80
|
+
pinnedOverflow: {
|
|
81
|
+
zh: (n) => `〔约束/偏好类另有 ${n} 条未展示〕`,
|
|
82
|
+
en: (n) => `[${n} more constraint/preference entries not shown]`
|
|
83
|
+
},
|
|
78
84
|
userSettingsHeader: {
|
|
79
85
|
zh: "[用户设置] 来自 dsh-mneme 的用户画像与规则:",
|
|
80
86
|
en: "[User settings] Profile and rules from dsh-mneme:"
|
package/src/local-embedder.js
CHANGED
|
@@ -21,6 +21,32 @@ function modelHash(model) {
|
|
|
21
21
|
return `${model}#${hashString(model)}`;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Model families whose embedding is the CLS token rather than a mean over tokens.
|
|
26
|
+
*
|
|
27
|
+
* BGE (BAAI/bge-*) is trained that way: the model's own `1_Pooling/config.json`
|
|
28
|
+
* declares `pooling_mode_cls_token: true` / `pooling_mode_mean_tokens: false`, and
|
|
29
|
+
* the official README says "select the last hidden state of the first token" plus
|
|
30
|
+
* L2 normalization. transformers.js's `feature-extraction` defaults to `mean`, so
|
|
31
|
+
* feeding it a BGE model silently produces slightly-off vectors — no error, just a
|
|
32
|
+
* worse ranking. This table is the extension point: a family goes in only once its
|
|
33
|
+
* pooling is confirmed against the model's own `1_Pooling` (or equivalent) config.
|
|
34
|
+
*/
|
|
35
|
+
const CLS_POOLED_MODEL_PATTERNS = [/bge/i];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the pooling to embed with.
|
|
39
|
+
*
|
|
40
|
+
* `auto` (default) picks by model family — BGE → `cls`, everything else → `mean`,
|
|
41
|
+
* so models that were never affected keep their exact previous behavior. An
|
|
42
|
+
* explicit `cls` / `mean` wins over the family table.
|
|
43
|
+
*/
|
|
44
|
+
export function resolvePooling(pooling, model) {
|
|
45
|
+
const mode = String(pooling ?? "auto").trim().toLowerCase();
|
|
46
|
+
if (mode === "cls" || mode === "mean") return mode;
|
|
47
|
+
return CLS_POOLED_MODEL_PATTERNS.some((re) => re.test(String(model ?? ""))) ? "cls" : "mean";
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
/**
|
|
25
51
|
* Lazy default loader: dynamic import keeps module load cheap.
|
|
26
52
|
*
|
|
@@ -71,8 +97,9 @@ function tensorToRows(tensor) {
|
|
|
71
97
|
|
|
72
98
|
/**
|
|
73
99
|
* ONNX text embedder backed by transformers.js (onnxruntime-node underneath).
|
|
74
|
-
* Runs fully offline
|
|
75
|
-
*
|
|
100
|
+
* Runs fully offline; pooling is resolved per model family (BGE → CLS, else mean —
|
|
101
|
+
* see resolvePooling) and vectors are L2-normalized. `engineFactory` is injectable
|
|
102
|
+
* for tests.
|
|
76
103
|
*/
|
|
77
104
|
export class LocalEmbedder {
|
|
78
105
|
constructor(opts = {}) {
|
|
@@ -87,6 +114,10 @@ export class LocalEmbedder {
|
|
|
87
114
|
// (含测试)保持裸 fetch 的现状,配置默认值由 index.js 传进来。
|
|
88
115
|
this.resilientModelDownload = opts.resilientModelDownload === true;
|
|
89
116
|
this.useDtype = opts.useDtype || "q8";
|
|
117
|
+
// 池化:配置值(auto/cls/mean)→ 生效值(cls/mean)。解析结果既进 embed(),
|
|
118
|
+
// 也进 modelHash —— 换池化就是换向量空间,旧索引必须被判失配后重建。
|
|
119
|
+
this.poolingMode = String(opts.pooling ?? "auto").trim().toLowerCase();
|
|
120
|
+
this.pooling = resolvePooling(this.poolingMode, this.model);
|
|
90
121
|
// 模型镜像下载源(embedModelMirror,#188 补充):空 = transformers 默认。
|
|
91
122
|
this.remoteHost = String(opts.remoteHost ?? "").trim();
|
|
92
123
|
this.logger = opts.logger ?? null;
|
|
@@ -114,19 +145,19 @@ export class LocalEmbedder {
|
|
|
114
145
|
this.extractor = await this.engineFactory("feature-extraction", this.model, options);
|
|
115
146
|
this.ready = true; // service reads this to flush queued re-embeds
|
|
116
147
|
this.logger?.info?.(
|
|
117
|
-
`[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device})`
|
|
148
|
+
`[dsh-mneme] local embedder ready: ${this.model} (dim=${this._dimension}, device=${this.device}, pooling=${this.pooling})`
|
|
118
149
|
);
|
|
119
150
|
return this;
|
|
120
151
|
}
|
|
121
152
|
|
|
122
|
-
/** Embed many texts with
|
|
153
|
+
/** Embed many texts with the model's pooling; chunks at batchSize. */
|
|
123
154
|
async embed(texts) {
|
|
124
155
|
if (!Array.isArray(texts)) throw new TypeError("embed expects an array of strings");
|
|
125
156
|
if (!this.extractor) throw new Error("LocalEmbedder not initialized");
|
|
126
157
|
const out = [];
|
|
127
158
|
for (let i = 0; i < texts.length; i += this.batchSize) {
|
|
128
159
|
const chunk = texts.slice(i, i + this.batchSize);
|
|
129
|
-
const tensor = await this.extractor(chunk, { pooling:
|
|
160
|
+
const tensor = await this.extractor(chunk, { pooling: this.pooling, normalize: true });
|
|
130
161
|
out.push(...tensorToRows(tensor));
|
|
131
162
|
}
|
|
132
163
|
return out;
|
|
@@ -142,7 +173,14 @@ export class LocalEmbedder {
|
|
|
142
173
|
}
|
|
143
174
|
|
|
144
175
|
get modelHash() {
|
|
145
|
-
|
|
176
|
+
// Pooling is part of the vector space: a CLS index cannot be queried with mean
|
|
177
|
+
// vectors (or vice versa). Keep the historical `model#hash` shape for `mean` so
|
|
178
|
+
// indexes that were never affected are not invalidated needlessly; every other
|
|
179
|
+
// pooling gets a fingerprint of its own, which makes the index-consistency gate
|
|
180
|
+
// flag the stale mean-space index and rebuild it.
|
|
181
|
+
return this.pooling === "mean"
|
|
182
|
+
? modelHash(this.model)
|
|
183
|
+
: modelHash(`${this.model}@${this.pooling}`);
|
|
146
184
|
}
|
|
147
185
|
|
|
148
186
|
dispose() {
|