@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/lib/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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.5",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/api.js
CHANGED
|
@@ -422,6 +422,107 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
422
422
|
}
|
|
423
423
|
});
|
|
424
424
|
|
|
425
|
+
// --- wiki-link back links (v0.6.1) --------------------------------------
|
|
426
|
+
// Read-only like the graph endpoints, so it stays open when apiToken is set.
|
|
427
|
+
// GET /api/dsh-mneme/wikilinks/backlinks?id=<memoryId> → memories whose
|
|
428
|
+
// content carries a [[wiki-link]] resolving to the given memory.
|
|
429
|
+
register({
|
|
430
|
+
kind: "exact",
|
|
431
|
+
path: "/api/dsh-mneme/wikilinks/backlinks",
|
|
432
|
+
handler(req, res) {
|
|
433
|
+
try {
|
|
434
|
+
const url = new URL(req.url, "http://localhost");
|
|
435
|
+
const id = (url.searchParams.get("id") ?? "").trim();
|
|
436
|
+
if (!id) {
|
|
437
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const memory = service.getById?.(id) ?? null;
|
|
441
|
+
const backlinks = (service.getBacklinks?.(id) ?? []).map(({ source, relation }) => ({
|
|
442
|
+
id: source.id,
|
|
443
|
+
title: source.title,
|
|
444
|
+
type: source.type,
|
|
445
|
+
created_at: relation.created_at
|
|
446
|
+
}));
|
|
447
|
+
sendJson(res, 200, {
|
|
448
|
+
memoryId: id,
|
|
449
|
+
memory: memory ? { id: memory.id, title: memory.title, type: memory.type } : null,
|
|
450
|
+
backlinks
|
|
451
|
+
});
|
|
452
|
+
} catch {
|
|
453
|
+
sendJson(res, 500, { error: "internal" });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// --- wiki-link forward links (v0.6.1) -----------------------------------
|
|
459
|
+
// GET /api/dsh-mneme/wikilinks/forward?id=<memoryId> → memories the given
|
|
460
|
+
// memory explicitly links to. Unresolved target titles surface with id:null.
|
|
461
|
+
register({
|
|
462
|
+
kind: "exact",
|
|
463
|
+
path: "/api/dsh-mneme/wikilinks/forward",
|
|
464
|
+
handler(req, res) {
|
|
465
|
+
try {
|
|
466
|
+
const url = new URL(req.url, "http://localhost");
|
|
467
|
+
const id = (url.searchParams.get("id") ?? "").trim();
|
|
468
|
+
if (!id) {
|
|
469
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const memory = service.getById?.(id) ?? null;
|
|
473
|
+
const links = (service.getForwardLinks?.(id) ?? []).map(({ target, relation }) => ({
|
|
474
|
+
id: target?.id ?? null,
|
|
475
|
+
title: target?.title ?? relation.to_entity,
|
|
476
|
+
type: target?.type ?? null,
|
|
477
|
+
created_at: relation.created_at
|
|
478
|
+
}));
|
|
479
|
+
sendJson(res, 200, {
|
|
480
|
+
memoryId: id,
|
|
481
|
+
memory: memory ? { id: memory.id, title: memory.title, type: memory.type } : null,
|
|
482
|
+
links
|
|
483
|
+
});
|
|
484
|
+
} catch {
|
|
485
|
+
sendJson(res, 500, { error: "internal" });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// --- wiki-link resolve (v0.6.1) -----------------------------------------
|
|
491
|
+
// GET /api/dsh-mneme/wikilinks/resolve?title=<title> → case-insensitive exact
|
|
492
|
+
// title match against the memories table (the resolution used when writing
|
|
493
|
+
// links_to relations). 404 when no memory matches.
|
|
494
|
+
register({
|
|
495
|
+
kind: "exact",
|
|
496
|
+
path: "/api/dsh-mneme/wikilinks/resolve",
|
|
497
|
+
handler(req, res) {
|
|
498
|
+
try {
|
|
499
|
+
const url = new URL(req.url, "http://localhost");
|
|
500
|
+
const title = (url.searchParams.get("title") ?? "").trim();
|
|
501
|
+
if (!title) {
|
|
502
|
+
sendJson(res, 400, { error: "missing-title" });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const memory = service.resolveWikiLink?.(title) ?? null;
|
|
506
|
+
if (!memory) {
|
|
507
|
+
sendJson(res, 404, { error: "memory-not-found", title });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// Note: no `source` field — it may carry file paths/internal host info
|
|
511
|
+
// and this endpoint is read-only without auth when apiToken is set.
|
|
512
|
+
sendJson(res, 200, {
|
|
513
|
+
title,
|
|
514
|
+
memory: {
|
|
515
|
+
id: memory.id,
|
|
516
|
+
title: memory.title,
|
|
517
|
+
type: memory.type
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
} catch {
|
|
521
|
+
sendJson(res, 500, { error: "internal" });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
425
526
|
// --- health: mirror sync state (F-NEW-03 / v0.3.6) ---
|
|
426
527
|
// Auth-gated; only returns a sanitized error code (never raw last_error which
|
|
427
528
|
// may leak paths/token-like strings/internal hosts). On state read failure it
|
|
@@ -507,8 +608,84 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
507
608
|
}
|
|
508
609
|
});
|
|
509
610
|
|
|
611
|
+
// --- memory tags (v0.6.2) ------------------------------------------------
|
|
612
|
+
// GET /api/dsh-mneme/memory/tags?id=<memoryId> → live entity_attrs-backed
|
|
613
|
+
// tag set for one memory plus the manualTagEnabled gate (so the panel
|
|
614
|
+
// can hide tag editing when the manual path is off). Read-only, stays
|
|
615
|
+
// open when apiToken is set (like list/search/semantic).
|
|
616
|
+
// POST /api/dsh-mneme/memory/tags { id, tags } → overwrite the live tag set
|
|
617
|
+
// via service.setMemoryTags (manualTagEnabled gate); 409 when the gate
|
|
618
|
+
// is closed. Auth-gated like the other write endpoints.
|
|
619
|
+
register({
|
|
620
|
+
kind: "exact",
|
|
621
|
+
path: "/api/dsh-mneme/memory/tags",
|
|
622
|
+
handler(req, res) {
|
|
623
|
+
try {
|
|
624
|
+
if (req.method === "POST" || req.method === "PUT") {
|
|
625
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
626
|
+
return readBody(req).then((text) => {
|
|
627
|
+
const body = parseBody(text);
|
|
628
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
629
|
+
if (!id) {
|
|
630
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const memory = service.getById?.(id) ?? null;
|
|
634
|
+
if (!memory) {
|
|
635
|
+
sendJson(res, 404, { error: "memory-not-found" });
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const result = service.setMemoryTags(id, Array.isArray(body.tags) ? body.tags : []);
|
|
639
|
+
if (result?.ok === false) {
|
|
640
|
+
sendJson(res, 409, { error: result.error || "tags-disabled" });
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
sendJson(res, 200, { ok: true, memoryId: id, tags: result?.tags ?? [] });
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
const url = new URL(req.url, "http://localhost");
|
|
647
|
+
const id = (url.searchParams.get("id") ?? "").trim();
|
|
648
|
+
if (!id) {
|
|
649
|
+
sendJson(res, 400, { error: "missing-id" });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const memory = service.getById?.(id) ?? null;
|
|
653
|
+
if (!memory) {
|
|
654
|
+
sendJson(res, 404, { error: "memory-not-found" });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const tags = service.getMemoryTags?.(id) ?? [];
|
|
658
|
+
sendJson(res, 200, {
|
|
659
|
+
memoryId: id,
|
|
660
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
661
|
+
manualTagEnabled: service.manualTagEnabled?.() ?? true
|
|
662
|
+
});
|
|
663
|
+
} catch {
|
|
664
|
+
sendJson(res, 500, { error: "internal" });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// --- directory view (v0.6.3) ---------------------------------------------
|
|
670
|
+
// GET /api/dsh-mneme/directory → memories grouped by tag as
|
|
671
|
+
// { groups: [{ tag, memories: [...] }], untagged: [...] }. Live-only
|
|
672
|
+
// (forgotten/archived/session-disposed excluded), groups tag-sorted, members
|
|
673
|
+
// importance+updated DESC. Read-only, stays open when apiToken is set.
|
|
674
|
+
register({
|
|
675
|
+
kind: "exact",
|
|
676
|
+
path: "/api/dsh-mneme/directory",
|
|
677
|
+
handler(req, res) {
|
|
678
|
+
try {
|
|
679
|
+
const dir = service.getDirectory?.() ?? { groups: [], untagged: [] };
|
|
680
|
+
sendJson(res, 200, dir);
|
|
681
|
+
} catch {
|
|
682
|
+
sendJson(res, 500, { error: "internal" });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
|
|
510
687
|
return {
|
|
511
|
-
routes:
|
|
688
|
+
routes: 19,
|
|
512
689
|
dispose: () => {
|
|
513
690
|
for (const dispose of disposers) dispose();
|
|
514
691
|
}
|
package/src/config.js
CHANGED
|
@@ -166,6 +166,33 @@ export const Config = z.object({
|
|
|
166
166
|
// Prefix/semantic search over entity names (used by recall).
|
|
167
167
|
entitySearchEnabled: z.boolean().default(true),
|
|
168
168
|
|
|
169
|
+
// --- wiki-link: explicit cross-memory [[links]] (v0.6.1) ----------------
|
|
170
|
+
// Opt-in, off by default. When enabled, saveWithDedupe/update fire-and-forget
|
|
171
|
+
// a wiki-link resolution pass: [[target]] / [[显示|target]] markers in a
|
|
172
|
+
// memory's content become links_to relations in entity_relations (idempotent,
|
|
173
|
+
// deduped by the unique relation index). The storage layer + read APIs
|
|
174
|
+
// (getBacklinks/getForwardLinks/resolveWikiLink) are always available
|
|
175
|
+
// regardless of this flag.
|
|
176
|
+
wikiLinkEnabled: z.boolean().default(false),
|
|
177
|
+
|
|
178
|
+
// --- tag system (v0.6.2) ---------------------------------------------------
|
|
179
|
+
// Opt-in: when autoTagEnabled is true, a light LLM pass runs after each
|
|
180
|
+
// autoDream consolidation and extracts 1-3 tags per retained memory
|
|
181
|
+
// (autoTagMaxPerRun bounds how many memories are tagged per run). The tag
|
|
182
|
+
// storage layer (store.setMemoryTags/getMemoryTags + tag: search + mirror
|
|
183
|
+
// `#tag` line) is always available regardless of this flag.
|
|
184
|
+
autoTagEnabled: z.boolean().default(false),
|
|
185
|
+
autoTagMaxPerRun: z.natural().min(1).max(100).default(10),
|
|
186
|
+
// Manual tagging (service.setMemoryTags / memory tools) is on by default;
|
|
187
|
+
// set false to disable the manual write path too.
|
|
188
|
+
manualTagEnabled: z.boolean().default(true),
|
|
189
|
+
|
|
190
|
+
// --- tag-weighted re-rank (v0.6.4) -------------------------------------
|
|
191
|
+
// Opt-in: boost candidates whose tags overlap the query/session tags.
|
|
192
|
+
tagBoostEnabled: z.boolean().default(false),
|
|
193
|
+
tagBoostFactor: z.number().min(1).max(2).default(1.15),
|
|
194
|
+
sessionTagBoostFactor: z.number().min(1).max(2).default(1.08),
|
|
195
|
+
|
|
169
196
|
// --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
|
|
170
197
|
// Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
|
|
171
198
|
// sleep fires when the store has been quiet for sleepIdleMinutes and deep-
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// v0.6.2 auto-tag: a lightweight LLM pass that runs after an autoDream
|
|
2
|
+
// consolidation and extracts 1-3 tags per retained memory. Opt-in via
|
|
3
|
+
// config.autoTagEnabled (default false); autoTagMaxPerRun (default 10) bounds
|
|
4
|
+
// how many memories are tagged per run.
|
|
5
|
+
//
|
|
6
|
+
// Design notes:
|
|
7
|
+
// - ONE batched LLM call per run (the consolidation route is reused). Each
|
|
8
|
+
// memory is a prompt line; the model replies with a JSON array of
|
|
9
|
+
// {"id","tags"} entries — the per-memory contract is `{"tags":[...]}`.
|
|
10
|
+
// - Fail-safe everywhere: a missing route / aborted stream / unparseable
|
|
11
|
+
// JSON / unknown id / illegal tag are all skipped, never thrown. Tagging
|
|
12
|
+
// must never degrade the consolidation run it rides on.
|
|
13
|
+
// - The actual writes go through service.applyMemoryTags inside a
|
|
14
|
+
// service.transaction, so the mirror re-renders exactly once per pass and
|
|
15
|
+
// the write hooks fire once (never per memory).
|
|
16
|
+
import { sanitizeTags, MAX_TAG_LENGTH } from "../parser/tag.js";
|
|
17
|
+
|
|
18
|
+
const TAG_PROMPT = `你是记忆库标签助手。下面是保留的记忆条目(id、标题、内容)。
|
|
19
|
+
对每条记忆提取 1-3 个中文或英文标签,用于检索分类。
|
|
20
|
+
标签规则:
|
|
21
|
+
- 只允许字符:字母、数字、下划线、中文、连字符(如:linux、考研、deepseek-r1)
|
|
22
|
+
- 标签长度 ≤ ${MAX_TAG_LENGTH} 字符
|
|
23
|
+
- 宁缺毋滥:提取最核心的 1-3 个,不要凑数
|
|
24
|
+
- 不要输出内容里没有依据的标签
|
|
25
|
+
只输出一个 JSON 数组,每项形如 { "id": "<记忆id>", "tags": ["标签1", "标签2"] }。
|
|
26
|
+
不要输出其他文字。`;
|
|
27
|
+
|
|
28
|
+
/** Same stream consumption contract as dream.js. Returns accumulated text or
|
|
29
|
+
* undefined when the stream aborted/errored. */
|
|
30
|
+
async function streamText(ctx, options) {
|
|
31
|
+
if (!ctx?.llm?.stream) return undefined;
|
|
32
|
+
let text = "";
|
|
33
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
34
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text;
|
|
35
|
+
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pull the outermost JSON array out of a model reply (same tolerant contract
|
|
43
|
+
* as sleep.js parseJsonArray): find the first `[` … last `]` and parse. */
|
|
44
|
+
function parseJsonArray(text) {
|
|
45
|
+
if (typeof text !== "string") return undefined;
|
|
46
|
+
const start = text.indexOf("[");
|
|
47
|
+
const end = text.lastIndexOf("]");
|
|
48
|
+
if (start === -1 || end <= start) return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
51
|
+
return Array.isArray(parsed) ? parsed : undefined;
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve the LLM route for the tag pass: the caller-provided consolidation
|
|
58
|
+
* route first, then dreamProvider/dreamModel. Falls through to undefined. */
|
|
59
|
+
function resolveTagRoute(route, config, logger) {
|
|
60
|
+
if (route?.provider && route?.model) return route;
|
|
61
|
+
if (config?.dreamProvider && config?.dreamModel) return { provider: config.dreamProvider, model: config.dreamModel };
|
|
62
|
+
logger?.warn?.("dsh-mneme auto-tag: no llm route available");
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run the auto-tag pass over retained memories.
|
|
68
|
+
* @param {object} opts
|
|
69
|
+
* @param {object} opts.ctx — { llm, logger }
|
|
70
|
+
* @param {object} opts.service — service handle (all/transaction/applyMemoryTags)
|
|
71
|
+
* @param {object} opts.config — plugin config (autoTagMaxPerRun, dreamProvider…)
|
|
72
|
+
* @param {object} [opts.route] — already-resolved consolidation route
|
|
73
|
+
* @returns {Promise<{ok: boolean, tagged: number, skipped: number, failed: number, skippedBy: boolean}>}
|
|
74
|
+
*/
|
|
75
|
+
export async function runAutoTag({ ctx, service, config, route }) {
|
|
76
|
+
const logger = ctx?.logger;
|
|
77
|
+
const maxPerRun = Number.isInteger(config?.autoTagMaxPerRun) && config.autoTagMaxPerRun > 0
|
|
78
|
+
? config.autoTagMaxPerRun
|
|
79
|
+
: 10;
|
|
80
|
+
// Retained = post-consolidation active memories, newest first, capped.
|
|
81
|
+
const memories = service.all()
|
|
82
|
+
.filter((m) => !m.forgotten && !m.archived && !m.session_disposed_at && m.type !== "summary")
|
|
83
|
+
.sort((a, b) => {
|
|
84
|
+
const ta = String(a.updated_at ?? "");
|
|
85
|
+
const tb = String(b.updated_at ?? "");
|
|
86
|
+
if (ta < tb) return 1;
|
|
87
|
+
if (ta > tb) return -1;
|
|
88
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
89
|
+
})
|
|
90
|
+
.slice(0, Math.max(1, maxPerRun));
|
|
91
|
+
if (!memories.length) return { ok: true, tagged: 0, skipped: 0, failed: 0, skippedBy: "empty" };
|
|
92
|
+
const tagRoute = resolveTagRoute(route, config, logger);
|
|
93
|
+
if (!tagRoute) return { ok: false, tagged: 0, skipped: memories.length, failed: 0, skippedBy: "no-route" };
|
|
94
|
+
|
|
95
|
+
const listText = memories
|
|
96
|
+
.map((m) => `id=${m.id} | title=${m.title} | content=${m.content}`)
|
|
97
|
+
.join("\n");
|
|
98
|
+
const text = await streamText(ctx, {
|
|
99
|
+
provider: tagRoute.provider,
|
|
100
|
+
model: tagRoute.model,
|
|
101
|
+
purpose: "compaction",
|
|
102
|
+
maxTokens: Math.min(2048, config?.dreamMaxTokens ?? 2048),
|
|
103
|
+
messages: [
|
|
104
|
+
{ role: "system", content: [{ type: "text", text: TAG_PROMPT }] },
|
|
105
|
+
{ role: "user", content: [{ type: "text", text: listText }] }
|
|
106
|
+
]
|
|
107
|
+
});
|
|
108
|
+
if (text === undefined) return { ok: false, tagged: 0, skipped: memories.length, failed: 0, skippedBy: "llm-failed" };
|
|
109
|
+
|
|
110
|
+
const entries = parseJsonArray(text);
|
|
111
|
+
if (!entries) {
|
|
112
|
+
logger?.warn?.("dsh-mneme auto-tag: no json array in llm output");
|
|
113
|
+
return { ok: false, tagged: 0, skipped: memories.length, failed: 0, skippedBy: "bad-json" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Validate ids against the candidate set (unknown ids are ignored, never
|
|
117
|
+
// written — a stray id could otherwise tag an unrelated memory).
|
|
118
|
+
const candidateIds = new Set(memories.map((m) => m.id));
|
|
119
|
+
const toWrite = [];
|
|
120
|
+
let skipped = 0;
|
|
121
|
+
let failed = 0;
|
|
122
|
+
const seenIds = new Set();
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (!entry || typeof entry !== "object") { failed++; continue; }
|
|
125
|
+
const id = typeof entry.id === "string" ? entry.id : undefined;
|
|
126
|
+
if (!id || !candidateIds.has(id)) { failed++; continue; }
|
|
127
|
+
if (seenIds.has(id)) continue; // first entry per id wins
|
|
128
|
+
seenIds.add(id);
|
|
129
|
+
// Reuse the shared sanitizer: strip, ≤20 chars, drop illegal, dedupe.
|
|
130
|
+
const tags = sanitizeTags(entry.tags);
|
|
131
|
+
if (!tags.length) { skipped++; continue; }
|
|
132
|
+
toWrite.push({ id, tags });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Write the whole batch under one transaction: mirror re-renders once.
|
|
136
|
+
let tagged = 0;
|
|
137
|
+
try {
|
|
138
|
+
service.transaction(() => {
|
|
139
|
+
for (const { id, tags } of toWrite) {
|
|
140
|
+
service.applyMemoryTags(id, tags);
|
|
141
|
+
tagged++;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
logger?.warn?.(`dsh-mneme auto-tag: write failed: ${String(error)}`);
|
|
146
|
+
return { ok: false, tagged, skipped, failed, skippedBy: "write-failed" };
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, tagged, skipped, failed, skippedBy: false };
|
|
149
|
+
}
|