@modusensus/dsh-mneme 0.7.16 → 0.7.18

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/src/store.js CHANGED
@@ -637,7 +637,7 @@ export function createStore(path) {
637
637
  return ts;
638
638
  }
639
639
 
640
- function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, updatedFrom = null, updatedTo = null } = {}) {
640
+ function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null } = {}) {
641
641
  const clauses = [];
642
642
  const params = [];
643
643
  if (type !== undefined) {
@@ -673,6 +673,12 @@ export function createStore(path) {
673
673
  } else if (!includeArchived) {
674
674
  clauses.push("archived = 0");
675
675
  }
676
+ // 与 list() 同过滤:deposited 视图的 total 才能和行保持一致。
677
+ if (depositedOnly) {
678
+ clauses.push(
679
+ "(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
680
+ );
681
+ }
676
682
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
677
683
  return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
678
684
  }
@@ -924,7 +930,7 @@ export function createStore(path) {
924
930
  return rows.map(toRow);
925
931
  }
926
932
 
927
- function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
933
+ function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, minImportance = null, source = null, updatedFrom = null, updatedTo = null } = {}) {
928
934
  const clauses = [];
929
935
  const params = [];
930
936
  if (type) {
@@ -962,6 +968,14 @@ export function createStore(path) {
962
968
  } else if (!includeArchived) {
963
969
  clauses.push("archived = 0");
964
970
  }
971
+ // depositedOnly:只看 autoDream 巩固过的记忆——receipt_chain 的 merge /
972
+ // update live verdict(record_id 即保留/更新目标)∪ source="dream" 的
973
+ // 直写沉淀(记忆库总览)。conflict 不算沉淀:两侧只被仲裁,内容未落。
974
+ if (depositedOnly) {
975
+ clauses.push(
976
+ "(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
977
+ );
978
+ }
965
979
  const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
966
980
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
967
981
  // "chrono" is pure newest-first — the stable order paged browsing (month
@@ -0,0 +1,218 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { EventEmitter } from "node:events";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+ import { createApi } from "../src/api.js";
7
+ import { createSettings } from "../src/settings.js";
8
+
9
+ // 覆盖 PR#73 删测后遗留的三个存活 API 空白(v0.7.16 回归补测):
10
+ // /api/dsh-mneme/delete 面板删记忆(POST + auth + 存在性 404)
11
+ // /api/dsh-mneme/entities 实体目录(只读、limit 夹取)
12
+ // /api/dsh-mneme/external-api 独立服务配置(GET 发 token / PUT 校验)
13
+ // 这三个路由在 src/api.js 里一直在跑,只是丢了测试。
14
+
15
+ class FakeRes extends EventEmitter {
16
+ constructor() { super(); this.statusCode = 200; this.body = ""; }
17
+ writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
18
+ end(text) { this.body = text ?? ""; this.emit("end"); return this; }
19
+ }
20
+
21
+ function req(path, method = "GET", body = null) {
22
+ const r = new EventEmitter();
23
+ r.url = path;
24
+ r.method = method;
25
+ r.headers = {};
26
+ if (body !== null) {
27
+ process.nextTick(() => {
28
+ r.emit("data", Buffer.from(JSON.stringify(body)));
29
+ r.emit("end");
30
+ });
31
+ }
32
+ return r;
33
+ }
34
+
35
+ function setup(apiToken = "") {
36
+ const store = createStore(":memory:");
37
+ const service = createService({ store, mirror: null, config: {} });
38
+ const settings = createSettings(store.db);
39
+ const commands = { add: () => {}, remove: () => {}, list: () => [] };
40
+ const routes = [];
41
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
42
+ createApi(ctx, service, settings, commands, null, undefined, apiToken);
43
+ return { store, service, routes, settings };
44
+ }
45
+
46
+ function handler(routes, path) {
47
+ return routes.find((r) => r.path === path);
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // /api/dsh-mneme/delete
52
+ // ---------------------------------------------------------------------------
53
+ test("delete is auth-gated and rejects non-POST", async () => {
54
+ const { routes } = setup("secret-token");
55
+ const route = handler(routes, "/api/dsh-mneme/delete");
56
+
57
+ // 未带 token → 401
58
+ let res = new FakeRes();
59
+ await route.handler(req("/api/dsh-mneme/delete", "POST", { id: "x" }), res);
60
+ assert.equal(res.statusCode, 401);
61
+ assert.equal(JSON.parse(res.body).error, "unauthorized");
62
+
63
+ // 带 token 但 GET → 404
64
+ res = new FakeRes();
65
+ const ok = req("/api/dsh-mneme/delete", "GET");
66
+ ok.headers = { authorization: "Bearer secret-token" };
67
+ await route.handler(ok, res);
68
+ assert.equal(res.statusCode, 404);
69
+ });
70
+
71
+ test("delete validates id and reports missing/unknown 404", async () => {
72
+ const { routes, service } = setup("secret-token");
73
+ const route = handler(routes, "/api/dsh-mneme/delete");
74
+
75
+ // 空 id → 400
76
+ let res = new FakeRes();
77
+ let ok = req("/api/dsh-mneme/delete", "POST", { id: " " });
78
+ ok.headers = { authorization: "Bearer secret-token" };
79
+ await route.handler(ok, res);
80
+ assert.equal(res.statusCode, 400);
81
+ assert.equal(JSON.parse(res.body).error, "missing-id");
82
+
83
+ // 不存在的 id → 404(store.remove 静默,靠前置 getById 区分)
84
+ res = new FakeRes();
85
+ ok = req("/api/dsh-mneme/delete", "POST", { id: "no-such-memory" });
86
+ ok.headers = { authorization: "Bearer secret-token" };
87
+ await route.handler(ok, res);
88
+ assert.equal(res.statusCode, 404);
89
+ assert.equal(JSON.parse(res.body).error, "not-found");
90
+
91
+ // 确认没被误删
92
+ assert.equal(service.count(), 0);
93
+ });
94
+
95
+ test("delete removes an existing memory", async () => {
96
+ const { routes, service } = setup("secret-token");
97
+ const route = handler(routes, "/api/dsh-mneme/delete");
98
+
99
+ const { memory } = service.saveWithDedupe({ type: "project", title: "要删的", content: "删掉我" });
100
+ assert.ok(service.getById(memory.id));
101
+
102
+ const res = new FakeRes();
103
+ const ok = req("/api/dsh-mneme/delete", "POST", { id: memory.id });
104
+ ok.headers = { authorization: "Bearer secret-token" };
105
+ await route.handler(ok, res);
106
+ assert.equal(res.statusCode, 200);
107
+ assert.deepEqual(JSON.parse(res.body), { ok: true });
108
+ assert.equal(service.getById(memory.id), undefined);
109
+ assert.equal(service.count(), 0);
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // /api/dsh-mneme/entities
114
+ // ---------------------------------------------------------------------------
115
+ test("entities directory is read-only and returns empty list", async () => {
116
+ const { routes } = setup();
117
+ const route = handler(routes, "/api/dsh-mneme/entities");
118
+ const res = new FakeRes();
119
+ await route.handler(req("/api/dsh-mneme/entities"), res);
120
+ assert.equal(res.statusCode, 200);
121
+ assert.deepEqual(JSON.parse(res.body), { entities: [], total: 0 });
122
+ });
123
+
124
+ test("entities lists seeded entities without auth", async () => {
125
+ const { routes, service } = setup("secret-token"); // 有 token 但读路由不校验
126
+ service.createEntity({ name: "SQLite", type: "technology" });
127
+ service.createEntity({ name: "记忆", type: "concept" });
128
+
129
+ const route = handler(routes, "/api/dsh-mneme/entities");
130
+ const res = new FakeRes();
131
+ await route.handler(req("/api/dsh-mneme/entities"), res);
132
+ assert.equal(res.statusCode, 200);
133
+ const data = JSON.parse(res.body);
134
+ assert.equal(data.total, 2);
135
+ const names = data.entities.map((e) => e.name).sort();
136
+ assert.deepEqual(names, ["SQLite", "记忆"]);
137
+ for (const e of data.entities) {
138
+ assert.ok(e.type, "each entity carries a type");
139
+ assert.ok(Number.isInteger(e.mention_count));
140
+ }
141
+ });
142
+
143
+ test("entities clamps limit to [1, 1000]", async () => {
144
+ const { routes, service } = setup();
145
+ for (let i = 0; i < 5; i++) service.createEntity({ name: `e${i}`, type: "concept" });
146
+ const route = handler(routes, "/api/dsh-mneme/entities");
147
+
148
+ // 夹取语义:NaN/0 先被 `|| 500` 兜底成 500(0 不再走 max(1,) 下限),
149
+ // 负数才被夹到 1,超限(>1000)夹到 1000。断言按真实实现收紧。
150
+ for (const [qs, want] of [["limit=abc", 5], ["limit=0", 5], ["limit=-10", 1], ["limit=5000", 5]]) {
151
+ const res = new FakeRes();
152
+ await route.handler(req(`/api/dsh-mneme/entities?${qs}`), res);
153
+ const data = JSON.parse(res.body);
154
+ assert.equal(data.entities.length, want, `${qs} → ${want} (got ${data.entities.length})`);
155
+ }
156
+ });
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // /api/dsh-mneme/external-api
160
+ // ---------------------------------------------------------------------------
161
+ test("external-api GET materializes and persists a token", async () => {
162
+ const { routes, settings } = setup();
163
+ const route = handler(routes, "/api/dsh-mneme/external-api");
164
+
165
+ const res = new FakeRes();
166
+ await route.handler(req("/api/dsh-mneme/external-api"), res);
167
+ assert.equal(res.statusCode, 200);
168
+ const cfg = JSON.parse(res.body).config;
169
+ assert.ok(typeof cfg.token === "string" && cfg.token.length >= 16, "token is generated");
170
+
171
+ // 持久化后二次 GET 返回同一 token
172
+ const res2 = new FakeRes();
173
+ await route.handler(req("/api/dsh-mneme/external-api"), res2);
174
+ assert.equal(JSON.parse(res2.body).config.token, cfg.token);
175
+ assert.ok(settings.getExternalApi().token, "token persisted to settings kv");
176
+ });
177
+
178
+ test("external-api PUT requires auth and validates port/host", async () => {
179
+ const { routes } = setup("secret-token");
180
+ const route = handler(routes, "/api/dsh-mneme/external-api");
181
+
182
+ // 未带 token → 401
183
+ let res = new FakeRes();
184
+ await route.handler(req("/api/dsh-mneme/external-api", "PUT", { enabled: true }), res);
185
+ assert.equal(res.statusCode, 401);
186
+
187
+ // 端口越界 → 400
188
+ res = new FakeRes();
189
+ let ok = req("/api/dsh-mneme/external-api", "PUT", { port: 70000 });
190
+ ok.headers = { authorization: "Bearer secret-token" };
191
+ await route.handler(ok, res);
192
+ assert.equal(res.statusCode, 400);
193
+ assert.equal(JSON.parse(res.body).error, "invalid-port");
194
+
195
+ // host 带协议 → 400
196
+ res = new FakeRes();
197
+ ok = req("/api/dsh-mneme/external-api", "PUT", { host: "https://example.com" });
198
+ ok.headers = { authorization: "Bearer secret-token" };
199
+ await route.handler(ok, res);
200
+ assert.equal(res.statusCode, 400);
201
+ assert.equal(JSON.parse(res.body).error, "invalid-host");
202
+ });
203
+
204
+ test("external-api PUT applies valid config and echoes it", async () => {
205
+ const { routes } = setup("secret-token");
206
+ const route = handler(routes, "/api/dsh-mneme/external-api");
207
+
208
+ const res = new FakeRes();
209
+ const ok = req("/api/dsh-mneme/external-api", "PUT", { enabled: true, port: 8790, host: "127.0.0.1" });
210
+ ok.headers = { authorization: "Bearer secret-token" };
211
+ await route.handler(ok, res);
212
+ assert.equal(res.statusCode, 200);
213
+ const cfg = JSON.parse(res.body).config;
214
+ assert.equal(cfg.enabled, true);
215
+ assert.equal(cfg.port, 8790);
216
+ assert.equal(cfg.host, "127.0.0.1");
217
+ assert.ok(typeof cfg.token === "string");
218
+ });
package/test/api.test.js CHANGED
@@ -994,3 +994,58 @@ test("GET /api/dsh-mneme/list?archived=only lists just archived rows; default li
994
994
  await route.handler(req("/api/dsh-mneme/list"), back);
995
995
  assert.equal(JSON.parse(back.body).total, 2);
996
996
  });
997
+
998
+ test("GET /api/dsh-mneme/list?deposited=only lists dream-touched memories (receipts ∪ source=dream)", async () => {
999
+ const { routes, service } = setup();
1000
+ const plain = service.saveWithDedupe({ type: "preference", title: "无关", content: "plain" });
1001
+ const merged = service.saveWithDedupe({ type: "project", title: "被巩固", content: "merged" });
1002
+ const updated = service.saveWithDedupe({ type: "decision", title: "被更新", content: "updated" });
1003
+ service.saveWithDedupe({ type: "summary", title: "总览", content: "overview", source: "dream" });
1004
+ const ids = [plain, merged, updated].map((r) => r.memory.id);
1005
+
1006
+ // 巩固账本:merge 落在 keepSource、update 落在目标;conflict 只仲裁不落
1007
+ // 内容,不算沉淀。verdict='live' 才有效。
1008
+ service.saveReceipt({
1009
+ receipt_id: "r-merge", run_id: "run-1", record_id: merged.memory.id, kind: "merge",
1010
+ input_digest: "d1", keep_source: merged.memory.id, sources: [merged.memory.id, ids[0]],
1011
+ verdict: "live", count_before: 2, count_after: 1, policy_epoch: 0,
1012
+ created_at: new Date().toISOString()
1013
+ });
1014
+ service.saveReceipt({
1015
+ receipt_id: "r-update", run_id: "run-1", record_id: updated.memory.id, kind: "update",
1016
+ input_digest: "d2", verdict: "live", count_before: 1, count_after: 1,
1017
+ policy_epoch: 0, created_at: new Date().toISOString()
1018
+ });
1019
+ service.saveReceipt({
1020
+ receipt_id: "r-conflict", run_id: "run-1", record_id: "winner-x", kind: "conflict",
1021
+ input_digest: "d3", winner_id: "winner-x", loser_id: "loser-y", verdict: "live",
1022
+ count_before: 2, count_after: 2, policy_epoch: 0, created_at: new Date().toISOString()
1023
+ });
1024
+
1025
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/list");
1026
+ const res = new FakeRes();
1027
+ await route.handler(req("/api/dsh-mneme/list?deposited=only"), res);
1028
+ const data = JSON.parse(res.body);
1029
+ assert.deepEqual(
1030
+ data.items.map((m) => m.title).sort(),
1031
+ ["总览", "被巩固", "被更新"],
1032
+ "deposited view = receipt merge/update records ∪ source=dream writes"
1033
+ );
1034
+ assert.equal(data.total, 3, "total honors the deposited filter");
1035
+
1036
+ // 默认列表不受 deposited 过滤影响
1037
+ const def = new FakeRes();
1038
+ await route.handler(req("/api/dsh-mneme/list"), def);
1039
+ assert.equal(JSON.parse(def.body).total, 4);
1040
+
1041
+ // 与 archived=only 可叠加:归档的沉淀记忆才出现在交集视图里
1042
+ const upd = routes.find((r) => r.path === "/api/dsh-mneme/update");
1043
+ const arch = new FakeRes();
1044
+ await upd.handler(req("/api/dsh-mneme/update", "POST", { id: merged.memory.id, archived: true }), arch);
1045
+ assert.equal(arch.statusCode, 200);
1046
+ const both = new FakeRes();
1047
+ await route.handler(req("/api/dsh-mneme/list?deposited=only&archived=only"), both);
1048
+ const bothData = JSON.parse(both.body);
1049
+ assert.deepEqual(bothData.items.map((m) => m.title), ["被巩固"]);
1050
+ assert.equal(bothData.total, 1);
1051
+ });
@@ -160,6 +160,104 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
160
160
  );
161
161
  });
162
162
 
163
+ // Three alignment/softness guarantees born from field feedback: (a) the
164
+ // portalled entry keeps tracking the live New-Session class (host and skin
165
+ // rewrite it asynchronously, so a mount-time snapshot goes stale), (b) it
166
+ // fills the same width as that button and inherits its native centering,
167
+ // (c) the toolbar dropdown escapes the transform stacking trap — the
168
+ // container needs the z-index because transform creates the context.
169
+ test("entry tracks native class, fills native width, and lifts the toolbar dropdown", () => {
170
+ assert.ok(
171
+ clientSource.includes("new MutationObserver"),
172
+ "the entry must re-sync the copied class via MutationObserver"
173
+ );
174
+ assert.ok(
175
+ clientSource.includes('attributeFilter: ["class"]'),
176
+ "the observer must watch class attribute changes"
177
+ );
178
+ assert.ok(
179
+ /\.mneme-topentry-native\{width:100%;/.test(clientSource),
180
+ "the entry button must fill the same width as the New-Session row"
181
+ );
182
+ assert.equal(
183
+ clientSource.includes(".mneme-topentry-native .mneme-topentry-label{flex:1;min-width:0;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}"),
184
+ false,
185
+ "the old left-aligned label override must go; native centering applies"
186
+ );
187
+ assert.ok(
188
+ /\.mneme-xtools\{[^}]*z-index:3\}/.test(clientSource),
189
+ "the toolbar container must carry z-index:3 (sticky month header is 2, drawer 6)"
190
+ );
191
+ });
192
+
193
+ // Importance renders as Lucide star glyphs (the morphicons-paired data set;
194
+ // the runtime cannot require the ESM-only morphicons engine, so the path
195
+ // ships inline like the other stroke icons), not raw ★ text. Only the
196
+ // drawer's edit-mode <option> labels keep the text form — SVG cannot render
197
+ // inside <option>.
198
+ test("importance renders as star glyphs, not raw text stars", () => {
199
+ assert.ok(
200
+ clientSource.includes("STAR_PATH_D"),
201
+ "the Lucide star path data must be inlined"
202
+ );
203
+ assert.ok(
204
+ clientSource.includes("const ImportanceStars"),
205
+ "the star-row component must exist"
206
+ );
207
+ assert.equal(
208
+ (clientSource.match(/"★"\.repeat/g) || []).length,
209
+ 1,
210
+ "only the drawer edit <option> labels may keep the ★ text form"
211
+ );
212
+ assert.ok(
213
+ /h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
214
+ "the drawer detail must render the star row"
215
+ );
216
+ assert.ok(
217
+ /h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
218
+ "the card foot must render the star row"
219
+ );
220
+ });
221
+
222
+ // better-sidebar ecosystem integration is an optional capability (official
223
+ // external-plugin-guide §2.2): 'betterSidebar' IS declared in inject (DSH's
224
+ // runtime gates ctx property access on the inject declaration — probing
225
+ // without declaring fails the whole loader entry, verified in the field) and
226
+ // marked as an optional peer dependency, so when it is absent the service
227
+ // resolves to undefined and registration skips gracefully. Registration
228
+ // probes at runtime inside a named ctx.effect (scope cleanup on HMR/disable)
229
+ // with a bounded retry for service-provision ordering.
230
+ test("better-sidebar tab declares optional inject, probes, and skips gracefully", () => {
231
+ assert.ok(
232
+ /const inject = \["slots", "locale", "betterSidebar"\];/.test(clientSource),
233
+ "module inject must declare betterSidebar — DSH gates ctx property access on it"
234
+ );
235
+ assert.ok(
236
+ /const bs = ctx\.betterSidebar;[\s\S]{0,60}typeof bs\.registerTab === "function"/.test(clientSource),
237
+ "the tab registration must still probe ctx.betterSidebar at runtime (undefined when absent)"
238
+ );
239
+ assert.ok(
240
+ /id: "dsh-mneme:memory"/.test(clientSource),
241
+ "the registered tab id must be package-prefixed"
242
+ );
243
+ assert.ok(
244
+ /title: \(\) => t\("memory\.view\.label"\)/.test(clientSource),
245
+ "the tab title must reuse the localized 记忆库 label"
246
+ );
247
+ assert.ok(
248
+ /component: \(\) => h\(MemoryExplorer, \{ t \}\)/.test(clientSource),
249
+ "the tab must reuse the MemoryExplorer views"
250
+ );
251
+ assert.ok(
252
+ clientSource.includes('"dsh-mneme: better-sidebar tab"'),
253
+ "the registration effect must carry a named label for scope cleanup"
254
+ );
255
+ assert.ok(
256
+ /if \(\+\+tries <= 10\) timer = setTimeout\(attempt, 1000\);/.test(clientSource),
257
+ "the service probe must retry with a bound (10 × 1s), not loop forever"
258
+ );
259
+ });
260
+
163
261
  // The graph toggle must not read as "share": the primitives share icon is
164
262
  // banned and a custom node-graph glyph takes its place.
165
263
  test("graph toggle uses a node-graph glyph, not the share icon", () => {
@@ -174,6 +272,44 @@ test("graph toggle uses a node-graph glyph, not the share icon", () => {
174
272
  );
175
273
  });
176
274
 
275
+ // 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
276
+ // 沉淀/归档的完整浏览走记忆库的 deposited/archived 筛选视图(chip 预置 +
277
+ // 状态页入口跳转),详情抽屉给归档记忆一个反向的「恢复」。
278
+ test("status dashboard links into deposited/archived library views", () => {
279
+ assert.ok(
280
+ clientSource.includes('"/api/dsh-mneme/list?deposited=only&limit=8&order=chrono"'),
281
+ "the workbench deposited preview must read the server-side deposited view"
282
+ );
283
+ assert.ok(
284
+ clientSource.includes('"/api/dsh-mneme/list?archived=only&limit=3&order=chrono"'),
285
+ "the workbench archived preview must cap at 3 rows backed by a server total"
286
+ );
287
+ assert.ok(
288
+ clientSource.includes("browseWithFilter"),
289
+ "the status page must jump into the library with preset filters"
290
+ );
291
+ assert.ok(
292
+ /view === "status" && h\(StatusPanel, \{ t, onBrowse: browseWithFilter \}\)/.test(clientSource),
293
+ "the status panel must receive the browse-jump callback"
294
+ );
295
+ assert.ok(
296
+ clientSource.includes('(depositedOnly ? "&deposited=only" : "")'),
297
+ "the library filterQS must carry the deposited chip"
298
+ );
299
+ assert.ok(
300
+ clientSource.includes('(archivedOnly ? "&archived=only" : "")'),
301
+ "the library filterQS must carry the archived chip"
302
+ );
303
+ assert.ok(
304
+ clientSource.includes('postUpdate({ archived: false }, { restored: true })'),
305
+ "the drawer must offer restore for archived memories"
306
+ );
307
+ assert.ok(
308
+ clientSource.includes('"memory.status.viewAll"'),
309
+ "the view-all entries must come from the dictionary"
310
+ );
311
+ });
312
+
177
313
  // Every memory feature lives in the main-area library now: the explorer
178
314
  // hosts three sub-views (browse / entities / settings) switched by tabs whose
179
315
  // labels come from dedicated dictionary keys.
@@ -0,0 +1,96 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readdirSync, readFileSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // lib/ 运行冒烟(v0.7.16 回归补测,重建被 PR#73 删除的 lib-smoke.test.js)。
8
+ // npm 包实际加载的是 lib/(package main → lib/index.js),历史教训 v0.7.8
9
+ // 只改 src 忘了同步 lib 发出去旧产物(issue #65)。这里补两层守护:
10
+ // 1) 静态:src/ 与 lib/ 逐文件字节一致(check-sync.js 的测试版,CI 常驻);
11
+ // 2) 运行时:直接从 lib/ 导入核心模块跑关键链路,证明打包产物可独立加载。
12
+
13
+ const root = fileURLToPath(new URL("..", import.meta.url)); // dsh-mneme/
14
+ const srcDir = join(root, "src");
15
+ const libDir = join(root, "lib");
16
+
17
+ function walk(dir) {
18
+ const out = [];
19
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
20
+ const full = join(dir, entry.name);
21
+ if (entry.isDirectory()) out.push(...walk(full));
22
+ else if (entry.isFile()) out.push(full);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ test("src/ and lib/ are byte-identical (no publish drift)", () => {
28
+ const srcFiles = walk(srcDir);
29
+ assert.ok(srcFiles.length > 0, "src tree is not empty");
30
+ const drift = [];
31
+ for (const file of srcFiles) {
32
+ const rel = relative(srcDir, file);
33
+ const dest = join(libDir, rel);
34
+ if (!readFileSync(dest, "utf8")) {
35
+ drift.push(`missing lib/${rel}`);
36
+ } else if (!readFileSync(file).equals(readFileSync(dest))) {
37
+ drift.push(`differ lib/${rel}`);
38
+ }
39
+ }
40
+ assert.deepEqual(drift, [], "src↔lib must stay in sync (npm run sync)");
41
+ });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // lib 运行时冒烟:直接加载 npm 实际分发的产物
45
+ // ---------------------------------------------------------------------------
46
+ test("lib store+service runs a save/get/count cycle", async () => {
47
+ const { createStore } = await import("../lib/store.js");
48
+ const { createService } = await import("../lib/service.js");
49
+
50
+ const store = createStore(":memory:");
51
+ const service = createService({ store, mirror: null, config: {} });
52
+ const { memory } = service.saveWithDedupe({ type: "preference", title: "语言", content: "中文" });
53
+ assert.ok(memory.id);
54
+ assert.ok(service.getById(memory.id));
55
+ assert.equal(service.count(), 1);
56
+ });
57
+
58
+ test("lib summarize parses extraction JSON robustly", async () => {
59
+ const { parseSummaryJson } = await import("../lib/summarize.js");
60
+
61
+ // 前后夹带杂质的原始输出 → 只取数组
62
+ const parsed = parseSummaryJson("```json\n[{\"type\":\"preference\",\"title\":\"x\",\"content\":\"y\",\"importance\":9}]\n```");
63
+ assert.equal(parsed.length, 1);
64
+ assert.equal(parsed[0].importance, 5, "importance clamped to [1,5]");
65
+
66
+ // 非法 JSON / 非数组 → 空数组,不抛
67
+ assert.deepEqual(parseSummaryJson("no array here"), []);
68
+ assert.deepEqual(parseSummaryJson('{"type":"preference"}'), []);
69
+ assert.deepEqual(parseSummaryJson(""), []);
70
+ });
71
+
72
+ test("lib summarize constructs with autoSummarize disabled", async () => {
73
+ const { createSummarizer } = await import("../lib/summarize.js");
74
+ const summarizer = createSummarizer({}, {}, { autoSummarize: false });
75
+ assert.equal(typeof summarizer.dispose, "function");
76
+ summarizer.dispose();
77
+ });
78
+
79
+ test("lib inject constructs and renders an empty block", async () => {
80
+ const { createInjector } = await import("../lib/inject.js");
81
+ const registered = [];
82
+ const ctx = {
83
+ systemPrompt: {
84
+ context: (def) => { registered.push(def); return () => {}; }
85
+ }
86
+ };
87
+ const service = { injectCandidates: () => [], injectSettings: () => [] };
88
+ const dispose = createInjector(ctx, service, {}, {});
89
+ assert.equal(typeof dispose, "function");
90
+ assert.equal(registered.length, 2, "memory + user-settings contexts registered");
91
+
92
+ const memoryBlock = registered.find((d) => d.name === "memory");
93
+ assert.equal(typeof memoryBlock.text, "function");
94
+ assert.equal(memoryBlock.text({}), ""); // 无候选 → 空块
95
+ dispose();
96
+ });