@modusensus/dsh-mneme 0.6.1 → 0.6.5

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
@@ -1,5 +1,6 @@
1
1
  import { DatabaseSync } from "node:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { sanitizeTags } from "./parser/tag.js";
3
4
 
4
5
  const SCHEMA = `
5
6
  CREATE TABLE IF NOT EXISTS memories (
@@ -1684,6 +1685,162 @@ export function createStore(path) {
1684
1685
  return memories;
1685
1686
  }
1686
1687
 
1688
+ // --- tag storage (v0.6.2) ------------------------------------------------
1689
+ // Tags ride the snapshot-style entity_attrs table (attr_key='tags'), so one
1690
+ // memory has exactly one live tags row; setMemoryTags invalidates any prior
1691
+ // live row and inserts a fresh one (idempotent overwrite). entity_id is the
1692
+ // memory id itself (the memory is its own tag entity), memory_id is kept so
1693
+ // the existing memory-scoped attr queries (getAttrsByMemory / findMemoriesByAttr)
1694
+ // and the bulk tag map all work without a special path.
1695
+
1696
+ /** Normalize an arbitrary tags input to a deduplicated string array.
1697
+ * Delegates to parser/tag.js sanitizeTags (shared validation with parseTags
1698
+ * and the autoDream tag-extractor): strips a leading `#`, trims, drops
1699
+ * non-strings/blanks/over-long/illegal-char tags. Kept as a thin alias so
1700
+ * the tag write path validates identically to the parser path. */
1701
+ function normalizeTags(tags) {
1702
+ return sanitizeTags(tags);
1703
+ }
1704
+
1705
+ /**
1706
+ * Set (overwrite) the live tag set for a memory. Exactly one tags row stays
1707
+ * live per memory: any prior live row is invalidated first, then one fresh
1708
+ * row is written (no-op when tags is empty — the invalidated row is removed
1709
+ * so "clear tags" = no live row). Returns the stored tag array.
1710
+ */
1711
+ function setMemoryTags(memoryId, tags) {
1712
+ const arr = normalizeTags(tags);
1713
+ const now = nowIso();
1714
+ // Atomic: the invalidation and the fresh row must land together, so a
1715
+ // mid-write crash never leaves the old live row gone without a replacement.
1716
+ // SAVEPOINT (not BEGIN) so this nests safely inside service.transaction().
1717
+ db.exec("SAVEPOINT set_memory_tags");
1718
+ try {
1719
+ db.prepare(
1720
+ `UPDATE entity_attrs SET valid_until = ?
1721
+ WHERE attr_key = 'tags' AND memory_id = ? AND valid_until IS NULL`
1722
+ ).run(now, memoryId);
1723
+ if (arr.length) {
1724
+ const id = randomUUID();
1725
+ db.prepare(
1726
+ `INSERT INTO entity_attrs (id, entity_id, attr_key, attr_value, memory_id, valid_from, valid_until, confidence, source)
1727
+ VALUES (?, ?, 'tags', ?, ?, ?, NULL, 1.0, 'manual')`
1728
+ ).run(id, memoryId, JSON.stringify(arr), memoryId, now);
1729
+ }
1730
+ db.exec("RELEASE set_memory_tags");
1731
+ } catch (e) {
1732
+ db.exec("ROLLBACK TO set_memory_tags");
1733
+ db.exec("RELEASE set_memory_tags");
1734
+ throw e;
1735
+ }
1736
+ return arr;
1737
+ }
1738
+
1739
+ /** Live tags for a memory ([] when none / unknown). */
1740
+ function getMemoryTags(memoryId) {
1741
+ const row = db.prepare(
1742
+ `SELECT attr_value FROM entity_attrs
1743
+ WHERE attr_key = 'tags' AND memory_id = ? AND valid_until IS NULL
1744
+ ORDER BY valid_from DESC LIMIT 1`
1745
+ ).get(memoryId);
1746
+ if (!row) return [];
1747
+ try {
1748
+ const arr = JSON.parse(row.attr_value);
1749
+ return Array.isArray(arr) ? arr : [];
1750
+ } catch {
1751
+ return [];
1752
+ }
1753
+ }
1754
+
1755
+ /** Bulk live-tags lookup for mirror rendering. Returns Map<memoryId, string[]>. */
1756
+ function getMemoryTagsMap(ids) {
1757
+ const out = new Map();
1758
+ const list = (Array.isArray(ids) ? ids : []).filter(Boolean);
1759
+ for (let i = 0; i < list.length; i += 100) {
1760
+ const chunk = list.slice(i, i + 100);
1761
+ const rows = db.prepare(
1762
+ `SELECT memory_id, attr_value FROM entity_attrs
1763
+ WHERE attr_key = 'tags' AND valid_until IS NULL
1764
+ AND memory_id IN (${chunk.map(() => "?").join(",")})`
1765
+ ).all(...chunk);
1766
+ for (const row of rows) {
1767
+ try {
1768
+ const arr = JSON.parse(row.attr_value);
1769
+ if (Array.isArray(arr) && arr.length) out.set(row.memory_id, arr);
1770
+ } catch { /* corrupt row: skip */ }
1771
+ }
1772
+ }
1773
+ return out;
1774
+ }
1775
+
1776
+ /**
1777
+ * Memories carrying a live tags row that contains EVERY requested tag
1778
+ * (AND semantics for a multi-tag query). attr_value is a JSON array, so the
1779
+ * match uses quoted `"tag"` substrings — `tag:lin` never collides with
1780
+ * `linux` because JSON array elements are quote-delimited. Only live rows
1781
+ * (valid_until IS NULL) with a memory reference participate; each memory
1782
+ * appears once.
1783
+ */
1784
+ function findMemoriesByTags(tags) {
1785
+ const list = normalizeTags(tags);
1786
+ if (!list.length) return [];
1787
+ const where = list.map(() => `attr_value LIKE ? ESCAPE '\\'`).join(" AND ");
1788
+ const params = list.map((t) => `%"${escapeLike(t)}"%`);
1789
+ const rows = db.prepare(
1790
+ `SELECT DISTINCT memory_id FROM entity_attrs
1791
+ WHERE attr_key = 'tags' AND valid_until IS NULL
1792
+ AND memory_id IS NOT NULL AND memory_id != ''
1793
+ AND (${where})`
1794
+ ).all(...params);
1795
+ // Same live-memory filter as getDirectory/store.search: forgotten/archived/
1796
+ // session-disposed memories are invisible to `tag:` recall.
1797
+ const stmt = db.prepare(
1798
+ "SELECT * FROM memories WHERE id = ? AND forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL"
1799
+ );
1800
+ const memories = [];
1801
+ for (const { memory_id } of rows) {
1802
+ const row = stmt.get(memory_id);
1803
+ if (row) memories.push(toRow(row));
1804
+ }
1805
+ return memories;
1806
+ }
1807
+
1808
+ /**
1809
+ * Directory view (v0.6.3): group live memories by their entity_attrs-backed
1810
+ * tag set. A memory carrying N tags appears under all N tag folders; a memory
1811
+ * with no live tags lands in `untagged`. Only live rows participate —
1812
+ * forgotten, archived and session-disposed memories are excluded. Groups are
1813
+ * ordered by tag (locale-aware), group members and untagged follow the
1814
+ * canonical memory order (importance DESC, updated_at DESC, id).
1815
+ * @returns {{groups: {tag: string, memories: object[]}[], untagged: object[]}}
1816
+ */
1817
+ function getDirectory() {
1818
+ const rows = db.prepare(
1819
+ `SELECT * FROM memories
1820
+ WHERE forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL
1821
+ ORDER BY importance DESC, updated_at DESC, id`
1822
+ ).all();
1823
+ const memories = rows.map(toRow);
1824
+ const tagMap = getMemoryTagsMap(memories.map((m) => m.id));
1825
+ const byTag = new Map(); // tag -> memory[]
1826
+ const untagged = [];
1827
+ for (const m of memories) {
1828
+ const tags = tagMap.get(m.id);
1829
+ if (!tags || tags.length === 0) {
1830
+ untagged.push(m);
1831
+ continue;
1832
+ }
1833
+ for (const tag of tags) {
1834
+ if (!byTag.has(tag)) byTag.set(tag, []);
1835
+ byTag.get(tag).push(m);
1836
+ }
1837
+ }
1838
+ const groups = [...byTag.entries()]
1839
+ .sort((a, b) => a[0].localeCompare(b[0]))
1840
+ .map(([tag, ms]) => ({ tag, memories: ms }));
1841
+ return { groups, untagged };
1842
+ }
1843
+
1687
1844
  /**
1688
1845
  * Record a typed relation between two entities. metadata (optional) is a
1689
1846
  * free-form JSON blob describing the relation. Relations are append-only —
@@ -2055,6 +2212,11 @@ export function createStore(path) {
2055
2212
  getAttrHistory,
2056
2213
  getAttrsByMemory,
2057
2214
  findMemoriesByAttr,
2215
+ setMemoryTags,
2216
+ getMemoryTags,
2217
+ getMemoryTagsMap,
2218
+ findMemoriesByTags,
2219
+ getDirectory,
2058
2220
  saveRelation,
2059
2221
  saveWikiLinks,
2060
2222
  findByTitle,
package/test/api.test.js CHANGED
@@ -467,3 +467,83 @@ test("Bug10: vector-reindex with an embed-only OpenAI-compatible embedder return
467
467
  assert.equal(vectorIndex.dimension(), 3, "dimension written to vector_meta");
468
468
  assert.equal(vectorIndex.getEmbedding(service.all()[0].id).length, 3, "embedding persisted");
469
469
  });
470
+
471
+ // --- memory tags (v0.6.2) ---------------------------------------------------
472
+
473
+ test("GET /api/dsh-mneme/memory/tags returns live tags and the manual gate", async () => {
474
+ const { routes, service } = setup();
475
+ const mem = service.saveWithDedupe({ type: "preference", title: "A", content: "x" }).memory;
476
+ service.setMemoryTags(mem.id, ["bash", "考研"]);
477
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/tags");
478
+ const res = new FakeRes();
479
+ await route.handler(req(`/api/dsh-mneme/memory/tags?id=${mem.id}`), res);
480
+ assert.equal(res.statusCode, 200);
481
+ const data = JSON.parse(res.body);
482
+ assert.deepEqual(data.tags, ["bash", "考研"], "authoritative entity_attrs tags");
483
+ assert.equal(data.manualTagEnabled, true, "gate defaults on");
484
+ });
485
+
486
+ test("POST /api/dsh-mneme/memory/tags overwrites the tag set", async () => {
487
+ const { routes, service } = setup();
488
+ const mem = service.saveWithDedupe({ type: "preference", title: "A", content: "x" }).memory;
489
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/tags");
490
+ const res = new FakeRes();
491
+ await route.handler(req("/api/dsh-mneme/memory/tags", "POST", { id: mem.id, tags: ["linux"] }), res);
492
+ assert.equal(res.statusCode, 200);
493
+ const data = JSON.parse(res.body);
494
+ assert.equal(data.ok, true);
495
+ assert.deepEqual(data.tags, ["linux"]);
496
+ assert.deepEqual(service.getMemoryTags(mem.id), ["linux"], "tag set persisted");
497
+ });
498
+
499
+ test("POST /api/dsh-mneme/memory/tags 409s when manualTagEnabled is off", async () => {
500
+ const store = createStore(":memory:");
501
+ const service = createService({ store, mirror: null, config: { manualTagEnabled: false } });
502
+ const settings = createSettings(store.db);
503
+ const routes = [];
504
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
505
+ createApi(ctx, service, settings, { add() {}, remove() {}, list() { return []; } });
506
+ const mem = service.saveWithDedupe({ type: "preference", title: "A", content: "x" }).memory;
507
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/memory/tags");
508
+ const res = new FakeRes();
509
+ await route.handler(req("/api/dsh-mneme/memory/tags", "POST", { id: mem.id, tags: ["x"] }), res);
510
+ assert.equal(res.statusCode, 409, "manual tag write rejected");
511
+ assert.match(JSON.parse(res.body).error, /manualTagEnabled/);
512
+ const get = new FakeRes();
513
+ await route.handler(req(`/api/dsh-mneme/memory/tags?id=${mem.id}`), get);
514
+ assert.equal(JSON.parse(get.body).manualTagEnabled, false, "GET reports the gate off");
515
+ });
516
+
517
+ // v0.6.3 directory view endpoint: /api/dsh-mneme/directory returns the
518
+ // tag-grouped directory (groups + untagged) as JSON.
519
+ test("GET /api/dsh-mneme/directory groups tagged memories by tag", async () => {
520
+ const { routes, service } = setup();
521
+ const a = service.saveWithDedupe({ type: "preference", title: "Linux", content: "内核" }).memory;
522
+ const b = service.saveWithDedupe({ type: "preference", title: "考研", content: "公共管理" }).memory;
523
+ service.setMemoryTags(a.id, ["linux"]);
524
+ service.setMemoryTags(b.id, ["考研"]);
525
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/directory");
526
+ assert.ok(route, "directory route must be registered");
527
+ const res = new FakeRes();
528
+ await route.handler(req("/api/dsh-mneme/directory"), res);
529
+ assert.equal(res.statusCode, 200);
530
+ const data = JSON.parse(res.body);
531
+ assert.deepEqual(Object.keys(data).sort(), ["groups", "untagged"]);
532
+ assert.equal(data.groups.length, 2);
533
+ const linux = data.groups.find((g) => g.tag === "linux");
534
+ assert.deepEqual(linux.memories.map((m) => m.id), [a.id]);
535
+ assert.equal(linux.memories[0].title, "Linux");
536
+ });
537
+
538
+ test("GET /api/dsh-mneme/directory reports untagged memories", async () => {
539
+ const { routes, service } = setup();
540
+ const tagged = service.saveWithDedupe({ type: "preference", title: "Tagged", content: "x" }).memory;
541
+ const bare = service.saveWithDedupe({ type: "project", title: "Bare", content: "y" }).memory;
542
+ service.setMemoryTags(tagged.id, ["linux"]);
543
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/directory");
544
+ const res = new FakeRes();
545
+ await route.handler(req("/api/dsh-mneme/directory"), res);
546
+ assert.equal(res.statusCode, 200);
547
+ const data = JSON.parse(res.body);
548
+ assert.deepEqual(data.untagged.map((m) => m.id), [bare.id]);
549
+ });
@@ -0,0 +1,82 @@
1
+ // v0.6.5 整合边界测试:跨 v0.6.1-0.6.4 的边界场景。
2
+ // 覆盖:循环 wiki-link / 空 tag 清除 / 超长 tag 丢弃 / 多 tag 目录分组 /
3
+ // autoTag 非法输出 fail-safe / tag 搜索大小写。
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { createStore } from "../src/store.js";
7
+ import { createService } from "../src/service.js";
8
+ import { parseWikiLinks } from "../src/parser/wiki-link.js";
9
+ import { parseTags } from "../src/parser/tag.js";
10
+ import { extractQueryTags } from "../src/search/tag-boost.js";
11
+
12
+ function makeService(config = {}) {
13
+ const store = createStore(":memory:");
14
+ const service = createService({ store, mirror: null, config });
15
+ return { store, service };
16
+ }
17
+
18
+ test("wiki-link: mutual links (A→B and B→A) resolve without loops", () => {
19
+ const { store, service } = makeService({ wikiLinkEnabled: true });
20
+ const a = service.saveWithDedupe({
21
+ type: "preference", title: "Alpha", content: "参见 [[Beta]]", importance: 3,
22
+ }).memory;
23
+ const b = service.saveWithDedupe({
24
+ type: "preference", title: "Beta", content: "反链 [[Alpha]]", importance: 3,
25
+ }).memory;
26
+ store.saveWikiLinks({ memoryId: a.id, title: a.title, targets: ["Beta"] });
27
+ store.saveWikiLinks({ memoryId: b.id, title: b.title, targets: ["Alpha"] });
28
+ const ab = service.getForwardLinks(a.id);
29
+ const ba = service.getBacklinks(a.id);
30
+ assert.ok(ab.some((r) => r.target.id === b.id), "A links to B");
31
+ assert.ok(ba.some((r) => r.source.id === b.id), "B backlinks to A");
32
+ store.close();
33
+ });
34
+
35
+ test("tags: setting empty array clears the live tag row", () => {
36
+ const { store } = makeService();
37
+ const a = store.save({ type: "preference", title: "A", content: "c", tags: [] });
38
+ store.setMemoryTags(a.id, ["规划", "前端"]);
39
+ assert.deepEqual(store.getMemoryTags(a.id), ["规划", "前端"]);
40
+ store.setMemoryTags(a.id, []);
41
+ assert.deepEqual(store.getMemoryTags(a.id), []);
42
+ store.close();
43
+ });
44
+
45
+ test("parseTags: over-length and invalid tags are dropped", () => {
46
+ const out = parseTags("#短 #这是一个超过二十个字符的超级长标签 #ok_123 #带-连字符");
47
+ assert.ok(out.includes("短"), "short CJK tag kept");
48
+ assert.ok(out.includes("ok_123"), "alnum underscore kept");
49
+ assert.ok(out.includes("带-连字符"), "hyphen kept");
50
+ assert.ok(!out.some((t) => t.length > 20), "no tag exceeds 20 chars");
51
+ });
52
+
53
+ test("tag search: matches case-insensitively and composes with keywords", async () => {
54
+ const { store, service } = makeService();
55
+ const a = service.saveWithDedupe({
56
+ type: "preference", title: "规划", content: "博客重构", importance: 3,
57
+ }).memory;
58
+ store.setMemoryTags(a.id, ["规划"]);
59
+ const r = await service.searchMemories("tag:规划 博客", {
60
+ mode: "keyword", limit: 10, useRerank: false,
61
+ });
62
+ assert.ok(r.some((m) => m.id === a.id), "tag: prefix + keyword composition");
63
+ store.close();
64
+ });
65
+
66
+ test("directory: many-tag memory appears under every tag group", () => {
67
+ const { store, service } = makeService();
68
+ const a = store.save({ type: "preference", title: "多标签", content: "c", tags: [] });
69
+ store.setMemoryTags(a.id, ["规划", "前端", "博客"]);
70
+ const dir = store.getDirectory();
71
+ const groups = dir.groups.filter((g) => g.tag === "规划" || g.tag === "前端" || g.tag === "博客");
72
+ assert.equal(groups.length, 3, "one group per tag");
73
+ for (const g of groups) {
74
+ assert.ok(g.memories.some((m) => m.id === a.id), `memory under ${g.tag}`);
75
+ }
76
+ store.close();
77
+ });
78
+
79
+ test("extractQueryTags: known-tag mention is case-insensitive", () => {
80
+ const tags = extractQueryTags("PLANNING 进度", ["planning"]);
81
+ assert.deepEqual(tags, ["planning"]);
82
+ });
@@ -268,3 +268,101 @@ test("detail content renders wiki-links and resolves them on click", () => {
268
268
  "the panel must render inside a .mneme-backlinks block"
269
269
  );
270
270
  });
271
+
272
+ // v0.6.2 tag system: the detail pane renders tags as clickable chips wired to
273
+ // the memory/tags endpoints, and a tag: query always goes server-side.
274
+ test("detail pane renders editable tag chips backed by the tags endpoint", () => {
275
+ assert.ok(
276
+ clientSource.includes("/api/dsh-mneme/memory/tags?id="),
277
+ "the panel must fetch the selected memory's tags via the GET endpoint"
278
+ );
279
+ assert.ok(
280
+ clientSource.includes('method: "POST"'),
281
+ "adding/removing a tag must POST to the tags endpoint"
282
+ );
283
+ assert.ok(
284
+ clientSource.includes("onClick: () => onTagClick(tag)"),
285
+ "a tag chip click must trigger a tag: search"
286
+ );
287
+ assert.ok(
288
+ clientSource.includes("tagManual"),
289
+ "the panel must read the manualTagEnabled gate to hide editing"
290
+ );
291
+ });
292
+
293
+ test("tag: queries run server-side regardless of the semantic toggle", () => {
294
+ assert.ok(
295
+ clientSource.includes('(!semantic && !q.startsWith("tag:"))'),
296
+ "tag: must bypass the semantic toggle and use the search endpoint"
297
+ );
298
+ assert.ok(
299
+ clientSource.includes('setQuery(`tag:${tag}`)'),
300
+ "a tag chip click must set the query to tag:<tag>"
301
+ );
302
+ });
303
+
304
+ test("tag UI ships localized labels in both dictionaries", () => {
305
+ for (const key of ["tagAdd", "tagRemove", "tagPlaceholder", "tagsEmpty"]) {
306
+ assert.ok(
307
+ clientSource.includes(`"memory.explorer.${key}"`),
308
+ `memory.explorer.${key} key must exist`
309
+ );
310
+ }
311
+ });
312
+
313
+ // v0.6.3 directory view: a tag-folder tree backed by the directory endpoint.
314
+ // Folders accordion via data-expanded controlled by MemoryExplorer-lifted
315
+ // state; a memory entry click jumps back into the browser via onJump.
316
+ test("directory sub-view mounts DirectoryPanel backed by the directory endpoint", () => {
317
+ assert.ok(
318
+ clientSource.includes("h(DirectoryPanel, { t, onJump: jumpToMemory, collapsed: dirCollapsed, setCollapsed: setDirCollapsed })"),
319
+ "the directory panel must be embedded as a sub-view with lifted collapse state"
320
+ );
321
+ assert.ok(
322
+ clientSource.includes("/api/dsh-mneme/directory"),
323
+ "DirectoryPanel must fetch the directory endpoint"
324
+ );
325
+ assert.ok(
326
+ clientSource.includes('className: "mneme-directory"'),
327
+ "the directory panel must render inside the .mneme-directory block"
328
+ );
329
+ });
330
+
331
+ test("directory folders accordion via data-expanded driven by MemoryExplorer state", () => {
332
+ assert.ok(
333
+ clientSource.includes('"data-expanded": String(open)'),
334
+ "folder expansion must be driven by the data-expanded attribute"
335
+ );
336
+ assert.ok(
337
+ clientSource.includes("const [dirCollapsed, setDirCollapsed] = useState({})"),
338
+ "collapse state must live in MemoryExplorer so tab switches keep it"
339
+ );
340
+ assert.ok(
341
+ clientSource.includes('"aria-expanded": String(open)'),
342
+ "folder headers must announce their expanded state"
343
+ );
344
+ });
345
+
346
+ test("directory entry click jumps to the memory detail via onJump", () => {
347
+ assert.ok(
348
+ clientSource.includes("onClick: () => onJump(m)"),
349
+ "clicking a memory entry must call onJump with the memory"
350
+ );
351
+ assert.ok(
352
+ clientSource.includes("formatDate(m.updated_at || m.created_at)"),
353
+ "entries must render a timestamp via formatDate"
354
+ );
355
+ assert.ok(
356
+ clientSource.includes("memory.directory.untagged"),
357
+ "untagged memories must be bucketed under a labeled fallback folder"
358
+ );
359
+ });
360
+
361
+ test("directory ships localized labels in both dictionaries", () => {
362
+ for (const key of ["memory.explorer.tabDirectory", "memory.directory.untagged", "memory.directory.loading", "memory.directory.empty"]) {
363
+ assert.ok(
364
+ clientSource.includes(`"${key}"`),
365
+ `${key} key must exist`
366
+ );
367
+ }
368
+ });
@@ -0,0 +1,134 @@
1
+ // v0.6.3 目录视图测试。
2
+ // 覆盖:store.getDirectory 按 tag 分组 / 组排序(tag 字典序)/
3
+ // 组内成员排序(importance DESC → updated_at DESC)/
4
+ // 无 tag 记忆进 untagged / disposed·archived·forgotten 过滤 /
5
+ // 多 tag 记忆出现在每个 tag 文件夹 / service.getDirectory 输出 wire DTO。
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { createStore } from "../src/store.js";
9
+ import { createService } from "../src/service.js";
10
+
11
+ function setup() {
12
+ const store = createStore(":memory:");
13
+ const service = createService({ store, mirror: null, config: {} });
14
+ return { store, service };
15
+ }
16
+
17
+ function seed(store, title, { importance = 3, type = "preference", tags, session_id } = {}) {
18
+ const mem = store.save({ type, title, content: `content of ${title}`, importance, session_id });
19
+ if (tags && tags.length) store.setMemoryTags(mem.id, tags);
20
+ return mem;
21
+ }
22
+
23
+ // Bump a row's updated_at directly so ordering assertions are deterministic
24
+ // (nowIso() has ~ms resolution and a same-ms save loses the tiebreaker to the
25
+ // random id).
26
+ function setUpdatedAt(store, id, iso) {
27
+ store.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?").run(iso, id);
28
+ }
29
+
30
+ // ============================================================ grouping
31
+
32
+ test("getDirectory groups live memories by their live tag set", () => {
33
+ const { store } = setup();
34
+ const a = seed(store, "Linux 笔记", { tags: ["linux"] });
35
+ const b = seed(store, "考研计划", { tags: ["考研"] });
36
+ const dir = store.getDirectory();
37
+ assert.equal(dir.groups.length, 2);
38
+ const linux = dir.groups.find((g) => g.tag === "linux");
39
+ const exam = dir.groups.find((g) => g.tag === "考研");
40
+ assert.deepEqual(linux.memories.map((m) => m.id), [a.id]);
41
+ assert.deepEqual(exam.memories.map((m) => m.id), [b.id]);
42
+ assert.deepEqual(dir.untagged, []);
43
+ store.close();
44
+ });
45
+
46
+ test("getDirectory sorts groups by tag (locale-aware)", () => {
47
+ const { store } = setup();
48
+ seed(store, "B", { tags: ["zebra"] });
49
+ seed(store, "A", { tags: ["alpha"] });
50
+ seed(store, "C", { tags: ["æon"] });
51
+ const dir = store.getDirectory();
52
+ const tags = dir.groups.map((g) => g.tag);
53
+ const sorted = [...tags].sort((x, y) => x.localeCompare(y));
54
+ assert.deepEqual(tags, sorted, "groups must come back tag-sorted");
55
+ store.close();
56
+ });
57
+
58
+ test("getDirectory orders group members by importance DESC then updated_at DESC", () => {
59
+ const { store } = setup();
60
+ // same importance → updated_at decides
61
+ const older = seed(store, "older", { tags: ["linux"], importance: 2 });
62
+ const newer = seed(store, "newer", { tags: ["linux"], importance: 2 });
63
+ setUpdatedAt(store, older.id, "2026-01-01T00:00:00.000Z");
64
+ setUpdatedAt(store, newer.id, "2026-08-01T00:00:00.000Z");
65
+ // higher importance wins regardless of age
66
+ const important = seed(store, "important", { tags: ["linux"], importance: 5 });
67
+ setUpdatedAt(store, important.id, "2025-01-01T00:00:00.000Z");
68
+ const dir = store.getDirectory();
69
+ const linux = dir.groups.find((g) => g.tag === "linux");
70
+ assert.deepEqual(
71
+ linux.memories.map((m) => m.id),
72
+ [important.id, newer.id, older.id],
73
+ "importance DESC primary, updated_at DESC secondary"
74
+ );
75
+ store.close();
76
+ });
77
+
78
+ test("getDirectory collects untagged memories and orders them identically", () => {
79
+ const { store } = setup();
80
+ const a = seed(store, "untagged one", { importance: 1 });
81
+ const b = seed(store, "untagged two", { importance: 4 });
82
+ seed(store, "tagged", { tags: ["linux"], importance: 3 });
83
+ const dir = store.getDirectory();
84
+ assert.deepEqual(dir.untagged.map((m) => m.id), [b.id, a.id], "importance DESC inside untagged");
85
+ assert.equal(dir.groups.some((g) => g.tag === "linux"), true);
86
+ store.close();
87
+ });
88
+
89
+ test("getDirectory excludes forgotten, archived and session-disposed memories", () => {
90
+ const { store } = setup();
91
+ const forgotten = seed(store, "forgotten", { tags: ["linux"] });
92
+ const archived = seed(store, "archived", { tags: ["linux"] });
93
+ const disposed = seed(store, "disposed", { tags: ["linux"], type: "summary", session_id: "sess-disposed" });
94
+ const keep = seed(store, "keep", { tags: ["linux"] });
95
+ store.setForget(forgotten.id, true);
96
+ store.setArchived(archived.id, true);
97
+ store.setDisposedBySession(disposed.session_id, true);
98
+ const dir = store.getDirectory();
99
+ const linux = dir.groups.find((g) => g.tag === "linux");
100
+ assert.deepEqual(linux.memories.map((m) => m.id), [keep.id], "only the live row survives");
101
+ assert.deepEqual(dir.untagged, []);
102
+ store.close();
103
+ });
104
+
105
+ test("getDirectory lists a multi-tag memory under every tag folder once each", () => {
106
+ const { store } = setup();
107
+ const multi = seed(store, "multi", { tags: ["linux", "bash"] });
108
+ const single = seed(store, "single", { tags: ["linux"] });
109
+ const dir = store.getDirectory();
110
+ const linux = dir.groups.find((g) => g.tag === "linux");
111
+ const bash = dir.groups.find((g) => g.tag === "bash");
112
+ // `single` is written second → newer updated_at → ranks before `multi`.
113
+ assert.deepEqual(linux.memories.map((m) => m.id), [single.id, multi.id]);
114
+ assert.deepEqual(bash.memories.map((m) => m.id), [multi.id]);
115
+ assert.equal(dir.untagged.length, 0);
116
+ store.close();
117
+ });
118
+
119
+ // ============================================================ service
120
+
121
+ test("service.getDirectory returns the wire DTO shape with tags preserved", () => {
122
+ const { store, service } = setup();
123
+ const mem = seed(store, "笔记", { tags: ["linux"] });
124
+ seed(store, "裸条目", { importance: 2 });
125
+ const dir = service.getDirectory();
126
+ assert.deepEqual(Object.keys(dir).sort(), ["groups", "untagged"]);
127
+ const linux = dir.groups.find((g) => g.tag === "linux");
128
+ assert.equal(linux.memories[0].id, mem.id);
129
+ assert.equal(linux.memories[0].title, "笔记");
130
+ assert.equal(typeof linux.memories[0].updated_at, "string", "DTO carries updated_at for the entry row");
131
+ assert.equal(dir.untagged.length, 1);
132
+ assert.equal(dir.untagged[0].title, "裸条目");
133
+ store.close();
134
+ });