@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/README.md +25 -2
- package/lib/api.js +178 -1
- package/lib/client.js +363 -9
- package/lib/config.js +27 -0
- package/lib/dream/tag-extractor.js +149 -0
- package/lib/dream.js +20 -0
- package/lib/mirror.js +9 -0
- package/lib/parser/tag.js +59 -0
- package/lib/parser/wiki-link.js +38 -0
- package/lib/search/tag-boost.js +61 -0
- package/lib/service.js +211 -4
- package/lib/store.js +242 -2
- package/package.json +1 -1
- package/src/api.js +178 -1
- package/src/config.js +27 -0
- package/src/dream/tag-extractor.js +149 -0
- package/src/dream.js +20 -0
- package/src/mirror.js +9 -0
- package/src/parser/tag.js +59 -0
- package/src/parser/wiki-link.js +38 -0
- package/src/search/tag-boost.js +61 -0
- package/src/service.js +211 -4
- package/src/store.js +242 -2
- package/test/api.test.js +80 -0
- package/test/boundary-v0625.test.js +82 -0
- package/test/client.test.js +134 -0
- package/test/directory.test.js +134 -0
- package/test/tag-boost.test.js +125 -0
- package/test/tag.test.js +294 -0
- package/test/wiki-link.test.js +332 -0
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(
|
|
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
|
+
});
|
package/test/client.test.js
CHANGED
|
@@ -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
|
+
});
|