@modusensus/dsh-mneme 0.6.0 → 0.6.1

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/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
package/src/config.js CHANGED
@@ -166,6 +166,15 @@ 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
+
169
178
  // --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
170
179
  // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
171
180
  // sleep fires when the store has been quiet for sleepIdleMinutes and deep-
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Wiki-Link parser (v0.6.1). Given a memory's content, extract explicit
3
+ * cross-memory links of the form [[target]] or [[显示|target]]:
4
+ * [[target]] → { display: "target", target: "target" }
5
+ * [[显示|target]] → { display: "显示", target: "target" }
6
+ *
7
+ * Unclosed / empty-target / multi-pipe / bracket-nested markers are treated as
8
+ * illegal and ignored. Pure module: no store, no side effects — resolution is a
9
+ * separate step (resolveWikiLink) that needs a store handle.
10
+ */
11
+
12
+ /** @returns {{display: string, target: string}[]} in source order. */
13
+ export function parseWikiLinks(content) {
14
+ if (typeof content !== "string" || content.length === 0) return [];
15
+ const links = [];
16
+ // [^\[\]]* keeps a single match from crossing `]]`; an unclosed `[[` never
17
+ // matches, and `[[a [[b]] c]]` only yields the inner `[[b]]`.
18
+ const re = /\[\[([^\[\]]*)\]\]/g;
19
+ let m;
20
+ while ((m = re.exec(content)) !== null) {
21
+ const inner = m[1];
22
+ const parts = inner.split("|");
23
+ if (parts.length > 2) continue; // 多管道 → 非法,忽略
24
+ const rawTarget = (parts.length === 2 ? parts[1] : parts[0]).trim();
25
+ if (!rawTarget) continue; // 空目标 → 非法,忽略
26
+ const rawDisplay = parts[0].trim();
27
+ links.push({ display: rawDisplay || rawTarget, target: rawTarget });
28
+ }
29
+ return links;
30
+ }
31
+
32
+ /** Resolve a wiki-link target title to a memory row via a case-insensitive
33
+ * exact title match (store.findByTitle). Returns undefined when absent or the
34
+ * store exposes no such lookup. */
35
+ export function resolveWikiLink(store, title) {
36
+ if (!store || typeof title !== "string" || !title.trim()) return undefined;
37
+ return store.findByTitle?.(title.trim());
38
+ }
package/src/service.js CHANGED
@@ -3,6 +3,7 @@ import { TYPE_FILE } from "./mirror.js";
3
3
  import { evaluateMemoryQuality } from "./quality-filter.js";
4
4
  import { createBM25Index } from "./search/bm25.js";
5
5
  import { adaptiveThreshold } from "./search/adaptive.js";
6
+ import { parseWikiLinks } from "./parser/wiki-link.js";
6
7
 
7
8
  const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
8
9
 
@@ -228,6 +229,36 @@ export function createService({ store, mirror, config, onWrite, logger }) {
228
229
  }
229
230
  }
230
231
 
232
+ /**
233
+ * Fire-and-forget wiki-link resolution for a freshly saved/updated memory
234
+ * (v0.6.1). Opt-in via config.wikiLinkEnabled. Parses [[target]] /
235
+ * [[显示|target]] markers out of the memory content and writes links_to
236
+ * relations (idempotent via the unique relation index). Runs through
237
+ * service.enqueue so it serializes with autoDream/sleep and never overlaps
238
+ * another background pass. Fully fail-safe: parse/store errors are swallowed
239
+ * and logged, never a write failure.
240
+ */
241
+ function scheduleWikiLinkResolve(memory) {
242
+ if (txDepth > 0) return; // deferred to the transaction's commit
243
+ if (!config.wikiLinkEnabled || !memory?.id) return;
244
+ try {
245
+ const links = parseWikiLinks(memory?.content ?? "");
246
+ if (!links.length) return;
247
+ const targets = [...new Set(links.map((l) => l.target).filter(Boolean))];
248
+ enqueue(() => {
249
+ try {
250
+ store.saveWikiLinks({ memoryId: memory.id, title: memory.title, targets });
251
+ } catch (err) {
252
+ logger?.warn?.("wiki link resolve failed:", err);
253
+ }
254
+ }).catch((err) => {
255
+ logger?.warn?.("wiki link resolve failed:", err);
256
+ });
257
+ } catch (err) {
258
+ logger?.warn?.("wiki link resolve failed:", err);
259
+ }
260
+ }
261
+
231
262
  /**
232
263
  * Cross-encoder rerank over a candidate list (best effort). Reranker
233
264
  * failures degrade to the original candidate order — reranking is an
@@ -802,6 +833,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
802
833
  afterSync("write");
803
834
  notifyWrite();
804
835
  scheduleEmbed(result);
836
+ scheduleWikiLinkResolve(result);
805
837
  return { action: "merged", memory: result };
806
838
  }
807
839
  const created = store.save({
@@ -821,6 +853,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
821
853
  notifyWrite();
822
854
  scheduleEmbed(result);
823
855
  scheduleEntityExtraction(result);
856
+ scheduleWikiLinkResolve(result);
824
857
  return { action: "created", memory: result };
825
858
  }
826
859
 
@@ -1286,8 +1319,52 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1286
1319
  }
1287
1320
  }
1288
1321
 
1322
+ /**
1323
+ * Forward links (v0.6.1): memories the given memory explicitly links to via
1324
+ * [[wiki-links]] in its content. Reads the links_to relations whose
1325
+ * from_entity is this memory's title and resolves each to_entity back to a
1326
+ * memory row (case-insensitive title match). Returns [{ target, relation }];
1327
+ * a target title with no matching memory surfaces as { target: null }.
1328
+ */
1329
+ function getForwardLinks(memoryId) {
1330
+ const memory = store.getById(memoryId);
1331
+ if (!memory) return [];
1332
+ const out = [];
1333
+ for (const rel of store.getRelations(memory.title) ?? []) {
1334
+ if (rel.relation_type !== "links_to" || rel.from_entity !== memory.title) continue;
1335
+ out.push({ target: store.findByTitle?.(rel.to_entity) ?? null, relation: rel });
1336
+ }
1337
+ return out;
1338
+ }
1339
+
1340
+ /**
1341
+ * Back links (v0.6.1): memories that explicitly link TO the given memory
1342
+ * (their content carries a wiki-link whose target resolves to this memory's
1343
+ * title). Reads the links_to relations whose to_entity is this memory's
1344
+ * title; the linking memory is rel.memory_id (the source that wrote the
1345
+ * relation). Deduped per source memory; missing/self links are dropped.
1346
+ * Returns [{ source, relation }].
1347
+ */
1348
+ function getBacklinks(memoryId) {
1349
+ const memory = store.getById(memoryId);
1350
+ if (!memory) return [];
1351
+ const out = [];
1352
+ const seen = new Set();
1353
+ for (const rel of store.getRelations(memory.title) ?? []) {
1354
+ if (rel.relation_type !== "links_to" || rel.to_entity !== memory.title) continue;
1355
+ const source = rel.memory_id ? store.getById(rel.memory_id) : undefined;
1356
+ if (!source || source.id === memory.id || seen.has(source.id)) continue;
1357
+ seen.add(source.id);
1358
+ out.push({ source, relation: rel });
1359
+ }
1360
+ return out;
1361
+ }
1362
+
1289
1363
  return {
1290
1364
  saveWithDedupe,
1365
+ getBacklinks,
1366
+ getForwardLinks,
1367
+ resolveWikiLink: (title) => store.findByTitle?.(title),
1291
1368
  recoverMirror,
1292
1369
  getMirrorHealth,
1293
1370
  getMirrorState: () => store.getMirrorState(),
@@ -1393,6 +1470,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1393
1470
  const sync = afterSync("write");
1394
1471
  notifyWrite();
1395
1472
  scheduleEmbed(updated);
1473
+ scheduleWikiLinkResolve(updated);
1396
1474
  // Audit peer B: when the mirror sync failed, the store write landed but
1397
1475
  // the mirror did not converge — return an explicit degraded receipt rather
1398
1476
  // than a plain success. Non-enumerable so existing deepEqual assertions on
@@ -1504,6 +1582,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1504
1582
  // migrates entity_attrs on merge. Bookkeeping writes like the audit
1505
1583
  // passthroughs above — never write-hook-triggering memory mutations.
1506
1584
  saveRelation: (r) => store.saveRelation(r),
1585
+ saveWikiLinks: (r) => store.saveWikiLinks(r),
1507
1586
  listEntities: (o) => store.listEntities(o),
1508
1587
  getRelations: (id) => store.getRelations(id),
1509
1588
  saveAttr: (r) => store.saveAttr(r),
package/src/store.js CHANGED
@@ -552,6 +552,17 @@ export function createStore(path) {
552
552
  db.exec("PRAGMA journal_mode = WAL;");
553
553
  db.exec(SCHEMA);
554
554
 
555
+ // Wiki-link dedup (v0.6.1): a (from_entity, to_entity) pair is unique only for
556
+ // relation_type='links_to'. This is a PARTIAL index scoped to links_to, so the
557
+ // append-only semantics of all other relation types (uses/depends_on/part_of/
558
+ // related_to/supersedes — the extractor and autoDream write these per-run
559
+ // without global dedup, and supersedes rows carry distinct metadata like
560
+ // attr_key/old_value) are preserved. Idempotent (IF NOT EXISTS), atomic, and
561
+ // race-safe. Legacy DBs have no links_to rows yet, so the index builds cleanly
562
+ // everywhere and never breaks plugin startup (a full-table UNIQUE index would
563
+ // fail on legacy duplicates).
564
+ 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'");
565
+
555
566
  // Schema migrations for legacy databases (idempotent). Each ADD COLUMN is
556
567
  // also race-safe: two concurrently-opening processes can both pass the
557
568
  // PRAGMA table_info check before either ALTERs, so the ALTER itself is
@@ -652,6 +663,19 @@ export function createStore(path) {
652
663
  return toRow(row);
653
664
  }
654
665
 
666
+ /**
667
+ * Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
668
+ * folds ASCII case (CJK titles are inherently case-free, so they match
669
+ * verbatim). Returns the first matching memory or undefined. Best-effort —
670
+ * used by wiki-link target resolution and the read APIs.
671
+ */
672
+ function findByTitle(title) {
673
+ if (typeof title !== "string" || !title.trim()) return undefined;
674
+ return toRow(db.prepare(
675
+ "SELECT * FROM memories WHERE title = ? COLLATE NOCASE LIMIT 1"
676
+ ).get(title.trim()));
677
+ }
678
+
655
679
  function save(memory) {
656
680
  const id = memory.id ?? randomUUID();
657
681
  const type = memory.type;
@@ -1662,7 +1686,10 @@ export function createStore(path) {
1662
1686
 
1663
1687
  /**
1664
1688
  * Record a typed relation between two entities. metadata (optional) is a
1665
- * free-form JSON blob describing the relation. Relations are append-only.
1689
+ * free-form JSON blob describing the relation. Relations are append-only
1690
+ * callers that need idempotency (e.g. wiki-links, via saveWikiLinks) guard
1691
+ * with their own existence check plus the partial links_to unique index
1692
+ * (idx_relations_wikilink) as a race backstop.
1666
1693
  */
1667
1694
  function saveRelation({ from_entity, to_entity, relation_type, memory_id, metadata }) {
1668
1695
  const id = randomUUID();
@@ -1676,7 +1703,56 @@ export function createStore(path) {
1676
1703
  `INSERT INTO entity_relations (id, from_entity, to_entity, relation_type, memory_id, created_at, metadata)
1677
1704
  VALUES (?, ?, ?, ?, ?, ?, ?)`
1678
1705
  ).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));
1706
+ return toRelation(db.prepare(
1707
+ "SELECT * FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ? LIMIT 1"
1708
+ ).get(from_entity, to_entity, relation_type));
1709
+ }
1710
+
1711
+ /**
1712
+ * Record wiki-link relations (v0.6.1). For each target title, resolve the
1713
+ * target memory (case-insensitive title match via findByTitle) and write a
1714
+ * links_to relation:
1715
+ * from_entity = source memory title, to_entity = canonical target memory
1716
+ * title, relation_type = 'links_to', memory_id = source memory id.
1717
+ * Using the canonical resolved title keeps the graph case-consistent
1718
+ * ([[beta]] and [[Beta]] collapse onto the same to_entity), so backlink
1719
+ * lookups never fight the way a target was typed.
1720
+ * Fail-safe: a target with no matching memory is skipped (never an error).
1721
+ * Idempotent: an already-existing triple is a silent no-op (existence check
1722
+ * here + the idx_relations_wikilink partial unique index as a race backstop),
1723
+ * so `saved` only counts newly written relations. Returns { saved, skipped }.
1724
+ */
1725
+ function saveWikiLinks({ memoryId, title, targets }) {
1726
+ const saved = [];
1727
+ const skipped = [];
1728
+ const seen = new Set(); // canonical (lowercased) targets already handled
1729
+ const existsStmt = db.prepare(
1730
+ "SELECT id FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ?"
1731
+ );
1732
+ const list = Array.isArray(targets)
1733
+ ? targets.filter((t) => typeof t === "string" && t.trim())
1734
+ : [];
1735
+ for (const raw of list) {
1736
+ const target = raw.trim();
1737
+ const key = target.toLowerCase();
1738
+ if (seen.has(key)) continue; // dedupe within a single call (case-insensitive)
1739
+ seen.add(key);
1740
+ const targetMem = findByTitle(target);
1741
+ if (!targetMem) {
1742
+ skipped.push(target); // 目标不存在 → 跳过(Fail-safe)
1743
+ continue;
1744
+ }
1745
+ const toEntity = targetMem.title;
1746
+ if (existsStmt.get(title, toEntity, "links_to")) continue; // already linked → no-op
1747
+ saved.push(saveRelation({
1748
+ from_entity: title,
1749
+ to_entity: toEntity,
1750
+ relation_type: "links_to",
1751
+ memory_id: memoryId,
1752
+ metadata: { target_memory_id: targetMem.id }
1753
+ }));
1754
+ }
1755
+ return { saved, skipped };
1680
1756
  }
1681
1757
 
1682
1758
  /**
@@ -1980,6 +2056,8 @@ export function createStore(path) {
1980
2056
  getAttrsByMemory,
1981
2057
  findMemoriesByAttr,
1982
2058
  saveRelation,
2059
+ saveWikiLinks,
2060
+ findByTitle,
1983
2061
  migrateAttrsToMemory,
1984
2062
  getRelations,
1985
2063
  setMirrorState,
@@ -232,3 +232,39 @@ 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
+ });