@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/src/dream/sleep.js
CHANGED
|
@@ -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(
|
|
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",
|
|
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/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
|
+
});
|