@modusensus/dsh-mneme 0.7.14 → 0.7.16
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 +2 -2
- package/README.md +6 -4
- package/lib/api.js +338 -7
- package/lib/client.js +1238 -204
- package/lib/dream/sleep.js +17 -15
- package/lib/dream.js +94 -48
- package/lib/index.js +25 -2
- package/lib/mirror.js +93 -59
- package/lib/service.js +2 -0
- package/lib/settings.js +174 -0
- package/lib/store.js +77 -4
- package/package.json +12 -1
- package/src/api.js +338 -7
- package/src/dream/sleep.js +17 -15
- package/src/dream.js +94 -48
- package/src/index.js +25 -2
- package/src/mirror.js +93 -59
- package/src/service.js +2 -0
- package/src/settings.js +174 -0
- package/src/store.js +77 -4
- package/test/api.test.js +485 -2
- package/test/client.test.js +82 -52
- package/test/dream.test.js +1 -1
- package/test/helpers/peer-worker.mjs +17 -2
- package/test/llm-audit.test.js +34 -4
- package/test/peer-blockers.test.js +8 -2
- package/test/reasoning-effort.test.js +64 -0
- package/test/settings.test.js +136 -0
package/src/store.js
CHANGED
|
@@ -303,6 +303,25 @@ function escapeLike(q) {
|
|
|
303
303
|
return q.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// 日期过滤参数归一化(list/count 共用,保证分页 total 与行同过滤):接受 ISO
|
|
307
|
+
// 日期("2026-09-01")或完整时间戳,返回闭区间的 UTC ISO 边界。date-only 的
|
|
308
|
+
// updatedFrom 按当天 00:00:00.000Z 起、updatedTo 按当天 23:59:59.999Z 收;非
|
|
309
|
+
// 法值一律返回 undefined → 不进 WHERE(忽略而非报错:面板传坏参数时宁可放宽
|
|
310
|
+
// 过滤也不要白屏)。updated_at 列是 toISOString 产生的 UTC "Z" 字符串,字典
|
|
311
|
+
// 序与时间序一致,SQL 里可直接比较。
|
|
312
|
+
function updatedAtBounds(updatedFrom, updatedTo) {
|
|
313
|
+
const norm = (raw, endOfDay) => {
|
|
314
|
+
if (typeof raw !== "string" || !raw.trim()) return undefined;
|
|
315
|
+
const s = raw.trim();
|
|
316
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
317
|
+
const ms = Date.parse(dateOnly ? `${s}T00:00:00.000Z` : s);
|
|
318
|
+
if (Number.isNaN(ms)) return undefined;
|
|
319
|
+
if (dateOnly && endOfDay) return `${s}T23:59:59.999Z`;
|
|
320
|
+
return new Date(ms).toISOString();
|
|
321
|
+
};
|
|
322
|
+
return { from: norm(updatedFrom, false), to: norm(updatedTo, true) };
|
|
323
|
+
}
|
|
324
|
+
|
|
306
325
|
function parseTags(raw) {
|
|
307
326
|
try {
|
|
308
327
|
const arr = JSON.parse(raw);
|
|
@@ -618,7 +637,7 @@ export function createStore(path) {
|
|
|
618
637
|
return ts;
|
|
619
638
|
}
|
|
620
639
|
|
|
621
|
-
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false } = {}) {
|
|
640
|
+
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, updatedFrom = null, updatedTo = null } = {}) {
|
|
622
641
|
const clauses = [];
|
|
623
642
|
const params = [];
|
|
624
643
|
if (type !== undefined) {
|
|
@@ -634,10 +653,24 @@ export function createStore(path) {
|
|
|
634
653
|
clauses.push("source = ?");
|
|
635
654
|
params.push(source);
|
|
636
655
|
}
|
|
656
|
+
// updated_at 闭区间:与 list() 共用 updatedAtBounds 归一化,非法值被忽略
|
|
657
|
+
// (不进 WHERE),total 才能和行保持同过滤。
|
|
658
|
+
const bounds = updatedAtBounds(updatedFrom, updatedTo);
|
|
659
|
+
if (bounds.from) {
|
|
660
|
+
clauses.push("updated_at >= ?");
|
|
661
|
+
params.push(bounds.from);
|
|
662
|
+
}
|
|
663
|
+
if (bounds.to) {
|
|
664
|
+
clauses.push("updated_at <= ?");
|
|
665
|
+
params.push(bounds.to);
|
|
666
|
+
}
|
|
637
667
|
if (!includeForgotten) {
|
|
638
668
|
clauses.push("forgotten = 0");
|
|
639
669
|
}
|
|
640
|
-
|
|
670
|
+
// 与 list() 同过滤:total 才能和归档列表的行保持一致。
|
|
671
|
+
if (onlyArchived) {
|
|
672
|
+
clauses.push("archived = 1");
|
|
673
|
+
} else if (!includeArchived) {
|
|
641
674
|
clauses.push("archived = 0");
|
|
642
675
|
}
|
|
643
676
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
@@ -891,7 +924,7 @@ export function createStore(path) {
|
|
|
891
924
|
return rows.map(toRow);
|
|
892
925
|
}
|
|
893
926
|
|
|
894
|
-
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, minImportance = null, source = null } = {}) {
|
|
927
|
+
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
|
|
895
928
|
const clauses = [];
|
|
896
929
|
const params = [];
|
|
897
930
|
if (type) {
|
|
@@ -908,10 +941,25 @@ export function createStore(path) {
|
|
|
908
941
|
clauses.push("source = ?");
|
|
909
942
|
params.push(source);
|
|
910
943
|
}
|
|
944
|
+
// Optional updated_at closed range (date-only "to" is normalized to the
|
|
945
|
+
// end of that day). Same helper as count() so total matches the rows.
|
|
946
|
+
const bounds = updatedAtBounds(updatedFrom, updatedTo);
|
|
947
|
+
if (bounds.from) {
|
|
948
|
+
clauses.push("updated_at >= ?");
|
|
949
|
+
params.push(bounds.from);
|
|
950
|
+
}
|
|
951
|
+
if (bounds.to) {
|
|
952
|
+
clauses.push("updated_at <= ?");
|
|
953
|
+
params.push(bounds.to);
|
|
954
|
+
}
|
|
911
955
|
if (!includeForgotten) {
|
|
912
956
|
clauses.push("forgotten = 0");
|
|
913
957
|
}
|
|
914
|
-
|
|
958
|
+
// onlyArchived:只看归档(状态页的归档列表用);与 includeArchived(含
|
|
959
|
+
// 归档混看)互斥,同时给时归档视图优先。
|
|
960
|
+
if (onlyArchived) {
|
|
961
|
+
clauses.push("archived = 1");
|
|
962
|
+
} else if (!includeArchived) {
|
|
915
963
|
clauses.push("archived = 0");
|
|
916
964
|
}
|
|
917
965
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
@@ -1598,6 +1646,30 @@ export function createStore(path) {
|
|
|
1598
1646
|
).all(memoryId).map(toAttr);
|
|
1599
1647
|
}
|
|
1600
1648
|
|
|
1649
|
+
/**
|
|
1650
|
+
* 一条记忆关联到的实体(记忆详情侧栏用):entity_attrs.memory_id 反查实体,
|
|
1651
|
+
* 一条 JOIN 完成。同一记忆对同一实体的多次提及(多条 attr 行)按 name 去重,
|
|
1652
|
+
* 每个实体只出现一次,按提及次数降序。只取 name/type——attr 详情走
|
|
1653
|
+
* entity-attrs 端点。无关联(或记忆不存在)返回空数组。
|
|
1654
|
+
*/
|
|
1655
|
+
function entitiesForMemory(memoryId) {
|
|
1656
|
+
const rows = db.prepare(
|
|
1657
|
+
`SELECT e.id, e.name, e.type, e.mention_count, e.last_seen
|
|
1658
|
+
FROM entity_attrs ea JOIN entities e ON e.id = ea.entity_id
|
|
1659
|
+
WHERE ea.memory_id = ?
|
|
1660
|
+
GROUP BY e.id
|
|
1661
|
+
ORDER BY e.mention_count DESC, e.last_seen DESC, e.name ASC`
|
|
1662
|
+
).all(memoryId ?? "");
|
|
1663
|
+
const seen = new Set();
|
|
1664
|
+
const out = [];
|
|
1665
|
+
for (const row of rows) {
|
|
1666
|
+
if (seen.has(row.name)) continue;
|
|
1667
|
+
seen.add(row.name);
|
|
1668
|
+
out.push({ name: row.name, type: row.type ?? null });
|
|
1669
|
+
}
|
|
1670
|
+
return out;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1601
1673
|
/**
|
|
1602
1674
|
* Memories carrying a currently-valid attr matching key=value (deduped).
|
|
1603
1675
|
* When value is empty/undefined, the attr_value filter is dropped and every
|
|
@@ -1941,6 +2013,7 @@ export function createStore(path) {
|
|
|
1941
2013
|
getCurrentAttrs,
|
|
1942
2014
|
getAttrHistory,
|
|
1943
2015
|
getAttrsByMemory,
|
|
2016
|
+
entitiesForMemory,
|
|
1944
2017
|
findMemoriesByAttr,
|
|
1945
2018
|
saveRelation,
|
|
1946
2019
|
migrateAttrsToMemory,
|
package/test/api.test.js
CHANGED
|
@@ -6,6 +6,11 @@ import { createService } from "../src/service.js";
|
|
|
6
6
|
import { createApi } from "../src/api.js";
|
|
7
7
|
import { createSettings } from "../src/settings.js";
|
|
8
8
|
import { createVectorIndex } from "../src/vector-index.js";
|
|
9
|
+
import { Config } from "../src/config.js";
|
|
10
|
+
import { parseHumanEdits } from "../src/mirror.js";
|
|
11
|
+
|
|
12
|
+
// 解析后的 schema 默认值,作为 /features effective 的 bundle 配置侧样本。
|
|
13
|
+
const FLAGS_CFG = Config({});
|
|
9
14
|
|
|
10
15
|
class FakeRes extends EventEmitter {
|
|
11
16
|
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
@@ -27,7 +32,7 @@ function req(path, method = "GET", body = null) {
|
|
|
27
32
|
return r;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
function setup(embedder, apiToken = "") {
|
|
35
|
+
function setup(embedder, apiToken = "", config = null) {
|
|
31
36
|
const store = createStore(":memory:");
|
|
32
37
|
const service = createService({ store, mirror: null, config: {} });
|
|
33
38
|
const settings = createSettings(store.db);
|
|
@@ -45,7 +50,7 @@ function setup(embedder, apiToken = "") {
|
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
};
|
|
48
|
-
const api = createApi(ctx, service, settings, commands, embedder, undefined, apiToken);
|
|
53
|
+
const api = createApi(ctx, service, settings, commands, embedder, undefined, apiToken, config);
|
|
49
54
|
return { store, service, routes, api, settings, apiToken };
|
|
50
55
|
}
|
|
51
56
|
|
|
@@ -511,3 +516,481 @@ test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder return
|
|
|
511
516
|
assert.equal(vectorIndex.dimension(), 3, "dimension written to vector_meta");
|
|
512
517
|
assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
|
|
513
518
|
});
|
|
519
|
+
|
|
520
|
+
// --- feature flags(/features:overrides + effective)------------------------
|
|
521
|
+
|
|
522
|
+
test("GET /api/dsh-mneme/features returns empty overrides and effective config defaults", async () => {
|
|
523
|
+
const { routes } = setup(undefined, "", FLAGS_CFG);
|
|
524
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
525
|
+
const res = new FakeRes();
|
|
526
|
+
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
527
|
+
assert.equal(res.statusCode, 200);
|
|
528
|
+
const data = JSON.parse(res.body);
|
|
529
|
+
assert.deepEqual(data.overrides, {});
|
|
530
|
+
// effective 覆盖全部 30 个白名单键,未覆盖时取 bundle 配置的解析默认值;
|
|
531
|
+
// dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
|
|
532
|
+
// 不编造给前端 → 30 - 2 = 28
|
|
533
|
+
assert.equal(Object.keys(data.effective).length, 28);
|
|
534
|
+
assert.equal(data.effective.autoInject, true);
|
|
535
|
+
assert.equal(data.effective.codingRetrospect, false);
|
|
536
|
+
assert.equal(data.effective.distillMaxChars, 24000);
|
|
537
|
+
assert.equal(data.effective.codingBoostFactor, 2);
|
|
538
|
+
// 新增布尔键(含嵌套点号键)从 bundle 配置的对象子字段/顶层取默认值
|
|
539
|
+
assert.equal(data.effective.bm25SearchEnabled, true);
|
|
540
|
+
assert.equal(data.effective.conflictFreezeEnabled, false);
|
|
541
|
+
assert.equal(data.effective.trustEpistemicWeighting, false);
|
|
542
|
+
assert.equal(data.effective.reflectionFailureTracking, true);
|
|
543
|
+
assert.equal(data.effective["memoryQualityFilter.enabled"], true);
|
|
544
|
+
assert.equal(data.effective["llmAudit.enabled"], true);
|
|
545
|
+
// 新增字符串 / URL / 枚举键
|
|
546
|
+
assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
|
|
547
|
+
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
548
|
+
assert.equal(data.effective.ollamaModel, "nomic-embed-text");
|
|
549
|
+
assert.equal(data.effective.embedProvider, "openai");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
|
|
553
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
554
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
555
|
+
const res = new FakeRes();
|
|
556
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000 }), res);
|
|
557
|
+
assert.equal(res.statusCode, 200);
|
|
558
|
+
const data = JSON.parse(res.body);
|
|
559
|
+
assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000 });
|
|
560
|
+
assert.equal(data.effective.autoDream, false, "override wins over config default");
|
|
561
|
+
assert.equal(data.effective.distillMaxChars, 48000);
|
|
562
|
+
assert.equal(data.effective.autoInject, true, "keys not in the patch still report config");
|
|
563
|
+
assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000 });
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys", async () => {
|
|
567
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
568
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
569
|
+
const patch = {
|
|
570
|
+
"memoryQualityFilter.enabled": false,
|
|
571
|
+
"llmAudit.enabled": false,
|
|
572
|
+
embedProvider: "local",
|
|
573
|
+
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
574
|
+
dreamProvider: " siliconflow ",
|
|
575
|
+
dreamModel: ""
|
|
576
|
+
};
|
|
577
|
+
const res = new FakeRes();
|
|
578
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", patch), res);
|
|
579
|
+
assert.equal(res.statusCode, 200);
|
|
580
|
+
const data = JSON.parse(res.body);
|
|
581
|
+
assert.deepEqual(data.overrides, {
|
|
582
|
+
"memoryQualityFilter.enabled": false,
|
|
583
|
+
"llmAudit.enabled": false,
|
|
584
|
+
embedProvider: "local",
|
|
585
|
+
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
586
|
+
dreamProvider: "siliconflow",
|
|
587
|
+
dreamModel: ""
|
|
588
|
+
}, "nested keys persist flat, free strings are trimmed");
|
|
589
|
+
// 嵌套键的覆盖值压过 bundle 配置的对象子字段
|
|
590
|
+
assert.equal(data.effective["memoryQualityFilter.enabled"], false);
|
|
591
|
+
assert.equal(data.effective["llmAudit.enabled"], false);
|
|
592
|
+
assert.equal(data.effective.embedProvider, "local");
|
|
593
|
+
assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
|
|
594
|
+
assert.equal(data.effective.dreamProvider, "siliconflow");
|
|
595
|
+
assert.equal(data.effective.dreamModel, "", "empty string is a legal override");
|
|
596
|
+
assert.deepEqual(settings.getFeatureFlags(), data.overrides, "overrides persisted");
|
|
597
|
+
|
|
598
|
+
// 非法枚举 / 非 http 协议的 URL → 400 且不落库
|
|
599
|
+
const badEnum = new FakeRes();
|
|
600
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { embedProvider: "bogus" }), badEnum);
|
|
601
|
+
assert.equal(badEnum.statusCode, 400);
|
|
602
|
+
assert.match(JSON.parse(badEnum.body).error, /embedProvider/);
|
|
603
|
+
|
|
604
|
+
const badUrl = new FakeRes();
|
|
605
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { ollamaBaseUrl: "ftp://localhost:11434" }), badUrl);
|
|
606
|
+
assert.equal(badUrl.statusCode, 400);
|
|
607
|
+
assert.match(JSON.parse(badUrl.body).error, /ollamaBaseUrl/);
|
|
608
|
+
|
|
609
|
+
assert.deepEqual(settings.getFeatureFlags(), data.overrides, "rejected writes persist nothing");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test("PUT /api/dsh-mneme/features rejects unknown keys, bad types and bad ranges with 400", async () => {
|
|
613
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
614
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
615
|
+
|
|
616
|
+
const unknown = new FakeRes();
|
|
617
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { noSuchFlag: true }), unknown);
|
|
618
|
+
assert.equal(unknown.statusCode, 400);
|
|
619
|
+
assert.match(JSON.parse(unknown.body).error, /noSuchFlag/);
|
|
620
|
+
|
|
621
|
+
const badType = new FakeRes();
|
|
622
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { autoInject: "yes" }), badType);
|
|
623
|
+
assert.equal(badType.statusCode, 400);
|
|
624
|
+
assert.match(JSON.parse(badType.body).error, /autoInject/);
|
|
625
|
+
|
|
626
|
+
const badRange = new FakeRes();
|
|
627
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { codingBoostFactor: 9 }), badRange);
|
|
628
|
+
assert.equal(badRange.statusCode, 400);
|
|
629
|
+
assert.match(JSON.parse(badRange.body).error, /codingBoostFactor/);
|
|
630
|
+
|
|
631
|
+
// 空 patch 不携带任何意图,直接 400
|
|
632
|
+
const empty = new FakeRes();
|
|
633
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", {}), empty);
|
|
634
|
+
assert.equal(empty.statusCode, 400);
|
|
635
|
+
|
|
636
|
+
assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("PUT /api/dsh-mneme/features is token-gated; GET stays open", async () => {
|
|
640
|
+
const { routes } = setup(undefined, "secret-token", FLAGS_CFG);
|
|
641
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
642
|
+
|
|
643
|
+
const get = new FakeRes();
|
|
644
|
+
await route.handler(req("/api/dsh-mneme/features"), get);
|
|
645
|
+
assert.equal(get.statusCode, 200, "read stays open without token");
|
|
646
|
+
|
|
647
|
+
const put = new FakeRes();
|
|
648
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false }), put);
|
|
649
|
+
assert.equal(put.statusCode, 401);
|
|
650
|
+
|
|
651
|
+
const ok = req("/api/dsh-mneme/features", "PUT", { autoDream: false });
|
|
652
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
653
|
+
const okRes = new FakeRes();
|
|
654
|
+
await route.handler(ok, okRes);
|
|
655
|
+
assert.equal(okRes.statusCode, 200);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// --- 交互式记忆库面板:日期过滤 / update / memories/entities / export / import / dream-status ---
|
|
659
|
+
|
|
660
|
+
test("GET /api/dsh-mneme/list supports updatedFrom/updatedTo and count matches", async () => {
|
|
661
|
+
const { routes, service, store } = setup();
|
|
662
|
+
service.saveWithDedupe({ type: "preference", title: "旧", content: "1" });
|
|
663
|
+
service.saveWithDedupe({ type: "preference", title: "中", content: "2" });
|
|
664
|
+
service.saveWithDedupe({ type: "preference", title: "新", content: "3" });
|
|
665
|
+
const setAt = (title, at) => store.db.prepare("UPDATE memories SET updated_at = ? WHERE title = ?").run(at, title);
|
|
666
|
+
setAt("旧", "2026-08-01T00:00:00.000Z");
|
|
667
|
+
setAt("中", "2026-09-01T12:00:00.000Z");
|
|
668
|
+
setAt("新", "2026-09-15T23:00:00.000Z");
|
|
669
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
670
|
+
|
|
671
|
+
const from = new FakeRes();
|
|
672
|
+
await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-09-01"), from);
|
|
673
|
+
const fromData = JSON.parse(from.body);
|
|
674
|
+
assert.equal(fromData.items.length, 2);
|
|
675
|
+
assert.equal(fromData.total, 2, "count filter matches list filter");
|
|
676
|
+
assert.deepEqual(fromData.items.map((m) => m.title).sort(), ["中", "新"]);
|
|
677
|
+
|
|
678
|
+
// date-only updatedTo 闭区间含当天全天(中 09-01T12:00 命中)
|
|
679
|
+
const range = new FakeRes();
|
|
680
|
+
await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-08-01&updatedTo=2026-09-01"), range);
|
|
681
|
+
const rangeData = JSON.parse(range.body);
|
|
682
|
+
assert.equal(rangeData.items.length, 2);
|
|
683
|
+
assert.equal(rangeData.total, 2);
|
|
684
|
+
|
|
685
|
+
const to = new FakeRes();
|
|
686
|
+
await route.handler(req("/api/dsh-mneme/list?updatedTo=2026-08-31"), to);
|
|
687
|
+
const toData = JSON.parse(to.body);
|
|
688
|
+
assert.equal(toData.items.length, 1);
|
|
689
|
+
assert.equal(toData.items[0].title, "旧");
|
|
690
|
+
|
|
691
|
+
// 非法日期值 → 忽略(不报错、不过滤)
|
|
692
|
+
const bad = new FakeRes();
|
|
693
|
+
await route.handler(req("/api/dsh-mneme/list?updatedFrom=not-a-date&updatedTo=%E4%B9%B1"), bad);
|
|
694
|
+
const badData = JSON.parse(bad.body);
|
|
695
|
+
assert.equal(badData.items.length, 3);
|
|
696
|
+
assert.equal(badData.total, 3);
|
|
697
|
+
|
|
698
|
+
// 完整时间戳同样生效
|
|
699
|
+
const ts = new FakeRes();
|
|
700
|
+
await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-09-02T00:00:00.000Z"), ts);
|
|
701
|
+
const tsData = JSON.parse(ts.body);
|
|
702
|
+
assert.equal(tsData.items.length, 1);
|
|
703
|
+
assert.equal(tsData.items[0].title, "新");
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("POST /api/dsh-mneme/update edits title/importance and archives", async () => {
|
|
707
|
+
const { routes, service } = setup();
|
|
708
|
+
const created = service.saveWithDedupe({ type: "preference", title: "原始", content: "原始内容" });
|
|
709
|
+
const id = created.memory.id;
|
|
710
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
711
|
+
|
|
712
|
+
const edit = new FakeRes();
|
|
713
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, title: "改名", importance: 5 }), edit);
|
|
714
|
+
assert.equal(edit.statusCode, 200);
|
|
715
|
+
const row = JSON.parse(edit.body).memory;
|
|
716
|
+
assert.equal(row.title, "改名");
|
|
717
|
+
assert.equal(row.importance, 5);
|
|
718
|
+
assert.equal(row.archived, false, "archived booleanized in the response row");
|
|
719
|
+
assert.notEqual(row.updated_at, created.memory.updated_at, "updated_at advances");
|
|
720
|
+
|
|
721
|
+
const archive = new FakeRes();
|
|
722
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, archived: true }), archive);
|
|
723
|
+
assert.equal(archive.statusCode, 200);
|
|
724
|
+
assert.equal(JSON.parse(archive.body).memory.archived, true);
|
|
725
|
+
assert.equal(service.getById(id).archived, true);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test("POST /api/dsh-mneme/update archives replaced content into content_history", async () => {
|
|
729
|
+
const { routes, service } = setup();
|
|
730
|
+
const created = service.saveWithDedupe({ type: "preference", title: "历史", content: "第一版" });
|
|
731
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
732
|
+
const res = new FakeRes();
|
|
733
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id: created.memory.id, content: "第二版" }), res);
|
|
734
|
+
assert.equal(res.statusCode, 200);
|
|
735
|
+
const row = service.getById(created.memory.id);
|
|
736
|
+
assert.equal(row.content, "第二版");
|
|
737
|
+
assert.equal(row.content_history[0].content, "第一版");
|
|
738
|
+
assert.equal(row.content_history[0].source, "human_override");
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("POST /api/dsh-mneme/update returns 400/404 for bad requests", async () => {
|
|
742
|
+
const { routes, service } = setup();
|
|
743
|
+
const created = service.saveWithDedupe({ type: "preference", title: "存在", content: "x" });
|
|
744
|
+
const id = created.memory.id;
|
|
745
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
746
|
+
|
|
747
|
+
const missing = new FakeRes();
|
|
748
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { title: "无 id" }), missing);
|
|
749
|
+
assert.equal(missing.statusCode, 400);
|
|
750
|
+
|
|
751
|
+
const noFields = new FakeRes();
|
|
752
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id }), noFields);
|
|
753
|
+
assert.equal(noFields.statusCode, 400);
|
|
754
|
+
|
|
755
|
+
const emptyTitle = new FakeRes();
|
|
756
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, title: " " }), emptyTitle);
|
|
757
|
+
assert.equal(emptyTitle.statusCode, 400);
|
|
758
|
+
|
|
759
|
+
const emptyContent = new FakeRes();
|
|
760
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, content: "" }), emptyContent);
|
|
761
|
+
assert.equal(emptyContent.statusCode, 400);
|
|
762
|
+
|
|
763
|
+
const badImportance = new FakeRes();
|
|
764
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, importance: 9 }), badImportance);
|
|
765
|
+
assert.equal(badImportance.statusCode, 400);
|
|
766
|
+
|
|
767
|
+
const badTags = new FakeRes();
|
|
768
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, tags: "x" }), badTags);
|
|
769
|
+
assert.equal(badTags.statusCode, 400);
|
|
770
|
+
|
|
771
|
+
const badArchived = new FakeRes();
|
|
772
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, archived: "yes" }), badArchived);
|
|
773
|
+
assert.equal(badArchived.statusCode, 400);
|
|
774
|
+
|
|
775
|
+
const gone = new FakeRes();
|
|
776
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id: "no-such-id", title: "x" }), gone);
|
|
777
|
+
assert.equal(gone.statusCode, 404);
|
|
778
|
+
assert.deepEqual(JSON.parse(gone.body), { error: "not-found" });
|
|
779
|
+
|
|
780
|
+
assert.equal(service.getById(id).title, "存在", "failed writes persist nothing");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("POST /api/dsh-mneme/update is token-gated", async () => {
|
|
784
|
+
const { routes } = setup(undefined, "secret-token");
|
|
785
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
786
|
+
const res = new FakeRes();
|
|
787
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id: "x", title: "y" }), res);
|
|
788
|
+
assert.equal(res.statusCode, 401);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test("GET /api/dsh-mneme/memories/entities returns entities linked to a memory", async () => {
|
|
792
|
+
const { routes, service } = setup();
|
|
793
|
+
const a = service.saveWithDedupe({ type: "project", title: "A", content: "提到甲" });
|
|
794
|
+
const b = service.saveWithDedupe({ type: "project", title: "B", content: "无关" });
|
|
795
|
+
const entity = service.createEntity({ name: "甲", type: "person" });
|
|
796
|
+
// 同一记忆两次提及同一实体(两条 attr)→ 去重后只出现一次
|
|
797
|
+
service.saveAttr({ entity_id: entity.id, attr_key: "角色", attr_value: "负责人", memory_id: a.memory.id });
|
|
798
|
+
service.saveAttr({ entity_id: entity.id, attr_key: "团队", attr_value: "前端", memory_id: a.memory.id });
|
|
799
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/memories/entities");
|
|
800
|
+
|
|
801
|
+
const res = new FakeRes();
|
|
802
|
+
await route.handler(req(`/api/dsh-mneme/memories/entities?memoryId=${a.memory.id}`), res);
|
|
803
|
+
assert.equal(res.statusCode, 200);
|
|
804
|
+
const data = JSON.parse(res.body);
|
|
805
|
+
assert.equal(data.entities.length, 1, "deduped by mention");
|
|
806
|
+
assert.equal(data.entities[0].name, "甲");
|
|
807
|
+
assert.equal(data.entities[0].type, "person");
|
|
808
|
+
|
|
809
|
+
const none = new FakeRes();
|
|
810
|
+
await route.handler(req(`/api/dsh-mneme/memories/entities?memoryId=${b.memory.id}`), none);
|
|
811
|
+
assert.deepEqual(JSON.parse(none.body), { entities: [] });
|
|
812
|
+
|
|
813
|
+
const missing = new FakeRes();
|
|
814
|
+
await route.handler(req("/api/dsh-mneme/memories/entities"), missing);
|
|
815
|
+
assert.equal(missing.statusCode, 400);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("GET /api/dsh-mneme/export json carries full rows and version", async () => {
|
|
819
|
+
const { routes, service } = setup();
|
|
820
|
+
service.saveWithDedupe({ type: "preference", title: "甲", content: "内容甲" });
|
|
821
|
+
const hidden = service.saveWithDedupe({ type: "preference", title: "乙", content: "内容乙" });
|
|
822
|
+
service.setForget(hidden.memory.id, true);
|
|
823
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/export");
|
|
824
|
+
const res = new FakeRes();
|
|
825
|
+
await route.handler(req("/api/dsh-mneme/export"), res);
|
|
826
|
+
assert.equal(res.statusCode, 200);
|
|
827
|
+
const data = JSON.parse(res.body);
|
|
828
|
+
assert.equal(data.count, 2, "export includes forgotten rows");
|
|
829
|
+
assert.equal(data.memories.length, 2);
|
|
830
|
+
assert.equal(typeof data.version, "string", "version comes from package.json");
|
|
831
|
+
assert.ok(data.exported_at);
|
|
832
|
+
assert.equal(data.memories.every((m) => typeof m.archived === "boolean" && typeof m.forgotten === "boolean"), true);
|
|
833
|
+
const times = data.memories.map((m) => m.updated_at);
|
|
834
|
+
assert.deepEqual(times, [...times].sort().reverse(), "updated_at DESC");
|
|
835
|
+
assert.match(res.headers["Content-Disposition"], /attachment; filename="dsh-mneme-export-\d{8}\.json"/);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("GET /api/dsh-mneme/export markdown feeds straight back through import", async () => {
|
|
839
|
+
const { routes, service } = setup();
|
|
840
|
+
const a = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文优先" });
|
|
841
|
+
const b = service.saveWithDedupe({ type: "preference", title: "风格", content: "简洁" });
|
|
842
|
+
const exportRoute = routes.find((r) => r.path === "/api/dsh-mneme/export");
|
|
843
|
+
const importRoute = routes.find((r) => r.path === "/api/dsh-mneme/import");
|
|
844
|
+
|
|
845
|
+
const res = new FakeRes();
|
|
846
|
+
await exportRoute.handler(req("/api/dsh-mneme/export?format=markdown"), res);
|
|
847
|
+
assert.equal(res.statusCode, 200);
|
|
848
|
+
assert.match(res.headers["Content-Type"], /text\/markdown/);
|
|
849
|
+
assert.match(res.headers["Content-Disposition"], /attachment; filename="dsh-mneme-export-\d{8}\.md"/);
|
|
850
|
+
const md = res.body;
|
|
851
|
+
assert.ok(md.includes("- **ID**: `" + a.memory.id + "`"), "anchor line present (readHumanEdits-compatible)");
|
|
852
|
+
|
|
853
|
+
// 黄金用例:导出文本原样导入 → 解析出全部条目,且字段无漂移
|
|
854
|
+
const back = new FakeRes();
|
|
855
|
+
await importRoute.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: md }), back);
|
|
856
|
+
assert.equal(back.statusCode, 200);
|
|
857
|
+
const data = JSON.parse(back.body);
|
|
858
|
+
assert.equal(data.type, "preference");
|
|
859
|
+
assert.ok(data.merged >= 2, "both memories parsed back");
|
|
860
|
+
assert.equal(service.getById(a.memory.id).title, "语言");
|
|
861
|
+
assert.equal(service.getById(a.memory.id).content, "中文优先");
|
|
862
|
+
assert.equal(service.getById(b.memory.id).title, "风格");
|
|
863
|
+
assert.equal(service.getById(b.memory.id).content, "简洁");
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("POST /api/dsh-mneme/import validates type/markdown and tolerates zero edits", async () => {
|
|
867
|
+
const { routes } = setup();
|
|
868
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/import");
|
|
869
|
+
|
|
870
|
+
const badType = new FakeRes();
|
|
871
|
+
await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preferences", markdown: "x" }), badType);
|
|
872
|
+
assert.equal(badType.statusCode, 400, "TYPE_FILE keys are singular, plural is rejected");
|
|
873
|
+
|
|
874
|
+
const badMd = new FakeRes();
|
|
875
|
+
await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "" }), badMd);
|
|
876
|
+
assert.equal(badMd.statusCode, 400);
|
|
877
|
+
|
|
878
|
+
const zero = new FakeRes();
|
|
879
|
+
await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "# 空镜像" }), zero);
|
|
880
|
+
assert.equal(zero.statusCode, 200);
|
|
881
|
+
assert.deepEqual(JSON.parse(zero.body), { merged: 0, type: "preference" });
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test("POST /api/dsh-mneme/import is token-gated", async () => {
|
|
885
|
+
const { routes } = setup(undefined, "secret-token");
|
|
886
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/import");
|
|
887
|
+
const res = new FakeRes();
|
|
888
|
+
await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "x" }), res);
|
|
889
|
+
assert.equal(res.statusCode, 401);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", async () => {
|
|
893
|
+
const { routes, service } = setup();
|
|
894
|
+
service.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", provider: "ollama", model: "qwen", snapshot_hash: "h1", input_count: 2, receipt: "r1" });
|
|
895
|
+
service.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "failed", error: "boom", snapshot_hash: "h2", input_count: 0, receipt: "r2" });
|
|
896
|
+
const pending = service.saveConflictPending({ memory_a: "aaaa", memory_b: "bbbb", reason: "矛盾" });
|
|
897
|
+
|
|
898
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status");
|
|
899
|
+
const res = new FakeRes();
|
|
900
|
+
await route.handler(req("/api/dsh-mneme/dream-status"), res);
|
|
901
|
+
assert.equal(res.statusCode, 200);
|
|
902
|
+
const data = JSON.parse(res.body);
|
|
903
|
+
assert.equal(data.runs.length, 2);
|
|
904
|
+
assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC");
|
|
905
|
+
assert.deepEqual(data.lastRun, data.runs[0]);
|
|
906
|
+
assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "error", "model", "provider", "status"]);
|
|
907
|
+
assert.equal(data.runs[0].error, "boom");
|
|
908
|
+
assert.equal(data.runs[1].provider, "ollama");
|
|
909
|
+
assert.equal(data.pendingConflicts, 1);
|
|
910
|
+
assert.deepEqual(data.pendingMemoryIds.sort(), ["aaaa", "bbbb"]);
|
|
911
|
+
|
|
912
|
+
// 解决后队列清空
|
|
913
|
+
service.resolveConflictPending(pending.id, { winner: "aaaa" });
|
|
914
|
+
const after = new FakeRes();
|
|
915
|
+
await route.handler(req("/api/dsh-mneme/dream-status"), after);
|
|
916
|
+
const afterData = JSON.parse(after.body);
|
|
917
|
+
assert.equal(afterData.pendingConflicts, 0);
|
|
918
|
+
assert.deepEqual(afterData.pendingMemoryIds, []);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("parseHumanEdits is the pure core of readHumanEdits (CRLF tolerant)", () => {
|
|
922
|
+
const digest = "0".repeat(64);
|
|
923
|
+
const text = [
|
|
924
|
+
"# x\r\n",
|
|
925
|
+
"\r\n",
|
|
926
|
+
"## 标题\r\n",
|
|
927
|
+
"\r\n",
|
|
928
|
+
"- **ID**: `m1`\n",
|
|
929
|
+
"- **类型**: preference\n",
|
|
930
|
+
"- **重要性**: 3\n",
|
|
931
|
+
"- **标签**: \n",
|
|
932
|
+
"- **更新时间**: 2026-01-01T00:00:00.000Z\n",
|
|
933
|
+
"\n",
|
|
934
|
+
`<!-- mirror-digest: ${digest} -->\n`,
|
|
935
|
+
"正文一段\n",
|
|
936
|
+
"\n",
|
|
937
|
+
"---\n",
|
|
938
|
+
"\n",
|
|
939
|
+
"## 另一条\n",
|
|
940
|
+
"\r\n",
|
|
941
|
+
"- **ID**: `m2`\n",
|
|
942
|
+
"- **类型**: preference\n",
|
|
943
|
+
"- **重要性**: 4\n",
|
|
944
|
+
"- **标签**: \n",
|
|
945
|
+
"- **更新时间**: 2026-01-02T00:00:00.000Z\n",
|
|
946
|
+
"\n",
|
|
947
|
+
"内容二\n",
|
|
948
|
+
"\n",
|
|
949
|
+
"---\n"
|
|
950
|
+
].join("");
|
|
951
|
+
const edits = parseHumanEdits(text);
|
|
952
|
+
assert.equal(edits.length, 2);
|
|
953
|
+
assert.equal(edits[0].id, "m1");
|
|
954
|
+
assert.equal(edits[0].title, "标题");
|
|
955
|
+
assert.equal(edits[0].content, "正文一段");
|
|
956
|
+
assert.equal(edits[1].id, "m2");
|
|
957
|
+
assert.equal(edits[1].content, "内容二");
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("GET /api/dsh-mneme/list?archived=only lists just archived rows; default list hides them", async () => {
|
|
961
|
+
const { routes, service } = setup();
|
|
962
|
+
service.saveWithDedupe({ type: "preference", title: "在用", content: "keep" });
|
|
963
|
+
service.saveWithDedupe({ type: "project", title: "已归档", content: "gone" });
|
|
964
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
965
|
+
const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
966
|
+
|
|
967
|
+
const def0 = new FakeRes();
|
|
968
|
+
await route.handler(req("/api/dsh-mneme/list"), def0);
|
|
969
|
+
const target = JSON.parse(def0.body).items.find((m) => m.title === "已归档");
|
|
970
|
+
|
|
971
|
+
const arch = new FakeRes();
|
|
972
|
+
await upd.handler(req("/api/dsh-mneme/update", "POST", { id: target.id, archived: true }), arch);
|
|
973
|
+
assert.equal(arch.statusCode, 200);
|
|
974
|
+
|
|
975
|
+
const def = new FakeRes();
|
|
976
|
+
await route.handler(req("/api/dsh-mneme/list"), def);
|
|
977
|
+
assert.equal(JSON.parse(def.body).total, 1, "default list hides archived rows");
|
|
978
|
+
|
|
979
|
+
const only = new FakeRes();
|
|
980
|
+
await route.handler(req("/api/dsh-mneme/list?archived=only"), only);
|
|
981
|
+
const onlyData = JSON.parse(only.body);
|
|
982
|
+
assert.equal(onlyData.total, 1, "archived-only count matches rows");
|
|
983
|
+
assert.deepEqual(onlyData.items.map((m) => m.title), ["已归档"]);
|
|
984
|
+
assert.equal(onlyData.items[0].archived, true);
|
|
985
|
+
|
|
986
|
+
// 恢复(unarchive)后归档视图清空、默认列表重新可见
|
|
987
|
+
const restore = new FakeRes();
|
|
988
|
+
await upd.handler(req("/api/dsh-mneme/update", "POST", { id: target.id, archived: false }), restore);
|
|
989
|
+
assert.equal(restore.statusCode, 200);
|
|
990
|
+
const empty = new FakeRes();
|
|
991
|
+
await route.handler(req("/api/dsh-mneme/list?archived=only"), empty);
|
|
992
|
+
assert.equal(JSON.parse(empty.body).total, 0);
|
|
993
|
+
const back = new FakeRes();
|
|
994
|
+
await route.handler(req("/api/dsh-mneme/list"), back);
|
|
995
|
+
assert.equal(JSON.parse(back.body).total, 2);
|
|
996
|
+
});
|