@modusensus/dsh-mneme 0.8.14 → 0.8.15
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/LICENSE +21 -21
- package/README.md +655 -654
- package/assets/icon.svg +3 -3
- package/bin/cli.mjs +689 -689
- package/bin/dsh-mneme-mcp.mjs +510 -510
- package/bin/dsh-mneme-serve.mjs +151 -151
- package/cordis.patch.yml +21 -21
- package/docs/CONFIGURATION.md +263 -0
- package/docs/CONTINUITY.md +124 -0
- package/docs/DAEMON.md +46 -0
- package/docs/ENTITIES.md +245 -0
- package/docs/LOCAL_MODEL.md +222 -0
- package/docs/SEMANTIC.md +256 -0
- package/docs/SLEEP.md +164 -0
- package/docs/STORAGE.md +89 -0
- package/lib/api-standalone.js +673 -673
- package/lib/api.js +1437 -1433
- package/lib/bootstrap.js +211 -211
- package/lib/client.js +5444 -5295
- package/lib/commands.js +95 -95
- package/lib/config.js +880 -843
- package/lib/content-hash.js +54 -54
- package/lib/continuity.js +220 -220
- package/lib/document-index.js +94 -94
- package/lib/document.js +303 -303
- package/lib/dream/clustering.js +118 -118
- package/lib/dream/decisions.js +674 -674
- package/lib/dream/narratives.js +44 -44
- package/lib/dream/sleep.js +819 -819
- package/lib/dream.js +1501 -1501
- package/lib/embedding.js +177 -177
- package/lib/entities/extractor.js +252 -252
- package/lib/graph/anchoring.js +179 -179
- package/lib/guide.js +74 -74
- package/lib/heat.js +144 -144
- package/lib/hot-memory.js +46 -46
- package/lib/index.js +535 -503
- package/lib/inject.js +464 -450
- package/lib/lang.js +248 -229
- package/lib/local-embedder.js +358 -358
- package/lib/maintenance.js +142 -142
- package/lib/mirror.js +330 -330
- package/lib/organize.js +450 -450
- package/lib/peak-hours.js +117 -117
- package/lib/placement.js +13 -0
- package/lib/pre-inject-gate.js +340 -0
- package/lib/quality-filter.js +126 -126
- package/lib/recall-stats.js +175 -175
- package/lib/reranker.js +269 -269
- package/lib/runtime/adopt.js +176 -176
- package/lib/runtime/closure.js +172 -172
- package/lib/runtime/download.js +247 -247
- package/lib/runtime/layout.js +265 -265
- package/lib/runtime/loader.js +252 -252
- package/lib/runtime/model-fetch.js +361 -361
- package/lib/runtime/provision.js +237 -237
- package/lib/runtime/tarball.js +130 -130
- package/lib/runtime/verify.js +223 -204
- package/lib/scope.js +146 -146
- package/lib/search/adaptive.js +60 -60
- package/lib/search/bm25.js +96 -96
- package/lib/semantic.js +229 -229
- package/lib/sensitive-scan.js +209 -209
- package/lib/serve.js +215 -215
- package/lib/service.js +2663 -2605
- package/lib/settings.js +517 -511
- package/lib/store.js +3007 -3007
- package/lib/summarize.js +891 -856
- package/lib/tools.js +743 -737
- package/lib/vector-index.js +110 -110
- package/lib/version-check.js +122 -122
- package/lib/write-admission.js +402 -402
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +118 -118
- package/runtime-manifest.json +526 -526
- package/scripts/benchmark-embed.js +201 -201
- package/scripts/benchmark-recall.js +249 -249
- package/scripts/benchmark-rerank.js +166 -166
- package/scripts/build-runtime-manifest.mjs +224 -224
- package/scripts/changelog-prep.mjs +36 -36
- package/scripts/check-sync.js +42 -42
- package/scripts/e2e-dsh.js +220 -220
- package/scripts/mneme-runtime.mjs +277 -277
- package/scripts/stress-dsh.js +255 -255
- package/scripts/sync-lib.js +47 -47
- package/scripts/sync-test-badge.mjs +69 -69
- package/scripts/test-count-sync.mjs +44 -44
- package/src/api-standalone.js +673 -673
- package/src/api.js +1437 -1433
- package/src/bootstrap.js +211 -211
- package/src/commands.js +95 -95
- package/src/config.js +880 -843
- package/src/content-hash.js +54 -54
- package/src/continuity.js +220 -220
- package/src/document-index.js +94 -94
- package/src/document.js +303 -303
- package/src/dream/clustering.js +118 -118
- package/src/dream/decisions.js +674 -674
- package/src/dream/narratives.js +44 -44
- package/src/dream/sleep.js +819 -819
- package/src/dream.js +1501 -1501
- package/src/embedding.js +177 -177
- package/src/entities/extractor.js +252 -252
- package/src/graph/anchoring.js +179 -179
- package/src/guide.js +74 -74
- package/src/heat.js +144 -144
- package/src/hot-memory.js +46 -46
- package/src/index.js +535 -503
- package/src/inject.js +464 -450
- package/src/lang.js +248 -229
- package/src/local-embedder.js +358 -358
- package/src/maintenance.js +142 -142
- package/src/mirror.js +330 -330
- package/src/organize.js +450 -450
- package/src/peak-hours.js +117 -117
- package/src/placement.js +13 -0
- package/src/pre-inject-gate.js +340 -0
- package/src/quality-filter.js +126 -126
- package/src/recall-stats.js +175 -175
- package/src/reranker.js +269 -269
- package/src/runtime/adopt.js +176 -176
- package/src/runtime/closure.js +172 -172
- package/src/runtime/download.js +247 -247
- package/src/runtime/layout.js +265 -265
- package/src/runtime/loader.js +252 -252
- package/src/runtime/model-fetch.js +361 -361
- package/src/runtime/provision.js +237 -237
- package/src/runtime/tarball.js +130 -130
- package/src/runtime/verify.js +223 -204
- package/src/scope.js +146 -146
- package/src/search/adaptive.js +60 -60
- package/src/search/bm25.js +96 -96
- package/src/semantic.js +229 -229
- package/src/sensitive-scan.js +209 -209
- package/src/serve.js +215 -215
- package/src/service.js +2663 -2605
- package/src/settings.js +517 -511
- package/src/store.js +3007 -3007
- package/src/summarize.js +891 -856
- package/src/tools.js +743 -737
- package/src/vector-index.js +110 -110
- package/src/version-check.js +122 -122
- package/src/write-admission.js +402 -402
- package/test/adaptive-inject-budget.test.js +85 -85
- package/test/api-routes-gaps.test.js +218 -218
- package/test/api.test.js +1702 -1690
- package/test/audit.test.js +448 -448
- package/test/baseline-persist.test.js +248 -248
- package/test/benchmark.test.js +94 -94
- package/test/bootstrap.test.js +220 -220
- package/test/changelog-prep.test.js +60 -60
- package/test/client.test.js +1230 -1169
- package/test/clustering.test.js +100 -100
- package/test/commands.test.js +106 -106
- package/test/config.test.js +66 -66
- package/test/conflict-freeze.test.js +300 -300
- package/test/content-hash.test.js +204 -204
- package/test/continuity.test.js +333 -333
- package/test/document-dir.test.js +233 -233
- package/test/document.test.js +671 -671
- package/test/dream-archive-guardrail.test.js +116 -116
- package/test/dream-candidate-hybrid.test.js +281 -281
- package/test/dream-coverage-degrade.test.js +86 -86
- package/test/dream-failure-backoff.test.js +173 -173
- package/test/dream-peak-hours.test.js +194 -194
- package/test/dream.test.js +1385 -1383
- package/test/embedding-ready.test.js +196 -196
- package/test/entities.test.js +627 -547
- package/test/entity-recall.test.js +179 -179
- package/test/epistemic.test.js +325 -325
- package/test/fnew-0112.test.js +311 -311
- package/test/fnew-03.test.js +422 -422
- package/test/graph-anchoring.test.js +184 -184
- package/test/graph-api.test.js +175 -175
- package/test/graph-inject-budget.test.js +103 -103
- package/test/graph-passive-confirm.test.js +91 -91
- package/test/graph-weight-evolution.test.js +104 -104
- package/test/heat.test.js +175 -175
- package/test/helpers/dream-mock.js +82 -82
- package/test/helpers/peer-worker.mjs +25 -25
- package/test/helpers/runtime-source.js +60 -60
- package/test/helpers/tar-builder.js +80 -80
- package/test/helpers/write-admission-samples.js +308 -308
- package/test/hot-memory.test.js +145 -145
- package/test/inject-content-cap.test.js +91 -91
- package/test/inject-first-round-bm25.test.js +89 -89
- package/test/inject-heat.test.js +77 -77
- package/test/inject-parent-gate.test.js +106 -106
- package/test/inject-pools.test.js +194 -194
- package/test/inject-recall.test.js +105 -105
- package/test/inject-rotation.test.js +161 -161
- package/test/inject.test.js +420 -420
- package/test/lang.test.js +87 -87
- package/test/lastrunat-persist.test.js +101 -101
- package/test/lib-smoke.test.js +96 -96
- package/test/llm-audit.test.js +598 -598
- package/test/llm-message-source.test.js +207 -207
- package/test/local-embedder.test.js +292 -292
- package/test/maintenance.test.js +391 -391
- package/test/mcp-stdio.test.js +385 -385
- package/test/mirror-dirty.test.js +424 -424
- package/test/mirror-edit-digest.test.js +187 -187
- package/test/mirror-generation.test.js +499 -499
- package/test/mirror-types.test.js +208 -208
- package/test/mirror.test.js +249 -249
- package/test/narrative-bars.test.js +162 -162
- package/test/organize.test.js +418 -418
- package/test/peer-blockers.test.js +193 -193
- package/test/placement.test.js +36 -0
- package/test/policy-epoch.test.js +259 -259
- package/test/pre-inject-gate.test.js +434 -0
- package/test/quality-filter.test.js +186 -186
- package/test/reasoning-effort.test.js +752 -752
- package/test/recall-anti-update.test.js +82 -82
- package/test/recall-evals.test.js +253 -253
- package/test/recall-layer.test.js +326 -326
- package/test/recall-stats.test.js +189 -189
- package/test/receipt-chain.test.js +451 -451
- package/test/reflection.test.js +226 -226
- package/test/reindex-backfill.test.js +62 -62
- package/test/reranker-single-logit.test.js +73 -73
- package/test/reranker.test.js +197 -197
- package/test/resident-status.test.js +83 -83
- package/test/runtime-adopt.test.js +206 -206
- package/test/runtime-cli.test.js +89 -89
- package/test/runtime-closure.test.js +193 -193
- package/test/runtime-download.test.js +302 -302
- package/test/runtime-layout.test.js +163 -163
- package/test/runtime-loader.test.js +255 -255
- package/test/runtime-manifest.test.js +159 -159
- package/test/runtime-model-fetch.test.js +348 -348
- package/test/runtime-provision.test.js +101 -101
- package/test/runtime-tarball.test.js +81 -81
- package/test/runtime-verify.test.js +319 -279
- package/test/scope-candidates.test.js +239 -239
- package/test/scope-explicit.test.js +452 -452
- package/test/scope-inject-soft.test.js +106 -106
- package/test/scope-retrieval.test.js +197 -197
- package/test/scope-storage.test.js +255 -255
- package/test/scope-strict.test.js +367 -367
- package/test/search-fusion.test.js +90 -90
- package/test/semantic.test.js +124 -124
- package/test/sensitive-scan.test.js +207 -207
- package/test/serve-bin.test.js +121 -121
- package/test/serve.test.js +196 -196
- package/test/service-search.test.js +174 -174
- package/test/service.test.js +405 -405
- package/test/settings.test.js +261 -261
- package/test/skip-invalid-restore.test.js +262 -262
- package/test/sleep-action-set.test.js +422 -422
- package/test/sleep-heat.test.js +126 -126
- package/test/sleep-scheduler-rearm.test.js +89 -89
- package/test/sleep.test.js +527 -527
- package/test/standalone-api.test.js +751 -751
- package/test/store-vector-cache.test.js +98 -98
- package/test/store.test.js +396 -396
- package/test/stress.test.js +209 -209
- package/test/summarize-occurred-at.test.js +127 -0
- package/test/summarize-reasoning-effort.test.js +189 -189
- package/test/summarize.test.js +1465 -1465
- package/test/supersede-state-tracking.test.js +394 -0
- package/test/test-count-sync.test.js +60 -60
- package/test/tools-dto-schema-matrix.test.js +325 -325
- package/test/tools-memory-get-schema.test.js +65 -65
- package/test/tools.test.js +428 -374
- package/test/updated-at-semantics.test.js +113 -113
- package/test/vector-index.test.js +221 -221
- package/test/version-check.test.js +152 -152
- package/test/webServer-optional.test.js +82 -82
- package/test/write-admission.test.js +668 -668
package/test/api.test.js
CHANGED
|
@@ -1,1690 +1,1702 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { EventEmitter } from "node:events";
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
import { createStore } from "../src/store.js";
|
|
6
|
-
import { createService } from "../src/service.js";
|
|
7
|
-
import { createApi } from "../src/api.js";
|
|
8
|
-
import { createInjector } from "../src/inject.js";
|
|
9
|
-
import { createSettings } from "../src/settings.js";
|
|
10
|
-
import { createVectorIndex } from "../src/vector-index.js";
|
|
11
|
-
import { Config } from "../src/config.js";
|
|
12
|
-
import { parseHumanEdits } from "../src/mirror.js";
|
|
13
|
-
|
|
14
|
-
// 解析后的 schema 默认值,作为 /features effective 的 bundle 配置侧样本。
|
|
15
|
-
const FLAGS_CFG = Config({});
|
|
16
|
-
|
|
17
|
-
class FakeRes extends EventEmitter {
|
|
18
|
-
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
19
|
-
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
20
|
-
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function req(path, method = "GET", body = null) {
|
|
24
|
-
const r = new EventEmitter();
|
|
25
|
-
r.url = path;
|
|
26
|
-
r.method = method;
|
|
27
|
-
r.headers = {};
|
|
28
|
-
if (body !== null) {
|
|
29
|
-
process.nextTick(() => {
|
|
30
|
-
r.emit("data", Buffer.from(JSON.stringify(body)));
|
|
31
|
-
r.emit("end");
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
return r;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function setup(embedder, apiToken = "", config = null, semantic = undefined) {
|
|
38
|
-
const store = createStore(":memory:");
|
|
39
|
-
const service = createService({ store, mirror: null, config: {} });
|
|
40
|
-
const settings = createSettings(store.db);
|
|
41
|
-
const commands = {
|
|
42
|
-
add: (def) => settings.addCommand(def),
|
|
43
|
-
remove: (id) => settings.removeCommand(id),
|
|
44
|
-
list: () => settings.listCommands()
|
|
45
|
-
};
|
|
46
|
-
const routes = [];
|
|
47
|
-
const ctx = {
|
|
48
|
-
webServer: {
|
|
49
|
-
register(route) {
|
|
50
|
-
routes.push(route);
|
|
51
|
-
return () => {};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
const api = createApi(ctx, service, settings, commands, embedder, semantic, apiToken, config);
|
|
56
|
-
return { store, service, routes, api, settings, apiToken };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function findHandler(routes, path) {
|
|
60
|
-
const route = routes.find((r) => r.path === path || (r.kind === "prefix" && path.startsWith(r.path)));
|
|
61
|
-
return route;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
test("registers list, search, and get prefix routes", () => {
|
|
65
|
-
const { routes } = setup();
|
|
66
|
-
const paths = routes.map((r) => r.path);
|
|
67
|
-
assert.ok(paths.includes("/api/dsh-mneme/list"));
|
|
68
|
-
assert.ok(paths.includes("/api/dsh-mneme/search"));
|
|
69
|
-
assert.ok(paths.includes("/api/dsh-mneme"));
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("GET /api/dsh-mneme/list returns memories as JSON", async () => {
|
|
73
|
-
const { routes, service } = setup();
|
|
74
|
-
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
75
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
76
|
-
const res = new FakeRes();
|
|
77
|
-
await route.handler(req("/api/dsh-mneme/list?type=preference"), res);
|
|
78
|
-
assert.equal(res.statusCode, 200);
|
|
79
|
-
const data = JSON.parse(res.body);
|
|
80
|
-
assert.equal(data.items.length, 1);
|
|
81
|
-
assert.equal(data.items[0].title, "语言");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("GET /api/dsh-mneme/search?q= returns matches", async () => {
|
|
85
|
-
const { routes, service } = setup();
|
|
86
|
-
service.saveWithDedupe({ type: "project", title: "记忆插件", content: "SQLite 中文搜索" });
|
|
87
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
|
|
88
|
-
const res = new FakeRes();
|
|
89
|
-
await route.handler(req("/api/dsh-mneme/search?q=%E4%B8%AD%E6%96%87"), res);
|
|
90
|
-
assert.equal(res.statusCode, 200);
|
|
91
|
-
const data = JSON.parse(res.body);
|
|
92
|
-
assert.equal(data.items.length, 1);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("unknown route under prefix returns 404 json", async () => {
|
|
96
|
-
const { routes } = setup();
|
|
97
|
-
const route = findHandler(routes, "/api/dsh-mneme");
|
|
98
|
-
const res = new FakeRes();
|
|
99
|
-
await route.handler(req("/api/dsh-mneme/nope"), res);
|
|
100
|
-
assert.equal(res.statusCode, 404);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("list total excludes forgotten entries", async () => {
|
|
104
|
-
const { routes, service } = setup();
|
|
105
|
-
service.saveWithDedupe({ type: "preference", title: "正常", content: "可见" });
|
|
106
|
-
const forgotten = service.saveWithDedupe({ type: "preference", title: "遗忘", content: "隐藏" });
|
|
107
|
-
service.saveWithDedupe({ type: "project", title: "项目", content: "其他类型" });
|
|
108
|
-
service.setForget(forgotten.memory.id, true);
|
|
109
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
110
|
-
const res = new FakeRes();
|
|
111
|
-
await route.handler(req("/api/dsh-mneme/list?type=preference"), res);
|
|
112
|
-
assert.equal(res.statusCode, 200);
|
|
113
|
-
const data = JSON.parse(res.body);
|
|
114
|
-
assert.equal(data.items.length, 1);
|
|
115
|
-
assert.equal(data.total, 1, "total matches visible items, forgotten excluded");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("list honors limit/offset", async () => {
|
|
119
|
-
const { routes, service } = setup();
|
|
120
|
-
service.saveWithDedupe({ type: "preference", title: "a", content: "1" });
|
|
121
|
-
service.saveWithDedupe({ type: "preference", title: "b", content: "2" });
|
|
122
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
123
|
-
const res = new FakeRes();
|
|
124
|
-
await route.handler(req("/api/dsh-mneme/list?limit=1&offset=0"), res);
|
|
125
|
-
const data = JSON.parse(res.body);
|
|
126
|
-
assert.equal(data.items.length, 1);
|
|
127
|
-
assert.equal(data.total, 2);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("responses carry application/json content-type", async () => {
|
|
131
|
-
const { routes } = setup();
|
|
132
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
133
|
-
const res = new FakeRes();
|
|
134
|
-
await route.handler(req("/api/dsh-mneme/list"), res);
|
|
135
|
-
assert.match(res.headers["Content-Type"], /application\/json/);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("handler errors return 500 json instead of leaking to host", async () => {
|
|
139
|
-
const routes = [];
|
|
140
|
-
const ctx = {
|
|
141
|
-
webServer: {
|
|
142
|
-
register(route) {
|
|
143
|
-
routes.push(route);
|
|
144
|
-
return () => {};
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
const service = {
|
|
149
|
-
list() { throw new Error("boom"); },
|
|
150
|
-
count() { throw new Error("boom"); },
|
|
151
|
-
search() { throw new Error("boom"); },
|
|
152
|
-
toApiList() { return []; }
|
|
153
|
-
};
|
|
154
|
-
createApi(ctx, service, {}, { add: () => { throw new Error("x"); }, remove: () => false, list: () => [] });
|
|
155
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
156
|
-
const res = new FakeRes();
|
|
157
|
-
await route.handler(req("/api/dsh-mneme/list"), res);
|
|
158
|
-
assert.equal(res.statusCode, 500);
|
|
159
|
-
assert.deepEqual(JSON.parse(res.body), { error: "internal" });
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("GET /api/dsh-mneme/profile returns stored profile", async () => {
|
|
163
|
-
const { routes, settings } = setup();
|
|
164
|
-
settings.setProfile("我是前端");
|
|
165
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/profile");
|
|
166
|
-
const res = new FakeRes();
|
|
167
|
-
await route.handler(req("/api/dsh-mneme/profile"), res);
|
|
168
|
-
assert.equal(res.statusCode, 200);
|
|
169
|
-
assert.equal(JSON.parse(res.body).profile, "我是前端");
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test("PUT /api/dsh-mneme/profile saves profile", async () => {
|
|
173
|
-
const { routes, settings } = setup();
|
|
174
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/profile");
|
|
175
|
-
const res = new FakeRes();
|
|
176
|
-
await route.handler(req("/api/dsh-mneme/profile", "PUT", { profile: "新画像" }), res);
|
|
177
|
-
assert.equal(res.statusCode, 200);
|
|
178
|
-
assert.equal(JSON.parse(res.body).profile, "新画像");
|
|
179
|
-
assert.equal(settings.getProfile(), "新画像");
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("GET /api/dsh-mneme/rules returns stored rules", async () => {
|
|
183
|
-
const { routes, settings } = setup();
|
|
184
|
-
settings.setRules(["规则1", "规则2"]);
|
|
185
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/rules");
|
|
186
|
-
const res = new FakeRes();
|
|
187
|
-
await route.handler(req("/api/dsh-mneme/rules"), res);
|
|
188
|
-
assert.equal(res.statusCode, 200);
|
|
189
|
-
assert.deepEqual(JSON.parse(res.body).rules, ["规则1", "规则2"]);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("PUT /api/dsh-mneme/rules saves rules", async () => {
|
|
193
|
-
const { routes, settings } = setup();
|
|
194
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/rules");
|
|
195
|
-
const res = new FakeRes();
|
|
196
|
-
await route.handler(req("/api/dsh-mneme/rules", "PUT", { rules: ["a", "b"] }), res);
|
|
197
|
-
assert.equal(res.statusCode, 200);
|
|
198
|
-
assert.deepEqual(settings.getRules(), ["a", "b"]);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// --- panel mode (light/standard) ---
|
|
202
|
-
|
|
203
|
-
test("GET /api/dsh-mneme/mode defaults to standard", async () => {
|
|
204
|
-
const { routes } = setup();
|
|
205
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
206
|
-
const res = new FakeRes();
|
|
207
|
-
await route.handler(req("/api/dsh-mneme/mode"), res);
|
|
208
|
-
assert.equal(res.statusCode, 200);
|
|
209
|
-
assert.deepEqual(JSON.parse(res.body), { mode: "standard" });
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test("PUT /api/dsh-mneme/mode validates the enum and persists", async () => {
|
|
213
|
-
const { routes, settings } = setup();
|
|
214
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
215
|
-
|
|
216
|
-
const put = new FakeRes();
|
|
217
|
-
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), put);
|
|
218
|
-
assert.equal(put.statusCode, 200);
|
|
219
|
-
assert.deepEqual(JSON.parse(put.body), { mode: "light" });
|
|
220
|
-
assert.equal(settings.getPanelMode(), "light");
|
|
221
|
-
|
|
222
|
-
const back = new FakeRes();
|
|
223
|
-
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "standard" }), back);
|
|
224
|
-
assert.deepEqual(JSON.parse(back.body), { mode: "standard" });
|
|
225
|
-
|
|
226
|
-
const bad = new FakeRes();
|
|
227
|
-
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "turbo" }), bad);
|
|
228
|
-
assert.equal(bad.statusCode, 400);
|
|
229
|
-
assert.equal(settings.getPanelMode(), "standard", "invalid value not persisted");
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test("PUT /api/dsh-mneme/mode is token-gated like other settings writes", async () => {
|
|
233
|
-
const { routes } = setup(undefined, "secret-token");
|
|
234
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
235
|
-
const res = new FakeRes();
|
|
236
|
-
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), res);
|
|
237
|
-
assert.equal(res.statusCode, 401);
|
|
238
|
-
const ok = new FakeRes();
|
|
239
|
-
const authed = req("/api/dsh-mneme/mode", "PUT", { mode: "light" });
|
|
240
|
-
authed.headers = { authorization: "Bearer secret-token" };
|
|
241
|
-
await route.handler(authed, ok);
|
|
242
|
-
assert.equal(ok.statusCode, 200);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("GET /api/dsh-mneme/commands lists commands", async () => {
|
|
246
|
-
const { routes, settings } = setup();
|
|
247
|
-
settings.addCommand({ name: "agenda", instruction: "x" });
|
|
248
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
|
|
249
|
-
const res = new FakeRes();
|
|
250
|
-
await route.handler(req("/api/dsh-mneme/commands"), res);
|
|
251
|
-
assert.equal(res.statusCode, 200);
|
|
252
|
-
assert.equal(JSON.parse(res.body).commands.length, 1);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
test("POST /api/dsh-mneme/commands adds a command; DELETE removes", async () => {
|
|
256
|
-
const { routes } = setup();
|
|
257
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
|
|
258
|
-
const res = new FakeRes();
|
|
259
|
-
await route.handler(req("/api/dsh-mneme/commands", "POST", { name: "fmt", description: "d", instruction: "格式化" }), res);
|
|
260
|
-
assert.equal(res.statusCode, 200);
|
|
261
|
-
const { command } = JSON.parse(res.body);
|
|
262
|
-
assert.equal(command.name, "fmt");
|
|
263
|
-
const del = new FakeRes();
|
|
264
|
-
await route.handler(req(`/api/dsh-mneme/commands?id=${command.id}`, "DELETE"), del);
|
|
265
|
-
assert.equal(JSON.parse(del.body).removed, true);
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
test("POST /api/dsh-mneme/commands rejects invalid name with 400", async () => {
|
|
269
|
-
const { routes } = setup();
|
|
270
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
|
|
271
|
-
const res = new FakeRes();
|
|
272
|
-
await route.handler(req("/api/dsh-mneme/commands", "POST", { name: "Bad Name", instruction: "x" }), res);
|
|
273
|
-
assert.equal(res.statusCode, 400);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
// --- vector search routes ---
|
|
277
|
-
|
|
278
|
-
function setupWithEmbedder(embedder) {
|
|
279
|
-
const base = setup(embedder);
|
|
280
|
-
base.settings.setVectorConfig({
|
|
281
|
-
enabled: true,
|
|
282
|
-
baseUrl: "https://api.example.com/v1",
|
|
283
|
-
apiKey: "sk-test",
|
|
284
|
-
model: "text-embedding-v3"
|
|
285
|
-
});
|
|
286
|
-
return base;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
test("vector-config defaults and round-trips through PUT/GET", async () => {
|
|
290
|
-
const { routes, settings } = setup();
|
|
291
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
|
|
292
|
-
|
|
293
|
-
const get1 = new FakeRes();
|
|
294
|
-
await route.handler(req("/api/dsh-mneme/vector-config"), get1);
|
|
295
|
-
assert.equal(JSON.parse(get1.body).config.enabled, false);
|
|
296
|
-
|
|
297
|
-
const put = new FakeRes();
|
|
298
|
-
await route.handler(req("/api/dsh-mneme/vector-config", "PUT", { enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-x", model: "text-embedding-3-small" }), put);
|
|
299
|
-
assert.equal(JSON.parse(put.body).config.model, "text-embedding-3-small");
|
|
300
|
-
assert.equal(settings.getVectorConfig().enabled, true);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
test("search mode=vector merges vector results when embedder returns a vector", async () => {
|
|
304
|
-
const embedder = {
|
|
305
|
-
embed: async () => [1, 0, 0],
|
|
306
|
-
reindexMissing: async () => ({ indexed: 0, skipped: 0 })
|
|
307
|
-
};
|
|
308
|
-
const { routes, store, service } = setupWithEmbedder(embedder);
|
|
309
|
-
const v = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
|
|
310
|
-
store.setEmbedding(v.memory.id, [1, 0, 0]);
|
|
311
|
-
service.saveWithDedupe({ type: "preference", title: "狗", content: "喜欢狗" });
|
|
312
|
-
|
|
313
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
|
|
314
|
-
const res = new FakeRes();
|
|
315
|
-
await route.handler(req("/api/dsh-mneme/search?q=%E7%8C%AB&mode=vector"), res);
|
|
316
|
-
const data = JSON.parse(res.body);
|
|
317
|
-
assert.equal(data.mode, "vector");
|
|
318
|
-
assert.equal(data.items.length, 1, "keyword hit + vector fill merged");
|
|
319
|
-
assert.equal(data.items[0].title, "猫");
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
test("search falls back to keyword when embedder disabled or unavailable", async () => {
|
|
323
|
-
// embedder that resolves null (disabled provider)
|
|
324
|
-
const embedder = { embed: async () => null, reindexMissing: async () => ({ indexed: 0, skipped: 0 }) };
|
|
325
|
-
const { routes, service } = setupWithEmbedder(embedder);
|
|
326
|
-
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
|
|
327
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
|
|
328
|
-
const res = new FakeRes();
|
|
329
|
-
await route.handler(req("/api/dsh-mneme/search?q=%E4%B8%AD%E6%96%87"), res);
|
|
330
|
-
const data = JSON.parse(res.body);
|
|
331
|
-
assert.equal(data.mode, "keyword");
|
|
332
|
-
assert.equal(data.items.length, 1);
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
test("vector-reindex calls embedder and returns counts", async () => {
|
|
336
|
-
const embedder = { reindexMissing: async () => ({ indexed: 2, skipped: 1 }) };
|
|
337
|
-
const { routes } = setupWithEmbedder(embedder);
|
|
338
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
|
|
339
|
-
const res = new FakeRes();
|
|
340
|
-
await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
|
|
341
|
-
const data = JSON.parse(res.body);
|
|
342
|
-
assert.equal(data.indexed, 2);
|
|
343
|
-
assert.equal(data.skipped, 1);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
test("vector-config masks apiKey; empty/masked key on PUT keeps existing", async () => {
|
|
347
|
-
const { routes, settings } = setup();
|
|
348
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
|
|
349
|
-
|
|
350
|
-
// PUT stores the real key but responds masked
|
|
351
|
-
const put = new FakeRes();
|
|
352
|
-
await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
|
|
353
|
-
enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-abcdefghijklmnop", model: "m1"
|
|
354
|
-
}), put);
|
|
355
|
-
const putBody = JSON.parse(put.body);
|
|
356
|
-
assert.equal(putBody.config.apiKey, "sk-***mnop", "PUT response masked");
|
|
357
|
-
assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "storage keeps the real key");
|
|
358
|
-
|
|
359
|
-
// GET returns masked key, other fields intact
|
|
360
|
-
const get = new FakeRes();
|
|
361
|
-
await route.handler(req("/api/dsh-mneme/vector-config"), get);
|
|
362
|
-
const getBody = JSON.parse(get.body);
|
|
363
|
-
assert.equal(getBody.config.apiKey, "sk-***mnop");
|
|
364
|
-
assert.equal(getBody.config.baseUrl, "https://api.openai.com/v1");
|
|
365
|
-
assert.equal(getBody.config.enabled, true);
|
|
366
|
-
|
|
367
|
-
// PUT with empty apiKey keeps the previous key
|
|
368
|
-
const put2 = new FakeRes();
|
|
369
|
-
await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
|
|
370
|
-
enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "", model: "m2"
|
|
371
|
-
}), put2);
|
|
372
|
-
assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "empty key keeps existing");
|
|
373
|
-
assert.equal(JSON.parse(put2.body).config.model, "m2");
|
|
374
|
-
|
|
375
|
-
// PUT with a masked apiKey (client round-trip) also keeps the previous key
|
|
376
|
-
const put3 = new FakeRes();
|
|
377
|
-
await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
|
|
378
|
-
enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-***mnop", model: "m3"
|
|
379
|
-
}), put3);
|
|
380
|
-
assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "masked key keeps existing");
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
test("apiToken protects write/secret endpoints while read endpoints stay open", async () => {
|
|
384
|
-
const { routes } = setup(undefined, "secret-token");
|
|
385
|
-
const list = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
386
|
-
const profile = routes.find((r) => r.path === "/api/dsh-mneme/profile");
|
|
387
|
-
const vec = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
|
|
388
|
-
const reindex = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
|
|
389
|
-
|
|
390
|
-
// read-only endpoint stays open without a token
|
|
391
|
-
const resList = new FakeRes();
|
|
392
|
-
await list.handler(req("/api/dsh-mneme/list"), resList);
|
|
393
|
-
assert.equal(resList.statusCode, 200, "list stays open");
|
|
394
|
-
|
|
395
|
-
// secret endpoint without token → 401
|
|
396
|
-
const resVec = new FakeRes();
|
|
397
|
-
await vec.handler(req("/api/dsh-mneme/vector-config"), resVec);
|
|
398
|
-
assert.equal(resVec.statusCode, 401, "vector-config GET requires token");
|
|
399
|
-
|
|
400
|
-
// write endpoint without token → 401
|
|
401
|
-
const resProfile = new FakeRes();
|
|
402
|
-
await profile.handler(req("/api/dsh-mneme/profile", "PUT", { profile: "x" }), resProfile);
|
|
403
|
-
assert.equal(resProfile.statusCode, 401, "profile PUT requires token");
|
|
404
|
-
|
|
405
|
-
// reindex without token → 401
|
|
406
|
-
const resReindex = new FakeRes();
|
|
407
|
-
await reindex.handler(req("/api/dsh-mneme/vector-reindex"), resReindex);
|
|
408
|
-
assert.equal(resReindex.statusCode, 401, "vector-reindex requires token");
|
|
409
|
-
|
|
410
|
-
// with a Bearer token everything is allowed
|
|
411
|
-
const authReq = (path, method = "GET", body = null) => {
|
|
412
|
-
const r = req(path, method, body);
|
|
413
|
-
r.headers = { authorization: "Bearer secret-token" };
|
|
414
|
-
return r;
|
|
415
|
-
};
|
|
416
|
-
const resVecOk = new FakeRes();
|
|
417
|
-
await vec.handler(authReq("/api/dsh-mneme/vector-config"), resVecOk);
|
|
418
|
-
assert.equal(resVecOk.statusCode, 200, "vector-config GET with token");
|
|
419
|
-
const resProfileOk = new FakeRes();
|
|
420
|
-
await profile.handler(authReq("/api/dsh-mneme/profile", "PUT", { profile: "hi" }), resProfileOk);
|
|
421
|
-
assert.equal(resProfileOk.statusCode, 200, "profile PUT with token");
|
|
422
|
-
|
|
423
|
-
// wrong token → 401
|
|
424
|
-
const bad = req("/api/dsh-mneme/vector-config");
|
|
425
|
-
bad.headers = { authorization: "Bearer wrong" };
|
|
426
|
-
const resBad = new FakeRes();
|
|
427
|
-
await vec.handler(bad, resBad);
|
|
428
|
-
assert.equal(resBad.statusCode, 401, "wrong token rejected");
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
test("no apiToken configured keeps all endpoints open", async () => {
|
|
432
|
-
const { routes } = setup();
|
|
433
|
-
const vec = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
|
|
434
|
-
const res = new FakeRes();
|
|
435
|
-
await vec.handler(req("/api/dsh-mneme/vector-config"), res);
|
|
436
|
-
assert.equal(res.statusCode, 200, "open when apiToken is unset");
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
// --- #118: /semantic exposes embedder readiness for the status card ----------
|
|
440
|
-
|
|
441
|
-
test("GET /api/dsh-mneme/semantic reports ready state per embedder", async () => {
|
|
442
|
-
const fetchSem = async (embedder) => {
|
|
443
|
-
const { routes } = setup(embedder);
|
|
444
|
-
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
445
|
-
const res = new FakeRes();
|
|
446
|
-
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
447
|
-
assert.equal(res.statusCode, 200);
|
|
448
|
-
return JSON.parse(res.body);
|
|
449
|
-
};
|
|
450
|
-
// no embedder → everything null
|
|
451
|
-
assert.equal((await fetchSem(undefined)).embedProvider, null);
|
|
452
|
-
assert.equal((await fetchSem(undefined)).ready, null, "no embedder → ready null");
|
|
453
|
-
|
|
454
|
-
// Ollama-style embedder mid-init (ready:false) → ready false
|
|
455
|
-
class OllamaEmbedder { constructor() { this.ready = false; } }
|
|
456
|
-
const mid = await fetchSem(new OllamaEmbedder());
|
|
457
|
-
assert.equal(mid.embedProvider, "OllamaEmbedder");
|
|
458
|
-
assert.equal(mid.ready, false, "not-yet-ready embedder → ready false");
|
|
459
|
-
|
|
460
|
-
// ready after init
|
|
461
|
-
const ready = new OllamaEmbedder(); ready.ready = true;
|
|
462
|
-
assert.equal((await fetchSem(ready)).ready, true);
|
|
463
|
-
|
|
464
|
-
// legacy OpenAI embedder has no `ready` prop → treated as ready
|
|
465
|
-
assert.equal((await fetchSem({ embed() {} })).ready, true, "no ready prop → assumed ready");
|
|
466
|
-
|
|
467
|
-
// legacy embedder carries an explicit display name (constructor.name of a
|
|
468
|
-
// literal is "Object", which the status card must not render verbatim)
|
|
469
|
-
const named = await fetchSem({ name: "OpenAI", embed() {} });
|
|
470
|
-
assert.equal(named.embedProvider, "OpenAI", "explicit name beats constructor.name");
|
|
471
|
-
assert.equal(named.ready, true);
|
|
472
|
-
});
|
|
473
|
-
|
|
474
|
-
// --- #135: /semantic must tell "not configured" apart from "degraded" ---------
|
|
475
|
-
|
|
476
|
-
test("GET /api/dsh-mneme/semantic reports configured/coverage/degraded", async () => {
|
|
477
|
-
const fetchSem = async (embedder, semantic) => {
|
|
478
|
-
const { routes } = setup(embedder, "", null, semantic);
|
|
479
|
-
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
480
|
-
const res = new FakeRes();
|
|
481
|
-
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
482
|
-
assert.equal(res.statusCode, 200);
|
|
483
|
-
return JSON.parse(res.body);
|
|
484
|
-
};
|
|
485
|
-
// Stats double: `total` is the memory count, `embedded` the indexed count.
|
|
486
|
-
const index = (embeddedCount, totalCount) => ({
|
|
487
|
-
vectorIndex: { getStats: () => ({ embeddedCount, totalCount }) }
|
|
488
|
-
});
|
|
489
|
-
const openai = (ready, configured) => ({
|
|
490
|
-
name: "OpenAI",
|
|
491
|
-
get ready() { return ready; },
|
|
492
|
-
get configured() { return configured; },
|
|
493
|
-
embed() {}
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
// No embedder at all → everything reports absence, not a false green.
|
|
497
|
-
const none = await fetchSem(undefined, index(0, 0));
|
|
498
|
-
assert.equal(none.configured, null);
|
|
499
|
-
assert.equal(none.reason, "no-embedder");
|
|
500
|
-
assert.equal(none.degraded, false);
|
|
501
|
-
assert.deepEqual(none.coverage, { embedded: 0, total: 0, ratio: null },
|
|
502
|
-
"empty store → ratio null (0 would read as 'nothing embedded')");
|
|
503
|
-
|
|
504
|
-
// Configured embedder still initializing: unreachable ≠ unconfigured.
|
|
505
|
-
assert.equal((await fetchSem(openai(false, true), index(0, 346))).reason, "initializing");
|
|
506
|
-
|
|
507
|
-
// Never configured: reported as such even though the index has memories.
|
|
508
|
-
const unconfigured = await fetchSem(openai(false, false), index(0, 346));
|
|
509
|
-
assert.equal(unconfigured.ready, false);
|
|
510
|
-
assert.equal(unconfigured.configured, false);
|
|
511
|
-
assert.equal(unconfigured.reason, "not-configured");
|
|
512
|
-
assert.equal(unconfigured.degraded, false, "unconfigured was never meant to index");
|
|
513
|
-
assert.equal(unconfigured.coverage.ratio, 0);
|
|
514
|
-
|
|
515
|
-
// Configured, memories present, zero vectors — the state this machine sat in
|
|
516
|
-
// for weeks while the card showed a healthy provider.
|
|
517
|
-
const degraded = await fetchSem(openai(true, true), index(0, 346));
|
|
518
|
-
assert.equal(degraded.reason, null, "a complete config has no abnormal reason");
|
|
519
|
-
assert.equal(degraded.degraded, true);
|
|
520
|
-
assert.equal(degraded.coverage.ratio, 0);
|
|
521
|
-
|
|
522
|
-
// Healthy index.
|
|
523
|
-
const ok = await fetchSem(openai(true, true), index(173, 346));
|
|
524
|
-
assert.equal(ok.degraded, false);
|
|
525
|
-
assert.equal(ok.coverage.ratio, 0.5);
|
|
526
|
-
|
|
527
|
-
// Adapters without a `configured` field (third-party/test doubles) keep the
|
|
528
|
-
// old contract: assumed configured, `ready` decides.
|
|
529
|
-
const legacy = await fetchSem({ embed() {} }, index(5, 10));
|
|
530
|
-
assert.equal(legacy.configured, true);
|
|
531
|
-
assert.equal(legacy.reason, null);
|
|
532
|
-
assert.equal(legacy.ready, true);
|
|
533
|
-
|
|
534
|
-
// No stats provider (no vector index) must not throw — coverage falls back
|
|
535
|
-
// to zeros and an empty store is never "degraded".
|
|
536
|
-
const noIndex = await fetchSem(openai(true, true), null);
|
|
537
|
-
assert.deepEqual(noIndex.coverage, { embedded: 0, total: 0, ratio: null });
|
|
538
|
-
assert.equal(noIndex.degraded, false);
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
// --- Bug8: llm-audit API (pagination + stats) --------------------------------
|
|
542
|
-
|
|
543
|
-
test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
|
|
544
|
-
const { routes, service } = setup();
|
|
545
|
-
for (let i = 0; i < 5; i++) {
|
|
546
|
-
service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", input_tokens: 10, output_tokens: 5, status: "success", related_memory_ids: [] });
|
|
547
|
-
}
|
|
548
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
|
|
549
|
-
const res = new FakeRes();
|
|
550
|
-
await route.handler(req("/api/dsh-mneme/semantic/llm-audit?page=2&pageSize=2"), res);
|
|
551
|
-
assert.equal(res.statusCode, 200);
|
|
552
|
-
const data = JSON.parse(res.body);
|
|
553
|
-
assert.equal(data.total, 5);
|
|
554
|
-
assert.equal(data.page, 2);
|
|
555
|
-
assert.equal(data.pageSize, 2);
|
|
556
|
-
assert.equal(data.items.length, 2, "second page of 2");
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
test("GET /api/dsh-mneme/semantic/llm-audit filters by source", async () => {
|
|
560
|
-
const { routes, service } = setup();
|
|
561
|
-
service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", status: "success" });
|
|
562
|
-
service.saveLlmAudit({ trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2", status: "success" });
|
|
563
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
|
|
564
|
-
const res = new FakeRes();
|
|
565
|
-
await route.handler(req("/api/dsh-mneme/semantic/llm-audit?source=autoSummarize"), res);
|
|
566
|
-
const data = JSON.parse(res.body);
|
|
567
|
-
assert.equal(data.total, 1);
|
|
568
|
-
assert.equal(data.items[0].operation_type, "summarize_compress");
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates tokens by source and status", async () => {
|
|
572
|
-
const { routes, service } = setup();
|
|
573
|
-
service.saveLlmAudit({
|
|
574
|
-
trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1",
|
|
575
|
-
input_tokens: 100, output_tokens: 50, total_tokens: 150, duration_ms: 12, status: "success", related_memory_ids: []
|
|
576
|
-
});
|
|
577
|
-
service.saveLlmAudit({
|
|
578
|
-
trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2",
|
|
579
|
-
input_tokens: 20, output_tokens: 10, total_tokens: 30, duration_ms: 5, status: "error", error_message: "boom", related_memory_ids: []
|
|
580
|
-
});
|
|
581
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
|
|
582
|
-
const res = new FakeRes();
|
|
583
|
-
await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
|
|
584
|
-
assert.equal(res.statusCode, 200);
|
|
585
|
-
const data = JSON.parse(res.body);
|
|
586
|
-
assert.equal(data.total_calls, 2);
|
|
587
|
-
assert.equal(data.input_tokens, 120);
|
|
588
|
-
assert.equal(data.output_tokens, 60);
|
|
589
|
-
assert.equal(data.total_tokens, 180);
|
|
590
|
-
assert.equal(data.total_duration_ms, 17);
|
|
591
|
-
assert.ok(data.by_source.some((s) => s.source === "autoDream" && s.total_tokens === 150), "autoDream aggregate present");
|
|
592
|
-
assert.ok(data.by_status.some((s) => s.status === "error" && s.c === 1), "error status counted");
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
// --- issue #10: vector-reindex with an embed-only OpenAI-compatible embedder --
|
|
596
|
-
|
|
597
|
-
test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder returns the real count and records the model fingerprint", async () => {
|
|
598
|
-
const store = createStore(":memory:");
|
|
599
|
-
const service = createService({ store, mirror: null, config: {} });
|
|
600
|
-
const settings = createSettings(store.db);
|
|
601
|
-
const vectorIndex = createVectorIndex({ store });
|
|
602
|
-
const embedder = {
|
|
603
|
-
embed: async (text) => [0.1, 0.2, 0.3], // OpenAI-compatible single-text embed
|
|
604
|
-
modelHash: "text-embedding-3#abc",
|
|
605
|
-
dimension: 3
|
|
606
|
-
};
|
|
607
|
-
// A pre-index row written before the embedder is attached (so it still has no vector).
|
|
608
|
-
service.saveWithDedupe({ type: "project", title: "待回填", content: "缺少向量的存量记忆" });
|
|
609
|
-
const routes = [];
|
|
610
|
-
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
611
|
-
createApi(ctx, service, settings, { add() {}, remove() {}, list() { return []; } }, embedder, { vectorIndex }, "");
|
|
612
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
|
|
613
|
-
const res = new FakeRes();
|
|
614
|
-
await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
|
|
615
|
-
assert.equal(res.statusCode, 200);
|
|
616
|
-
const data = JSON.parse(res.body);
|
|
617
|
-
assert.equal(data.indexed, 1, "actual indexed count, not 0");
|
|
618
|
-
assert.equal(data.skipped, 0);
|
|
619
|
-
assert.equal(vectorIndex.modelHash(), "text-embedding-3#abc", "model_hash written to vector_meta");
|
|
620
|
-
assert.equal(vectorIndex.dimension(), 3, "dimension written to vector_meta");
|
|
621
|
-
assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
// --- /info(反馈预填的插件版本)-----------------------------------------------
|
|
625
|
-
|
|
626
|
-
test("GET /api/dsh-mneme/info returns the package version for feedback prefills", async () => {
|
|
627
|
-
const { routes } = setup(undefined);
|
|
628
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/info");
|
|
629
|
-
const res = new FakeRes();
|
|
630
|
-
await route.handler(req("/api/dsh-mneme/info"), res);
|
|
631
|
-
assert.equal(res.statusCode, 200);
|
|
632
|
-
const data = JSON.parse(res.body);
|
|
633
|
-
// 与插件根 package.json 的版本一致(反馈 issue/邮件的预填环境信息依赖它)。
|
|
634
|
-
const expected = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
635
|
-
assert.equal(data.version, expected);
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
// --- feature flags(/features:overrides + effective)------------------------
|
|
639
|
-
|
|
640
|
-
test("GET /api/dsh-mneme/features returns empty overrides and effective config defaults", async () => {
|
|
641
|
-
const { routes } = setup(undefined, "", FLAGS_CFG);
|
|
642
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
643
|
-
const res = new FakeRes();
|
|
644
|
-
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
645
|
-
assert.equal(res.statusCode, 200);
|
|
646
|
-
const data = JSON.parse(res.body);
|
|
647
|
-
assert.deepEqual(data.overrides, {});
|
|
648
|
-
// effective 覆盖全部 52 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
|
|
649
|
-
// dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
|
|
650
|
-
// dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
|
|
651
|
-
// recallFusion/signalTransparency、issue #109 新增的实体抽取路由
|
|
652
|
-
// entityExtractionProvider/entityExtractionModel/entityExtractionReasoning、
|
|
653
|
-
// issue #127 新增的 summarize 五键、issue #126 新增的 sleepActionSet,
|
|
654
|
-
// 以及 issue #125 新增的候选集三键、issue #17(v0.8.0 A1/A3)新增的
|
|
655
|
-
// scopeEnabled/strictScope、issue #194 新增的 resilientModelDownload),
|
|
656
|
-
// 未覆盖时取 bundle 配置的解析默认值;
|
|
657
|
-
// dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
|
|
658
|
-
// 不编造给前端 → 62 - 2 = 60(issue #164① 新增 injectContentMaxChars,
|
|
659
|
-
// issue #164 叙述条批次新增 dreamNarrativeEnabled/dreamNarrativeMinCluster,
|
|
660
|
-
// issue #239 新增 summarizeMinWindowChars/summarizeMaxRunsPerSession、
|
|
661
|
-
// 第 4 项的 summarizePeakHours/summarizePeakMaxDeferMinutes 与
|
|
662
|
-
// 第 5 项的 injectUncertaintyAdaptive、
|
|
663
|
-
// issue #257 新增 sleepMaxTokens、issue #258 新增总览路由两键与
|
|
664
|
-
// dreamSummaryMaxInputs、issue #230 新增 documentMemoryEnabled/
|
|
665
|
-
// documentInjectBudget、issue #249 第一批新增 injectGuidanceEnabled/
|
|
666
|
-
// pinnedInjectBudget、issue #249 N3 新增 continuityRescueEnabled,
|
|
667
|
-
// v0.8.5 新增 disableMemorySearch/disableMemoryArchive,
|
|
668
|
-
// 本地嵌入池化新增 localEmbedPooling,issue #315 新增 summarizeReasoningEffort,
|
|
669
|
-
// issue #239 第 4 项镜像到巩固新增 dreamPeakHours/dreamPeakMaxDeferMinutes,
|
|
670
|
-
// issue #292 新增 autoDreamFailureBackoff、
|
|
671
|
-
// issue #254 新增 writeAdmission.enabled/writeAdmission.enforce,
|
|
672
|
-
// issue #34 恢复(#333)新增 injectTimePrefix、
|
|
673
|
-
// issue #24 块1 新增 graphAnchoringEnabled/graphSeedCap/graphCascadeDepth、
|
|
674
|
-
// 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget、
|
|
675
|
-
// 块4 新增 graphPassiveConfirm、
|
|
676
|
-
// issue #339 新增 dreamMergeGuard、
|
|
677
|
-
// issue #218/E5 新增 injectHeatEnabled、
|
|
678
|
-
// issue #164 A2 新增 sensitiveScanEnabled(顶层扁平键,走 configFlagValue
|
|
679
|
-
|
|
680
|
-
assert.equal(data.effective.
|
|
681
|
-
assert.equal(data.effective.
|
|
682
|
-
assert.equal(data.effective.
|
|
683
|
-
assert.equal(data.effective.
|
|
684
|
-
assert.equal(data.effective.
|
|
685
|
-
|
|
686
|
-
assert.equal(data.effective.
|
|
687
|
-
assert.equal(data.effective.
|
|
688
|
-
assert.equal(data.effective.
|
|
689
|
-
assert.equal(data.effective.
|
|
690
|
-
assert.equal(data.effective.
|
|
691
|
-
assert.equal(data.effective.
|
|
692
|
-
assert.equal(data.effective.
|
|
693
|
-
|
|
694
|
-
assert.equal(data.effective.
|
|
695
|
-
assert.equal(data.effective.
|
|
696
|
-
assert.equal(data.effective.
|
|
697
|
-
|
|
698
|
-
assert.equal(data.effective
|
|
699
|
-
assert.equal(data.effective
|
|
700
|
-
|
|
701
|
-
assert.equal(data.effective
|
|
702
|
-
assert.equal(data.effective["
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
assert.equal(data.effective.
|
|
707
|
-
|
|
708
|
-
assert.equal(data.effective.
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
assert.equal(data.effective.
|
|
712
|
-
assert.equal(data.effective.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
assert.equal(data.effective.
|
|
716
|
-
assert.equal(data.effective.
|
|
717
|
-
// issue #
|
|
718
|
-
assert.equal(data.effective.
|
|
719
|
-
|
|
720
|
-
assert.equal(data.effective.
|
|
721
|
-
|
|
722
|
-
assert.equal(data.effective.
|
|
723
|
-
|
|
724
|
-
assert.equal(data.effective.
|
|
725
|
-
|
|
726
|
-
assert.equal(data.effective.
|
|
727
|
-
|
|
728
|
-
assert.equal(data.effective.
|
|
729
|
-
|
|
730
|
-
assert.equal(data.effective.
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
assert.equal(
|
|
743
|
-
|
|
744
|
-
assert.deepEqual(
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
"writeAdmission.
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
"
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
assert.
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
assert.equal(
|
|
798
|
-
assert.
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
assert.equal(
|
|
803
|
-
assert.
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const
|
|
813
|
-
await route.handler(req("/api/dsh-mneme/features", "PUT", {
|
|
814
|
-
assert.equal(
|
|
815
|
-
assert.match(JSON.parse(
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
assert.
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
const
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
assert.
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
assert.equal(
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
assert.equal(
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
assert.equal(
|
|
933
|
-
assert.equal(
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
const {
|
|
939
|
-
const
|
|
940
|
-
const
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
assert.equal(
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const
|
|
956
|
-
await route.handler(req("/api/dsh-mneme/update", "POST", {
|
|
957
|
-
assert.equal(
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
await route.handler(req("/api/dsh-mneme/update", "POST", { id
|
|
961
|
-
assert.equal(
|
|
962
|
-
|
|
963
|
-
const
|
|
964
|
-
await route.handler(req("/api/dsh-mneme/update", "POST", { id,
|
|
965
|
-
assert.equal(
|
|
966
|
-
|
|
967
|
-
const
|
|
968
|
-
await route.handler(req("/api/dsh-mneme/update", "POST", { id,
|
|
969
|
-
assert.equal(
|
|
970
|
-
|
|
971
|
-
const
|
|
972
|
-
await route.handler(req("/api/dsh-mneme/update", "POST", { id
|
|
973
|
-
assert.equal(
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
const
|
|
1010
|
-
await route.handler(req(
|
|
1011
|
-
assert.equal(
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
|
|
1043
|
-
assert.
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
assert.equal(
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
);
|
|
1060
|
-
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
assert.equal(
|
|
1065
|
-
|
|
1066
|
-
assert.
|
|
1067
|
-
assert.
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
assert.equal(
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
const route = routes.find((r) => r.path === "/api/dsh-mneme/
|
|
1107
|
-
const res = new FakeRes();
|
|
1108
|
-
await route.handler(req("/api/dsh-mneme/
|
|
1109
|
-
assert.equal(res.statusCode,
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
assert.equal(
|
|
1128
|
-
assert.
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
const
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
);
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
assert.
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
const
|
|
1176
|
-
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
const
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
//
|
|
1191
|
-
const
|
|
1192
|
-
await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner:
|
|
1193
|
-
assert.equal(
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
assert.equal(
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
assert.equal(
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
const
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
"\n",
|
|
1232
|
-
"
|
|
1233
|
-
"\n",
|
|
1234
|
-
"
|
|
1235
|
-
"
|
|
1236
|
-
"-
|
|
1237
|
-
"-
|
|
1238
|
-
"-
|
|
1239
|
-
"-
|
|
1240
|
-
"
|
|
1241
|
-
|
|
1242
|
-
"
|
|
1243
|
-
"\n",
|
|
1244
|
-
"---\n"
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
const
|
|
1275
|
-
await route.handler(req("/api/dsh-mneme/list
|
|
1276
|
-
const
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
const
|
|
1283
|
-
await
|
|
1284
|
-
assert.equal(
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
assert.
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
const
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
const
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
});
|
|
1309
|
-
service.
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
"
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
const
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
assert.
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
assert.equal(
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
});
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
const
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
if (
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
assert.equal(
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
assert.equal(
|
|
1452
|
-
assert.
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
assert.equal(
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
assert.equal(MOCK_LLM.lastOptions.
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
const
|
|
1481
|
-
|
|
1482
|
-
assert.
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
const
|
|
1488
|
-
const
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
assert.
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
const
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
assert.equal(
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
assert.equal(res.
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
const
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
//
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset:
|
|
1590
|
-
|
|
1591
|
-
//
|
|
1592
|
-
onEvent({ header: {} });
|
|
1593
|
-
res = new FakeRes();
|
|
1594
|
-
await route.handler(req("/api/dsh-mneme/inject-status"), res);
|
|
1595
|
-
assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset:
|
|
1596
|
-
|
|
1597
|
-
//
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
});
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
const
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
const
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
const
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
const
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
const
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { createStore } from "../src/store.js";
|
|
6
|
+
import { createService } from "../src/service.js";
|
|
7
|
+
import { createApi } from "../src/api.js";
|
|
8
|
+
import { createInjector } from "../src/inject.js";
|
|
9
|
+
import { createSettings } from "../src/settings.js";
|
|
10
|
+
import { createVectorIndex } from "../src/vector-index.js";
|
|
11
|
+
import { Config } from "../src/config.js";
|
|
12
|
+
import { parseHumanEdits } from "../src/mirror.js";
|
|
13
|
+
|
|
14
|
+
// 解析后的 schema 默认值,作为 /features effective 的 bundle 配置侧样本。
|
|
15
|
+
const FLAGS_CFG = Config({});
|
|
16
|
+
|
|
17
|
+
class FakeRes extends EventEmitter {
|
|
18
|
+
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
19
|
+
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
20
|
+
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function req(path, method = "GET", body = null) {
|
|
24
|
+
const r = new EventEmitter();
|
|
25
|
+
r.url = path;
|
|
26
|
+
r.method = method;
|
|
27
|
+
r.headers = {};
|
|
28
|
+
if (body !== null) {
|
|
29
|
+
process.nextTick(() => {
|
|
30
|
+
r.emit("data", Buffer.from(JSON.stringify(body)));
|
|
31
|
+
r.emit("end");
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return r;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setup(embedder, apiToken = "", config = null, semantic = undefined) {
|
|
38
|
+
const store = createStore(":memory:");
|
|
39
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
40
|
+
const settings = createSettings(store.db);
|
|
41
|
+
const commands = {
|
|
42
|
+
add: (def) => settings.addCommand(def),
|
|
43
|
+
remove: (id) => settings.removeCommand(id),
|
|
44
|
+
list: () => settings.listCommands()
|
|
45
|
+
};
|
|
46
|
+
const routes = [];
|
|
47
|
+
const ctx = {
|
|
48
|
+
webServer: {
|
|
49
|
+
register(route) {
|
|
50
|
+
routes.push(route);
|
|
51
|
+
return () => {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const api = createApi(ctx, service, settings, commands, embedder, semantic, apiToken, config);
|
|
56
|
+
return { store, service, routes, api, settings, apiToken };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findHandler(routes, path) {
|
|
60
|
+
const route = routes.find((r) => r.path === path || (r.kind === "prefix" && path.startsWith(r.path)));
|
|
61
|
+
return route;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test("registers list, search, and get prefix routes", () => {
|
|
65
|
+
const { routes } = setup();
|
|
66
|
+
const paths = routes.map((r) => r.path);
|
|
67
|
+
assert.ok(paths.includes("/api/dsh-mneme/list"));
|
|
68
|
+
assert.ok(paths.includes("/api/dsh-mneme/search"));
|
|
69
|
+
assert.ok(paths.includes("/api/dsh-mneme"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("GET /api/dsh-mneme/list returns memories as JSON", async () => {
|
|
73
|
+
const { routes, service } = setup();
|
|
74
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
|
|
75
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
76
|
+
const res = new FakeRes();
|
|
77
|
+
await route.handler(req("/api/dsh-mneme/list?type=preference"), res);
|
|
78
|
+
assert.equal(res.statusCode, 200);
|
|
79
|
+
const data = JSON.parse(res.body);
|
|
80
|
+
assert.equal(data.items.length, 1);
|
|
81
|
+
assert.equal(data.items[0].title, "语言");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("GET /api/dsh-mneme/search?q= returns matches", async () => {
|
|
85
|
+
const { routes, service } = setup();
|
|
86
|
+
service.saveWithDedupe({ type: "project", title: "记忆插件", content: "SQLite 中文搜索" });
|
|
87
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
|
|
88
|
+
const res = new FakeRes();
|
|
89
|
+
await route.handler(req("/api/dsh-mneme/search?q=%E4%B8%AD%E6%96%87"), res);
|
|
90
|
+
assert.equal(res.statusCode, 200);
|
|
91
|
+
const data = JSON.parse(res.body);
|
|
92
|
+
assert.equal(data.items.length, 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("unknown route under prefix returns 404 json", async () => {
|
|
96
|
+
const { routes } = setup();
|
|
97
|
+
const route = findHandler(routes, "/api/dsh-mneme");
|
|
98
|
+
const res = new FakeRes();
|
|
99
|
+
await route.handler(req("/api/dsh-mneme/nope"), res);
|
|
100
|
+
assert.equal(res.statusCode, 404);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("list total excludes forgotten entries", async () => {
|
|
104
|
+
const { routes, service } = setup();
|
|
105
|
+
service.saveWithDedupe({ type: "preference", title: "正常", content: "可见" });
|
|
106
|
+
const forgotten = service.saveWithDedupe({ type: "preference", title: "遗忘", content: "隐藏" });
|
|
107
|
+
service.saveWithDedupe({ type: "project", title: "项目", content: "其他类型" });
|
|
108
|
+
service.setForget(forgotten.memory.id, true);
|
|
109
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
110
|
+
const res = new FakeRes();
|
|
111
|
+
await route.handler(req("/api/dsh-mneme/list?type=preference"), res);
|
|
112
|
+
assert.equal(res.statusCode, 200);
|
|
113
|
+
const data = JSON.parse(res.body);
|
|
114
|
+
assert.equal(data.items.length, 1);
|
|
115
|
+
assert.equal(data.total, 1, "total matches visible items, forgotten excluded");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("list honors limit/offset", async () => {
|
|
119
|
+
const { routes, service } = setup();
|
|
120
|
+
service.saveWithDedupe({ type: "preference", title: "a", content: "1" });
|
|
121
|
+
service.saveWithDedupe({ type: "preference", title: "b", content: "2" });
|
|
122
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
123
|
+
const res = new FakeRes();
|
|
124
|
+
await route.handler(req("/api/dsh-mneme/list?limit=1&offset=0"), res);
|
|
125
|
+
const data = JSON.parse(res.body);
|
|
126
|
+
assert.equal(data.items.length, 1);
|
|
127
|
+
assert.equal(data.total, 2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("responses carry application/json content-type", async () => {
|
|
131
|
+
const { routes } = setup();
|
|
132
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
133
|
+
const res = new FakeRes();
|
|
134
|
+
await route.handler(req("/api/dsh-mneme/list"), res);
|
|
135
|
+
assert.match(res.headers["Content-Type"], /application\/json/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("handler errors return 500 json instead of leaking to host", async () => {
|
|
139
|
+
const routes = [];
|
|
140
|
+
const ctx = {
|
|
141
|
+
webServer: {
|
|
142
|
+
register(route) {
|
|
143
|
+
routes.push(route);
|
|
144
|
+
return () => {};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const service = {
|
|
149
|
+
list() { throw new Error("boom"); },
|
|
150
|
+
count() { throw new Error("boom"); },
|
|
151
|
+
search() { throw new Error("boom"); },
|
|
152
|
+
toApiList() { return []; }
|
|
153
|
+
};
|
|
154
|
+
createApi(ctx, service, {}, { add: () => { throw new Error("x"); }, remove: () => false, list: () => [] });
|
|
155
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
156
|
+
const res = new FakeRes();
|
|
157
|
+
await route.handler(req("/api/dsh-mneme/list"), res);
|
|
158
|
+
assert.equal(res.statusCode, 500);
|
|
159
|
+
assert.deepEqual(JSON.parse(res.body), { error: "internal" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("GET /api/dsh-mneme/profile returns stored profile", async () => {
|
|
163
|
+
const { routes, settings } = setup();
|
|
164
|
+
settings.setProfile("我是前端");
|
|
165
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/profile");
|
|
166
|
+
const res = new FakeRes();
|
|
167
|
+
await route.handler(req("/api/dsh-mneme/profile"), res);
|
|
168
|
+
assert.equal(res.statusCode, 200);
|
|
169
|
+
assert.equal(JSON.parse(res.body).profile, "我是前端");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("PUT /api/dsh-mneme/profile saves profile", async () => {
|
|
173
|
+
const { routes, settings } = setup();
|
|
174
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/profile");
|
|
175
|
+
const res = new FakeRes();
|
|
176
|
+
await route.handler(req("/api/dsh-mneme/profile", "PUT", { profile: "新画像" }), res);
|
|
177
|
+
assert.equal(res.statusCode, 200);
|
|
178
|
+
assert.equal(JSON.parse(res.body).profile, "新画像");
|
|
179
|
+
assert.equal(settings.getProfile(), "新画像");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("GET /api/dsh-mneme/rules returns stored rules", async () => {
|
|
183
|
+
const { routes, settings } = setup();
|
|
184
|
+
settings.setRules(["规则1", "规则2"]);
|
|
185
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/rules");
|
|
186
|
+
const res = new FakeRes();
|
|
187
|
+
await route.handler(req("/api/dsh-mneme/rules"), res);
|
|
188
|
+
assert.equal(res.statusCode, 200);
|
|
189
|
+
assert.deepEqual(JSON.parse(res.body).rules, ["规则1", "规则2"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("PUT /api/dsh-mneme/rules saves rules", async () => {
|
|
193
|
+
const { routes, settings } = setup();
|
|
194
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/rules");
|
|
195
|
+
const res = new FakeRes();
|
|
196
|
+
await route.handler(req("/api/dsh-mneme/rules", "PUT", { rules: ["a", "b"] }), res);
|
|
197
|
+
assert.equal(res.statusCode, 200);
|
|
198
|
+
assert.deepEqual(settings.getRules(), ["a", "b"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// --- panel mode (light/standard) ---
|
|
202
|
+
|
|
203
|
+
test("GET /api/dsh-mneme/mode defaults to standard", async () => {
|
|
204
|
+
const { routes } = setup();
|
|
205
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
206
|
+
const res = new FakeRes();
|
|
207
|
+
await route.handler(req("/api/dsh-mneme/mode"), res);
|
|
208
|
+
assert.equal(res.statusCode, 200);
|
|
209
|
+
assert.deepEqual(JSON.parse(res.body), { mode: "standard" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("PUT /api/dsh-mneme/mode validates the enum and persists", async () => {
|
|
213
|
+
const { routes, settings } = setup();
|
|
214
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
215
|
+
|
|
216
|
+
const put = new FakeRes();
|
|
217
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), put);
|
|
218
|
+
assert.equal(put.statusCode, 200);
|
|
219
|
+
assert.deepEqual(JSON.parse(put.body), { mode: "light" });
|
|
220
|
+
assert.equal(settings.getPanelMode(), "light");
|
|
221
|
+
|
|
222
|
+
const back = new FakeRes();
|
|
223
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "standard" }), back);
|
|
224
|
+
assert.deepEqual(JSON.parse(back.body), { mode: "standard" });
|
|
225
|
+
|
|
226
|
+
const bad = new FakeRes();
|
|
227
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "turbo" }), bad);
|
|
228
|
+
assert.equal(bad.statusCode, 400);
|
|
229
|
+
assert.equal(settings.getPanelMode(), "standard", "invalid value not persisted");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("PUT /api/dsh-mneme/mode is token-gated like other settings writes", async () => {
|
|
233
|
+
const { routes } = setup(undefined, "secret-token");
|
|
234
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
235
|
+
const res = new FakeRes();
|
|
236
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), res);
|
|
237
|
+
assert.equal(res.statusCode, 401);
|
|
238
|
+
const ok = new FakeRes();
|
|
239
|
+
const authed = req("/api/dsh-mneme/mode", "PUT", { mode: "light" });
|
|
240
|
+
authed.headers = { authorization: "Bearer secret-token" };
|
|
241
|
+
await route.handler(authed, ok);
|
|
242
|
+
assert.equal(ok.statusCode, 200);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("GET /api/dsh-mneme/commands lists commands", async () => {
|
|
246
|
+
const { routes, settings } = setup();
|
|
247
|
+
settings.addCommand({ name: "agenda", instruction: "x" });
|
|
248
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
|
|
249
|
+
const res = new FakeRes();
|
|
250
|
+
await route.handler(req("/api/dsh-mneme/commands"), res);
|
|
251
|
+
assert.equal(res.statusCode, 200);
|
|
252
|
+
assert.equal(JSON.parse(res.body).commands.length, 1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("POST /api/dsh-mneme/commands adds a command; DELETE removes", async () => {
|
|
256
|
+
const { routes } = setup();
|
|
257
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
|
|
258
|
+
const res = new FakeRes();
|
|
259
|
+
await route.handler(req("/api/dsh-mneme/commands", "POST", { name: "fmt", description: "d", instruction: "格式化" }), res);
|
|
260
|
+
assert.equal(res.statusCode, 200);
|
|
261
|
+
const { command } = JSON.parse(res.body);
|
|
262
|
+
assert.equal(command.name, "fmt");
|
|
263
|
+
const del = new FakeRes();
|
|
264
|
+
await route.handler(req(`/api/dsh-mneme/commands?id=${command.id}`, "DELETE"), del);
|
|
265
|
+
assert.equal(JSON.parse(del.body).removed, true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("POST /api/dsh-mneme/commands rejects invalid name with 400", async () => {
|
|
269
|
+
const { routes } = setup();
|
|
270
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/commands");
|
|
271
|
+
const res = new FakeRes();
|
|
272
|
+
await route.handler(req("/api/dsh-mneme/commands", "POST", { name: "Bad Name", instruction: "x" }), res);
|
|
273
|
+
assert.equal(res.statusCode, 400);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// --- vector search routes ---
|
|
277
|
+
|
|
278
|
+
function setupWithEmbedder(embedder) {
|
|
279
|
+
const base = setup(embedder);
|
|
280
|
+
base.settings.setVectorConfig({
|
|
281
|
+
enabled: true,
|
|
282
|
+
baseUrl: "https://api.example.com/v1",
|
|
283
|
+
apiKey: "sk-test",
|
|
284
|
+
model: "text-embedding-v3"
|
|
285
|
+
});
|
|
286
|
+
return base;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
test("vector-config defaults and round-trips through PUT/GET", async () => {
|
|
290
|
+
const { routes, settings } = setup();
|
|
291
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
|
|
292
|
+
|
|
293
|
+
const get1 = new FakeRes();
|
|
294
|
+
await route.handler(req("/api/dsh-mneme/vector-config"), get1);
|
|
295
|
+
assert.equal(JSON.parse(get1.body).config.enabled, false);
|
|
296
|
+
|
|
297
|
+
const put = new FakeRes();
|
|
298
|
+
await route.handler(req("/api/dsh-mneme/vector-config", "PUT", { enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-x", model: "text-embedding-3-small" }), put);
|
|
299
|
+
assert.equal(JSON.parse(put.body).config.model, "text-embedding-3-small");
|
|
300
|
+
assert.equal(settings.getVectorConfig().enabled, true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("search mode=vector merges vector results when embedder returns a vector", async () => {
|
|
304
|
+
const embedder = {
|
|
305
|
+
embed: async () => [1, 0, 0],
|
|
306
|
+
reindexMissing: async () => ({ indexed: 0, skipped: 0 })
|
|
307
|
+
};
|
|
308
|
+
const { routes, store, service } = setupWithEmbedder(embedder);
|
|
309
|
+
const v = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
|
|
310
|
+
store.setEmbedding(v.memory.id, [1, 0, 0]);
|
|
311
|
+
service.saveWithDedupe({ type: "preference", title: "狗", content: "喜欢狗" });
|
|
312
|
+
|
|
313
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
|
|
314
|
+
const res = new FakeRes();
|
|
315
|
+
await route.handler(req("/api/dsh-mneme/search?q=%E7%8C%AB&mode=vector"), res);
|
|
316
|
+
const data = JSON.parse(res.body);
|
|
317
|
+
assert.equal(data.mode, "vector");
|
|
318
|
+
assert.equal(data.items.length, 1, "keyword hit + vector fill merged");
|
|
319
|
+
assert.equal(data.items[0].title, "猫");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("search falls back to keyword when embedder disabled or unavailable", async () => {
|
|
323
|
+
// embedder that resolves null (disabled provider)
|
|
324
|
+
const embedder = { embed: async () => null, reindexMissing: async () => ({ indexed: 0, skipped: 0 }) };
|
|
325
|
+
const { routes, service } = setupWithEmbedder(embedder);
|
|
326
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
|
|
327
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/search");
|
|
328
|
+
const res = new FakeRes();
|
|
329
|
+
await route.handler(req("/api/dsh-mneme/search?q=%E4%B8%AD%E6%96%87"), res);
|
|
330
|
+
const data = JSON.parse(res.body);
|
|
331
|
+
assert.equal(data.mode, "keyword");
|
|
332
|
+
assert.equal(data.items.length, 1);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("vector-reindex calls embedder and returns counts", async () => {
|
|
336
|
+
const embedder = { reindexMissing: async () => ({ indexed: 2, skipped: 1 }) };
|
|
337
|
+
const { routes } = setupWithEmbedder(embedder);
|
|
338
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
|
|
339
|
+
const res = new FakeRes();
|
|
340
|
+
await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
|
|
341
|
+
const data = JSON.parse(res.body);
|
|
342
|
+
assert.equal(data.indexed, 2);
|
|
343
|
+
assert.equal(data.skipped, 1);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("vector-config masks apiKey; empty/masked key on PUT keeps existing", async () => {
|
|
347
|
+
const { routes, settings } = setup();
|
|
348
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
|
|
349
|
+
|
|
350
|
+
// PUT stores the real key but responds masked
|
|
351
|
+
const put = new FakeRes();
|
|
352
|
+
await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
|
|
353
|
+
enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-abcdefghijklmnop", model: "m1"
|
|
354
|
+
}), put);
|
|
355
|
+
const putBody = JSON.parse(put.body);
|
|
356
|
+
assert.equal(putBody.config.apiKey, "sk-***mnop", "PUT response masked");
|
|
357
|
+
assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "storage keeps the real key");
|
|
358
|
+
|
|
359
|
+
// GET returns masked key, other fields intact
|
|
360
|
+
const get = new FakeRes();
|
|
361
|
+
await route.handler(req("/api/dsh-mneme/vector-config"), get);
|
|
362
|
+
const getBody = JSON.parse(get.body);
|
|
363
|
+
assert.equal(getBody.config.apiKey, "sk-***mnop");
|
|
364
|
+
assert.equal(getBody.config.baseUrl, "https://api.openai.com/v1");
|
|
365
|
+
assert.equal(getBody.config.enabled, true);
|
|
366
|
+
|
|
367
|
+
// PUT with empty apiKey keeps the previous key
|
|
368
|
+
const put2 = new FakeRes();
|
|
369
|
+
await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
|
|
370
|
+
enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "", model: "m2"
|
|
371
|
+
}), put2);
|
|
372
|
+
assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "empty key keeps existing");
|
|
373
|
+
assert.equal(JSON.parse(put2.body).config.model, "m2");
|
|
374
|
+
|
|
375
|
+
// PUT with a masked apiKey (client round-trip) also keeps the previous key
|
|
376
|
+
const put3 = new FakeRes();
|
|
377
|
+
await route.handler(req("/api/dsh-mneme/vector-config", "PUT", {
|
|
378
|
+
enabled: true, baseUrl: "https://api.openai.com/v1", apiKey: "sk-***mnop", model: "m3"
|
|
379
|
+
}), put3);
|
|
380
|
+
assert.equal(settings.getVectorConfig().apiKey, "sk-abcdefghijklmnop", "masked key keeps existing");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("apiToken protects write/secret endpoints while read endpoints stay open", async () => {
|
|
384
|
+
const { routes } = setup(undefined, "secret-token");
|
|
385
|
+
const list = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
386
|
+
const profile = routes.find((r) => r.path === "/api/dsh-mneme/profile");
|
|
387
|
+
const vec = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
|
|
388
|
+
const reindex = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
|
|
389
|
+
|
|
390
|
+
// read-only endpoint stays open without a token
|
|
391
|
+
const resList = new FakeRes();
|
|
392
|
+
await list.handler(req("/api/dsh-mneme/list"), resList);
|
|
393
|
+
assert.equal(resList.statusCode, 200, "list stays open");
|
|
394
|
+
|
|
395
|
+
// secret endpoint without token → 401
|
|
396
|
+
const resVec = new FakeRes();
|
|
397
|
+
await vec.handler(req("/api/dsh-mneme/vector-config"), resVec);
|
|
398
|
+
assert.equal(resVec.statusCode, 401, "vector-config GET requires token");
|
|
399
|
+
|
|
400
|
+
// write endpoint without token → 401
|
|
401
|
+
const resProfile = new FakeRes();
|
|
402
|
+
await profile.handler(req("/api/dsh-mneme/profile", "PUT", { profile: "x" }), resProfile);
|
|
403
|
+
assert.equal(resProfile.statusCode, 401, "profile PUT requires token");
|
|
404
|
+
|
|
405
|
+
// reindex without token → 401
|
|
406
|
+
const resReindex = new FakeRes();
|
|
407
|
+
await reindex.handler(req("/api/dsh-mneme/vector-reindex"), resReindex);
|
|
408
|
+
assert.equal(resReindex.statusCode, 401, "vector-reindex requires token");
|
|
409
|
+
|
|
410
|
+
// with a Bearer token everything is allowed
|
|
411
|
+
const authReq = (path, method = "GET", body = null) => {
|
|
412
|
+
const r = req(path, method, body);
|
|
413
|
+
r.headers = { authorization: "Bearer secret-token" };
|
|
414
|
+
return r;
|
|
415
|
+
};
|
|
416
|
+
const resVecOk = new FakeRes();
|
|
417
|
+
await vec.handler(authReq("/api/dsh-mneme/vector-config"), resVecOk);
|
|
418
|
+
assert.equal(resVecOk.statusCode, 200, "vector-config GET with token");
|
|
419
|
+
const resProfileOk = new FakeRes();
|
|
420
|
+
await profile.handler(authReq("/api/dsh-mneme/profile", "PUT", { profile: "hi" }), resProfileOk);
|
|
421
|
+
assert.equal(resProfileOk.statusCode, 200, "profile PUT with token");
|
|
422
|
+
|
|
423
|
+
// wrong token → 401
|
|
424
|
+
const bad = req("/api/dsh-mneme/vector-config");
|
|
425
|
+
bad.headers = { authorization: "Bearer wrong" };
|
|
426
|
+
const resBad = new FakeRes();
|
|
427
|
+
await vec.handler(bad, resBad);
|
|
428
|
+
assert.equal(resBad.statusCode, 401, "wrong token rejected");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("no apiToken configured keeps all endpoints open", async () => {
|
|
432
|
+
const { routes } = setup();
|
|
433
|
+
const vec = routes.find((r) => r.path === "/api/dsh-mneme/vector-config");
|
|
434
|
+
const res = new FakeRes();
|
|
435
|
+
await vec.handler(req("/api/dsh-mneme/vector-config"), res);
|
|
436
|
+
assert.equal(res.statusCode, 200, "open when apiToken is unset");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// --- #118: /semantic exposes embedder readiness for the status card ----------
|
|
440
|
+
|
|
441
|
+
test("GET /api/dsh-mneme/semantic reports ready state per embedder", async () => {
|
|
442
|
+
const fetchSem = async (embedder) => {
|
|
443
|
+
const { routes } = setup(embedder);
|
|
444
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
445
|
+
const res = new FakeRes();
|
|
446
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
447
|
+
assert.equal(res.statusCode, 200);
|
|
448
|
+
return JSON.parse(res.body);
|
|
449
|
+
};
|
|
450
|
+
// no embedder → everything null
|
|
451
|
+
assert.equal((await fetchSem(undefined)).embedProvider, null);
|
|
452
|
+
assert.equal((await fetchSem(undefined)).ready, null, "no embedder → ready null");
|
|
453
|
+
|
|
454
|
+
// Ollama-style embedder mid-init (ready:false) → ready false
|
|
455
|
+
class OllamaEmbedder { constructor() { this.ready = false; } }
|
|
456
|
+
const mid = await fetchSem(new OllamaEmbedder());
|
|
457
|
+
assert.equal(mid.embedProvider, "OllamaEmbedder");
|
|
458
|
+
assert.equal(mid.ready, false, "not-yet-ready embedder → ready false");
|
|
459
|
+
|
|
460
|
+
// ready after init
|
|
461
|
+
const ready = new OllamaEmbedder(); ready.ready = true;
|
|
462
|
+
assert.equal((await fetchSem(ready)).ready, true);
|
|
463
|
+
|
|
464
|
+
// legacy OpenAI embedder has no `ready` prop → treated as ready
|
|
465
|
+
assert.equal((await fetchSem({ embed() {} })).ready, true, "no ready prop → assumed ready");
|
|
466
|
+
|
|
467
|
+
// legacy embedder carries an explicit display name (constructor.name of a
|
|
468
|
+
// literal is "Object", which the status card must not render verbatim)
|
|
469
|
+
const named = await fetchSem({ name: "OpenAI", embed() {} });
|
|
470
|
+
assert.equal(named.embedProvider, "OpenAI", "explicit name beats constructor.name");
|
|
471
|
+
assert.equal(named.ready, true);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// --- #135: /semantic must tell "not configured" apart from "degraded" ---------
|
|
475
|
+
|
|
476
|
+
test("GET /api/dsh-mneme/semantic reports configured/coverage/degraded", async () => {
|
|
477
|
+
const fetchSem = async (embedder, semantic) => {
|
|
478
|
+
const { routes } = setup(embedder, "", null, semantic);
|
|
479
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
480
|
+
const res = new FakeRes();
|
|
481
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
482
|
+
assert.equal(res.statusCode, 200);
|
|
483
|
+
return JSON.parse(res.body);
|
|
484
|
+
};
|
|
485
|
+
// Stats double: `total` is the memory count, `embedded` the indexed count.
|
|
486
|
+
const index = (embeddedCount, totalCount) => ({
|
|
487
|
+
vectorIndex: { getStats: () => ({ embeddedCount, totalCount }) }
|
|
488
|
+
});
|
|
489
|
+
const openai = (ready, configured) => ({
|
|
490
|
+
name: "OpenAI",
|
|
491
|
+
get ready() { return ready; },
|
|
492
|
+
get configured() { return configured; },
|
|
493
|
+
embed() {}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// No embedder at all → everything reports absence, not a false green.
|
|
497
|
+
const none = await fetchSem(undefined, index(0, 0));
|
|
498
|
+
assert.equal(none.configured, null);
|
|
499
|
+
assert.equal(none.reason, "no-embedder");
|
|
500
|
+
assert.equal(none.degraded, false);
|
|
501
|
+
assert.deepEqual(none.coverage, { embedded: 0, total: 0, ratio: null },
|
|
502
|
+
"empty store → ratio null (0 would read as 'nothing embedded')");
|
|
503
|
+
|
|
504
|
+
// Configured embedder still initializing: unreachable ≠ unconfigured.
|
|
505
|
+
assert.equal((await fetchSem(openai(false, true), index(0, 346))).reason, "initializing");
|
|
506
|
+
|
|
507
|
+
// Never configured: reported as such even though the index has memories.
|
|
508
|
+
const unconfigured = await fetchSem(openai(false, false), index(0, 346));
|
|
509
|
+
assert.equal(unconfigured.ready, false);
|
|
510
|
+
assert.equal(unconfigured.configured, false);
|
|
511
|
+
assert.equal(unconfigured.reason, "not-configured");
|
|
512
|
+
assert.equal(unconfigured.degraded, false, "unconfigured was never meant to index");
|
|
513
|
+
assert.equal(unconfigured.coverage.ratio, 0);
|
|
514
|
+
|
|
515
|
+
// Configured, memories present, zero vectors — the state this machine sat in
|
|
516
|
+
// for weeks while the card showed a healthy provider.
|
|
517
|
+
const degraded = await fetchSem(openai(true, true), index(0, 346));
|
|
518
|
+
assert.equal(degraded.reason, null, "a complete config has no abnormal reason");
|
|
519
|
+
assert.equal(degraded.degraded, true);
|
|
520
|
+
assert.equal(degraded.coverage.ratio, 0);
|
|
521
|
+
|
|
522
|
+
// Healthy index.
|
|
523
|
+
const ok = await fetchSem(openai(true, true), index(173, 346));
|
|
524
|
+
assert.equal(ok.degraded, false);
|
|
525
|
+
assert.equal(ok.coverage.ratio, 0.5);
|
|
526
|
+
|
|
527
|
+
// Adapters without a `configured` field (third-party/test doubles) keep the
|
|
528
|
+
// old contract: assumed configured, `ready` decides.
|
|
529
|
+
const legacy = await fetchSem({ embed() {} }, index(5, 10));
|
|
530
|
+
assert.equal(legacy.configured, true);
|
|
531
|
+
assert.equal(legacy.reason, null);
|
|
532
|
+
assert.equal(legacy.ready, true);
|
|
533
|
+
|
|
534
|
+
// No stats provider (no vector index) must not throw — coverage falls back
|
|
535
|
+
// to zeros and an empty store is never "degraded".
|
|
536
|
+
const noIndex = await fetchSem(openai(true, true), null);
|
|
537
|
+
assert.deepEqual(noIndex.coverage, { embedded: 0, total: 0, ratio: null });
|
|
538
|
+
assert.equal(noIndex.degraded, false);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// --- Bug8: llm-audit API (pagination + stats) --------------------------------
|
|
542
|
+
|
|
543
|
+
test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
|
|
544
|
+
const { routes, service } = setup();
|
|
545
|
+
for (let i = 0; i < 5; i++) {
|
|
546
|
+
service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", input_tokens: 10, output_tokens: 5, status: "success", related_memory_ids: [] });
|
|
547
|
+
}
|
|
548
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
|
|
549
|
+
const res = new FakeRes();
|
|
550
|
+
await route.handler(req("/api/dsh-mneme/semantic/llm-audit?page=2&pageSize=2"), res);
|
|
551
|
+
assert.equal(res.statusCode, 200);
|
|
552
|
+
const data = JSON.parse(res.body);
|
|
553
|
+
assert.equal(data.total, 5);
|
|
554
|
+
assert.equal(data.page, 2);
|
|
555
|
+
assert.equal(data.pageSize, 2);
|
|
556
|
+
assert.equal(data.items.length, 2, "second page of 2");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("GET /api/dsh-mneme/semantic/llm-audit filters by source", async () => {
|
|
560
|
+
const { routes, service } = setup();
|
|
561
|
+
service.saveLlmAudit({ trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1", status: "success" });
|
|
562
|
+
service.saveLlmAudit({ trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2", status: "success" });
|
|
563
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit");
|
|
564
|
+
const res = new FakeRes();
|
|
565
|
+
await route.handler(req("/api/dsh-mneme/semantic/llm-audit?source=autoSummarize"), res);
|
|
566
|
+
const data = JSON.parse(res.body);
|
|
567
|
+
assert.equal(data.total, 1);
|
|
568
|
+
assert.equal(data.items[0].operation_type, "summarize_compress");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("GET /api/dsh-mneme/semantic/llm-audit/stats aggregates tokens by source and status", async () => {
|
|
572
|
+
const { routes, service } = setup();
|
|
573
|
+
service.saveLlmAudit({
|
|
574
|
+
trigger_source: "autoDream", operation_type: "dream_consolidate", model_id: "m1",
|
|
575
|
+
input_tokens: 100, output_tokens: 50, total_tokens: 150, duration_ms: 12, status: "success", related_memory_ids: []
|
|
576
|
+
});
|
|
577
|
+
service.saveLlmAudit({
|
|
578
|
+
trigger_source: "autoSummarize", operation_type: "summarize_compress", model_id: "m2",
|
|
579
|
+
input_tokens: 20, output_tokens: 10, total_tokens: 30, duration_ms: 5, status: "error", error_message: "boom", related_memory_ids: []
|
|
580
|
+
});
|
|
581
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/semantic/llm-audit/stats");
|
|
582
|
+
const res = new FakeRes();
|
|
583
|
+
await route.handler(req("/api/dsh-mneme/semantic/llm-audit/stats?days=7"), res);
|
|
584
|
+
assert.equal(res.statusCode, 200);
|
|
585
|
+
const data = JSON.parse(res.body);
|
|
586
|
+
assert.equal(data.total_calls, 2);
|
|
587
|
+
assert.equal(data.input_tokens, 120);
|
|
588
|
+
assert.equal(data.output_tokens, 60);
|
|
589
|
+
assert.equal(data.total_tokens, 180);
|
|
590
|
+
assert.equal(data.total_duration_ms, 17);
|
|
591
|
+
assert.ok(data.by_source.some((s) => s.source === "autoDream" && s.total_tokens === 150), "autoDream aggregate present");
|
|
592
|
+
assert.ok(data.by_status.some((s) => s.status === "error" && s.c === 1), "error status counted");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// --- issue #10: vector-reindex with an embed-only OpenAI-compatible embedder --
|
|
596
|
+
|
|
597
|
+
test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder returns the real count and records the model fingerprint", async () => {
|
|
598
|
+
const store = createStore(":memory:");
|
|
599
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
600
|
+
const settings = createSettings(store.db);
|
|
601
|
+
const vectorIndex = createVectorIndex({ store });
|
|
602
|
+
const embedder = {
|
|
603
|
+
embed: async (text) => [0.1, 0.2, 0.3], // OpenAI-compatible single-text embed
|
|
604
|
+
modelHash: "text-embedding-3#abc",
|
|
605
|
+
dimension: 3
|
|
606
|
+
};
|
|
607
|
+
// A pre-index row written before the embedder is attached (so it still has no vector).
|
|
608
|
+
service.saveWithDedupe({ type: "project", title: "待回填", content: "缺少向量的存量记忆" });
|
|
609
|
+
const routes = [];
|
|
610
|
+
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
611
|
+
createApi(ctx, service, settings, { add() {}, remove() {}, list() { return []; } }, embedder, { vectorIndex }, "");
|
|
612
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/vector-reindex");
|
|
613
|
+
const res = new FakeRes();
|
|
614
|
+
await route.handler(req("/api/dsh-mneme/vector-reindex"), res);
|
|
615
|
+
assert.equal(res.statusCode, 200);
|
|
616
|
+
const data = JSON.parse(res.body);
|
|
617
|
+
assert.equal(data.indexed, 1, "actual indexed count, not 0");
|
|
618
|
+
assert.equal(data.skipped, 0);
|
|
619
|
+
assert.equal(vectorIndex.modelHash(), "text-embedding-3#abc", "model_hash written to vector_meta");
|
|
620
|
+
assert.equal(vectorIndex.dimension(), 3, "dimension written to vector_meta");
|
|
621
|
+
assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// --- /info(反馈预填的插件版本)-----------------------------------------------
|
|
625
|
+
|
|
626
|
+
test("GET /api/dsh-mneme/info returns the package version for feedback prefills", async () => {
|
|
627
|
+
const { routes } = setup(undefined);
|
|
628
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/info");
|
|
629
|
+
const res = new FakeRes();
|
|
630
|
+
await route.handler(req("/api/dsh-mneme/info"), res);
|
|
631
|
+
assert.equal(res.statusCode, 200);
|
|
632
|
+
const data = JSON.parse(res.body);
|
|
633
|
+
// 与插件根 package.json 的版本一致(反馈 issue/邮件的预填环境信息依赖它)。
|
|
634
|
+
const expected = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
635
|
+
assert.equal(data.version, expected);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
// --- feature flags(/features:overrides + effective)------------------------
|
|
639
|
+
|
|
640
|
+
test("GET /api/dsh-mneme/features returns empty overrides and effective config defaults", async () => {
|
|
641
|
+
const { routes } = setup(undefined, "", FLAGS_CFG);
|
|
642
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
643
|
+
const res = new FakeRes();
|
|
644
|
+
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
645
|
+
assert.equal(res.statusCode, 200);
|
|
646
|
+
const data = JSON.parse(res.body);
|
|
647
|
+
assert.deepEqual(data.overrides, {});
|
|
648
|
+
// effective 覆盖全部 52 个白名单键(含 v0.7.20 heatEnabled、Issue #89 新增
|
|
649
|
+
// dreamSkipInvalid/allowCrossTypeMerge/dreamMinIntervalMinutes、面板可调的
|
|
650
|
+
// dreamMaxTokens、睡眠路由 sleepProvider/sleepModel、PR1 新增的
|
|
651
|
+
// recallFusion/signalTransparency、issue #109 新增的实体抽取路由
|
|
652
|
+
// entityExtractionProvider/entityExtractionModel/entityExtractionReasoning、
|
|
653
|
+
// issue #127 新增的 summarize 五键、issue #126 新增的 sleepActionSet,
|
|
654
|
+
// 以及 issue #125 新增的候选集三键、issue #17(v0.8.0 A1/A3)新增的
|
|
655
|
+
// scopeEnabled/strictScope、issue #194 新增的 resilientModelDownload),
|
|
656
|
+
// 未覆盖时取 bundle 配置的解析默认值;
|
|
657
|
+
// dreamProvider/dreamModel 无 schema 默认值(Config({}) 解析为 undefined),
|
|
658
|
+
// 不编造给前端 → 62 - 2 = 60(issue #164① 新增 injectContentMaxChars,
|
|
659
|
+
// issue #164 叙述条批次新增 dreamNarrativeEnabled/dreamNarrativeMinCluster,
|
|
660
|
+
// issue #239 新增 summarizeMinWindowChars/summarizeMaxRunsPerSession、
|
|
661
|
+
// 第 4 项的 summarizePeakHours/summarizePeakMaxDeferMinutes 与
|
|
662
|
+
// 第 5 项的 injectUncertaintyAdaptive、
|
|
663
|
+
// issue #257 新增 sleepMaxTokens、issue #258 新增总览路由两键与
|
|
664
|
+
// dreamSummaryMaxInputs、issue #230 新增 documentMemoryEnabled/
|
|
665
|
+
// documentInjectBudget、issue #249 第一批新增 injectGuidanceEnabled/
|
|
666
|
+
// pinnedInjectBudget、issue #249 N3 新增 continuityRescueEnabled,
|
|
667
|
+
// v0.8.5 新增 disableMemorySearch/disableMemoryArchive,
|
|
668
|
+
// 本地嵌入池化新增 localEmbedPooling,issue #315 新增 summarizeReasoningEffort,
|
|
669
|
+
// issue #239 第 4 项镜像到巩固新增 dreamPeakHours/dreamPeakMaxDeferMinutes,
|
|
670
|
+
// issue #292 新增 autoDreamFailureBackoff、
|
|
671
|
+
// issue #254 新增 writeAdmission.enabled/writeAdmission.enforce,
|
|
672
|
+
// issue #34 恢复(#333)新增 injectTimePrefix、
|
|
673
|
+
// issue #24 块1 新增 graphAnchoringEnabled/graphSeedCap/graphCascadeDepth、
|
|
674
|
+
// 块2 新增 graphWeightEnabled/graphWeightDelta、块3 新增 graphInjectHint/graphInjectBudget、
|
|
675
|
+
// 块4 新增 graphPassiveConfirm、
|
|
676
|
+
// issue #339 新增 dreamMergeGuard、
|
|
677
|
+
// issue #218/E5 新增 injectHeatEnabled、
|
|
678
|
+
// issue #164 A2 新增 sensitiveScanEnabled(顶层扁平键,走 configFlagValue 默认分支)、
|
|
679
|
+
// issue #380 新增 preInjectGate.enabled/preInjectGate.enforce(注入前判定两级开关))
|
|
680
|
+
assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2);
|
|
681
|
+
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
682
|
+
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
683
|
+
assert.equal(data.effective.dreamMergeGuard, true);
|
|
684
|
+
assert.equal(data.effective.injectHeatEnabled, false);
|
|
685
|
+
// issue #380:注入前判定两级开关,默认全关(默认路径零行为变化)
|
|
686
|
+
assert.equal(data.effective["preInjectGate.enabled"], false);
|
|
687
|
+
assert.equal(data.effective["preInjectGate.enforce"], false);
|
|
688
|
+
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
689
|
+
assert.equal(data.effective.autoDreamFailureBackoff, false);
|
|
690
|
+
assert.equal(data.effective.dreamMaxTokens, 131072);
|
|
691
|
+
assert.equal(data.effective.sleepProvider, "");
|
|
692
|
+
assert.equal(data.effective.sleepModel, "");
|
|
693
|
+
assert.equal(data.effective.autoInject, true);
|
|
694
|
+
assert.equal(data.effective.codingRetrospect, false);
|
|
695
|
+
assert.equal(data.effective.distillMaxChars, 24000);
|
|
696
|
+
assert.equal(data.effective.codingBoostFactor, 2);
|
|
697
|
+
// 新增布尔键(含嵌套点号键)从 bundle 配置的对象子字段/顶层取默认值
|
|
698
|
+
assert.equal(data.effective.bm25SearchEnabled, true);
|
|
699
|
+
assert.equal(data.effective.conflictFreezeEnabled, false);
|
|
700
|
+
assert.equal(data.effective.trustEpistemicWeighting, false);
|
|
701
|
+
assert.equal(data.effective.reflectionFailureTracking, true);
|
|
702
|
+
assert.equal(data.effective["memoryQualityFilter.enabled"], true);
|
|
703
|
+
assert.equal(data.effective["llmAudit.enabled"], true);
|
|
704
|
+
// #254:写入准入两个键都默认关(默认路径零行为变化),面板可逐项启停。
|
|
705
|
+
assert.equal(data.effective["writeAdmission.enabled"], false);
|
|
706
|
+
assert.equal(data.effective["writeAdmission.enforce"], false);
|
|
707
|
+
// #164 A2:密钥/PII 判据自己的闸,同样默认关(#332 的行为逐字节保留)。
|
|
708
|
+
assert.equal(data.effective.sensitiveScanEnabled, false);
|
|
709
|
+
// 新增字符串 / URL / 枚举键
|
|
710
|
+
assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
|
|
711
|
+
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
712
|
+
assert.equal(data.effective.ollamaModel, "nomic-embed-text");
|
|
713
|
+
assert.equal(data.effective.embedProvider, "openai");
|
|
714
|
+
// PR1:融合配方枚举 + 信号透明布尔(均有默认值,故计入 effective 计数)
|
|
715
|
+
assert.equal(data.effective.recallFusion, "blend");
|
|
716
|
+
assert.equal(data.effective.signalTransparency, false);
|
|
717
|
+
// issue #109:实体抽取路由三键(均有默认值,故计入 effective 计数)
|
|
718
|
+
assert.equal(data.effective.entityExtractionProvider, "");
|
|
719
|
+
assert.equal(data.effective.entityExtractionModel, "");
|
|
720
|
+
assert.equal(data.effective.entityExtractionReasoning, "none");
|
|
721
|
+
// issue #315:蒸馏思考强度(默认 none = 不发送字段,行为不变)
|
|
722
|
+
assert.equal(data.effective.summarizeReasoningEffort, "none");
|
|
723
|
+
// issue #127:summarize 节流五键(均有默认值,故计入 effective 计数)
|
|
724
|
+
assert.equal(data.effective.summarizeMinIntervalMinutes, 0);
|
|
725
|
+
assert.equal(data.effective.summarizeMaxEntriesPerRun, 0);
|
|
726
|
+
assert.equal(data.effective.summarizeDedupeMode, "off");
|
|
727
|
+
assert.equal(data.effective.summarizeDedupeMinSim, 0.92);
|
|
728
|
+
assert.equal(data.effective.summarizeDedupeWindowHours, 24);
|
|
729
|
+
// issue #126:sleep 动作集(默认 conflict = 现状;面板可切 full)
|
|
730
|
+
assert.equal(data.effective.sleepActionSet, "conflict");
|
|
731
|
+
// issue #125:候选集三键(默认 window / 0 / 0.85)
|
|
732
|
+
assert.equal(data.effective.dreamCandidateMode, "window");
|
|
733
|
+
assert.equal(data.effective.dreamCandidateMax, 0);
|
|
734
|
+
assert.equal(data.effective.dreamCandidateMinSim, 0.85);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
|
|
738
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
739
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
740
|
+
const res = new FakeRes();
|
|
741
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000 }), res);
|
|
742
|
+
assert.equal(res.statusCode, 200);
|
|
743
|
+
const data = JSON.parse(res.body);
|
|
744
|
+
assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000 });
|
|
745
|
+
assert.equal(data.effective.autoDream, false, "override wins over config default");
|
|
746
|
+
assert.equal(data.effective.distillMaxChars, 48000);
|
|
747
|
+
assert.equal(data.effective.autoInject, true, "keys not in the patch still report config");
|
|
748
|
+
assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000 });
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys", async () => {
|
|
752
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
753
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
754
|
+
const patch = {
|
|
755
|
+
"memoryQualityFilter.enabled": false,
|
|
756
|
+
"llmAudit.enabled": false,
|
|
757
|
+
// #254:新点号键必须同时进 settings 白名单**和** api.js 的 NESTED_FLAG_PATHS。
|
|
758
|
+
// 只加白名单时 configFlagValue("writeAdmission.enabled") 取的是 cfg 顶层的
|
|
759
|
+
// 同名字符串(不存在)→ effective 静默少键、PUT 存了也读不回来,且面板无感。
|
|
760
|
+
// 这里往返一次就是钉这件事的(计数锁只钉数量,钉不住值)。
|
|
761
|
+
"writeAdmission.enabled": true,
|
|
762
|
+
"writeAdmission.enforce": true,
|
|
763
|
+
// #380:同款点号键往返——白名单与 NESTED_FLAG_PATHS 成对缺一即静默缺键。
|
|
764
|
+
"preInjectGate.enabled": true,
|
|
765
|
+
"preInjectGate.enforce": true,
|
|
766
|
+
// #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返;
|
|
767
|
+
// 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。
|
|
768
|
+
sensitiveScanEnabled: true,
|
|
769
|
+
embedProvider: "local",
|
|
770
|
+
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
771
|
+
dreamProvider: " siliconflow ",
|
|
772
|
+
dreamModel: ""
|
|
773
|
+
};
|
|
774
|
+
const res = new FakeRes();
|
|
775
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", patch), res);
|
|
776
|
+
assert.equal(res.statusCode, 200);
|
|
777
|
+
const data = JSON.parse(res.body);
|
|
778
|
+
assert.deepEqual(data.overrides, {
|
|
779
|
+
"memoryQualityFilter.enabled": false,
|
|
780
|
+
"llmAudit.enabled": false,
|
|
781
|
+
"writeAdmission.enabled": true,
|
|
782
|
+
"writeAdmission.enforce": true,
|
|
783
|
+
"preInjectGate.enabled": true,
|
|
784
|
+
"preInjectGate.enforce": true,
|
|
785
|
+
sensitiveScanEnabled: true,
|
|
786
|
+
embedProvider: "local",
|
|
787
|
+
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
788
|
+
dreamProvider: "siliconflow",
|
|
789
|
+
dreamModel: ""
|
|
790
|
+
}, "nested keys persist flat, free strings are trimmed");
|
|
791
|
+
// 嵌套键的覆盖值压过 bundle 配置的对象子字段
|
|
792
|
+
assert.equal(data.effective["memoryQualityFilter.enabled"], false);
|
|
793
|
+
assert.equal(data.effective["llmAudit.enabled"], false);
|
|
794
|
+
assert.equal(data.effective["writeAdmission.enabled"], true);
|
|
795
|
+
assert.equal(data.effective["writeAdmission.enforce"], true);
|
|
796
|
+
// #380:往返回来的覆盖值压过 bundle 配置默认
|
|
797
|
+
assert.equal(data.effective["preInjectGate.enabled"], true);
|
|
798
|
+
assert.equal(data.effective["preInjectGate.enforce"], true);
|
|
799
|
+
assert.equal(data.effective.sensitiveScanEnabled, true);
|
|
800
|
+
assert.equal(data.effective.embedProvider, "local");
|
|
801
|
+
assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
|
|
802
|
+
assert.equal(data.effective.dreamProvider, "siliconflow");
|
|
803
|
+
assert.equal(data.effective.dreamModel, "", "empty string is a legal override");
|
|
804
|
+
assert.deepEqual(settings.getFeatureFlags(), data.overrides, "overrides persisted");
|
|
805
|
+
|
|
806
|
+
// 非法枚举 / 非 http 协议的 URL → 400 且不落库
|
|
807
|
+
const badEnum = new FakeRes();
|
|
808
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { embedProvider: "bogus" }), badEnum);
|
|
809
|
+
assert.equal(badEnum.statusCode, 400);
|
|
810
|
+
assert.match(JSON.parse(badEnum.body).error, /embedProvider/);
|
|
811
|
+
|
|
812
|
+
const badUrl = new FakeRes();
|
|
813
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { ollamaBaseUrl: "ftp://localhost:11434" }), badUrl);
|
|
814
|
+
assert.equal(badUrl.statusCode, 400);
|
|
815
|
+
assert.match(JSON.parse(badUrl.body).error, /ollamaBaseUrl/);
|
|
816
|
+
|
|
817
|
+
assert.deepEqual(settings.getFeatureFlags(), data.overrides, "rejected writes persist nothing");
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("PUT /api/dsh-mneme/features rejects unknown keys, bad types and bad ranges with 400", async () => {
|
|
821
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
822
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
823
|
+
|
|
824
|
+
const unknown = new FakeRes();
|
|
825
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { noSuchFlag: true }), unknown);
|
|
826
|
+
assert.equal(unknown.statusCode, 400);
|
|
827
|
+
assert.match(JSON.parse(unknown.body).error, /noSuchFlag/);
|
|
828
|
+
|
|
829
|
+
const badType = new FakeRes();
|
|
830
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { autoInject: "yes" }), badType);
|
|
831
|
+
assert.equal(badType.statusCode, 400);
|
|
832
|
+
assert.match(JSON.parse(badType.body).error, /autoInject/);
|
|
833
|
+
|
|
834
|
+
const badRange = new FakeRes();
|
|
835
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { codingBoostFactor: 9 }), badRange);
|
|
836
|
+
assert.equal(badRange.statusCode, 400);
|
|
837
|
+
assert.match(JSON.parse(badRange.body).error, /codingBoostFactor/);
|
|
838
|
+
|
|
839
|
+
// 空 patch 不携带任何意图,直接 400
|
|
840
|
+
const empty = new FakeRes();
|
|
841
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", {}), empty);
|
|
842
|
+
assert.equal(empty.statusCode, 400);
|
|
843
|
+
|
|
844
|
+
assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test("PUT /api/dsh-mneme/features is token-gated; GET stays open", async () => {
|
|
848
|
+
const { routes } = setup(undefined, "secret-token", FLAGS_CFG);
|
|
849
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
850
|
+
|
|
851
|
+
const get = new FakeRes();
|
|
852
|
+
await route.handler(req("/api/dsh-mneme/features"), get);
|
|
853
|
+
assert.equal(get.statusCode, 200, "read stays open without token");
|
|
854
|
+
|
|
855
|
+
const put = new FakeRes();
|
|
856
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false }), put);
|
|
857
|
+
assert.equal(put.statusCode, 401);
|
|
858
|
+
|
|
859
|
+
const ok = req("/api/dsh-mneme/features", "PUT", { autoDream: false });
|
|
860
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
861
|
+
const okRes = new FakeRes();
|
|
862
|
+
await route.handler(ok, okRes);
|
|
863
|
+
assert.equal(okRes.statusCode, 200);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// --- 交互式记忆库面板:日期过滤 / update / memories/entities / export / import / dream-status ---
|
|
867
|
+
|
|
868
|
+
test("GET /api/dsh-mneme/list supports updatedFrom/updatedTo and count matches", async () => {
|
|
869
|
+
const { routes, service, store } = setup();
|
|
870
|
+
service.saveWithDedupe({ type: "preference", title: "旧", content: "1" });
|
|
871
|
+
service.saveWithDedupe({ type: "preference", title: "中", content: "2" });
|
|
872
|
+
service.saveWithDedupe({ type: "preference", title: "新", content: "3" });
|
|
873
|
+
const setAt = (title, at) => store.db.prepare("UPDATE memories SET updated_at = ? WHERE title = ?").run(at, title);
|
|
874
|
+
setAt("旧", "2026-08-01T00:00:00.000Z");
|
|
875
|
+
setAt("中", "2026-09-01T12:00:00.000Z");
|
|
876
|
+
setAt("新", "2026-09-15T23:00:00.000Z");
|
|
877
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
878
|
+
|
|
879
|
+
const from = new FakeRes();
|
|
880
|
+
await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-09-01"), from);
|
|
881
|
+
const fromData = JSON.parse(from.body);
|
|
882
|
+
assert.equal(fromData.items.length, 2);
|
|
883
|
+
assert.equal(fromData.total, 2, "count filter matches list filter");
|
|
884
|
+
assert.deepEqual(fromData.items.map((m) => m.title).sort(), ["中", "新"]);
|
|
885
|
+
|
|
886
|
+
// date-only updatedTo 闭区间含当天全天(中 09-01T12:00 命中)
|
|
887
|
+
const range = new FakeRes();
|
|
888
|
+
await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-08-01&updatedTo=2026-09-01"), range);
|
|
889
|
+
const rangeData = JSON.parse(range.body);
|
|
890
|
+
assert.equal(rangeData.items.length, 2);
|
|
891
|
+
assert.equal(rangeData.total, 2);
|
|
892
|
+
|
|
893
|
+
const to = new FakeRes();
|
|
894
|
+
await route.handler(req("/api/dsh-mneme/list?updatedTo=2026-08-31"), to);
|
|
895
|
+
const toData = JSON.parse(to.body);
|
|
896
|
+
assert.equal(toData.items.length, 1);
|
|
897
|
+
assert.equal(toData.items[0].title, "旧");
|
|
898
|
+
|
|
899
|
+
// 非法日期值 → 忽略(不报错、不过滤)
|
|
900
|
+
const bad = new FakeRes();
|
|
901
|
+
await route.handler(req("/api/dsh-mneme/list?updatedFrom=not-a-date&updatedTo=%E4%B9%B1"), bad);
|
|
902
|
+
const badData = JSON.parse(bad.body);
|
|
903
|
+
assert.equal(badData.items.length, 3);
|
|
904
|
+
assert.equal(badData.total, 3);
|
|
905
|
+
|
|
906
|
+
// 完整时间戳同样生效
|
|
907
|
+
const ts = new FakeRes();
|
|
908
|
+
await route.handler(req("/api/dsh-mneme/list?updatedFrom=2026-09-02T00:00:00.000Z"), ts);
|
|
909
|
+
const tsData = JSON.parse(ts.body);
|
|
910
|
+
assert.equal(tsData.items.length, 1);
|
|
911
|
+
assert.equal(tsData.items[0].title, "新");
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("POST /api/dsh-mneme/update edits title/importance and archives", async () => {
|
|
915
|
+
const { routes, service } = setup();
|
|
916
|
+
const created = service.saveWithDedupe({ type: "preference", title: "原始", content: "原始内容" });
|
|
917
|
+
const id = created.memory.id;
|
|
918
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
919
|
+
|
|
920
|
+
const edit = new FakeRes();
|
|
921
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, title: "改名", importance: 5 }), edit);
|
|
922
|
+
assert.equal(edit.statusCode, 200);
|
|
923
|
+
const row = JSON.parse(edit.body).memory;
|
|
924
|
+
assert.equal(row.title, "改名");
|
|
925
|
+
assert.equal(row.importance, 5);
|
|
926
|
+
assert.equal(row.archived, false, "archived booleanized in the response row");
|
|
927
|
+
assert.notEqual(row.updated_at, created.memory.updated_at, "updated_at advances");
|
|
928
|
+
|
|
929
|
+
const archive = new FakeRes();
|
|
930
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, archived: true }), archive);
|
|
931
|
+
assert.equal(archive.statusCode, 200);
|
|
932
|
+
assert.equal(JSON.parse(archive.body).memory.archived, true);
|
|
933
|
+
assert.equal(service.getById(id).archived, true);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("POST /api/dsh-mneme/update archives replaced content into content_history", async () => {
|
|
937
|
+
const { routes, service } = setup();
|
|
938
|
+
const created = service.saveWithDedupe({ type: "preference", title: "历史", content: "第一版" });
|
|
939
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
940
|
+
const res = new FakeRes();
|
|
941
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id: created.memory.id, content: "第二版" }), res);
|
|
942
|
+
assert.equal(res.statusCode, 200);
|
|
943
|
+
const row = service.getById(created.memory.id);
|
|
944
|
+
assert.equal(row.content, "第二版");
|
|
945
|
+
assert.equal(row.content_history[0].content, "第一版");
|
|
946
|
+
assert.equal(row.content_history[0].source, "human_override");
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test("POST /api/dsh-mneme/update returns 400/404 for bad requests", async () => {
|
|
950
|
+
const { routes, service } = setup();
|
|
951
|
+
const created = service.saveWithDedupe({ type: "preference", title: "存在", content: "x" });
|
|
952
|
+
const id = created.memory.id;
|
|
953
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
954
|
+
|
|
955
|
+
const missing = new FakeRes();
|
|
956
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { title: "无 id" }), missing);
|
|
957
|
+
assert.equal(missing.statusCode, 400);
|
|
958
|
+
|
|
959
|
+
const noFields = new FakeRes();
|
|
960
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id }), noFields);
|
|
961
|
+
assert.equal(noFields.statusCode, 400);
|
|
962
|
+
|
|
963
|
+
const emptyTitle = new FakeRes();
|
|
964
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, title: " " }), emptyTitle);
|
|
965
|
+
assert.equal(emptyTitle.statusCode, 400);
|
|
966
|
+
|
|
967
|
+
const emptyContent = new FakeRes();
|
|
968
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, content: "" }), emptyContent);
|
|
969
|
+
assert.equal(emptyContent.statusCode, 400);
|
|
970
|
+
|
|
971
|
+
const badImportance = new FakeRes();
|
|
972
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, importance: 9 }), badImportance);
|
|
973
|
+
assert.equal(badImportance.statusCode, 400);
|
|
974
|
+
|
|
975
|
+
const badTags = new FakeRes();
|
|
976
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, tags: "x" }), badTags);
|
|
977
|
+
assert.equal(badTags.statusCode, 400);
|
|
978
|
+
|
|
979
|
+
const badArchived = new FakeRes();
|
|
980
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id, archived: "yes" }), badArchived);
|
|
981
|
+
assert.equal(badArchived.statusCode, 400);
|
|
982
|
+
|
|
983
|
+
const gone = new FakeRes();
|
|
984
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id: "no-such-id", title: "x" }), gone);
|
|
985
|
+
assert.equal(gone.statusCode, 404);
|
|
986
|
+
assert.deepEqual(JSON.parse(gone.body), { error: "not-found" });
|
|
987
|
+
|
|
988
|
+
assert.equal(service.getById(id).title, "存在", "failed writes persist nothing");
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test("POST /api/dsh-mneme/update is token-gated", async () => {
|
|
992
|
+
const { routes } = setup(undefined, "secret-token");
|
|
993
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
994
|
+
const res = new FakeRes();
|
|
995
|
+
await route.handler(req("/api/dsh-mneme/update", "POST", { id: "x", title: "y" }), res);
|
|
996
|
+
assert.equal(res.statusCode, 401);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
test("GET /api/dsh-mneme/memories/entities returns entities linked to a memory", async () => {
|
|
1000
|
+
const { routes, service } = setup();
|
|
1001
|
+
const a = service.saveWithDedupe({ type: "project", title: "A", content: "提到甲" });
|
|
1002
|
+
const b = service.saveWithDedupe({ type: "project", title: "B", content: "无关" });
|
|
1003
|
+
const entity = service.createEntity({ name: "甲", type: "person" });
|
|
1004
|
+
// 同一记忆两次提及同一实体(两条 attr)→ 去重后只出现一次
|
|
1005
|
+
service.saveAttr({ entity_id: entity.id, attr_key: "角色", attr_value: "负责人", memory_id: a.memory.id });
|
|
1006
|
+
service.saveAttr({ entity_id: entity.id, attr_key: "团队", attr_value: "前端", memory_id: a.memory.id });
|
|
1007
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/memories/entities");
|
|
1008
|
+
|
|
1009
|
+
const res = new FakeRes();
|
|
1010
|
+
await route.handler(req(`/api/dsh-mneme/memories/entities?memoryId=${a.memory.id}`), res);
|
|
1011
|
+
assert.equal(res.statusCode, 200);
|
|
1012
|
+
const data = JSON.parse(res.body);
|
|
1013
|
+
assert.equal(data.entities.length, 1, "deduped by mention");
|
|
1014
|
+
assert.equal(data.entities[0].name, "甲");
|
|
1015
|
+
assert.equal(data.entities[0].type, "person");
|
|
1016
|
+
|
|
1017
|
+
const none = new FakeRes();
|
|
1018
|
+
await route.handler(req(`/api/dsh-mneme/memories/entities?memoryId=${b.memory.id}`), none);
|
|
1019
|
+
assert.deepEqual(JSON.parse(none.body), { entities: [] });
|
|
1020
|
+
|
|
1021
|
+
const missing = new FakeRes();
|
|
1022
|
+
await route.handler(req("/api/dsh-mneme/memories/entities"), missing);
|
|
1023
|
+
assert.equal(missing.statusCode, 400);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test("GET /api/dsh-mneme/export json carries full rows and version", async () => {
|
|
1027
|
+
const { routes, service } = setup();
|
|
1028
|
+
service.saveWithDedupe({ type: "preference", title: "甲", content: "内容甲" });
|
|
1029
|
+
const hidden = service.saveWithDedupe({ type: "preference", title: "乙", content: "内容乙" });
|
|
1030
|
+
service.setForget(hidden.memory.id, true);
|
|
1031
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/export");
|
|
1032
|
+
const res = new FakeRes();
|
|
1033
|
+
await route.handler(req("/api/dsh-mneme/export"), res);
|
|
1034
|
+
assert.equal(res.statusCode, 200);
|
|
1035
|
+
const data = JSON.parse(res.body);
|
|
1036
|
+
assert.equal(data.count, 2, "export includes forgotten rows");
|
|
1037
|
+
assert.equal(data.memories.length, 2);
|
|
1038
|
+
assert.equal(typeof data.version, "string", "version comes from package.json");
|
|
1039
|
+
assert.ok(data.exported_at);
|
|
1040
|
+
assert.equal(data.memories.every((m) => typeof m.archived === "boolean" && typeof m.forgotten === "boolean"), true);
|
|
1041
|
+
const times = data.memories.map((m) => m.updated_at);
|
|
1042
|
+
assert.deepEqual(times, [...times].sort().reverse(), "updated_at DESC");
|
|
1043
|
+
assert.match(res.headers["Content-Disposition"], /attachment; filename="dsh-mneme-export-\d{8}\.json"/);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test("GET /api/dsh-mneme/export markdown feeds straight back through import", async () => {
|
|
1047
|
+
const { routes, service } = setup();
|
|
1048
|
+
const a = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文优先" });
|
|
1049
|
+
const b = service.saveWithDedupe({ type: "preference", title: "风格", content: "简洁" });
|
|
1050
|
+
const exportRoute = routes.find((r) => r.path === "/api/dsh-mneme/export");
|
|
1051
|
+
const importRoute = routes.find((r) => r.path === "/api/dsh-mneme/import");
|
|
1052
|
+
|
|
1053
|
+
const res = new FakeRes();
|
|
1054
|
+
await exportRoute.handler(req("/api/dsh-mneme/export?format=markdown"), res);
|
|
1055
|
+
assert.equal(res.statusCode, 200);
|
|
1056
|
+
assert.match(res.headers["Content-Type"], /text\/markdown/);
|
|
1057
|
+
assert.match(res.headers["Content-Disposition"], /attachment; filename="dsh-mneme-export-\d{8}\.md"/);
|
|
1058
|
+
const md = res.body;
|
|
1059
|
+
assert.ok(md.includes("- **ID**: `" + a.memory.id + "`"), "anchor line present (readHumanEdits-compatible)");
|
|
1060
|
+
|
|
1061
|
+
// 导出是一个文档(#278 审查 F1/F3):frontmatter 只能有一份并在最前,分节不带
|
|
1062
|
+
// 各自的文件头;而且它的覆盖声明必须与正文条目数一致——「说一套写一套」比没有
|
|
1063
|
+
// 这个字段更糟,外部工具会照着它聚合。
|
|
1064
|
+
assert.equal([...md.matchAll(/^type: /gm)].length, 1, "只有一个 frontmatter 的 type 键");
|
|
1065
|
+
assert.match(md, /^type: memory-export$/m);
|
|
1066
|
+
assert.match(md, /^coverage: all$/m, "导出含归档/已遗忘行,声明要说实话");
|
|
1067
|
+
assert.equal(
|
|
1068
|
+
Number(md.match(/^covered: (\d+)$/m)[1]),
|
|
1069
|
+
(md.match(/^- \*\*ID\*\*: /gm) ?? []).length,
|
|
1070
|
+
"covered 必须等于实际导出的条目数"
|
|
1071
|
+
);
|
|
1072
|
+
|
|
1073
|
+
// 黄金用例:导出文本原样导入 → 解析出全部条目,且字段无漂移
|
|
1074
|
+
const back = new FakeRes();
|
|
1075
|
+
await importRoute.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: md }), back);
|
|
1076
|
+
assert.equal(back.statusCode, 200);
|
|
1077
|
+
const data = JSON.parse(back.body);
|
|
1078
|
+
assert.equal(data.type, "preference");
|
|
1079
|
+
assert.ok(data.merged >= 2, "both memories parsed back");
|
|
1080
|
+
assert.equal(service.getById(a.memory.id).title, "语言");
|
|
1081
|
+
assert.equal(service.getById(a.memory.id).content, "中文优先");
|
|
1082
|
+
assert.equal(service.getById(b.memory.id).title, "风格");
|
|
1083
|
+
assert.equal(service.getById(b.memory.id).content, "简洁");
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test("POST /api/dsh-mneme/import validates type/markdown and tolerates zero edits", async () => {
|
|
1087
|
+
const { routes } = setup();
|
|
1088
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/import");
|
|
1089
|
+
|
|
1090
|
+
const badType = new FakeRes();
|
|
1091
|
+
await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preferences", markdown: "x" }), badType);
|
|
1092
|
+
assert.equal(badType.statusCode, 400, "TYPE_FILE keys are singular, plural is rejected");
|
|
1093
|
+
|
|
1094
|
+
const badMd = new FakeRes();
|
|
1095
|
+
await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "" }), badMd);
|
|
1096
|
+
assert.equal(badMd.statusCode, 400);
|
|
1097
|
+
|
|
1098
|
+
const zero = new FakeRes();
|
|
1099
|
+
await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "# 空镜像" }), zero);
|
|
1100
|
+
assert.equal(zero.statusCode, 200);
|
|
1101
|
+
assert.deepEqual(JSON.parse(zero.body), { merged: 0, type: "preference" });
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
test("POST /api/dsh-mneme/import is token-gated", async () => {
|
|
1105
|
+
const { routes } = setup(undefined, "secret-token");
|
|
1106
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/import");
|
|
1107
|
+
const res = new FakeRes();
|
|
1108
|
+
await route.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: "x" }), res);
|
|
1109
|
+
assert.equal(res.statusCode, 401);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("GET /api/dsh-mneme/dream-status returns runs and pending conflict ids", async () => {
|
|
1113
|
+
const { routes, service } = setup();
|
|
1114
|
+
service.saveDreamRun({ created_at: "2026-01-01T00:00:00.000Z", status: "ok", provider: "ollama", model: "qwen", snapshot_hash: "h1", input_count: 2, receipt: "r1" });
|
|
1115
|
+
service.saveDreamRun({ created_at: "2026-01-02T00:00:00.000Z", status: "failed", error: "boom", snapshot_hash: "h2", input_count: 0, receipt: "r2" });
|
|
1116
|
+
const pending = service.saveConflictPending({ memory_a: "aaaa", memory_b: "bbbb", reason: "矛盾" });
|
|
1117
|
+
|
|
1118
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/dream-status");
|
|
1119
|
+
const res = new FakeRes();
|
|
1120
|
+
await route.handler(req("/api/dsh-mneme/dream-status"), res);
|
|
1121
|
+
assert.equal(res.statusCode, 200);
|
|
1122
|
+
const data = JSON.parse(res.body);
|
|
1123
|
+
assert.equal(data.runs.length, 2);
|
|
1124
|
+
assert.equal(data.runs[0].created_at, "2026-01-02T00:00:00.000Z", "created_at DESC");
|
|
1125
|
+
assert.deepEqual(data.lastRun, data.runs[0]);
|
|
1126
|
+
assert.deepEqual(Object.keys(data.lastRun).sort(), ["created_at", "demotion", "error", "model", "provider", "run_type", "status"]);
|
|
1127
|
+
assert.equal(data.lastRun.run_type, "auto", "default run_type is auto");
|
|
1128
|
+
assert.equal(data.lastRun.demotion, null, "no demotion info for non-sleep runs");
|
|
1129
|
+
assert.equal(data.runs[0].error, "boom");
|
|
1130
|
+
assert.equal(data.runs[1].provider, "ollama");
|
|
1131
|
+
assert.equal(data.pendingConflicts, 1);
|
|
1132
|
+
assert.deepEqual(data.pendingMemoryIds.sort(), ["aaaa", "bbbb"]);
|
|
1133
|
+
|
|
1134
|
+
// 解决后队列清空
|
|
1135
|
+
service.resolveConflictPending(pending.id, { winner: "aaaa" });
|
|
1136
|
+
const after = new FakeRes();
|
|
1137
|
+
await route.handler(req("/api/dsh-mneme/dream-status"), after);
|
|
1138
|
+
const afterData = JSON.parse(after.body);
|
|
1139
|
+
assert.equal(afterData.pendingConflicts, 0);
|
|
1140
|
+
assert.deepEqual(afterData.pendingMemoryIds, []);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
// v0.8.0 冲突集中处理:GET /conflicts 队列联表 + POST /conflicts/resolve 人工确认。
|
|
1144
|
+
test("GET /api/dsh-mneme/conflicts joins both sides of unresolved conflicts", async () => {
|
|
1145
|
+
const { routes, service } = setup();
|
|
1146
|
+
const a = service.saveWithDedupe({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 }).memory;
|
|
1147
|
+
const b = service.saveWithDedupe({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 }).memory;
|
|
1148
|
+
service.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
|
|
1149
|
+
|
|
1150
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/conflicts");
|
|
1151
|
+
const res = new FakeRes();
|
|
1152
|
+
await route.handler(req("/api/dsh-mneme/conflicts"), res);
|
|
1153
|
+
assert.equal(res.statusCode, 200);
|
|
1154
|
+
const data = JSON.parse(res.body);
|
|
1155
|
+
assert.equal(data.items.length, 1);
|
|
1156
|
+
assert.equal(data.total, 1);
|
|
1157
|
+
assert.equal(data.items[0].reason, "主题偏好前后矛盾");
|
|
1158
|
+
// saveConflictPending 会按 id 字典序归一 A/B 侧位——断言集合而非位置。
|
|
1159
|
+
assert.deepEqual(
|
|
1160
|
+
[data.items[0].memory_a.title, data.items[0].memory_b.title].sort(),
|
|
1161
|
+
["界面亮色切换", "界面暗色偏好"]
|
|
1162
|
+
);
|
|
1163
|
+
assert.equal(data.items[0].memory_a.archived, false);
|
|
1164
|
+
assert.equal(data.items[0].memory_b.archived, false);
|
|
1165
|
+
// 幽灵侧容忍缺失
|
|
1166
|
+
service.saveConflictPending({ memory_a: a.id, memory_b: "ghost-id", reason: "对方已删" });
|
|
1167
|
+
const res2 = new FakeRes();
|
|
1168
|
+
await route.handler(req("/api/dsh-mneme/conflicts"), res2);
|
|
1169
|
+
const data2 = JSON.parse(res2.body);
|
|
1170
|
+
assert.equal(data2.items.length, 2);
|
|
1171
|
+
assert.equal(data2.items.find((x) => x.reason === "对方已删").memory_b.missing, true);
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
test("POST /api/dsh-mneme/conflicts/resolve stamps and applies the disposition", async () => {
|
|
1175
|
+
const { store, routes, service } = setup();
|
|
1176
|
+
const a = service.saveWithDedupe({ type: "history", title: "界面暗色偏好", content: "用户当前使用暗色界面主题", importance: 3 }).memory;
|
|
1177
|
+
const b = service.saveWithDedupe({ type: "history", title: "界面亮色切换", content: "用户界面亮色切换计划", importance: 3 }).memory;
|
|
1178
|
+
const pending = service.saveConflictPending({ memory_a: a.id, memory_b: b.id, reason: "主题偏好前后矛盾" });
|
|
1179
|
+
|
|
1180
|
+
// store 会把 pair 按 id 字典序归一——从队列行反查「界面亮色切换」所在侧再选。
|
|
1181
|
+
const queueRoute = routes.find((r) => r.path === "/api/dsh-mneme/conflicts");
|
|
1182
|
+
const queueRes = new FakeRes();
|
|
1183
|
+
await queueRoute.handler(req("/api/dsh-mneme/conflicts"), queueRes);
|
|
1184
|
+
const queued = JSON.parse(queueRes.body).items.find((x) => x.id === pending.id);
|
|
1185
|
+
const keepSide = queued.memory_a.id === b.id ? "a" : "b";
|
|
1186
|
+
// loser 恒为 a(keeper 恒为「界面亮色切换」= b),与 A/B 侧位字母无关
|
|
1187
|
+
const loserId = a.id;
|
|
1188
|
+
|
|
1189
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/conflicts/resolve");
|
|
1190
|
+
// 保留「界面亮色切换」所在侧(apply 默认 true)→ 该侧保留,另一侧归档
|
|
1191
|
+
const res = new FakeRes();
|
|
1192
|
+
await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner: keepSide }), res);
|
|
1193
|
+
assert.equal(res.statusCode, 200);
|
|
1194
|
+
const data = JSON.parse(res.body);
|
|
1195
|
+
assert.equal(data.ok, true);
|
|
1196
|
+
assert.equal(data.conflict.resolved_winner, b.id);
|
|
1197
|
+
assert.equal(data.conflict.disposition.ok, true);
|
|
1198
|
+
assert.equal(store.getById(b.id).archived, false, "winner kept");
|
|
1199
|
+
assert.equal(store.getById(loserId).archived, true, "loser archived by apply");
|
|
1200
|
+
|
|
1201
|
+
// 处理过的 id 再 resolve → 幂等重盖(仍是 200;队列视图只列未解决行,
|
|
1202
|
+
// 正常 UI 不会再发这个请求,但接口层保持幂等)
|
|
1203
|
+
const res2 = new FakeRes();
|
|
1204
|
+
await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: pending.id, winner: "a" }), res2);
|
|
1205
|
+
assert.equal(res2.statusCode, 200);
|
|
1206
|
+
assert.equal(JSON.parse(res2.body).ok, true);
|
|
1207
|
+
|
|
1208
|
+
// 未知 id → 404;缺 id → 400
|
|
1209
|
+
const res3 = new FakeRes();
|
|
1210
|
+
await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: "nope", winner: "a" }), res3);
|
|
1211
|
+
assert.equal(res3.statusCode, 404);
|
|
1212
|
+
const res4 = new FakeRes();
|
|
1213
|
+
await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", {}), res4);
|
|
1214
|
+
assert.equal(res4.statusCode, 400);
|
|
1215
|
+
|
|
1216
|
+
// apply:false 只盖章不处置
|
|
1217
|
+
const c = service.saveWithDedupe({ type: "decision", title: "丙", content: "内容丙" }).memory;
|
|
1218
|
+
const d = service.saveWithDedupe({ type: "decision", title: "丁", content: "内容丁" }).memory;
|
|
1219
|
+
const p2 = service.saveConflictPending({ memory_a: c.id, memory_b: d.id, reason: "r2" });
|
|
1220
|
+
const res5 = new FakeRes();
|
|
1221
|
+
await route.handler(req("/api/dsh-mneme/conflicts/resolve", "POST", { id: p2.id, apply: false }), res5);
|
|
1222
|
+
assert.equal(res5.statusCode, 200);
|
|
1223
|
+
assert.equal(JSON.parse(res5.body).conflict.disposition, null);
|
|
1224
|
+
assert.equal(store.getById(c.id).archived, false);
|
|
1225
|
+
assert.equal(store.getById(d.id).archived, false);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
test("parseHumanEdits is the pure core of readHumanEdits (CRLF tolerant)", () => {
|
|
1229
|
+
const digest = "0".repeat(64);
|
|
1230
|
+
const text = [
|
|
1231
|
+
"# x\r\n",
|
|
1232
|
+
"\r\n",
|
|
1233
|
+
"## 标题\r\n",
|
|
1234
|
+
"\r\n",
|
|
1235
|
+
"- **ID**: `m1`\n",
|
|
1236
|
+
"- **类型**: preference\n",
|
|
1237
|
+
"- **重要性**: 3\n",
|
|
1238
|
+
"- **标签**: \n",
|
|
1239
|
+
"- **更新时间**: 2026-01-01T00:00:00.000Z\n",
|
|
1240
|
+
"\n",
|
|
1241
|
+
`<!-- mirror-digest: ${digest} -->\n`,
|
|
1242
|
+
"正文一段\n",
|
|
1243
|
+
"\n",
|
|
1244
|
+
"---\n",
|
|
1245
|
+
"\n",
|
|
1246
|
+
"## 另一条\n",
|
|
1247
|
+
"\r\n",
|
|
1248
|
+
"- **ID**: `m2`\n",
|
|
1249
|
+
"- **类型**: preference\n",
|
|
1250
|
+
"- **重要性**: 4\n",
|
|
1251
|
+
"- **标签**: \n",
|
|
1252
|
+
"- **更新时间**: 2026-01-02T00:00:00.000Z\n",
|
|
1253
|
+
"\n",
|
|
1254
|
+
"内容二\n",
|
|
1255
|
+
"\n",
|
|
1256
|
+
"---\n"
|
|
1257
|
+
].join("");
|
|
1258
|
+
const edits = parseHumanEdits(text);
|
|
1259
|
+
assert.equal(edits.length, 2);
|
|
1260
|
+
assert.equal(edits[0].id, "m1");
|
|
1261
|
+
assert.equal(edits[0].title, "标题");
|
|
1262
|
+
assert.equal(edits[0].content, "正文一段");
|
|
1263
|
+
assert.equal(edits[1].id, "m2");
|
|
1264
|
+
assert.equal(edits[1].content, "内容二");
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test("GET /api/dsh-mneme/list?archived=only lists just archived rows; default list hides them", async () => {
|
|
1268
|
+
const { routes, service } = setup();
|
|
1269
|
+
service.saveWithDedupe({ type: "preference", title: "在用", content: "keep" });
|
|
1270
|
+
service.saveWithDedupe({ type: "project", title: "已归档", content: "gone" });
|
|
1271
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
1272
|
+
const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
1273
|
+
|
|
1274
|
+
const def0 = new FakeRes();
|
|
1275
|
+
await route.handler(req("/api/dsh-mneme/list"), def0);
|
|
1276
|
+
const target = JSON.parse(def0.body).items.find((m) => m.title === "已归档");
|
|
1277
|
+
|
|
1278
|
+
const arch = new FakeRes();
|
|
1279
|
+
await upd.handler(req("/api/dsh-mneme/update", "POST", { id: target.id, archived: true }), arch);
|
|
1280
|
+
assert.equal(arch.statusCode, 200);
|
|
1281
|
+
|
|
1282
|
+
const def = new FakeRes();
|
|
1283
|
+
await route.handler(req("/api/dsh-mneme/list"), def);
|
|
1284
|
+
assert.equal(JSON.parse(def.body).total, 1, "default list hides archived rows");
|
|
1285
|
+
|
|
1286
|
+
const only = new FakeRes();
|
|
1287
|
+
await route.handler(req("/api/dsh-mneme/list?archived=only"), only);
|
|
1288
|
+
const onlyData = JSON.parse(only.body);
|
|
1289
|
+
assert.equal(onlyData.total, 1, "archived-only count matches rows");
|
|
1290
|
+
assert.deepEqual(onlyData.items.map((m) => m.title), ["已归档"]);
|
|
1291
|
+
assert.equal(onlyData.items[0].archived, true);
|
|
1292
|
+
|
|
1293
|
+
// 恢复(unarchive)后归档视图清空、默认列表重新可见
|
|
1294
|
+
const restore = new FakeRes();
|
|
1295
|
+
await upd.handler(req("/api/dsh-mneme/update", "POST", { id: target.id, archived: false }), restore);
|
|
1296
|
+
assert.equal(restore.statusCode, 200);
|
|
1297
|
+
const empty = new FakeRes();
|
|
1298
|
+
await route.handler(req("/api/dsh-mneme/list?archived=only"), empty);
|
|
1299
|
+
assert.equal(JSON.parse(empty.body).total, 0);
|
|
1300
|
+
const back = new FakeRes();
|
|
1301
|
+
await route.handler(req("/api/dsh-mneme/list"), back);
|
|
1302
|
+
assert.equal(JSON.parse(back.body).total, 2);
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
test("GET /api/dsh-mneme/list?deposited=only lists dream-touched memories (receipts ∪ source=dream)", async () => {
|
|
1306
|
+
const { routes, service } = setup();
|
|
1307
|
+
const plain = service.saveWithDedupe({ type: "preference", title: "无关", content: "plain" });
|
|
1308
|
+
const merged = service.saveWithDedupe({ type: "project", title: "被巩固", content: "merged" });
|
|
1309
|
+
const updated = service.saveWithDedupe({ type: "decision", title: "被更新", content: "updated" });
|
|
1310
|
+
service.saveWithDedupe({ type: "summary", title: "总览", content: "overview", source: "dream" });
|
|
1311
|
+
const ids = [plain, merged, updated].map((r) => r.memory.id);
|
|
1312
|
+
|
|
1313
|
+
// 巩固账本:merge 落在 keepSource、update 落在目标;conflict 只仲裁不落
|
|
1314
|
+
// 内容,不算沉淀。verdict='live' 才有效。
|
|
1315
|
+
service.saveReceipt({
|
|
1316
|
+
receipt_id: "r-merge", run_id: "run-1", record_id: merged.memory.id, kind: "merge",
|
|
1317
|
+
input_digest: "d1", keep_source: merged.memory.id, sources: [merged.memory.id, ids[0]],
|
|
1318
|
+
verdict: "live", count_before: 2, count_after: 1, policy_epoch: 0,
|
|
1319
|
+
created_at: new Date().toISOString()
|
|
1320
|
+
});
|
|
1321
|
+
service.saveReceipt({
|
|
1322
|
+
receipt_id: "r-update", run_id: "run-1", record_id: updated.memory.id, kind: "update",
|
|
1323
|
+
input_digest: "d2", verdict: "live", count_before: 1, count_after: 1,
|
|
1324
|
+
policy_epoch: 0, created_at: new Date().toISOString()
|
|
1325
|
+
});
|
|
1326
|
+
service.saveReceipt({
|
|
1327
|
+
receipt_id: "r-conflict", run_id: "run-1", record_id: "winner-x", kind: "conflict",
|
|
1328
|
+
input_digest: "d3", winner_id: "winner-x", loser_id: "loser-y", verdict: "live",
|
|
1329
|
+
count_before: 2, count_after: 2, policy_epoch: 0, created_at: new Date().toISOString()
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
|
|
1333
|
+
const res = new FakeRes();
|
|
1334
|
+
await route.handler(req("/api/dsh-mneme/list?deposited=only"), res);
|
|
1335
|
+
const data = JSON.parse(res.body);
|
|
1336
|
+
assert.deepEqual(
|
|
1337
|
+
data.items.map((m) => m.title).sort(),
|
|
1338
|
+
["总览", "被巩固", "被更新"],
|
|
1339
|
+
"deposited view = receipt merge/update records ∪ source=dream writes"
|
|
1340
|
+
);
|
|
1341
|
+
assert.equal(data.total, 3, "total honors the deposited filter");
|
|
1342
|
+
|
|
1343
|
+
// 默认列表不受 deposited 过滤影响
|
|
1344
|
+
const def = new FakeRes();
|
|
1345
|
+
await route.handler(req("/api/dsh-mneme/list"), def);
|
|
1346
|
+
assert.equal(JSON.parse(def.body).total, 4);
|
|
1347
|
+
|
|
1348
|
+
// 与 archived=only 可叠加:归档的沉淀记忆才出现在交集视图里
|
|
1349
|
+
const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
|
|
1350
|
+
const arch = new FakeRes();
|
|
1351
|
+
await upd.handler(req("/api/dsh-mneme/update", "POST", { id: merged.memory.id, archived: true }), arch);
|
|
1352
|
+
assert.equal(arch.statusCode, 200);
|
|
1353
|
+
const both = new FakeRes();
|
|
1354
|
+
await route.handler(req("/api/dsh-mneme/list?deposited=only&archived=only"), both);
|
|
1355
|
+
const bothData = JSON.parse(both.body);
|
|
1356
|
+
assert.deepEqual(bothData.items.map((m) => m.title), ["被巩固"]);
|
|
1357
|
+
assert.equal(bothData.total, 1);
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
test("GET /api/dsh-mneme/list projects per-memory heat only when heatEnabled=true", async () => {
|
|
1361
|
+
// 默认(heatEnabled=false):heat 字段整体缺省——前端徽章据此自动隐藏
|
|
1362
|
+
const off = setup();
|
|
1363
|
+
off.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
|
|
1364
|
+
const r0 = new FakeRes();
|
|
1365
|
+
await off.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r0);
|
|
1366
|
+
const d0 = JSON.parse(r0.body);
|
|
1367
|
+
assert.equal(d0.total, 1);
|
|
1368
|
+
assert.equal("heat" in d0.items[0], false, "heat must be absent from the wire DTO when the flag is off");
|
|
1369
|
+
|
|
1370
|
+
// heatEnabled=true:逐条投影。λ=0 免疫类型(preference)恒 1.0;其余落在
|
|
1371
|
+
// [0,1] 区间(新建记忆 Δt≈0 接近满格,衰减数学由 heat.test.js 看门)。
|
|
1372
|
+
const on = setup(null, "", { heatEnabled: true });
|
|
1373
|
+
on.service.saveWithDedupe({ type: "preference", title: "免疫型", content: "immune" });
|
|
1374
|
+
on.service.saveWithDedupe({ type: "history", title: "会话历史", content: "recent" });
|
|
1375
|
+
const r1 = new FakeRes();
|
|
1376
|
+
await on.routes.find((r) => r.path === "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list"), r1);
|
|
1377
|
+
const d1 = JSON.parse(r1.body);
|
|
1378
|
+
assert.equal(d1.total, 2);
|
|
1379
|
+
const byTitle = Object.fromEntries(d1.items.map((m) => [m.title, m.heat]));
|
|
1380
|
+
assert.equal(byTitle["免疫型"], 1, "λ=0 immune types stay at full heat");
|
|
1381
|
+
for (const v of Object.values(byTitle)) {
|
|
1382
|
+
assert.ok(typeof v === "number" && v >= 0 && v <= 1, "heat values stay within [0,1]");
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// ---------------------------------------------------------------- llm-providers / test-model
|
|
1387
|
+
// Settings-panel support: enumerate host-registered providers/models so the
|
|
1388
|
+
// panel can offer a dropdown over real models, and probe connectivity with a
|
|
1389
|
+
// minimal LLM call (same harness path the consolidation uses).
|
|
1390
|
+
|
|
1391
|
+
function setupLlm(llm, apiToken = "", extraCtx = {}, config = null) {
|
|
1392
|
+
const store = createStore(":memory:");
|
|
1393
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
1394
|
+
const settings = createSettings(store.db);
|
|
1395
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
1396
|
+
const routes = [];
|
|
1397
|
+
const ctx = {
|
|
1398
|
+
webServer: { register(route) { routes.push(route); return () => {}; } },
|
|
1399
|
+
llm,
|
|
1400
|
+
...extraCtx
|
|
1401
|
+
};
|
|
1402
|
+
const api = createApi(ctx, service, settings, commands, undefined, undefined, apiToken, config);
|
|
1403
|
+
return { routes };
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const MOCK_LLM = {
|
|
1407
|
+
listProviders: () => [
|
|
1408
|
+
{ id: "deepseek", name: "DeepSeek" },
|
|
1409
|
+
{ id: "broken", name: "Broken Adapter" }
|
|
1410
|
+
],
|
|
1411
|
+
listModels: async (provider) => {
|
|
1412
|
+
if (provider === "broken") throw new Error("provider not reachable");
|
|
1413
|
+
return [{ id: "deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }];
|
|
1414
|
+
},
|
|
1415
|
+
async *stream(options) {
|
|
1416
|
+
MOCK_LLM.lastOptions = options;
|
|
1417
|
+
if (options.model === "bad-model") {
|
|
1418
|
+
yield {
|
|
1419
|
+
type: "finish",
|
|
1420
|
+
reason: { kind: "error", failure: { code: "AUTH_FAILED", message: "invalid api key" } }
|
|
1421
|
+
};
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (options.reasoningEffort === "reject") {
|
|
1425
|
+
throw new Error('UNSUPPORTED_REASONING_EFFORT: provider "mock" model "mock-model" does not support reasoning effort "reject"');
|
|
1426
|
+
}
|
|
1427
|
+
yield { type: "text-delta", index: 0, text: " ok " };
|
|
1428
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
test("GET /api/dsh-mneme/llm-providers lists host providers with models, per-provider best effort", async () => {
|
|
1433
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1434
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
|
|
1435
|
+
const res = new FakeRes();
|
|
1436
|
+
await route.handler(req("/api/dsh-mneme/llm-providers"), res);
|
|
1437
|
+
assert.equal(res.statusCode, 200);
|
|
1438
|
+
const data = JSON.parse(res.body);
|
|
1439
|
+
assert.equal(data.providers.length, 2);
|
|
1440
|
+
const deepseek = data.providers.find((p) => p.provider === "deepseek");
|
|
1441
|
+
assert.deepEqual(deepseek.models.map((m) => m.id), ["deepseek-chat", "deepseek-reasoner"]);
|
|
1442
|
+
const broken = data.providers.find((p) => p.provider === "broken");
|
|
1443
|
+
assert.deepEqual(broken.models, [], "a provider whose model list throws degrades to empty, not a hard fail");
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
test("GET /api/dsh-mneme/llm-providers without ctx.llm returns 501", async () => {
|
|
1447
|
+
const { routes } = setup();
|
|
1448
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
|
|
1449
|
+
const res = new FakeRes();
|
|
1450
|
+
await route.handler(req("/api/dsh-mneme/llm-providers"), res);
|
|
1451
|
+
assert.equal(res.statusCode, 501);
|
|
1452
|
+
assert.equal(JSON.parse(res.body).error, "llm-unavailable");
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
test("POST /api/dsh-mneme/test-model succeeds and reports reply + latency", async () => {
|
|
1456
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1457
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1458
|
+
const res = new FakeRes();
|
|
1459
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1460
|
+
assert.equal(res.statusCode, 200);
|
|
1461
|
+
const data = JSON.parse(res.body);
|
|
1462
|
+
assert.equal(data.ok, true);
|
|
1463
|
+
assert.equal(data.reply, "ok", "reply trimmed");
|
|
1464
|
+
assert.ok(data.durationMs >= 0);
|
|
1465
|
+
assert.equal(data.modelId, "deepseek:deepseek-chat", "modelId reports the probed route");
|
|
1466
|
+
assert.equal(MOCK_LLM.lastOptions.provider, "deepseek");
|
|
1467
|
+
assert.equal(MOCK_LLM.lastOptions.purpose, "dsh-mneme-connectivity-test");
|
|
1468
|
+
// 回归:探测必须是一条 user 消息(带 source)。system-only 对话自
|
|
1469
|
+
// 0.1.6-alpha.1 起被官方 API 整单拒绝(「messages: at least one message
|
|
1470
|
+
// is required」),探测按钮会 502。
|
|
1471
|
+
assert.equal(MOCK_LLM.lastOptions.messages[0].role, "user");
|
|
1472
|
+
// issue #326:kind 必须是生产者自有值,裸 "plugin" 被 DSH 0.1.7 的 V4 写入准入拒绝。
|
|
1473
|
+
assert.equal(MOCK_LLM.lastOptions.messages[0].source?.kind, "plugin:dsh-mneme");
|
|
1474
|
+
assert.equal("reasoningEffort" in MOCK_LLM.lastOptions, false, "no effort configured -> field omitted");
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
test("POST /api/dsh-mneme/test-model forwards reasoningEffort verbatim", async () => {
|
|
1478
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1479
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1480
|
+
const res = new FakeRes();
|
|
1481
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat", reasoningEffort: "low" }), res);
|
|
1482
|
+
assert.equal(res.statusCode, 200);
|
|
1483
|
+
assert.equal(MOCK_LLM.lastOptions.reasoningEffort, "low", "effort forwarded to the probe call");
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1486
|
+
test("POST /api/dsh-mneme/test-model reports stream-level failure with 502", async () => {
|
|
1487
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1488
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1489
|
+
const res = new FakeRes();
|
|
1490
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "bad-model" }), res);
|
|
1491
|
+
assert.equal(res.statusCode, 502);
|
|
1492
|
+
const data = JSON.parse(res.body);
|
|
1493
|
+
assert.equal(data.ok, false);
|
|
1494
|
+
assert.match(data.error, /AUTH_FAILED/, "stream failure reason surfaced like audit rows");
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
test("POST /api/dsh-mneme/test-model reports thrown failure with 502", async () => {
|
|
1498
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1499
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1500
|
+
const res = new FakeRes();
|
|
1501
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }), res);
|
|
1502
|
+
assert.equal(res.statusCode, 502);
|
|
1503
|
+
const data = JSON.parse(res.body);
|
|
1504
|
+
assert.equal(data.ok, false);
|
|
1505
|
+
assert.match(data.error, /UNSUPPORTED_REASONING_EFFORT/, "throw path surfaces the harness rejection");
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
test("POST /api/dsh-mneme/test-model resolves empty body against the consolidation route", async () => {
|
|
1509
|
+
// Empty provider/model = "test whatever consolidation uses now": the route
|
|
1510
|
+
// falls back to agentDefaultModel (config dreamProvider/dreamModel absent),
|
|
1511
|
+
// the probe runs against that model, and modelId reports what was tested.
|
|
1512
|
+
const { routes } = setupLlm(MOCK_LLM, "", {
|
|
1513
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "sel-provider", model: "sel-model" }) }
|
|
1514
|
+
});
|
|
1515
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1516
|
+
const res = new FakeRes();
|
|
1517
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
|
|
1518
|
+
assert.equal(res.statusCode, 200);
|
|
1519
|
+
const data = JSON.parse(res.body);
|
|
1520
|
+
assert.equal(data.ok, true);
|
|
1521
|
+
assert.equal(data.modelId, "sel-provider:sel-model", "probe ran against the resolved consolidation route");
|
|
1522
|
+
assert.equal(MOCK_LLM.lastOptions.provider, "sel-provider");
|
|
1523
|
+
assert.equal(MOCK_LLM.lastOptions.model, "sel-model");
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
test("POST /api/dsh-mneme/test-model validates input and llm availability", async () => {
|
|
1527
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1528
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1529
|
+
let res = new FakeRes();
|
|
1530
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek" }), res);
|
|
1531
|
+
assert.equal(res.statusCode, 400, "partial input (provider without model) rejected");
|
|
1532
|
+
assert.equal(JSON.parse(res.body).error, "missing-provider-or-model");
|
|
1533
|
+
|
|
1534
|
+
// empty body with no route (no agentDefaultModel, no config route) -> no-route
|
|
1535
|
+
res = new FakeRes();
|
|
1536
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
|
|
1537
|
+
assert.equal(res.statusCode, 400, "empty body with no resolvable route rejected");
|
|
1538
|
+
assert.equal(JSON.parse(res.body).error, "no-route");
|
|
1539
|
+
|
|
1540
|
+
const { routes: routesNoLlm } = setup();
|
|
1541
|
+
const routeNoLlm = routesNoLlm.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1542
|
+
res = new FakeRes();
|
|
1543
|
+
await routeNoLlm.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1544
|
+
assert.equal(res.statusCode, 501, "no ctx.llm -> llm-unavailable");
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
test("POST /api/dsh-mneme/test-model is auth-gated like other expensive endpoints", async () => {
|
|
1548
|
+
const { routes } = setupLlm(MOCK_LLM, "secret-token");
|
|
1549
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1550
|
+
const res = new FakeRes();
|
|
1551
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1552
|
+
assert.equal(res.statusCode, 401, "probe spends the user's API quota, so it must require auth");
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
test("GET /api/dsh-mneme/inject-status: suppressed only when minimal preset observed (issue #182)", async () => {
|
|
1556
|
+
const store = createStore(":memory:");
|
|
1557
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
1558
|
+
const settings = createSettings(store.db);
|
|
1559
|
+
const routes = [];
|
|
1560
|
+
let onEvent = null;
|
|
1561
|
+
const ctx = {
|
|
1562
|
+
webServer: {
|
|
1563
|
+
register(route) {
|
|
1564
|
+
routes.push(route);
|
|
1565
|
+
return () => {};
|
|
1566
|
+
}
|
|
1567
|
+
},
|
|
1568
|
+
on(type, fn) {
|
|
1569
|
+
onEvent = fn;
|
|
1570
|
+
return () => {};
|
|
1571
|
+
}
|
|
1572
|
+
};
|
|
1573
|
+
const api = createApi(
|
|
1574
|
+
ctx,
|
|
1575
|
+
service,
|
|
1576
|
+
settings,
|
|
1577
|
+
{ add() {}, remove() {}, list() { return []; } },
|
|
1578
|
+
undefined,
|
|
1579
|
+
undefined,
|
|
1580
|
+
"",
|
|
1581
|
+
Config({}) // autoInject default true
|
|
1582
|
+
);
|
|
1583
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/inject-status");
|
|
1584
|
+
|
|
1585
|
+
// 未观测到任何会话:agentPreset=null,不猜、不压制。
|
|
1586
|
+
let res = new FakeRes();
|
|
1587
|
+
await route.handler(req("/api/dsh-mneme/inject-status"), res);
|
|
1588
|
+
assert.equal(res.statusCode, 200);
|
|
1589
|
+
assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset: null, suppressed: false });
|
|
1590
|
+
|
|
1591
|
+
// minimal 会话事件到达(session/event 的第一参为 session)→ suppressed。
|
|
1592
|
+
onEvent({ header: { agentPreset: "minimal" } });
|
|
1593
|
+
res = new FakeRes();
|
|
1594
|
+
await route.handler(req("/api/dsh-mneme/inject-status"), res);
|
|
1595
|
+
assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset: "minimal", suppressed: true });
|
|
1596
|
+
|
|
1597
|
+
// standard 会话 → 无提示。
|
|
1598
|
+
onEvent({ header: { agentPreset: "standard" } });
|
|
1599
|
+
res = new FakeRes();
|
|
1600
|
+
await route.handler(req("/api/dsh-mneme/inject-status"), res);
|
|
1601
|
+
assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset: "standard", suppressed: false });
|
|
1602
|
+
|
|
1603
|
+
// 头里没有 agentPreset(降级形状)→ 回落 null,静默不猜。
|
|
1604
|
+
onEvent({ header: {} });
|
|
1605
|
+
res = new FakeRes();
|
|
1606
|
+
await route.handler(req("/api/dsh-mneme/inject-status"), res);
|
|
1607
|
+
assert.deepEqual(JSON.parse(res.body), { autoInject: true, agentPreset: null, suppressed: false });
|
|
1608
|
+
|
|
1609
|
+
// 非 GET → 404(与其他只读端点同款)。
|
|
1610
|
+
res = new FakeRes();
|
|
1611
|
+
await route.handler(req("/api/dsh-mneme/inject-status", "POST"), res);
|
|
1612
|
+
assert.equal(res.statusCode, 404);
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
test("inject-status: autoInject off is never suppressed (user's own choice, not a black box)", async () => {
|
|
1616
|
+
const store = createStore(":memory:");
|
|
1617
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
1618
|
+
const settings = createSettings(store.db);
|
|
1619
|
+
const routes = [];
|
|
1620
|
+
let onEvent = null;
|
|
1621
|
+
const ctx = {
|
|
1622
|
+
webServer: {
|
|
1623
|
+
register(route) {
|
|
1624
|
+
routes.push(route);
|
|
1625
|
+
return () => {};
|
|
1626
|
+
}
|
|
1627
|
+
},
|
|
1628
|
+
on(type, fn) {
|
|
1629
|
+
onEvent = fn;
|
|
1630
|
+
return () => {};
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
const api = createApi(
|
|
1634
|
+
ctx,
|
|
1635
|
+
service,
|
|
1636
|
+
settings,
|
|
1637
|
+
{ add() {}, remove() {}, list() { return []; } },
|
|
1638
|
+
undefined,
|
|
1639
|
+
undefined,
|
|
1640
|
+
"",
|
|
1641
|
+
Config({ autoInject: false })
|
|
1642
|
+
);
|
|
1643
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/inject-status");
|
|
1644
|
+
onEvent({ header: { agentPreset: "minimal" } });
|
|
1645
|
+
const res = new FakeRes();
|
|
1646
|
+
await route.handler(req("/api/dsh-mneme/inject-status"), res);
|
|
1647
|
+
assert.deepEqual(JSON.parse(res.body), { autoInject: false, agentPreset: "minimal", suppressed: false });
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
test("GET /api/dsh-mneme/inject-preview: returns last assembly snapshot (issue #179)", async () => {
|
|
1651
|
+
const store = createStore(":memory:");
|
|
1652
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
1653
|
+
const settings = createSettings(store.db);
|
|
1654
|
+
const routes = [];
|
|
1655
|
+
const ctx = {
|
|
1656
|
+
webServer: {
|
|
1657
|
+
register(route) {
|
|
1658
|
+
routes.push(route);
|
|
1659
|
+
return () => {};
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
const api = createApi(
|
|
1664
|
+
ctx,
|
|
1665
|
+
service,
|
|
1666
|
+
settings,
|
|
1667
|
+
{ add() {}, remove() {}, list() { return []; } },
|
|
1668
|
+
undefined,
|
|
1669
|
+
undefined,
|
|
1670
|
+
"",
|
|
1671
|
+
Config({})
|
|
1672
|
+
);
|
|
1673
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/inject-preview");
|
|
1674
|
+
assert.ok(route, "preview endpoint must be registered");
|
|
1675
|
+
|
|
1676
|
+
// 无渲染发生:snapshot=null(新宿主/新会话/autoInject 关闭同形)。
|
|
1677
|
+
let res = new FakeRes();
|
|
1678
|
+
await route.handler(req("/api/dsh-mneme/inject-preview"), res);
|
|
1679
|
+
assert.equal(res.statusCode, 200);
|
|
1680
|
+
assert.deepEqual(JSON.parse(res.body), { snapshot: null });
|
|
1681
|
+
|
|
1682
|
+
// 真实渲染一帧后:快照透传(api 层不做任何加工)。
|
|
1683
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "用户用中文交流", importance: 5 });
|
|
1684
|
+
const contexts = [];
|
|
1685
|
+
const promptCtx = {
|
|
1686
|
+
systemPrompt: { context(def) { contexts.push(def); return () => {}; } }
|
|
1687
|
+
};
|
|
1688
|
+
const injector = createInjector(promptCtx, service, settings, Config({ maxInjectedItems: 3, importanceThreshold: 3 }));
|
|
1689
|
+
contexts[0].text({});
|
|
1690
|
+
res = new FakeRes();
|
|
1691
|
+
await route.handler(req("/api/dsh-mneme/inject-preview"), res);
|
|
1692
|
+
const body = JSON.parse(res.body);
|
|
1693
|
+
assert.ok(body.snapshot, "snapshot present after a real render");
|
|
1694
|
+
assert.equal(body.snapshot.entries[0].title, "语言");
|
|
1695
|
+
assert.ok(body.snapshot.totalChars > 0);
|
|
1696
|
+
|
|
1697
|
+
// 非 GET → 404(与 inject-status 同款)。
|
|
1698
|
+
res = new FakeRes();
|
|
1699
|
+
await route.handler(req("/api/dsh-mneme/inject-preview", "POST"), res);
|
|
1700
|
+
assert.equal(res.statusCode, 404);
|
|
1701
|
+
injector(); // 快照是模块全局:用完即清,不污染后续用例
|
|
1702
|
+
});
|