@modusensus/dsh-mneme 0.6.0 → 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 (
@@ -552,6 +553,17 @@ export function createStore(path) {
552
553
  db.exec("PRAGMA journal_mode = WAL;");
553
554
  db.exec(SCHEMA);
554
555
 
556
+ // Wiki-link dedup (v0.6.1): a (from_entity, to_entity) pair is unique only for
557
+ // relation_type='links_to'. This is a PARTIAL index scoped to links_to, so the
558
+ // append-only semantics of all other relation types (uses/depends_on/part_of/
559
+ // related_to/supersedes — the extractor and autoDream write these per-run
560
+ // without global dedup, and supersedes rows carry distinct metadata like
561
+ // attr_key/old_value) are preserved. Idempotent (IF NOT EXISTS), atomic, and
562
+ // race-safe. Legacy DBs have no links_to rows yet, so the index builds cleanly
563
+ // everywhere and never breaks plugin startup (a full-table UNIQUE index would
564
+ // fail on legacy duplicates).
565
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_relations_wikilink ON entity_relations(from_entity, to_entity, relation_type) WHERE relation_type = 'links_to'");
566
+
555
567
  // Schema migrations for legacy databases (idempotent). Each ADD COLUMN is
556
568
  // also race-safe: two concurrently-opening processes can both pass the
557
569
  // PRAGMA table_info check before either ALTERs, so the ALTER itself is
@@ -652,6 +664,19 @@ export function createStore(path) {
652
664
  return toRow(row);
653
665
  }
654
666
 
667
+ /**
668
+ * Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
669
+ * folds ASCII case (CJK titles are inherently case-free, so they match
670
+ * verbatim). Returns the first matching memory or undefined. Best-effort —
671
+ * used by wiki-link target resolution and the read APIs.
672
+ */
673
+ function findByTitle(title) {
674
+ if (typeof title !== "string" || !title.trim()) return undefined;
675
+ return toRow(db.prepare(
676
+ "SELECT * FROM memories WHERE title = ? COLLATE NOCASE LIMIT 1"
677
+ ).get(title.trim()));
678
+ }
679
+
655
680
  function save(memory) {
656
681
  const id = memory.id ?? randomUUID();
657
682
  const type = memory.type;
@@ -1660,9 +1685,168 @@ export function createStore(path) {
1660
1685
  return memories;
1661
1686
  }
1662
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
+
1663
1844
  /**
1664
1845
  * Record a typed relation between two entities. metadata (optional) is a
1665
- * free-form JSON blob describing the relation. Relations are append-only.
1846
+ * free-form JSON blob describing the relation. Relations are append-only
1847
+ * callers that need idempotency (e.g. wiki-links, via saveWikiLinks) guard
1848
+ * with their own existence check plus the partial links_to unique index
1849
+ * (idx_relations_wikilink) as a race backstop.
1666
1850
  */
1667
1851
  function saveRelation({ from_entity, to_entity, relation_type, memory_id, metadata }) {
1668
1852
  const id = randomUUID();
@@ -1676,7 +1860,56 @@ export function createStore(path) {
1676
1860
  `INSERT INTO entity_relations (id, from_entity, to_entity, relation_type, memory_id, created_at, metadata)
1677
1861
  VALUES (?, ?, ?, ?, ?, ?, ?)`
1678
1862
  ).run(id, from_entity, to_entity, relation_type, memory_id ?? null, now, metaStr);
1679
- return toRelation(db.prepare("SELECT * FROM entity_relations WHERE id = ?").get(id));
1863
+ return toRelation(db.prepare(
1864
+ "SELECT * FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ? LIMIT 1"
1865
+ ).get(from_entity, to_entity, relation_type));
1866
+ }
1867
+
1868
+ /**
1869
+ * Record wiki-link relations (v0.6.1). For each target title, resolve the
1870
+ * target memory (case-insensitive title match via findByTitle) and write a
1871
+ * links_to relation:
1872
+ * from_entity = source memory title, to_entity = canonical target memory
1873
+ * title, relation_type = 'links_to', memory_id = source memory id.
1874
+ * Using the canonical resolved title keeps the graph case-consistent
1875
+ * ([[beta]] and [[Beta]] collapse onto the same to_entity), so backlink
1876
+ * lookups never fight the way a target was typed.
1877
+ * Fail-safe: a target with no matching memory is skipped (never an error).
1878
+ * Idempotent: an already-existing triple is a silent no-op (existence check
1879
+ * here + the idx_relations_wikilink partial unique index as a race backstop),
1880
+ * so `saved` only counts newly written relations. Returns { saved, skipped }.
1881
+ */
1882
+ function saveWikiLinks({ memoryId, title, targets }) {
1883
+ const saved = [];
1884
+ const skipped = [];
1885
+ const seen = new Set(); // canonical (lowercased) targets already handled
1886
+ const existsStmt = db.prepare(
1887
+ "SELECT id FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ?"
1888
+ );
1889
+ const list = Array.isArray(targets)
1890
+ ? targets.filter((t) => typeof t === "string" && t.trim())
1891
+ : [];
1892
+ for (const raw of list) {
1893
+ const target = raw.trim();
1894
+ const key = target.toLowerCase();
1895
+ if (seen.has(key)) continue; // dedupe within a single call (case-insensitive)
1896
+ seen.add(key);
1897
+ const targetMem = findByTitle(target);
1898
+ if (!targetMem) {
1899
+ skipped.push(target); // 目标不存在 → 跳过(Fail-safe)
1900
+ continue;
1901
+ }
1902
+ const toEntity = targetMem.title;
1903
+ if (existsStmt.get(title, toEntity, "links_to")) continue; // already linked → no-op
1904
+ saved.push(saveRelation({
1905
+ from_entity: title,
1906
+ to_entity: toEntity,
1907
+ relation_type: "links_to",
1908
+ memory_id: memoryId,
1909
+ metadata: { target_memory_id: targetMem.id }
1910
+ }));
1911
+ }
1912
+ return { saved, skipped };
1680
1913
  }
1681
1914
 
1682
1915
  /**
@@ -1979,7 +2212,14 @@ export function createStore(path) {
1979
2212
  getAttrHistory,
1980
2213
  getAttrsByMemory,
1981
2214
  findMemoriesByAttr,
2215
+ setMemoryTags,
2216
+ getMemoryTags,
2217
+ getMemoryTagsMap,
2218
+ findMemoriesByTags,
2219
+ getDirectory,
1982
2220
  saveRelation,
2221
+ saveWikiLinks,
2222
+ findByTitle,
1983
2223
  migrateAttrsToMemory,
1984
2224
  getRelations,
1985
2225
  setMirrorState,
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
+ });
@@ -232,3 +232,137 @@ test("explorer chrome aligns with the host design system", () => {
232
232
  "pill chips belong to the drawer era and must stay gone"
233
233
  );
234
234
  });
235
+
236
+ // The v0.6.1 wiki-link feature: the detail pane mounts a BacklinksPanel that
237
+ // fetches the two read-only link endpoints by the selected memory id and
238
+ // renders back/forward rows, each jumping back into the browser.
239
+ test("detail pane mounts a BacklinksPanel backed by the two link endpoints", () => {
240
+ assert.ok(
241
+ clientSource.includes("h(BacklinksPanel, { memory: selected, t, onJump: jumpToMemory })"),
242
+ "the detail pane must mount BacklinksPanel after the actions row"
243
+ );
244
+ assert.ok(
245
+ clientSource.includes("/api/dsh-mneme/wikilinks/backlinks?id="),
246
+ "BacklinksPanel must fetch the backlinks endpoint by memory id"
247
+ );
248
+ assert.ok(
249
+ clientSource.includes("/api/dsh-mneme/wikilinks/forward?id="),
250
+ "BacklinksPanel must fetch the forward-links endpoint by memory id"
251
+ );
252
+ });
253
+
254
+ // Detail content turns [[target]] / [[display|target]] into clickable links:
255
+ // the display text survives, the target title is kept for hover, and a click
256
+ // resolves the title through the resolve endpoint before jumping.
257
+ test("detail content renders wiki-links and resolves them on click", () => {
258
+ assert.ok(
259
+ clientSource.includes('className: "mneme-wikilink"'),
260
+ "inline [[target]] links must use the .mneme-wikilink style"
261
+ );
262
+ assert.ok(
263
+ clientSource.includes("/api/dsh-mneme/wikilinks/resolve?title="),
264
+ "clicking a wiki-link must resolve the title via the resolve endpoint"
265
+ );
266
+ assert.ok(
267
+ clientSource.includes('className: "mneme-backlinks"'),
268
+ "the panel must render inside a .mneme-backlinks block"
269
+ );
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
+ });