@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.
- package/README.md +27 -6
- package/lib/api.js +101 -0
- package/lib/client.js +129 -4
- package/lib/config.js +14 -0
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +2 -2
- package/lib/index.js +20 -0
- package/lib/parser/wiki-link.js +38 -0
- package/lib/service.js +108 -1
- package/lib/store.js +145 -8
- package/lib/tools.js +16 -5
- package/package.json +1 -1
- package/src/api.js +101 -0
- package/src/config.js +14 -0
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +2 -2
- package/src/index.js +20 -0
- package/src/parser/wiki-link.js +38 -0
- package/src/service.js +108 -1
- package/src/store.js +145 -8
- package/src/tools.js +16 -5
- package/test/client.test.js +36 -0
- package/test/service.test.js +106 -0
- package/test/store.test.js +76 -0
- package/test/tools.test.js +20 -0
- package/test/wiki-link.test.js +332 -0
package/lib/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/lib/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(
|
|
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/lib/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",
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
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.
|
|
4
|
+
"version": "0.6.1",
|
|
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
|
package/src/config.js
CHANGED
|
@@ -4,6 +4,11 @@ export const Config = z.object({
|
|
|
4
4
|
memoryDir: z.string().default("~/.dsh/memory"),
|
|
5
5
|
autoInject: z.boolean().default(true),
|
|
6
6
|
autoSummarize: z.boolean().default(true),
|
|
7
|
+
// Session lifecycle (v0.6.0): when enabled, deleting/disposing a session also
|
|
8
|
+
// archives every memory that was born in it (treating the session as a save
|
|
9
|
+
// point — entries stay recoverable via memory_archive/restoreBySession).
|
|
10
|
+
// Default OFF: legacy behavior, a disposed session leaves its memories active.
|
|
11
|
+
sessionLifecycleEnabled: z.boolean().default(false),
|
|
7
12
|
// Optional model override for summarization. When both are non-empty, they
|
|
8
13
|
// take priority over the session's current model. Empty = use the session's
|
|
9
14
|
// active provider/model (same as before).
|
|
@@ -161,6 +166,15 @@ export const Config = z.object({
|
|
|
161
166
|
// Prefix/semantic search over entity names (used by recall).
|
|
162
167
|
entitySearchEnabled: z.boolean().default(true),
|
|
163
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
|
+
|
|
164
178
|
// --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
|
|
165
179
|
// Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
|
|
166
180
|
// sleep fires when the store has been quiet for sleepIdleMinutes and deep-
|