@modusensus/dsh-mneme 0.7.10 → 0.7.12
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.en.md +437 -0
- package/README.md +364 -166
- package/bin/cli.mjs +603 -0
- package/{dsh-mneme/cordis.patch.yml → cordis.patch.yml} +1 -0
- package/lib/api-standalone.js +264 -0
- package/{dsh-mneme/lib → lib}/api.js +133 -289
- package/{dsh-mneme/lib → lib}/client.js +1008 -922
- package/{dsh-mneme/lib → lib}/config.js +54 -99
- package/{dsh-mneme/lib → lib}/dream/decisions.js +94 -143
- package/{dsh-mneme/lib → lib}/dream/sleep.js +12 -152
- package/{dsh-mneme/lib → lib}/dream.js +22 -168
- package/{dsh-mneme/src → lib}/hot-memory.js +0 -7
- package/{dsh-mneme/lib → lib}/index.js +45 -42
- package/{dsh-mneme/lib → lib}/inject.js +11 -53
- package/{dsh-mneme/src → lib}/mirror.js +1 -12
- package/{dsh-mneme/lib → lib}/quality-filter.js +1 -3
- package/{dsh-mneme/lib → lib}/reranker.js +1 -4
- package/{dsh-mneme/lib → lib}/service.js +20 -410
- package/{dsh-mneme/src → lib}/settings.js +34 -64
- package/{dsh-mneme/lib → lib}/store.js +38 -450
- package/{dsh-mneme/lib → lib}/summarize.js +10 -10
- package/lib/tools.js +274 -0
- package/package.json +45 -19
- package/{dsh-mneme/scripts → scripts}/e2e-dsh.js +3 -4
- package/{dsh-mneme/scripts → scripts}/sync-lib.js +5 -10
- package/src/api-standalone.js +264 -0
- package/{dsh-mneme/src → src}/api.js +133 -289
- package/{dsh-mneme/src → src}/config.js +54 -99
- package/{dsh-mneme/src → src}/dream/decisions.js +94 -143
- package/{dsh-mneme/src → src}/dream/sleep.js +12 -152
- package/{dsh-mneme/src → src}/dream.js +22 -168
- package/{dsh-mneme/lib → src}/hot-memory.js +0 -7
- package/{dsh-mneme/src → src}/index.js +45 -42
- package/{dsh-mneme/src → src}/inject.js +11 -53
- package/{dsh-mneme/lib → src}/mirror.js +1 -12
- package/{dsh-mneme/src → src}/quality-filter.js +1 -3
- package/{dsh-mneme/src → src}/reranker.js +1 -4
- package/{dsh-mneme/src → src}/service.js +20 -410
- package/{dsh-mneme/lib → src}/settings.js +34 -64
- package/{dsh-mneme/src → src}/store.js +38 -450
- package/{dsh-mneme/src → src}/summarize.js +10 -10
- package/src/tools.js +274 -0
- package/{dsh-mneme/test → test}/api.test.js +45 -156
- package/{dsh-mneme/test → test}/client.test.js +10 -206
- package/{dsh-mneme/test → test}/config.test.js +0 -21
- package/{dsh-mneme/test → test}/dream.test.js +1 -160
- package/{dsh-mneme/test → test}/graph-api.test.js +0 -34
- package/{dsh-mneme/test → test}/hot-memory.test.js +0 -29
- package/test/inject.test.js +120 -0
- package/{dsh-mneme/test → test}/llm-audit.test.js +4 -4
- package/{dsh-mneme/test → test}/reasoning-effort.test.js +0 -27
- package/{dsh-mneme/test → test}/recall-layer.test.js +5 -26
- package/{dsh-mneme/test → test}/reranker.test.js +0 -43
- package/{dsh-mneme/test → test}/service-search.test.js +0 -25
- package/{dsh-mneme/test → test}/service.test.js +0 -106
- package/{dsh-mneme/test → test}/settings.test.js +16 -35
- package/{dsh-mneme/test → test}/sleep.test.js +4 -171
- package/test/standalone-api.test.js +326 -0
- package/{dsh-mneme/test → test}/store.test.js +0 -76
- package/{dsh-mneme/test → test}/summarize.test.js +16 -15
- package/test/tools.test.js +265 -0
- package/.github/workflows/publish.yml +0 -58
- package/.github/workflows/test.yml +0 -32
- package/CHANGELOG.md +0 -91
- package/CONTRIBUTING.md +0 -293
- package/SECURITY.md +0 -718
- package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +0 -247
- package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +0 -145
- package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +0 -56
- package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +0 -77
- package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +0 -64
- package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +0 -90
- package/dsh-mneme/CHANGELOG.md +0 -419
- package/dsh-mneme/LICENSE +0 -21
- package/dsh-mneme/README.md +0 -501
- package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +0 -183
- package/dsh-mneme/docs/ENTITIES.md +0 -245
- package/dsh-mneme/docs/LOCAL_MODEL.md +0 -141
- package/dsh-mneme/docs/MIGRATION.md +0 -127
- package/dsh-mneme/docs/SEMANTIC.md +0 -256
- package/dsh-mneme/docs/SLEEP.md +0 -163
- package/dsh-mneme/lib/dream/tag-extractor.js +0 -156
- package/dsh-mneme/lib/heat.js +0 -136
- package/dsh-mneme/lib/parser/tag.js +0 -59
- package/dsh-mneme/lib/parser/wiki-link.js +0 -38
- package/dsh-mneme/lib/search/tag-boost.js +0 -61
- package/dsh-mneme/lib/tools.js +0 -458
- package/dsh-mneme/package-lock.json +0 -1936
- package/dsh-mneme/package.json +0 -83
- package/dsh-mneme/scripts/check-sync.js +0 -42
- package/dsh-mneme/src/client.js +0 -2242
- package/dsh-mneme/src/dream/tag-extractor.js +0 -156
- package/dsh-mneme/src/heat.js +0 -136
- package/dsh-mneme/src/parser/tag.js +0 -59
- package/dsh-mneme/src/parser/wiki-link.js +0 -38
- package/dsh-mneme/src/search/tag-boost.js +0 -61
- package/dsh-mneme/src/tools.js +0 -458
- package/dsh-mneme/test/boundary-v0625.test.js +0 -82
- package/dsh-mneme/test/directory.test.js +0 -134
- package/dsh-mneme/test/heat.test.js +0 -148
- package/dsh-mneme/test/inject.test.js +0 -206
- package/dsh-mneme/test/layered-types-stats.test.js +0 -144
- package/dsh-mneme/test/lib-smoke.test.js +0 -109
- package/dsh-mneme/test/normalize-decisions.test.js +0 -120
- package/dsh-mneme/test/provenance.test.js +0 -103
- package/dsh-mneme/test/recall-runs.test.js +0 -93
- package/dsh-mneme/test/sleep-heat.test.js +0 -112
- package/dsh-mneme/test/tag-boost.test.js +0 -125
- package/dsh-mneme/test/tag.test.js +0 -426
- package/dsh-mneme/test/tools.test.js +0 -674
- package/dsh-mneme/test/updated-at-semantics.test.js +0 -113
- package/dsh-mneme/test/wiki-link.test.js +0 -332
- package//346/250/252/345/271/205.png +0 -0
- /package/{dsh-mneme/lib → lib}/commands.js +0 -0
- /package/{dsh-mneme/lib → lib}/dream/clustering.js +0 -0
- /package/{dsh-mneme/lib → lib}/embedding.js +0 -0
- /package/{dsh-mneme/lib → lib}/entities/extractor.js +0 -0
- /package/{dsh-mneme/lib → lib}/local-embedder.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/adaptive.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/bm25.js +0 -0
- /package/{dsh-mneme/lib → lib}/vector-index.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-embed.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-recall.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-rerank.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/stress-dsh.js +0 -0
- /package/{dsh-mneme/src → src}/commands.js +0 -0
- /package/{dsh-mneme/src → src}/dream/clustering.js +0 -0
- /package/{dsh-mneme/src → src}/embedding.js +0 -0
- /package/{dsh-mneme/src → src}/entities/extractor.js +0 -0
- /package/{dsh-mneme/src → src}/local-embedder.js +0 -0
- /package/{dsh-mneme/src → src}/search/adaptive.js +0 -0
- /package/{dsh-mneme/src → src}/search/bm25.js +0 -0
- /package/{dsh-mneme/src → src}/vector-index.js +0 -0
- /package/{dsh-mneme/test → test}/audit.test.js +0 -0
- /package/{dsh-mneme/test → test}/benchmark.test.js +0 -0
- /package/{dsh-mneme/test → test}/clustering.test.js +0 -0
- /package/{dsh-mneme/test → test}/commands.test.js +0 -0
- /package/{dsh-mneme/test → test}/conflict-freeze.test.js +0 -0
- /package/{dsh-mneme/test → test}/entities.test.js +0 -0
- /package/{dsh-mneme/test → test}/epistemic.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-0112.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-03.test.js +0 -0
- /package/{dsh-mneme/test → test}/helpers/dream-mock.js +0 -0
- /package/{dsh-mneme/test → test}/local-embedder.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-dirty.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-edit-digest.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-generation.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror.test.js +0 -0
- /package/{dsh-mneme/test → test}/peer-blockers.test.js +0 -0
- /package/{dsh-mneme/test → test}/policy-epoch.test.js +0 -0
- /package/{dsh-mneme/test → test}/quality-filter.test.js +0 -0
- /package/{dsh-mneme/test → test}/recall-evals.test.js +0 -0
- /package/{dsh-mneme/test → test}/receipt-chain.test.js +0 -0
- /package/{dsh-mneme/test → test}/reflection.test.js +0 -0
- /package/{dsh-mneme/test → test}/search-fusion.test.js +0 -0
- /package/{dsh-mneme/test → test}/semantic.test.js +0 -0
- /package/{dsh-mneme/test → test}/stress.test.js +0 -0
- /package/{dsh-mneme/test → test}/vector-index.test.js +0 -0
|
@@ -45,14 +45,6 @@ function baseConfig(overrides = {}) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
// v0.7.0 热联合判定:慢衰减类型(project λ=0.0008)默认受热闸保护——40 天热值
|
|
49
|
-
// ≈0.5、100 天 ≈0.28,始终高于 sleepHeatThreshold(0.05),demotion 不触发。
|
|
50
|
-
// 降级语义测试把 project 的 λ 调快到 0.02(40 天热值≈0.03),复现"时间窗冷态
|
|
51
|
-
// 即降级"的旧路径;默认保守语义由 sleep-heat.test.js 单独覆盖。
|
|
52
|
-
function demotionConfig(overrides = {}) {
|
|
53
|
-
return baseConfig({ heatTypeDecay: { project: 0.02 }, ...overrides });
|
|
54
|
-
}
|
|
55
|
-
|
|
56
48
|
function setup() {
|
|
57
49
|
const store = createStore(":memory:");
|
|
58
50
|
const service = createService({ store, mirror: null, config: {} });
|
|
@@ -181,7 +173,7 @@ test("sleep: demotion shrinks cold memory to summary, keeps _full_content", asyn
|
|
|
181
173
|
const m = makeMemory(service, "cold", "原内容".repeat(60), "project");
|
|
182
174
|
service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
|
|
183
175
|
const ctx = mockCtx(() => "[]");
|
|
184
|
-
const result = await runSleep(ctx, service,
|
|
176
|
+
const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
|
|
185
177
|
const after = service.getById(m.id);
|
|
186
178
|
assert.equal(result.status, "ok");
|
|
187
179
|
assert.ok(after._full_content && after._full_content.length > 0, "full body preserved");
|
|
@@ -196,7 +188,7 @@ test("sleep: demotion fully archives memory past sleepCompressDays", async () =>
|
|
|
196
188
|
const m = makeMemory(service, "ancient", "很老的记忆", "project");
|
|
197
189
|
service.touchLastAccess(m.id, new Date(now - 100 * 86400000).toISOString());
|
|
198
190
|
const ctx = mockCtx(() => "[]");
|
|
199
|
-
const result = await runSleep(ctx, service,
|
|
191
|
+
const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
|
|
200
192
|
const after = service.getById(m.id);
|
|
201
193
|
assert.equal(result.status, "ok");
|
|
202
194
|
assert.equal(after.archived, true, "past compress days → archived");
|
|
@@ -283,7 +275,7 @@ test("sleep: a failing phase does not block the others (fail-safe)", async () =>
|
|
|
283
275
|
const m = makeMemory(service, "cold", "内容".repeat(60), "project");
|
|
284
276
|
service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
|
|
285
277
|
const ctx = mockCtx(() => "[]");
|
|
286
|
-
const result = await runSleep(ctx, service,
|
|
278
|
+
const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
|
|
287
279
|
assert.equal(result.phases.conflicts.status, "skipped", "conflicts phase degraded gracefully (no usable vectors)");
|
|
288
280
|
assert.equal(result.phases.demotion.status, "ok", "demotion still ran");
|
|
289
281
|
assert.equal(result.status, "ok", "overall run still ok despite conflicts degrading");
|
|
@@ -297,7 +289,7 @@ test("sleep: no LLM route skips LLM phases but demotion still runs", async () =>
|
|
|
297
289
|
const m = makeMemory(service, "cold", "内容".repeat(60), "project");
|
|
298
290
|
service.touchLastAccess(m.id, new Date(now - 40 * 86400000).toISOString());
|
|
299
291
|
const ctx = mockCtx(() => "[]", null); // currentSelection() → null, no route
|
|
300
|
-
const result = await runSleep(ctx, service,
|
|
292
|
+
const result = await runSleep(ctx, service, baseConfig(), ctx.logger, null, null);
|
|
301
293
|
assert.equal(result.phases.conflicts.status, "skipped", "no llm route → conflicts skipped");
|
|
302
294
|
assert.equal(result.phases.patterns.status, "skipped", "no llm route → patterns skipped");
|
|
303
295
|
assert.equal(result.phases.demotion.status, "ok", "demotion is LLM-free and runs");
|
|
@@ -371,162 +363,3 @@ test("sleep: runSleep is abortable via signal between phases", async () => {
|
|
|
371
363
|
assert.equal(result.phases.conflicts, undefined, "aborted before any phase ran");
|
|
372
364
|
store.close();
|
|
373
365
|
});
|
|
374
|
-
|
|
375
|
-
// ------------------------------------------------ entity extraction (issue #23)
|
|
376
|
-
|
|
377
|
-
// Write-path extractor is gated on entityExtractionEnabled (default off), so
|
|
378
|
-
// default installs never accumulate entities → ego-graph panel blank. This
|
|
379
|
-
// sleep phase backfills it; these tests cover gating, idempotence and
|
|
380
|
-
// fail-safe batching.
|
|
381
|
-
const ENTITY_LLM_JSON = JSON.stringify({
|
|
382
|
-
entities: [{ name: "X", type: "concept", attrs: [{ key: "k", value: "v" }] }],
|
|
383
|
-
relations: []
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
test("sleep: entity extraction skips when disabled", async () => {
|
|
387
|
-
const { service, store } = setup();
|
|
388
|
-
makeMemory(service, "记忆A", "内容A", "history");
|
|
389
|
-
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
390
|
-
const result = await runSleep(
|
|
391
|
-
ctx, service,
|
|
392
|
-
baseConfig({ sleepEntityExtractionEnabled: false }),
|
|
393
|
-
ctx.logger, null, null
|
|
394
|
-
);
|
|
395
|
-
assert.equal(result.phases["entity-extraction"].status, "skipped", "disabled → skipped");
|
|
396
|
-
store.close();
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
test("sleep: extracted memories are stamped and not re-extracted", async () => {
|
|
400
|
-
const { service, store } = setup();
|
|
401
|
-
const mem = makeMemory(service, "记忆B", "内容B", "history");
|
|
402
|
-
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
403
|
-
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
404
|
-
// First cycle backfills → memory gets entity_extracted_at stamped.
|
|
405
|
-
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
406
|
-
assert.equal(first.phases["entity-extraction"].status, "ok", "first cycle extracts");
|
|
407
|
-
assert.ok(service.getById(mem.id).metadata?.entity_extracted_at, "stamp written");
|
|
408
|
-
// Second cycle has nothing un-stamped left → skipped.
|
|
409
|
-
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
410
|
-
assert.equal(second.phases["entity-extraction"].status, "skipped", "nothing left to extract");
|
|
411
|
-
store.close();
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
test("sleep: entity-less extracts are stamped so they are not re-queued forever", async () => {
|
|
415
|
-
const { service, store } = setup();
|
|
416
|
-
const mem = makeMemory(service, "无实体记忆", "这里没有任何实体", "history");
|
|
417
|
-
// LLM legitimately returns no entities — must still be stamped, else every
|
|
418
|
-
// sleep cycle re-extracts the same text forever (issue #23 regression).
|
|
419
|
-
const ctx = mockCtx(() => JSON.stringify({ entities: [], relations: [] }), { provider: "p", model: "m" });
|
|
420
|
-
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
421
|
-
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
422
|
-
assert.equal(first.phases["entity-extraction"].status, "ok", "empty extract counts as handled");
|
|
423
|
-
assert.equal(first.phases["entity-extraction"].extracted, 1);
|
|
424
|
-
assert.ok(service.getById(mem.id).metadata?.entity_extracted_at, "stamp written even with no entities");
|
|
425
|
-
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
426
|
-
assert.equal(second.phases["entity-extraction"].status, "skipped", "stamped → not re-queued");
|
|
427
|
-
store.close();
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
test("sleep: entity extraction backfills entities for un-extracted memories", async () => {
|
|
431
|
-
const { service, store } = setup();
|
|
432
|
-
const mem = makeMemory(service, "记忆C", "内容C", "history");
|
|
433
|
-
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
434
|
-
const result = await runSleep(
|
|
435
|
-
ctx, service,
|
|
436
|
-
baseConfig({ sleepEntityExtractionEnabled: true }),
|
|
437
|
-
ctx.logger, null, null
|
|
438
|
-
);
|
|
439
|
-
const phase = result.phases["entity-extraction"];
|
|
440
|
-
assert.equal(phase.status, "ok");
|
|
441
|
-
assert.equal(phase.extracted, 1);
|
|
442
|
-
assert.ok(store.listEntities().length > 0, "entity minted");
|
|
443
|
-
assert.ok(service.getAttrsByMemory(mem.id).length > 0, "memory now carries entity attrs");
|
|
444
|
-
store.close();
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
test("sleep: a failing extraction does not abort the batch and retries next cycle", async () => {
|
|
448
|
-
const { service, store } = setup();
|
|
449
|
-
const bad = makeMemory(service, "坏记忆", "bad 内容", "history");
|
|
450
|
-
const good = makeMemory(service, "好记忆", "good 内容", "history");
|
|
451
|
-
const ctx = mockCtx(
|
|
452
|
-
(user) => (String(user).includes("bad") ? "这不是合法JSON{{{" : ENTITY_LLM_JSON),
|
|
453
|
-
{ provider: "p", model: "m" }
|
|
454
|
-
);
|
|
455
|
-
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
456
|
-
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
457
|
-
const phase = first.phases["entity-extraction"];
|
|
458
|
-
assert.equal(phase.extracted, 1, "good memory extracted");
|
|
459
|
-
assert.equal(phase.failed, 1, "bad memory counted as failed");
|
|
460
|
-
assert.equal(phase.status, "ok", "partial success is ok");
|
|
461
|
-
assert.ok(store.listEntities().length > 0, "at least one entity minted");
|
|
462
|
-
// Good memory stamped; bad one not → second cycle retries only the bad one.
|
|
463
|
-
assert.ok(service.getById(good.id).metadata?.entity_extracted_at, "good memory stamped");
|
|
464
|
-
assert.equal(service.getById(bad.id).metadata?.entity_extracted_at, undefined, "failed memory not stamped");
|
|
465
|
-
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
466
|
-
assert.equal(second.phases["entity-extraction"].extracted, 0, "bad memory still fails");
|
|
467
|
-
assert.equal(second.phases["entity-extraction"].failed, 1, "failed memory retried");
|
|
468
|
-
store.close();
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
test("sleep: metadata merge keeps unrelated fields when stamping (review #4)", async () => {
|
|
472
|
-
const { service, store } = setup();
|
|
473
|
-
const mem = makeMemory(service, "记忆M", "内容M", "history");
|
|
474
|
-
service.setMemoryMetadata(mem.id, { custom_flag: "keep-me" });
|
|
475
|
-
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
476
|
-
await runSleep(ctx, service, baseConfig({ sleepEntityExtractionEnabled: true }), ctx.logger, null, null);
|
|
477
|
-
const meta = service.getById(mem.id).metadata;
|
|
478
|
-
assert.equal(meta.custom_flag, "keep-me", "pre-existing metadata not clobbered");
|
|
479
|
-
assert.ok(meta.entity_extracted_at, "stamp added alongside");
|
|
480
|
-
store.close();
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
test("sleep: pending_extracted_at is cleared on success and on failure (review #3)", async () => {
|
|
484
|
-
const { service, store } = setup();
|
|
485
|
-
const good = makeMemory(service, "好记忆", "内容G", "history");
|
|
486
|
-
const bad = makeMemory(service, "坏记忆", "bad 内容", "history");
|
|
487
|
-
const ctx = mockCtx(
|
|
488
|
-
(user) => (String(user).includes("bad") ? "不是JSON{{{" : ENTITY_LLM_JSON),
|
|
489
|
-
{ provider: "p", model: "m" }
|
|
490
|
-
);
|
|
491
|
-
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
492
|
-
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
493
|
-
const phase = first.phases["entity-extraction"];
|
|
494
|
-
assert.equal(phase.extracted, 1);
|
|
495
|
-
assert.equal(phase.failed, 1);
|
|
496
|
-
assert.equal(service.getById(good.id).metadata?.pending_extracted_at, null, "success clears pending");
|
|
497
|
-
assert.equal(service.getById(bad.id).metadata?.pending_extracted_at, null, "failure clears pending");
|
|
498
|
-
store.close();
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
test("sleep: all-failed extraction surfaces failed status through deriveStatus (review #5)", async () => {
|
|
502
|
-
const { service, store } = setup();
|
|
503
|
-
makeMemory(service, "坏记忆1", "bad 一", "history");
|
|
504
|
-
makeMemory(service, "坏记忆2", "bad 二", "history");
|
|
505
|
-
const ctx = mockCtx(() => "不是JSON{{{", { provider: "p", model: "m" });
|
|
506
|
-
const result = await runSleep(ctx, service, baseConfig({ sleepEntityExtractionEnabled: true }), ctx.logger, null, null);
|
|
507
|
-
const phase = result.phases["entity-extraction"];
|
|
508
|
-
assert.equal(phase.status, "failed", "all-failed extraction reports failed");
|
|
509
|
-
assert.equal(phase.failed, 2);
|
|
510
|
-
assert.ok(["failed", "degraded"].includes(result.status), "deriveStatus folds failed into run status");
|
|
511
|
-
store.close();
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
test("store: listForEntityExtraction pages oldest un-stamped candidates (review #2)", async () => {
|
|
515
|
-
const { service, store } = setup();
|
|
516
|
-
const a = makeMemory(service, "甲", "内容甲", "history");
|
|
517
|
-
makeMemory(service, "乙", "内容乙", "history");
|
|
518
|
-
const archived = makeMemory(service, "归档", "内容归档", "history");
|
|
519
|
-
store.setArchived(archived.id, true);
|
|
520
|
-
makeMemory(service, "摘要", "内容摘要", "summary");
|
|
521
|
-
makeMemory(service, "丙", "内容丙", "history"); // second un-stamped candidate so oldest-first has two to compare
|
|
522
|
-
service.setMemoryMetadata(a.id, { entity_extracted_at: new Date().toISOString() });
|
|
523
|
-
const list = store.listForEntityExtraction({ limit: 10, offset: 0 });
|
|
524
|
-
const titles = list.map((m) => m.title);
|
|
525
|
-
assert.ok(!titles.includes("归档"), "archived excluded");
|
|
526
|
-
assert.ok(!titles.includes("摘要"), "summary excluded");
|
|
527
|
-
assert.ok(!titles.includes("甲"), "already-stamped excluded");
|
|
528
|
-
assert.ok(titles.includes("乙"), "un-stamped included");
|
|
529
|
-
assert.ok(titles.includes("丙"), "un-stamped included");
|
|
530
|
-
assert.equal(list[0].created_at <= list[1].created_at, true, "oldest first");
|
|
531
|
-
store.close();
|
|
532
|
-
});
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createSettings } from "../src/settings.js";
|
|
6
|
+
import { createStandaloneApi } from "../src/api-standalone.js";
|
|
7
|
+
import { Config, applyLightModePreset } from "../src/config.js";
|
|
8
|
+
|
|
9
|
+
// Real HTTP server on an OS-assigned port (port: 0), driven with fetch.
|
|
10
|
+
async function setup() {
|
|
11
|
+
const store = createStore(":memory:");
|
|
12
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
13
|
+
const settings = createSettings(store.db);
|
|
14
|
+
const api = createStandaloneApi({ service, store, config: {}, settings, logger: null, port: 0 });
|
|
15
|
+
await api.ready;
|
|
16
|
+
const base = `http://127.0.0.1:${api.port}`;
|
|
17
|
+
const auth = { authorization: `Bearer ${api.token}` };
|
|
18
|
+
return {
|
|
19
|
+
store,
|
|
20
|
+
service,
|
|
21
|
+
settings,
|
|
22
|
+
api,
|
|
23
|
+
base,
|
|
24
|
+
auth,
|
|
25
|
+
close: () => {
|
|
26
|
+
// Drop pooled keep-alive sockets so node --test can drain the loop.
|
|
27
|
+
api.server.closeIdleConnections?.();
|
|
28
|
+
api.server.close();
|
|
29
|
+
store.close();
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- auth ---------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
test("GET /health is open without a token; every other route 401s", async () => {
|
|
37
|
+
const { base, close } = await setup();
|
|
38
|
+
try {
|
|
39
|
+
const health = await fetch(`${base}/health`);
|
|
40
|
+
assert.equal(health.status, 200);
|
|
41
|
+
assert.deepEqual(await health.json(), { ok: true });
|
|
42
|
+
|
|
43
|
+
for (const path of ["/status", "/memories", "/memories/x", "/search?q=x"]) {
|
|
44
|
+
const res = await fetch(`${base}${path}`);
|
|
45
|
+
assert.equal(res.status, 401, `${path} requires a token`);
|
|
46
|
+
assert.deepEqual(await res.json(), { error: "unauthorized" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const bad = await fetch(`${base}/status`, { headers: { authorization: "Bearer wrong-token" } });
|
|
50
|
+
assert.equal(bad.status, 401, "wrong token rejected");
|
|
51
|
+
} finally {
|
|
52
|
+
close();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("token is persisted into settings kv and reused across restarts", async () => {
|
|
57
|
+
const store = createStore(":memory:");
|
|
58
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
59
|
+
const settings = createSettings(store.db);
|
|
60
|
+
const first = createStandaloneApi({ service, store, config: {}, settings, port: 0 });
|
|
61
|
+
await first.ready;
|
|
62
|
+
try {
|
|
63
|
+
assert.equal(settings.getExternalApi().token, first.token, "generated token persisted");
|
|
64
|
+
assert.ok(first.token.length >= 20, "token is a real random value");
|
|
65
|
+
const second = createStandaloneApi({ service, store, config: {}, settings, port: 0 });
|
|
66
|
+
await second.ready;
|
|
67
|
+
assert.equal(second.token, first.token, "restart reuses the persisted token");
|
|
68
|
+
second.server.closeIdleConnections?.();
|
|
69
|
+
second.server.close();
|
|
70
|
+
} finally {
|
|
71
|
+
first.server.closeIdleConnections?.();
|
|
72
|
+
first.server.close();
|
|
73
|
+
store.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- CRUD loop ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
test("POST/GET/DELETE /memories round trip", async () => {
|
|
80
|
+
const { base, auth, close } = await setup();
|
|
81
|
+
try {
|
|
82
|
+
const post = await fetch(`${base}/memories`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
85
|
+
body: JSON.stringify({ type: "preference", title: "语言", content: "始终用中文回复", importance: 4, tags: ["交流"] })
|
|
86
|
+
});
|
|
87
|
+
assert.equal(post.status, 201);
|
|
88
|
+
const created = await post.json();
|
|
89
|
+
assert.ok(created.id, "row id returned");
|
|
90
|
+
assert.equal(created.title, "语言");
|
|
91
|
+
assert.equal(created.importance, 4);
|
|
92
|
+
|
|
93
|
+
const list = await fetch(`${base}/memories?limit=10`, { headers: auth });
|
|
94
|
+
assert.equal(list.status, 200);
|
|
95
|
+
const listBody = await list.json();
|
|
96
|
+
assert.equal(listBody.total, 1);
|
|
97
|
+
assert.equal(listBody.items.length, 1);
|
|
98
|
+
|
|
99
|
+
const got = await fetch(`${base}/memories/${created.id}`, { headers: auth });
|
|
100
|
+
assert.equal(got.status, 200);
|
|
101
|
+
assert.equal((await got.json()).content, "始终用中文回复");
|
|
102
|
+
|
|
103
|
+
const del = await fetch(`${base}/memories/${created.id}`, { method: "DELETE", headers: auth });
|
|
104
|
+
assert.equal(del.status, 200);
|
|
105
|
+
assert.deepEqual(await del.json(), { ok: true });
|
|
106
|
+
|
|
107
|
+
const gone = await fetch(`${base}/memories/${created.id}`, { headers: auth });
|
|
108
|
+
assert.equal(gone.status, 404, "deleted row reads back as 404");
|
|
109
|
+
const delAgain = await fetch(`${base}/memories/${created.id}`, { method: "DELETE", headers: auth });
|
|
110
|
+
assert.equal(delAgain.status, 404, "deleting a missing row is 404, not a fake ok");
|
|
111
|
+
} finally {
|
|
112
|
+
close();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("POST /memories dedupes same title within a type (200 + merged content)", async () => {
|
|
117
|
+
const { base, auth, close } = await setup();
|
|
118
|
+
try {
|
|
119
|
+
const first = await fetch(`${base}/memories`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ type: "project", title: "mneme", content: "v1 内容" })
|
|
123
|
+
});
|
|
124
|
+
assert.equal(first.status, 201);
|
|
125
|
+
const row1 = await first.json();
|
|
126
|
+
|
|
127
|
+
const second = await fetch(`${base}/memories`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
130
|
+
body: JSON.stringify({ type: "project", title: "mneme", content: "v2 追加" })
|
|
131
|
+
});
|
|
132
|
+
assert.equal(second.status, 200, "merge, not a second creation");
|
|
133
|
+
const row2 = await second.json();
|
|
134
|
+
assert.equal(row2.id, row1.id, "same row reused");
|
|
135
|
+
assert.ok(row2.content.includes("v1 内容") && row2.content.includes("v2 追加"), "content appended");
|
|
136
|
+
|
|
137
|
+
const list = await fetch(`${base}/memories`, { headers: auth });
|
|
138
|
+
assert.equal((await list.json()).total, 1, "no duplicate row created");
|
|
139
|
+
} finally {
|
|
140
|
+
close();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("POST /memories validation: invalid type, bad JSON, non-array tags → 400", async () => {
|
|
145
|
+
const { base, auth, close } = await setup();
|
|
146
|
+
try {
|
|
147
|
+
const badType = await fetch(`${base}/memories`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ type: "diary", title: "t", content: "c" })
|
|
151
|
+
});
|
|
152
|
+
assert.equal(badType.status, 400);
|
|
153
|
+
assert.deepEqual(await badType.json(), { error: "invalid-type" });
|
|
154
|
+
|
|
155
|
+
const badJson = await fetch(`${base}/memories`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
158
|
+
body: "{not json"
|
|
159
|
+
});
|
|
160
|
+
assert.equal(badJson.status, 400);
|
|
161
|
+
assert.deepEqual(await badJson.json(), { error: "invalid-json" });
|
|
162
|
+
|
|
163
|
+
const badTags = await fetch(`${base}/memories`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ type: "project", title: "t", content: "c", tags: "x" })
|
|
167
|
+
});
|
|
168
|
+
assert.equal(badTags.status, 400);
|
|
169
|
+
assert.deepEqual(await badTags.json(), { error: "tags-must-be-an-array" });
|
|
170
|
+
|
|
171
|
+
const noTitle = await fetch(`${base}/memories`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
174
|
+
body: JSON.stringify({ type: "project", content: "c" })
|
|
175
|
+
});
|
|
176
|
+
assert.equal(noTitle.status, 400);
|
|
177
|
+
assert.deepEqual(await noTitle.json(), { error: "missing-title" });
|
|
178
|
+
} finally {
|
|
179
|
+
close();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// --- list filters / status / search ---------------------------------------------
|
|
184
|
+
|
|
185
|
+
test("GET /memories honors type + minImportance filters and paging", async () => {
|
|
186
|
+
const { base, auth, service, close } = await setup();
|
|
187
|
+
try {
|
|
188
|
+
service.saveWithDedupe({ type: "preference", title: "低", content: "i2", importance: 2 });
|
|
189
|
+
service.saveWithDedupe({ type: "preference", title: "高", content: "i5", importance: 5 });
|
|
190
|
+
service.saveWithDedupe({ type: "project", title: "项目", content: "i4", importance: 4 });
|
|
191
|
+
|
|
192
|
+
const byType = await fetch(`${base}/memories?type=preference`, { headers: auth });
|
|
193
|
+
assert.equal((await byType.json()).total, 2);
|
|
194
|
+
|
|
195
|
+
const byImportance = await fetch(`${base}/memories?minImportance=4`, { headers: auth });
|
|
196
|
+
const filtered = await byImportance.json();
|
|
197
|
+
assert.equal(filtered.total, 2);
|
|
198
|
+
assert.deepEqual(filtered.items.map((m) => m.title).sort(), ["项目", "高"]);
|
|
199
|
+
|
|
200
|
+
const paged = await fetch(`${base}/memories?limit=2&offset=0&order=chrono`, { headers: auth });
|
|
201
|
+
const page = await paged.json();
|
|
202
|
+
assert.equal(page.items.length, 2);
|
|
203
|
+
assert.equal(page.total, 3);
|
|
204
|
+
} finally {
|
|
205
|
+
close();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("GET /status reports version, per-type totals, entities and uptime", async () => {
|
|
210
|
+
const { base, auth, service, close } = await setup();
|
|
211
|
+
try {
|
|
212
|
+
service.saveWithDedupe({ type: "preference", title: "p", content: "c" });
|
|
213
|
+
service.saveWithDedupe({ type: "project", title: "j", content: "c" });
|
|
214
|
+
service.saveWithDedupe({ type: "project", title: "j2", content: "c" });
|
|
215
|
+
|
|
216
|
+
const res = await fetch(`${base}/status`, { headers: auth });
|
|
217
|
+
assert.equal(res.status, 200);
|
|
218
|
+
const body = await res.json();
|
|
219
|
+
assert.equal(body.version, "0.7.12");
|
|
220
|
+
assert.equal(body.memories.total, 3);
|
|
221
|
+
assert.equal(body.memories.byType.preference, 1);
|
|
222
|
+
assert.equal(body.memories.byType.project, 2);
|
|
223
|
+
assert.equal(body.entities, 0, "no extraction wired → empty entity table");
|
|
224
|
+
assert.ok(Number.isInteger(body.uptime_s));
|
|
225
|
+
} finally {
|
|
226
|
+
close();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("GET /search finds saved memories; empty q returns empty keyword result", async () => {
|
|
231
|
+
const { base, auth, close } = await setup();
|
|
232
|
+
try {
|
|
233
|
+
await fetch(`${base}/memories`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
236
|
+
body: JSON.stringify({ type: "project", title: "记忆插件", content: "SQLite 全文检索" })
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const res = await fetch(`${base}/search?q=${encodeURIComponent("全文检索")}&mode=keyword`, { headers: auth });
|
|
240
|
+
assert.equal(res.status, 200);
|
|
241
|
+
const body = await res.json();
|
|
242
|
+
assert.equal(body.mode, "keyword");
|
|
243
|
+
assert.equal(body.items.length, 1);
|
|
244
|
+
assert.equal(body.items[0].title, "记忆插件");
|
|
245
|
+
|
|
246
|
+
const empty = await fetch(`${base}/search?q=`, { headers: auth });
|
|
247
|
+
assert.deepEqual(await empty.json(), { items: [], mode: "keyword" });
|
|
248
|
+
} finally {
|
|
249
|
+
close();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("unknown path returns 404 json", async () => {
|
|
254
|
+
const { base, auth, close } = await setup();
|
|
255
|
+
try {
|
|
256
|
+
const res = await fetch(`${base}/nope`, { headers: auth });
|
|
257
|
+
assert.equal(res.status, 404);
|
|
258
|
+
assert.deepEqual(await res.json(), { error: "not-found" });
|
|
259
|
+
} finally {
|
|
260
|
+
close();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// --- light-mode preset (applyLightModePreset) ------------------------------------
|
|
265
|
+
|
|
266
|
+
const LIGHT_OFF_FIELDS = [
|
|
267
|
+
"entityExtractionEnabled",
|
|
268
|
+
"autoDream",
|
|
269
|
+
"sleepModeEnabled",
|
|
270
|
+
"rerankEnabled",
|
|
271
|
+
"autoReindexOnBoot",
|
|
272
|
+
"hybridInject",
|
|
273
|
+
"searchSemanticDedup",
|
|
274
|
+
"selectiveInjectEnabled",
|
|
275
|
+
"bm25SearchEnabled"
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
test("applyLightModePreset turns heavy features off and keeps the core loop", () => {
|
|
279
|
+
const cfg = applyLightModePreset(Config({ lightMode: true }));
|
|
280
|
+
for (const field of LIGHT_OFF_FIELDS) {
|
|
281
|
+
assert.equal(cfg[field], false, `${field} forced off in light mode`);
|
|
282
|
+
}
|
|
283
|
+
// Core loop preserved.
|
|
284
|
+
assert.equal(cfg.autoInject, true);
|
|
285
|
+
assert.equal(cfg.autoSummarize, true);
|
|
286
|
+
assert.equal(cfg.hotMemoryEnabled, true);
|
|
287
|
+
assert.equal(cfg.memoryQualityFilter.enabled, true);
|
|
288
|
+
// Unrelated knobs untouched.
|
|
289
|
+
assert.equal(cfg.dreamThresholdCount, 10);
|
|
290
|
+
assert.equal(cfg.dreamDelayMs, 2000);
|
|
291
|
+
assert.equal(cfg.maxInjectedItems, 5);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("applyLightModePreset keeps explicit non-preset values and is a no-op without lightMode", () => {
|
|
295
|
+
const tuned = applyLightModePreset(Config({ lightMode: true, dreamThresholdCount: 30, maxInjectedItems: 8 }));
|
|
296
|
+
assert.equal(tuned.dreamThresholdCount, 30, "operator values survive the preset");
|
|
297
|
+
assert.equal(tuned.maxInjectedItems, 8);
|
|
298
|
+
|
|
299
|
+
const plain = Config({});
|
|
300
|
+
assert.equal(applyLightModePreset(plain), plain, "identity when lightMode is unset");
|
|
301
|
+
const off = Config({ lightMode: false });
|
|
302
|
+
assert.equal(applyLightModePreset(off), off, "identity when lightMode is false");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("persisted panel_mode=light wins over a bundle config that did not ask for it", () => {
|
|
306
|
+
const store = createStore(":memory:");
|
|
307
|
+
const settings = createSettings(store.db);
|
|
308
|
+
try {
|
|
309
|
+
// Panel switched to light in a previous session; bundle config says nothing.
|
|
310
|
+
settings.setPanelMode("light");
|
|
311
|
+
const rawCfg = Config({});
|
|
312
|
+
const lightMode = rawCfg.lightMode === true || settings.getPanelMode() === "light";
|
|
313
|
+
const cfg = applyLightModePreset({ ...rawCfg, lightMode });
|
|
314
|
+
assert.equal(lightMode, true, "persisted mode forces light");
|
|
315
|
+
assert.equal(cfg.autoDream, false);
|
|
316
|
+
assert.equal(cfg.entityExtractionEnabled, false);
|
|
317
|
+
assert.equal(cfg.autoInject, true, "core injection stays on");
|
|
318
|
+
|
|
319
|
+
// standard (default) never forces the preset even with a light bundle flag off.
|
|
320
|
+
settings.setPanelMode("standard");
|
|
321
|
+
const raw2 = Config({});
|
|
322
|
+
assert.equal(raw2.lightMode === true || settings.getPanelMode() === "light", false);
|
|
323
|
+
} finally {
|
|
324
|
+
store.close();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
@@ -358,79 +358,3 @@ test("conflict_pending table persists across store reopen", () => {
|
|
|
358
358
|
assert.equal(pending[0].reason, "x");
|
|
359
359
|
s2.close();
|
|
360
360
|
});
|
|
361
|
-
|
|
362
|
-
// --- session lifecycle (v0.6.0): disposed rows are filtered out ------------
|
|
363
|
-
|
|
364
|
-
test("session_disposed_at column exists and survives migration", () => {
|
|
365
|
-
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-disposed-"));
|
|
366
|
-
const path = join(dir, "memory.db");
|
|
367
|
-
const s1 = createStore(path);
|
|
368
|
-
const { id } = s1.save({ type: "decision", title: "迁移", content: "c", session_id: "sess-m" });
|
|
369
|
-
s1.setDisposedBySession("sess-m", true);
|
|
370
|
-
const row = s1.db.prepare("SELECT session_disposed_at FROM memories WHERE id = ?").get(id);
|
|
371
|
-
assert.ok(row.session_disposed_at, "dispose timestamp persisted");
|
|
372
|
-
s1.close();
|
|
373
|
-
// reopen: column survives
|
|
374
|
-
const s2 = createStore(path);
|
|
375
|
-
const live = s2.list({ type: "decision" });
|
|
376
|
-
assert.ok(!live.some((m) => m.id === id), "disposed row excluded after reopen");
|
|
377
|
-
s2.close();
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
test("search and list exclude disposed rows unless includeDisposed", () => {
|
|
381
|
-
const store = createStore(":memory:");
|
|
382
|
-
const a = store.save({ type: "decision", title: "活着", content: "keyword alpha", session_id: "s1" });
|
|
383
|
-
const b = store.save({ type: "decision", title: "被销毁", content: "keyword beta", session_id: "s2" });
|
|
384
|
-
store.setDisposedBySession("s2", true);
|
|
385
|
-
|
|
386
|
-
const searchRes = store.search("keyword");
|
|
387
|
-
assert.ok(searchRes.some((m) => m.id === a.id), "live row found");
|
|
388
|
-
assert.ok(!searchRes.some((m) => m.id === b.id), "disposed row excluded from search");
|
|
389
|
-
const searchIncl = store.search("keyword", { includeDisposed: true });
|
|
390
|
-
assert.ok(searchIncl.some((m) => m.id === b.id), "includeDisposed surfaces it");
|
|
391
|
-
|
|
392
|
-
const listRes = store.list({ type: "decision" });
|
|
393
|
-
assert.ok(listRes.some((m) => m.id === a.id));
|
|
394
|
-
assert.ok(!listRes.some((m) => m.id === b.id), "disposed excluded from list");
|
|
395
|
-
const listIncl = store.list({ type: "decision", includeDisposed: true });
|
|
396
|
-
assert.ok(listIncl.some((m) => m.id === b.id), "includeDisposed lists it");
|
|
397
|
-
assert.equal(store.count("decision", { includeDisposed: true }), 2);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
test("searchVector excludes disposed rows", () => {
|
|
401
|
-
const store = createStore(":memory:");
|
|
402
|
-
const a = store.save({ type: "decision", title: "向量A", content: "v", session_id: "s1" });
|
|
403
|
-
const b = store.save({ type: "decision", title: "向量B", content: "v", session_id: "s2" });
|
|
404
|
-
store.setEmbedding(a.id, [1, 0, 0]);
|
|
405
|
-
store.setEmbedding(b.id, [1, 0, 0]);
|
|
406
|
-
store.setDisposedBySession("s2", true);
|
|
407
|
-
|
|
408
|
-
const res = store.searchVector([1, 0, 0], { limit: 10 });
|
|
409
|
-
assert.ok(res.some((m) => m.id === a.id));
|
|
410
|
-
assert.ok(!res.some((m) => m.id === b.id), "disposed row excluded from vector search");
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
test("setDisposedBySession round-trips and listBySession sees all states", () => {
|
|
414
|
-
const store = createStore(":memory:");
|
|
415
|
-
const a = store.save({ type: "decision", title: "会话记忆", content: "c", session_id: "sess-z" });
|
|
416
|
-
assert.equal(store.setDisposedBySession("sess-z", true), 1, "first dispose flips 1");
|
|
417
|
-
assert.equal(store.setDisposedBySession("sess-z", true), 0, "re-dispose is a no-op");
|
|
418
|
-
assert.equal(store.setDisposedBySession("sess-z", false), 1, "restore flips 1");
|
|
419
|
-
assert.equal(store.setDisposedBySession("sess-z", false), 0, "re-restore is a no-op");
|
|
420
|
-
assert.equal(store.getById(a.id).session_disposed_at, undefined, "mark cleared after restore");
|
|
421
|
-
|
|
422
|
-
const bySession = store.listBySession("sess-z");
|
|
423
|
-
assert.equal(bySession.length, 1);
|
|
424
|
-
assert.equal(bySession[0].id, a.id);
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
test("listBySession hides disposed by default, includeDisposed opts in", () => {
|
|
428
|
-
const store = createStore(":memory:");
|
|
429
|
-
const a = store.save({ type: "decision", title: "会话记忆", content: "c", session_id: "sess-y" });
|
|
430
|
-
store.setDisposedBySession("sess-y", true);
|
|
431
|
-
|
|
432
|
-
assert.equal(store.listBySession("sess-y").length, 0, "disposed row hidden by default");
|
|
433
|
-
const all = store.listBySession("sess-y", { includeDisposed: true });
|
|
434
|
-
assert.equal(all.length, 1, "includeDisposed reveals it");
|
|
435
|
-
assert.equal(all[0].id, a.id);
|
|
436
|
-
});
|
|
@@ -85,21 +85,6 @@ test("turn/end event triggers summarization and stores entries", async () => {
|
|
|
85
85
|
assert.ok(all.some((m) => m.type === "preference"));
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
test("session with only snapshotEvents() (DSH 0.1.2-rc.1) still summarizes", async () => {
|
|
89
|
-
const { events, store } = setup();
|
|
90
|
-
const handler = events.find((e) => e.name === "session/event").fn;
|
|
91
|
-
const session = {
|
|
92
|
-
id: "s6",
|
|
93
|
-
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
94
|
-
snapshotEvents: () => [userMessage("用快照接口提问"), { seq: 2, type: "turn/end" }]
|
|
95
|
-
};
|
|
96
|
-
await handler(session, { seq: 2, type: "turn/end" });
|
|
97
|
-
assert.equal(store.count(), 2);
|
|
98
|
-
const all = store.all();
|
|
99
|
-
assert.ok(all.some((m) => m.type === "decision"));
|
|
100
|
-
assert.ok(all.some((m) => m.type === "preference"));
|
|
101
|
-
});
|
|
102
|
-
|
|
103
88
|
test("skips summarization for events other than turn/end", async () => {
|
|
104
89
|
const { events, store, calls } = setup();
|
|
105
90
|
const handler = events.find((e) => e.name === "session/event").fn;
|
|
@@ -109,6 +94,22 @@ test("skips summarization for events other than turn/end", async () => {
|
|
|
109
94
|
assert.equal(calls.length, 0);
|
|
110
95
|
});
|
|
111
96
|
|
|
97
|
+
// DSH ≥0.1.2-rc removed Session.events; the session object only exposes
|
|
98
|
+
// snapshotEvents(). Regression for #59: with only the old .events path the
|
|
99
|
+
// collector saw an empty log and no LLM call ever happened.
|
|
100
|
+
test("reads events from snapshotEvents() when Session.events is absent", async () => {
|
|
101
|
+
const { events, store, calls } = setup();
|
|
102
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
103
|
+
const session = {
|
|
104
|
+
id: "s1",
|
|
105
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
106
|
+
snapshotEvents: () => [userMessage("帮我选型"), { seq: 2, type: "turn/end" }]
|
|
107
|
+
};
|
|
108
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
109
|
+
assert.equal(calls.length, 1, "an LLM call must be made");
|
|
110
|
+
assert.equal(store.count(), 2);
|
|
111
|
+
});
|
|
112
|
+
|
|
112
113
|
test("dispose unsubscribes and stops later turn/end events from summarizing", async () => {
|
|
113
114
|
const { events, summarizer, calls } = setup();
|
|
114
115
|
const handler = events.find((e) => e.name === "session/event").fn;
|