@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/README.md +8 -2
- package/lib/api.js +101 -0
- package/lib/client.js +129 -4
- package/lib/config.js +9 -0
- package/lib/parser/wiki-link.js +38 -0
- package/lib/service.js +79 -0
- package/lib/store.js +80 -2
- package/package.json +1 -1
- package/src/api.js +101 -0
- package/src/config.js +9 -0
- package/src/parser/wiki-link.js +38 -0
- package/src/service.js +79 -0
- package/src/store.js +80 -2
- package/test/client.test.js +36 -0
- package/test/wiki-link.test.js +332 -0
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(
|
|
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,
|
package/test/client.test.js
CHANGED
|
@@ -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
|
+
});
|