@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.
Files changed (75) hide show
  1. package/README.md +45 -8
  2. package/bin/dsh-mneme-mcp.mjs +11 -3
  3. package/lib/api-standalone.js +11 -0
  4. package/lib/api.js +21 -0
  5. package/lib/client.js +213 -31
  6. package/lib/config.js +106 -5
  7. package/lib/document.js +221 -0
  8. package/lib/dream/sleep.js +128 -12
  9. package/lib/dream.js +46 -11
  10. package/lib/guide.js +44 -0
  11. package/lib/heat.js +2 -0
  12. package/lib/index.js +65 -3
  13. package/lib/inject.js +91 -15
  14. package/lib/lang.js +6 -0
  15. package/lib/local-embedder.js +44 -6
  16. package/lib/organize.js +450 -0
  17. package/lib/recall-stats.js +23 -1
  18. package/lib/runtime/layout.js +30 -0
  19. package/lib/runtime/loader.js +4 -1
  20. package/lib/runtime/verify.js +19 -6
  21. package/lib/search/adaptive.js +38 -0
  22. package/lib/service.js +165 -12
  23. package/lib/settings.js +35 -4
  24. package/lib/store.js +126 -8
  25. package/lib/summarize.js +253 -8
  26. package/lib/tools.js +136 -8
  27. package/package.json +6 -6
  28. package/runtime-manifest.json +104 -174
  29. package/scripts/build-runtime-manifest.mjs +8 -1
  30. package/scripts/sync-test-badge.mjs +18 -12
  31. package/src/api-standalone.js +11 -0
  32. package/src/api.js +21 -0
  33. package/src/config.js +106 -5
  34. package/src/document.js +221 -0
  35. package/src/dream/sleep.js +128 -12
  36. package/src/dream.js +46 -11
  37. package/src/guide.js +44 -0
  38. package/src/heat.js +2 -0
  39. package/src/index.js +65 -3
  40. package/src/inject.js +91 -15
  41. package/src/lang.js +6 -0
  42. package/src/local-embedder.js +44 -6
  43. package/src/organize.js +450 -0
  44. package/src/recall-stats.js +23 -1
  45. package/src/runtime/layout.js +30 -0
  46. package/src/runtime/loader.js +4 -1
  47. package/src/runtime/verify.js +19 -6
  48. package/src/search/adaptive.js +38 -0
  49. package/src/service.js +165 -12
  50. package/src/settings.js +35 -4
  51. package/src/store.js +126 -8
  52. package/src/summarize.js +253 -8
  53. package/src/tools.js +136 -8
  54. package/test/adaptive-inject-budget.test.js +85 -0
  55. package/test/api.test.js +66 -3
  56. package/test/client.test.js +63 -2
  57. package/test/document.test.js +523 -0
  58. package/test/dream.test.js +51 -0
  59. package/test/inject-content-cap.test.js +5 -3
  60. package/test/inject-pools.test.js +170 -0
  61. package/test/inject-recall.test.js +105 -0
  62. package/test/inject.test.js +35 -1
  63. package/test/lastrunat-persist.test.js +101 -0
  64. package/test/llm-audit.test.js +247 -0
  65. package/test/local-embedder.test.js +45 -2
  66. package/test/organize.test.js +418 -0
  67. package/test/recall-stats.test.js +7 -1
  68. package/test/runtime-loader.test.js +18 -0
  69. package/test/runtime-manifest.test.js +2 -1
  70. package/test/runtime-verify.test.js +65 -0
  71. package/test/sleep.test.js +154 -0
  72. package/test/store.test.js +13 -0
  73. package/test/summarize.test.js +407 -3
  74. package/test/tools-dto-schema-matrix.test.js +325 -0
  75. package/test/tools.test.js +36 -3
@@ -7,6 +7,7 @@ import { createDreamScheduler } from "../src/dream.js";
7
7
  import { createSummarizer } from "../src/summarize.js";
8
8
  import { createApi } from "../src/api.js";
9
9
  import { createSettings } from "../src/settings.js";
10
+ import { createEntityStreamAdapter } from "../src/index.js";
10
11
  import { mockCtx } from "./helpers/dream-mock.js";
11
12
 
12
13
  // Bug8: LLM audit trail. Every background LLM call (autoDream consolidation +
@@ -195,6 +196,159 @@ test("autoSummarize writes an llm_audit_logs row (trigger_source autoSummarize)"
195
196
  store.close();
196
197
  });
197
198
 
199
+ // --- token 记账:用量嵌在 chunk.usage 下 -------------------------------------
200
+ // 真实 dsh-llm 协议的用量形状是 { type:"usage", usage: TokenUsage }(TokenUsage
201
+ // = { inputTokens, outputTokens, … }),chunk 顶层没有 token 字段。把整个 chunk
202
+ // 当用量对象读取会让 input/output 恒为 undefined,审计行落 0:本机实测 7 天 49
203
+ // 次 success 调用 total_tokens 全 0,面板「LLM 消耗」永远显示 0。下面三例锁住
204
+ // 真实形状,并保留用量平铺在顶层的兼容分支。
205
+
206
+ /** 在 base ctx 的流末尾(finish 之前)插入一个 usage chunk。 */
207
+ function withUsageChunk(base, usageChunk) {
208
+ return {
209
+ ...base,
210
+ llm: {
211
+ async *stream(options) {
212
+ for await (const chunk of base.llm.stream(options)) {
213
+ if (chunk.type === "finish") yield usageChunk;
214
+ yield chunk;
215
+ }
216
+ }
217
+ }
218
+ };
219
+ }
220
+
221
+ /** consolidation 决策:全部 keep,保证 run 成功且无需解析语义。 */
222
+ const keepAllDecisions = (listText) => {
223
+ const ids = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=\w+\s*\|\s*importance=\d+/g)].map((m) => m[1]);
224
+ return JSON.stringify(ids.map((id) => ({ action: "keep", ids: [id] })));
225
+ };
226
+
227
+ test("autoDream records tokens from the nested usage chunk (chunk.usage)", async () => {
228
+ const { store, service, config } = dreamSetup();
229
+ service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
230
+ const ctx = withUsageChunk(
231
+ mockCtx({ onConsolidation: keepAllDecisions }),
232
+ { type: "usage", usage: { inputTokens: 1200, outputTokens: 300, totalTokens: 1500 } }
233
+ );
234
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
235
+ const result = await dream.runDream(ctx, service, config);
236
+ assert.equal(result.ok, true);
237
+
238
+ const rows = service.listLlmAudits();
239
+ const consolidate = rows.find((r) => r.operation_type === "dream_consolidate");
240
+ assert.equal(consolidate.input_tokens, 1200, "input tokens come from chunk.usage");
241
+ assert.equal(consolidate.output_tokens, 300, "output tokens come from chunk.usage");
242
+ assert.equal(consolidate.total_tokens, 1500);
243
+ const summary = rows.find((r) => r.operation_type === "dream_summarize");
244
+ assert.equal(summary.total_tokens, 1500, "the dream summary call is accounted too");
245
+ store.close();
246
+ });
247
+
248
+ test("autoSummarize records tokens from the nested usage chunk (chunk.usage)", async () => {
249
+ const store = createStore(":memory:");
250
+ const service = createService({ store, mirror: null, config: {} });
251
+ const events = [];
252
+ const ctx = withUsageChunk({
253
+ on(name, fn) {
254
+ events.push({ name, fn });
255
+ return () => {};
256
+ },
257
+ logger: { warn: () => {} },
258
+ llm: {
259
+ async *stream() {
260
+ yield { type: "text-delta", index: 0, text: JSON.stringify([
261
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 }
262
+ ]) };
263
+ yield { type: "finish", reason: { kind: "stop" } };
264
+ }
265
+ }
266
+ }, { type: "usage", usage: { inputTokens: 800, outputTokens: 200, totalTokens: 1000 } });
267
+ const config = {
268
+ autoSummarize: true,
269
+ summarizeProvider: "deepseek",
270
+ summarizeModel: "deepseek-chat",
271
+ llmAudit: { enabled: true }
272
+ };
273
+ createSummarizer(ctx, service, config);
274
+ const handler = events.find((e) => e.name === "session/event").fn;
275
+ const session = {
276
+ id: "s1",
277
+ requestHeader: () => ({ config: {} }),
278
+ events: [
279
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "帮我选型" }] } },
280
+ { seq: 2, type: "turn/end" }
281
+ ]
282
+ };
283
+ await handler(session, { seq: 2, type: "turn/end" });
284
+
285
+ const rows = service.listLlmAudits();
286
+ assert.equal(rows.length, 1);
287
+ assert.equal(rows[0].input_tokens, 800, "input tokens come from chunk.usage");
288
+ assert.equal(rows[0].output_tokens, 200, "output tokens come from chunk.usage");
289
+ assert.equal(rows[0].total_tokens, 1000);
290
+ store.close();
291
+ });
292
+
293
+ test("flat top-level usage shape still records tokens", async () => {
294
+ const { store, service, config } = dreamSetup();
295
+ service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
296
+ const ctx = withUsageChunk(
297
+ mockCtx({ onConsolidation: keepAllDecisions }),
298
+ { type: "usage", inputTokens: 40, outputTokens: 10 }
299
+ );
300
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
301
+ await dream.runDream(ctx, service, config);
302
+ const consolidate = service.listLlmAudits().find((r) => r.operation_type === "dream_consolidate");
303
+ assert.equal(consolidate.total_tokens, 50, "flat shape is still readable");
304
+ store.close();
305
+ });
306
+
307
+ // --- token 记账的边界语义 ----------------------------------------------------
308
+ // 上面三例锁的是「有 usage chunk 且形状正确」。契约的另一半是「没有用量时的
309
+ // 行为」:未知不等于错误,记 0(不崩,也不凭空编数),同时保留协议演进时
310
+ // 用量平铺在 chunk 根的读法。
311
+
312
+ test("no usage chunk at all still writes 0 tokens and never crashes", async () => {
313
+ const { store, service, config } = dreamSetup();
314
+ service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
315
+ // 桩的原始形状就是不发 usage chunk:适配器未上报用量的情形走这条路径。
316
+ const ctx = mockCtx({ onConsolidation: keepAllDecisions });
317
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
318
+ const result = await dream.runDream(ctx, service, config);
319
+ assert.equal(result.ok, true, "缺少用量不该让巩固本身失败");
320
+ const rows = service.listLlmAudits();
321
+ assert.ok(rows.length >= 1, "巩固那行仍然要写下");
322
+ for (const row of rows) {
323
+ assert.equal(row.input_tokens, 0, "未知用量记 0,不编数");
324
+ assert.equal(row.output_tokens, 0);
325
+ assert.equal(row.total_tokens, 0);
326
+ }
327
+ store.close();
328
+ });
329
+
330
+ test("root-level flattened usage fields still record tokens (protocol fallback)", async () => {
331
+ const { store, service, config } = dreamSetup();
332
+ service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
333
+ // 用量数字直接挂在 chunk 根、没有嵌套 usage 对象,命中的是 `chunk.usage ?? chunk`
334
+ // 的兜底分支;这里用 snake_case,锁住读取端的别名链。
335
+ const ctx = withUsageChunk(
336
+ mockCtx({ onConsolidation: keepAllDecisions }),
337
+ { type: "usage", input_tokens: 9, output_tokens: 3 }
338
+ );
339
+ const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
340
+ const result = await dream.runDream(ctx, service, config);
341
+ assert.equal(result.ok, true);
342
+ const rows = service.listLlmAudits();
343
+ assert.ok(rows.length >= 1);
344
+ for (const row of rows) {
345
+ assert.equal(row.input_tokens, 9, "根级平铺字段仍可读");
346
+ assert.equal(row.output_tokens, 3);
347
+ assert.equal(row.total_tokens, 12);
348
+ }
349
+ store.close();
350
+ });
351
+
198
352
  // --- API surface ------------------------------------------------------------
199
353
 
200
354
  class FakeRes extends EventEmitter {
@@ -307,3 +461,96 @@ test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates by source and statu
307
461
  assert.equal(errStatus.c, 1);
308
462
  store.close();
309
463
  });
464
+
465
+ // ------------------------------------------- entity extraction (issue #250)
466
+ // 实体抽取是「每次写入记忆都会跑」的后台 LLM 链路,但 createEntityStreamAdapter
467
+ // 只拿到 llm 句柄、没有 service,所以从来没有写审计的能力——面板因此回答不了
468
+ // 「关掉实体抽取能省多少」。下面两条用例锁定 usage chunk 汇总落库,以及审计写失败
469
+ // 只 warn 不反噬(CONTRIBUTING 的 fail-safe 硬约定)。
470
+
471
+ test("entity extraction writes an llm_audit row with the streamed usage tokens", async () => {
472
+ const store = createStore(":memory:");
473
+ const service = createService({ store, mirror: null, config: {} });
474
+ const streamEntityText = createEntityStreamAdapter({
475
+ llm: {
476
+ async *stream() {
477
+ yield { type: "text-delta", index: 0, text: '{"entities":[],"relations":[]}' };
478
+ yield { type: "usage", usage: { inputTokens: 700, outputTokens: 180, totalTokens: 880 } };
479
+ yield { type: "finish", reason: { kind: "stop" } };
480
+ }
481
+ },
482
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
483
+ logger: { warn: () => {} },
484
+ service
485
+ });
486
+ const text = await streamEntityText(
487
+ [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
488
+ {}
489
+ );
490
+ assert.ok(text.includes("entities"), "the extracted text still reaches the extractor");
491
+
492
+ const row = service.listLlmAudits({ source: "entityExtract" })[0];
493
+ assert.ok(row, "entity_extract audit row present");
494
+ assert.equal(row.trigger_source, "entityExtract");
495
+ assert.equal(row.operation_type, "entity_extract");
496
+ assert.equal(row.status, "success");
497
+ assert.equal(row.model_id, "mock:mock-model", "the resolved route is recorded");
498
+ assert.equal(row.input_tokens, 700, "input tokens come from chunk.usage");
499
+ assert.equal(row.output_tokens, 180, "output tokens come from chunk.usage");
500
+ assert.equal(row.total_tokens, 880);
501
+ store.close();
502
+ });
503
+
504
+ test("entity extraction audit write failure only warns and never sinks the extraction", async () => {
505
+ const store = createStore(":memory:");
506
+ const service = createService({ store, mirror: null, config: {} });
507
+ // 审计表写不进去(只读库 / 磁盘满)绝不能把抽取本身拖失败—— fail-safe 是硬约定。
508
+ service.saveLlmAudit = () => { throw new Error("attempt to write a readonly database"); };
509
+ const warnings = [];
510
+ const streamEntityText = createEntityStreamAdapter({
511
+ llm: {
512
+ async *stream() {
513
+ yield { type: "text-delta", index: 0, text: '{"entities":[],"relations":[]}' };
514
+ yield { type: "usage", usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } };
515
+ yield { type: "finish", reason: { kind: "stop" } };
516
+ }
517
+ },
518
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
519
+ logger: { warn: (m) => warnings.push(String(m)) },
520
+ service
521
+ });
522
+ const text = await streamEntityText(
523
+ [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
524
+ {}
525
+ );
526
+ assert.ok(text.includes("entities"), "extraction text survives an audit write failure");
527
+ assert.ok(warnings.some((w) => w.includes("llm audit write failed")),
528
+ "the audit failure is logged rather than swallowed silently");
529
+ store.close();
530
+ });
531
+
532
+ test("entity extraction writes no audit row when llmAudit.enabled === false", async () => {
533
+ const store = createStore(":memory:");
534
+ const service = createService({ store, mirror: null, config: {} });
535
+ const streamEntityText = createEntityStreamAdapter({
536
+ llm: {
537
+ async *stream() {
538
+ yield { type: "text-delta", index: 0, text: '{"entities":[],"relations":[]}' };
539
+ yield { type: "usage", usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } };
540
+ yield { type: "finish", reason: { kind: "stop" } };
541
+ }
542
+ },
543
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
544
+ logger: { warn: () => {} },
545
+ service,
546
+ // 单闸门:与 dream / sleep / summarize 同一个 llmAudit.enabled,不新增第二道。
547
+ config: { llmAudit: { enabled: false } }
548
+ });
549
+ const text = await streamEntityText(
550
+ [{ role: "user", content: [{ type: "text", text: "记忆内容" }] }],
551
+ {}
552
+ );
553
+ assert.ok(text.includes("entities"), "turning the audit off must not break extraction");
554
+ assert.equal(service.listLlmAudits().length, 0, "no audit rows when disabled");
555
+ store.close();
556
+ });
@@ -79,7 +79,7 @@ test("LocalEmbedder init threads resilientModelDownload and logger to engineFact
79
79
  assert.equal(seen.resilientModelDownload, false, "直连构造不显式给 true = 保持现状");
80
80
  });
81
81
 
82
- test("LocalEmbedder embed returns [n, dim] with mean pooling and chunking", async () => {
82
+ test("LocalEmbedder embed returns [n, dim] with the model's pooling and chunking", async () => {
83
83
  const calls = [];
84
84
  const e = new LocalEmbedder({
85
85
  model: "Xenova/bge-small-zh-v1.5",
@@ -95,6 +95,18 @@ test("LocalEmbedder embed returns [n, dim] with mean pooling and chunking", asyn
95
95
  }
96
96
  // 3 texts at batchSize 2 -> two extractor calls.
97
97
  assert.deepEqual(calls.map((c) => c.count), [2, 1]);
98
+ // BGE 系按 CLS 训练(模型自带 1_Pooling/config.json: pooling_mode_cls_token=true)
99
+ assert.deepEqual(calls[0].opts, { pooling: "cls", normalize: true });
100
+ });
101
+
102
+ test("LocalEmbedder keeps mean pooling for models outside the CLS families", async () => {
103
+ const calls = [];
104
+ const e = new LocalEmbedder({
105
+ model: "sentence-transformers/all-MiniLM-L6-v2",
106
+ engineFactory: async () => makeFakeExtractor(384, calls)
107
+ });
108
+ await e.init();
109
+ await e.embed(["x"]);
98
110
  assert.deepEqual(calls[0].opts, { pooling: "mean", normalize: true });
99
111
  });
100
112
 
@@ -105,14 +117,45 @@ test("LocalEmbedder embedSingle returns a single vector", async () => {
105
117
  assert.equal(v.length, 384);
106
118
  });
107
119
 
120
+ test("LocalEmbedder resolves pooling by model family, explicit value wins", async () => {
121
+ const cases = [
122
+ [{ model: "Xenova/bge-small-zh-v1.5" }, "cls", "BGE 默认 cls"],
123
+ [{ model: "BAAI/bge-m3" }, "cls", "BGE 大小写无关"],
124
+ [{ model: "sentence-transformers/all-MiniLM-L6-v2" }, "mean", "非 BGE 族保持 mean"],
125
+ [{ model: "Xenova/bge-small-zh-v1.5", pooling: "mean" }, "mean", "显式覆盖 auto"],
126
+ [{ model: "sentence-transformers/all-MiniLM-L6-v2", pooling: "cls" }, "cls", "显式覆盖非 BGE"],
127
+ [{ model: "Xenova/bge-small-zh-v1.5", pooling: "AUTO" }, "cls", "大小写/空白容错"],
128
+ [{ model: "Xenova/bge-small-zh-v1.5", pooling: " bogus " }, "cls", "未知值退回 auto"]
129
+ ];
130
+ for (const [opts, expected, why] of cases) {
131
+ const calls = [];
132
+ const e = new LocalEmbedder({ ...opts, engineFactory: async () => makeFakeExtractor(512, calls) });
133
+ assert.equal(e.pooling, expected, why);
134
+ await e.init();
135
+ await e.embed(["x"]);
136
+ assert.equal(calls[0].opts.pooling, expected, `${why}: 必须真的传进 extractor`);
137
+ }
138
+ });
139
+
108
140
  test("LocalEmbedder dimension and modelHash are stable", () => {
109
141
  const a = new LocalEmbedder({ model: "Xenova/bge-small-zh-v1.5" });
110
142
  const b = new LocalEmbedder({ model: "Xenova/bge-small-zh-v1.5" });
111
143
  assert.equal(a.dimension, 512);
112
144
  assert.equal(a.modelHash, b.modelHash);
113
- assert.match(a.modelHash, /^Xenova\/bge-small-zh-v1\.5#[0-9a-f]+$/);
145
+ // BGE 解析为 cls,且池化进指纹(否则旧索引不会被判失配、不会重建)
146
+ assert.match(a.modelHash, /^Xenova\/bge-small-zh-v1\.5@cls#[0-9a-f]+$/);
114
147
  // Different model -> different hash.
115
148
  assert.notEqual(a.modelHash, new LocalEmbedder({ model: "other/model" }).modelHash);
149
+ // 显式 mean 保持历史指纹形状,不给未受影响的索引制造无谓重建
150
+ assert.match(
151
+ new LocalEmbedder({ model: "Xenova/bge-small-zh-v1.5", pooling: "mean" }).modelHash,
152
+ /^Xenova\/bge-small-zh-v1\.5#[0-9a-f]+$/
153
+ );
154
+ // 两种池化是不同向量空间,绝不能共用一个指纹
155
+ assert.notEqual(
156
+ a.modelHash,
157
+ new LocalEmbedder({ model: "Xenova/bge-small-zh-v1.5", pooling: "mean" }).modelHash
158
+ );
116
159
  });
117
160
 
118
161
  test("LocalEmbedder throws before init and after dispose", async () => {