@modusensus/dsh-mneme 0.5.3 → 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.
@@ -108,7 +108,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
108
108
  }
109
109
  const strictness = config.sleepConflictStrictness ?? "normal";
110
110
  const threshold = CONFLICT_THRESHOLDS[strictness] ?? CONFLICT_THRESHOLDS.normal;
111
- const memories = service.all().filter((m) => !m.archived && !m.forgotten && m.type !== "summary");
111
+ const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten && m.type !== "summary");
112
112
  if (memories.length < 2) return { status: "skipped", reason: "too few memories" };
113
113
  if (signal?.aborted) return { status: "aborted", reason: "user activity" };
114
114
 
@@ -239,7 +239,7 @@ function phaseDemotion(service, config, logger, runId, signal = null) {
239
239
  const archived = [];
240
240
  for (const m of service.all()) {
241
241
  if (signal?.aborted) break;
242
- if (m.archived || m.forgotten) continue;
242
+ if (m.archived || m.forgotten || m.session_disposed_at) continue;
243
243
  const ref = m.last_accessed_at ?? m.updated_at ?? m.created_at;
244
244
  if (!ref) continue;
245
245
  const t = new Date(ref).getTime();
@@ -273,7 +273,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
273
273
  const limit = config.sleepPatternMinMemories ?? 100;
274
274
  const memories = service
275
275
  .list({ limit: 200, includeForgotten: false })
276
- .filter((m) => !m.archived && m.type !== "summary" && m.type !== "pattern")
276
+ .filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary" && m.type !== "pattern")
277
277
  .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1))
278
278
  .slice(0, limit);
279
279
  if (memories.length === 0) return { status: "skipped", reason: "no memories to scan" };
@@ -342,7 +342,7 @@ function phaseRelations(service, config, logger, runId, signal = null) {
342
342
  if (entities.length < 2) return { status: "skipped", reason: "too few entities" };
343
343
  const orphans = entities.filter((e) => (service.getRelations(e.id) ?? []).length === 0);
344
344
  if (orphans.length === 0) return { status: "skipped", reason: "no orphan entities" };
345
- const memories = service.all().filter((m) => !m.archived && !m.forgotten);
345
+ const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && !m.forgotten);
346
346
  const seen = new Set();
347
347
  const related = [];
348
348
  const MAX_RELATIONS_PER_ORPHAN = 3;
package/src/dream.js CHANGED
@@ -495,7 +495,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
495
495
  let inFlight = null;
496
496
 
497
497
  function shouldTrigger(service) {
498
- const memories = service.all().filter((m) => !m.archived && m.type !== "summary");
498
+ const memories = service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary");
499
499
  const count = memories.length;
500
500
  const chars = totalChars(memories);
501
501
  const overBase = count >= baseline.count + thresholdCount || chars >= baseline.chars + thresholdChars;
@@ -844,7 +844,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
844
844
  : {}),
845
845
  messages: [
846
846
  { role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
847
- { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
847
+ { role: "user", content: [{ type: "text", text: service.all().filter((m) => !m.archived && !m.session_disposed_at && m.type !== "summary").map((m) => `- ${m.title}: ${m.content}`).join("\n") }] }
848
848
  ]
849
849
  }, reportUsage));
850
850
  } catch (error) {
package/src/index.js CHANGED
@@ -317,6 +317,26 @@ export const apply = (ctx, config) => {
317
317
  const summarizer = createSummarizer(ctx, service, cfg);
318
318
  disposers.push(summarizer.dispose);
319
319
 
320
+ // Session lifecycle (v0.6.0): when a session leaves the store and the toggle
321
+ // is enabled, mark every memory born in it as session-disposed (hidden from
322
+ // injection/search/dream but never destroyed — recoverable via
323
+ // restoreBySession). Default off, so a disposed session leaves its memories
324
+ // active (legacy behavior). Every path is guarded: a failure inside the
325
+ // callback must never propagate into DSH's session teardown (that would crash
326
+ // the plugin on the very delete action it serves).
327
+ if (cfg.sessionLifecycleEnabled) {
328
+ disposers.push(ctx.on("session/disposed", (session) => {
329
+ const sessionId = session?.id;
330
+ if (!sessionId) return;
331
+ try {
332
+ const { disposed } = service.disposeBySession(sessionId);
333
+ ctx.logger?.info?.(`[dsh-mneme] session disposed, hid ${disposed} memory(s) for ${sessionId}`);
334
+ } catch (error) {
335
+ ctx.logger?.warn?.(`[dsh-mneme] session dispose failed for ${sessionId}: ${String(error)}`);
336
+ }
337
+ }));
338
+ }
339
+
320
340
  if (ctx.webServer) {
321
341
  const api = createApi(ctx, service, settings, commands ?? {
322
342
  add: () => { throw new Error("commands unavailable"); },
@@ -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
@@ -331,7 +362,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
331
362
  function bm25Recall(q, limit) {
332
363
  if (config?.bm25SearchEnabled === false) return [];
333
364
  try {
334
- const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived);
365
+ const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived && !m.session_disposed_at);
335
366
  if (!docs.length) return [];
336
367
  return createBM25Index(docs).search(q, { limit });
337
368
  } catch {
@@ -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
 
@@ -999,6 +1032,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
999
1032
  tags: m.tags,
1000
1033
  importance: m.importance,
1001
1034
  source: m.source,
1035
+ // session_id is optional on the wire: only carry it when present, so the
1036
+ // DTO stays a lossless JSON object (undefined would vanish on serialize).
1037
+ ...(m.session_id != null ? { session_id: m.session_id } : {}),
1038
+ // Disposed state rides along when set, so a restore flow is not a blind
1039
+ // op — the caller can see which entries are hidden before restoreBySession.
1040
+ ...(m.session_disposed_at != null ? { disposed: true } : {}),
1002
1041
  created_at: m.created_at,
1003
1042
  updated_at: m.updated_at
1004
1043
  }));
@@ -1280,8 +1319,52 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1280
1319
  }
1281
1320
  }
1282
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
+
1283
1363
  return {
1284
1364
  saveWithDedupe,
1365
+ getBacklinks,
1366
+ getForwardLinks,
1367
+ resolveWikiLink: (title) => store.findByTitle?.(title),
1285
1368
  recoverMirror,
1286
1369
  getMirrorHealth,
1287
1370
  getMirrorState: () => store.getMirrorState(),
@@ -1339,6 +1422,28 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1339
1422
  afterSync("write");
1340
1423
  notifyWrite();
1341
1424
  },
1425
+ // Session lifecycle (v0.6.0): mark/clear the session-disposed state on every
1426
+ // memory born in a given session. Uses the dedicated `session_disposed_at`
1427
+ // column, orthogonal to `archived` — restoring a session never resurrects
1428
+ // memories the user archived on purpose. Nothing is destroyed; a session
1429
+ // treated as a save point is fully recoverable via restoreBySession.
1430
+ disposeBySession: (sessionId) => {
1431
+ const disposed = store.setDisposedBySession(sessionId, true);
1432
+ if (disposed > 0) {
1433
+ afterSync("write");
1434
+ notifyWrite();
1435
+ }
1436
+ return { disposed };
1437
+ },
1438
+ restoreBySession: (sessionId) => {
1439
+ const restored = store.setDisposedBySession(sessionId, false);
1440
+ if (restored > 0) {
1441
+ afterSync("write");
1442
+ notifyWrite();
1443
+ }
1444
+ return { restored };
1445
+ },
1446
+ listBySession: (sessionId, opts = {}) => toApiList(store.listBySession(sessionId, opts)),
1342
1447
  update: (id, p, ctx = {}) => {
1343
1448
  const old = store.getById(id);
1344
1449
  const updated = store.update(id, p);
@@ -1365,6 +1470,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1365
1470
  const sync = afterSync("write");
1366
1471
  notifyWrite();
1367
1472
  scheduleEmbed(updated);
1473
+ scheduleWikiLinkResolve(updated);
1368
1474
  // Audit peer B: when the mirror sync failed, the store write landed but
1369
1475
  // the mirror did not converge — return an explicit degraded receipt rather
1370
1476
  // than a plain success. Non-enumerable so existing deepEqual assertions on
@@ -1476,6 +1582,7 @@ export function createService({ store, mirror, config, onWrite, logger }) {
1476
1582
  // migrates entity_attrs on merge. Bookkeeping writes like the audit
1477
1583
  // passthroughs above — never write-hook-triggering memory mutations.
1478
1584
  saveRelation: (r) => store.saveRelation(r),
1585
+ saveWikiLinks: (r) => store.saveWikiLinks(r),
1479
1586
  listEntities: (o) => store.listEntities(o),
1480
1587
  getRelations: (id) => store.getRelations(id),
1481
1588
  saveAttr: (r) => store.saveAttr(r),
package/src/store.js CHANGED
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS memories (
11
11
  importance INTEGER NOT NULL DEFAULT 3,
12
12
  forgotten INTEGER NOT NULL DEFAULT 0,
13
13
  archived INTEGER NOT NULL DEFAULT 0,
14
+ session_disposed_at TEXT,
14
15
  source TEXT,
15
16
  session_id TEXT,
16
17
  content_history TEXT,
@@ -321,6 +322,7 @@ function toRow(row) {
321
322
  importance: row.importance,
322
323
  forgotten: row.forgotten === 1,
323
324
  archived: row.archived === 1,
325
+ session_disposed_at: row.session_disposed_at ?? undefined,
324
326
  source: row.source ?? undefined,
325
327
  session_id: row.session_id ?? undefined,
326
328
  content_history: parseJsonArray(row.content_history),
@@ -550,6 +552,17 @@ export function createStore(path) {
550
552
  db.exec("PRAGMA journal_mode = WAL;");
551
553
  db.exec(SCHEMA);
552
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
+
553
566
  // Schema migrations for legacy databases (idempotent). Each ADD COLUMN is
554
567
  // also race-safe: two concurrently-opening processes can both pass the
555
568
  // PRAGMA table_info check before either ALTERs, so the ALTER itself is
@@ -567,6 +580,7 @@ export function createStore(path) {
567
580
  };
568
581
 
569
582
  addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
583
+ addColumn("memories", "session_disposed_at", "ALTER TABLE memories ADD COLUMN session_disposed_at TEXT");
570
584
  addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
571
585
  addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
572
586
  addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
@@ -575,6 +589,12 @@ export function createStore(path) {
575
589
  addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
576
590
  addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
577
591
 
592
+ // Composite index for session-lifecycle queries (dispose/restore/listBySession).
593
+ // Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
594
+ // ADD COLUMN above, so the index would fail at db.exec(SCHEMA) time. CREATE
595
+ // INDEX IF NOT EXISTS is atomic, so the two-process race is safe here.
596
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id, session_disposed_at)");
597
+
578
598
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
579
599
  addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
580
600
  addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
@@ -618,7 +638,7 @@ export function createStore(path) {
618
638
  return ts;
619
639
  }
620
640
 
621
- function count(type, { includeForgotten = false, includeArchived = false } = {}) {
641
+ function count(type, { includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
622
642
  const clauses = [];
623
643
  const params = [];
624
644
  if (type !== undefined) {
@@ -631,6 +651,9 @@ export function createStore(path) {
631
651
  if (!includeArchived) {
632
652
  clauses.push("archived = 0");
633
653
  }
654
+ if (!includeDisposed) {
655
+ clauses.push("session_disposed_at IS NULL");
656
+ }
634
657
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
635
658
  return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
636
659
  }
@@ -640,6 +663,19 @@ export function createStore(path) {
640
663
  return toRow(row);
641
664
  }
642
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
+
643
679
  function save(memory) {
644
680
  const id = memory.id ?? randomUUID();
645
681
  const type = memory.type;
@@ -820,6 +856,45 @@ export function createStore(path) {
820
856
  return getById(id);
821
857
  }
822
858
 
859
+ // --- session lifecycle (v0.6.0) ------------------------------------------
860
+ // Session dispose is orthogonal to `archived`: memory_archive is the user/AI
861
+ // choosing to keep an entry long-term-but-quiet, while session_disposed_at
862
+ // marks entries hidden because the session they were born in was deleted
863
+ // (a reversible "undo" — restoreBySession clears it). They never clobber each
864
+ // other: restoreBySession must not resurrect user-archived memories.
865
+ // Mirrors list/search: disposed rows are hidden by default. A consumer that
866
+ // needs to see the full picture (e.g. a restore flow that tells the user
867
+ // "these N entries were hidden") opts in via includeDisposed.
868
+ function listBySession(sessionId, { includeDisposed = false } = {}) {
869
+ const disposedFilter = includeDisposed ? "" : "AND session_disposed_at IS NULL";
870
+ const rows = db.prepare(
871
+ `SELECT * FROM memories WHERE session_id = ? ${disposedFilter} ORDER BY updated_at DESC`
872
+ ).all(sessionId);
873
+ return rows.map(toRow);
874
+ }
875
+
876
+ // Idempotent by state guard, not timestamp compare (nowIso() differs every
877
+ // call, so a fresh-timestamp re-dispose would spuriously count): dispose only
878
+ // touches rows that are NOT yet disposed; restore only touches rows that ARE.
879
+ // updated_at is deliberately left alone — this is a lifecycle flag, not
880
+ // content — so a true flip is the sole trigger for a mirror generation.
881
+ function setDisposedBySession(sessionId, disposed) {
882
+ const at = disposed ? nowIso() : null;
883
+ let affected = 0;
884
+ runAtomically(() => {
885
+ const result = disposed
886
+ ? db.prepare(
887
+ "UPDATE memories SET session_disposed_at = ? WHERE session_id = ? AND session_disposed_at IS NULL"
888
+ ).run(at, sessionId)
889
+ : db.prepare(
890
+ "UPDATE memories SET session_disposed_at = NULL WHERE session_id = ? AND session_disposed_at IS NOT NULL"
891
+ ).run(sessionId);
892
+ affected = result.changes;
893
+ if (affected > 0) incrementGeneration();
894
+ });
895
+ return affected;
896
+ }
897
+
823
898
  // --- sleep-mode storage support (v0.4.0) ---------------------------------
824
899
  // touchLastAccess stamps the read time on recall/inject paths. It deliberately
825
900
  // does NOT bump the mirror generation: reads must not mark the mirror dirty.
@@ -876,6 +951,7 @@ export function createStore(path) {
876
951
  const rows = db.prepare(
877
952
  `SELECT * FROM memories
878
953
  WHERE forgotten = 0 AND archived = 0
954
+ AND session_disposed_at IS NULL
879
955
  AND (last_accessed_at IS NULL OR last_accessed_at < ?)
880
956
  ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
881
957
  LIMIT ?`
@@ -883,7 +959,7 @@ export function createStore(path) {
883
959
  return rows.map(toRow);
884
960
  }
885
961
 
886
- function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false } = {}) {
962
+ function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false, includeDisposed = false } = {}) {
887
963
  const clauses = [];
888
964
  const params = [];
889
965
  if (type) {
@@ -896,6 +972,9 @@ export function createStore(path) {
896
972
  if (!includeArchived) {
897
973
  clauses.push("archived = 0");
898
974
  }
975
+ if (!includeDisposed) {
976
+ clauses.push("session_disposed_at IS NULL");
977
+ }
899
978
  const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
900
979
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
901
980
  const rows = db.prepare(
@@ -953,7 +1032,7 @@ export function createStore(path) {
953
1032
  ).all(limit);
954
1033
  }
955
1034
 
956
- function search(query, { limit = 20, includeArchived = false } = {}) {
1035
+ function search(query, { limit = 20, includeArchived = false, includeDisposed = false } = {}) {
957
1036
  const q = String(query).trim();
958
1037
  if (!q) return [];
959
1038
  // Plain LIKE substring scan over title/content/tags (wildcards escaped so
@@ -962,9 +1041,10 @@ export function createStore(path) {
962
1041
  const like = `%${escapeLike(q)}%`;
963
1042
  const { limit: lim } = sanitizePage(limit, 0, 20);
964
1043
  const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
1044
+ const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
965
1045
  const rows = db.prepare(
966
1046
  `SELECT * FROM memories
967
- WHERE ${archivedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
1047
+ WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
968
1048
  ORDER BY
969
1049
  CASE WHEN title LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
970
1050
  importance DESC,
@@ -995,12 +1075,13 @@ export function createStore(path) {
995
1075
  * Brute-force cosine similarity over embedded rows. Returns rows decorated
996
1076
  * with a `score` (0..1). Only rows with a stored embedding participate.
997
1077
  */
998
- function searchVector(vector, { limit = 20, includeArchived = false, threshold = 0 } = {}) {
1078
+ function searchVector(vector, { limit = 20, includeArchived = false, includeDisposed = false, threshold = 0 } = {}) {
999
1079
  if (!Array.isArray(vector) || !vector.length) return [];
1000
1080
  const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
1081
+ const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
1001
1082
  const rows = db.prepare(
1002
1083
  `SELECT * FROM memories
1003
- WHERE ${archivedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
1084
+ WHERE ${archivedFilter}${disposedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
1004
1085
  ).all();
1005
1086
  const scored = [];
1006
1087
  for (const row of rows) {
@@ -1605,7 +1686,10 @@ export function createStore(path) {
1605
1686
 
1606
1687
  /**
1607
1688
  * Record a typed relation between two entities. metadata (optional) is a
1608
- * 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.
1609
1693
  */
1610
1694
  function saveRelation({ from_entity, to_entity, relation_type, memory_id, metadata }) {
1611
1695
  const id = randomUUID();
@@ -1619,7 +1703,56 @@ export function createStore(path) {
1619
1703
  `INSERT INTO entity_relations (id, from_entity, to_entity, relation_type, memory_id, created_at, metadata)
1620
1704
  VALUES (?, ?, ?, ?, ?, ?, ?)`
1621
1705
  ).run(id, from_entity, to_entity, relation_type, memory_id ?? null, now, metaStr);
1622
- 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 };
1623
1756
  }
1624
1757
 
1625
1758
  /**
@@ -1871,6 +2004,8 @@ export function createStore(path) {
1871
2004
  remove,
1872
2005
  setForget,
1873
2006
  setArchived,
2007
+ listBySession,
2008
+ setDisposedBySession,
1874
2009
  touchLastAccess,
1875
2010
  demoteToSummary,
1876
2011
  restoreContent,
@@ -1921,6 +2056,8 @@ export function createStore(path) {
1921
2056
  getAttrsByMemory,
1922
2057
  findMemoriesByAttr,
1923
2058
  saveRelation,
2059
+ saveWikiLinks,
2060
+ findByTitle,
1924
2061
  migrateAttrsToMemory,
1925
2062
  getRelations,
1926
2063
  setMirrorState,
package/src/tools.js CHANGED
@@ -180,9 +180,10 @@ export function createTools(ctx, service, config, embedder) {
180
180
 
181
181
  defineTool({
182
182
  name: "memory_delete",
183
- description: "Permanently delete a memory entry.",
183
+ description: "Permanently delete a memory entry. Pass id for exact delete, or query to delete the single best-matching entry by text — lets the agent honor 'delete the memory about X' without a prior list/search round trip.",
184
184
  parameters: {
185
- id: { type: "string", required: true }
185
+ id: { type: "string", description: "Exact memory id to delete (from memory_list/memory_search output)" },
186
+ query: { type: "string", description: "Delete the best-matching entry for this text (searches title/content/tags; uses hybrid recall when an embedder is configured)" }
186
187
  },
187
188
  output: {
188
189
  schema: {
@@ -193,9 +194,19 @@ export function createTools(ctx, service, config, embedder) {
193
194
  render: (_args, value) => TEXT_OUTPUT(value.deleted ? "Memory deleted." : "Memory not found.")
194
195
  },
195
196
  async execute(args) {
196
- const existed = service.getById(args.id) !== undefined;
197
- if (existed) service.remove(args.id);
198
- return { deleted: existed };
197
+ if (args.id) {
198
+ const existed = service.getById(args.id) !== undefined;
199
+ if (existed) service.remove(args.id);
200
+ return { deleted: existed };
201
+ }
202
+ if (args.query) {
203
+ const [best] = await service.searchMemories(args.query, { mode: "auto", topK: 1, useRerank: true });
204
+ if (best) {
205
+ service.remove(best.id);
206
+ return { deleted: true };
207
+ }
208
+ }
209
+ return { deleted: false };
199
210
  }
200
211
  }),
201
212
 
@@ -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
+ });