@modusensus/dsh-mneme 0.7.10 → 0.7.11
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.en.md +368 -0
- package/README.md +298 -169
- package/{dsh-mneme/cordis.patch.yml → cordis.patch.yml} +1 -0
- package/{dsh-mneme/lib → lib}/api.js +50 -295
- package/{dsh-mneme/lib → lib}/client.js +780 -936
- package/{dsh-mneme/src → lib}/config.js +6 -100
- package/{dsh-mneme/lib → lib}/dream/decisions.js +94 -143
- package/{dsh-mneme/lib → lib}/dream/sleep.js +12 -152
- package/{dsh-mneme/lib → lib}/dream.js +22 -168
- package/{dsh-mneme/src → lib}/hot-memory.js +0 -7
- package/{dsh-mneme/lib → lib}/index.js +6 -34
- package/{dsh-mneme/lib → lib}/inject.js +11 -53
- package/{dsh-mneme/src → lib}/mirror.js +1 -12
- package/{dsh-mneme/lib → lib}/quality-filter.js +1 -3
- package/{dsh-mneme/lib → lib}/reranker.js +1 -4
- package/{dsh-mneme/lib → lib}/service.js +20 -410
- package/{dsh-mneme/lib → lib}/settings.js +0 -70
- package/{dsh-mneme/lib → lib}/store.js +35 -450
- package/{dsh-mneme/lib → lib}/summarize.js +10 -10
- package/lib/tools.js +274 -0
- package/package.json +41 -19
- package/{dsh-mneme/scripts → scripts}/e2e-dsh.js +3 -4
- package/{dsh-mneme/scripts → scripts}/sync-lib.js +5 -10
- package/{dsh-mneme/src → src}/api.js +50 -295
- package/{dsh-mneme/lib → src}/config.js +6 -100
- package/{dsh-mneme/src → src}/dream/decisions.js +94 -143
- package/{dsh-mneme/src → src}/dream/sleep.js +12 -152
- package/{dsh-mneme/src → src}/dream.js +22 -168
- package/{dsh-mneme/lib → src}/hot-memory.js +0 -7
- package/{dsh-mneme/src → src}/index.js +6 -34
- package/{dsh-mneme/src → src}/inject.js +11 -53
- package/{dsh-mneme/lib → src}/mirror.js +1 -12
- package/{dsh-mneme/src → src}/quality-filter.js +1 -3
- package/{dsh-mneme/src → src}/reranker.js +1 -4
- package/{dsh-mneme/src → src}/service.js +20 -410
- package/{dsh-mneme/src → src}/settings.js +0 -70
- package/{dsh-mneme/src → src}/store.js +35 -450
- package/{dsh-mneme/src → src}/summarize.js +10 -10
- package/src/tools.js +274 -0
- package/{dsh-mneme/test → test}/api.test.js +1 -156
- package/{dsh-mneme/test → test}/client.test.js +10 -206
- package/{dsh-mneme/test → test}/config.test.js +0 -21
- package/{dsh-mneme/test → test}/dream.test.js +1 -160
- package/{dsh-mneme/test → test}/graph-api.test.js +0 -34
- package/{dsh-mneme/test → test}/hot-memory.test.js +0 -29
- package/test/inject.test.js +120 -0
- package/{dsh-mneme/test → test}/llm-audit.test.js +4 -4
- package/{dsh-mneme/test → test}/reasoning-effort.test.js +0 -27
- package/{dsh-mneme/test → test}/recall-layer.test.js +5 -26
- package/{dsh-mneme/test → test}/reranker.test.js +0 -43
- package/{dsh-mneme/test → test}/service-search.test.js +0 -25
- package/{dsh-mneme/test → test}/service.test.js +0 -106
- package/{dsh-mneme/test → test}/settings.test.js +0 -43
- package/{dsh-mneme/test → test}/sleep.test.js +4 -171
- package/{dsh-mneme/test → test}/store.test.js +0 -76
- package/{dsh-mneme/test → test}/summarize.test.js +16 -15
- package/test/tools.test.js +265 -0
- package/.github/workflows/publish.yml +0 -58
- package/.github/workflows/test.yml +0 -32
- package/CHANGELOG.md +0 -91
- package/CONTRIBUTING.md +0 -293
- package/SECURITY.md +0 -718
- package/docs/devlog/2026-08-14-dsh-mneme-dev-log.md +0 -247
- package/docs/devlog/2026-08-15-dsh-mneme-audit-stress-dev-log.md +0 -145
- package/docs/devlog/2026-08-15-dsh-mneme-pipeline-dev-log.md +0 -56
- package/docs/devlog/2026-08-15-dsh-mneme-reflection-dev-log.md +0 -77
- package/docs/devlog/2026-08-15-dsh-mneme-review-fixes-dev-log.md +0 -64
- package/docs/devlog/2026-08-15-dsh-mneme-semantic-dev-log.md +0 -90
- package/dsh-mneme/CHANGELOG.md +0 -419
- package/dsh-mneme/LICENSE +0 -21
- package/dsh-mneme/README.md +0 -501
- package/dsh-mneme/docs/AGENT_MEMORY_RESEARCH.md +0 -183
- package/dsh-mneme/docs/ENTITIES.md +0 -245
- package/dsh-mneme/docs/LOCAL_MODEL.md +0 -141
- package/dsh-mneme/docs/MIGRATION.md +0 -127
- package/dsh-mneme/docs/SEMANTIC.md +0 -256
- package/dsh-mneme/docs/SLEEP.md +0 -163
- package/dsh-mneme/lib/dream/tag-extractor.js +0 -156
- package/dsh-mneme/lib/heat.js +0 -136
- package/dsh-mneme/lib/parser/tag.js +0 -59
- package/dsh-mneme/lib/parser/wiki-link.js +0 -38
- package/dsh-mneme/lib/search/tag-boost.js +0 -61
- package/dsh-mneme/lib/tools.js +0 -458
- package/dsh-mneme/package-lock.json +0 -1936
- package/dsh-mneme/package.json +0 -83
- package/dsh-mneme/scripts/check-sync.js +0 -42
- package/dsh-mneme/src/client.js +0 -2242
- package/dsh-mneme/src/dream/tag-extractor.js +0 -156
- package/dsh-mneme/src/heat.js +0 -136
- package/dsh-mneme/src/parser/tag.js +0 -59
- package/dsh-mneme/src/parser/wiki-link.js +0 -38
- package/dsh-mneme/src/search/tag-boost.js +0 -61
- package/dsh-mneme/src/tools.js +0 -458
- package/dsh-mneme/test/boundary-v0625.test.js +0 -82
- package/dsh-mneme/test/directory.test.js +0 -134
- package/dsh-mneme/test/heat.test.js +0 -148
- package/dsh-mneme/test/inject.test.js +0 -206
- package/dsh-mneme/test/layered-types-stats.test.js +0 -144
- package/dsh-mneme/test/lib-smoke.test.js +0 -109
- package/dsh-mneme/test/normalize-decisions.test.js +0 -120
- package/dsh-mneme/test/provenance.test.js +0 -103
- package/dsh-mneme/test/recall-runs.test.js +0 -93
- package/dsh-mneme/test/sleep-heat.test.js +0 -112
- package/dsh-mneme/test/tag-boost.test.js +0 -125
- package/dsh-mneme/test/tag.test.js +0 -426
- package/dsh-mneme/test/tools.test.js +0 -674
- package/dsh-mneme/test/updated-at-semantics.test.js +0 -113
- package/dsh-mneme/test/wiki-link.test.js +0 -332
- package//346/250/252/345/271/205.png +0 -0
- /package/{dsh-mneme/lib → lib}/commands.js +0 -0
- /package/{dsh-mneme/lib → lib}/dream/clustering.js +0 -0
- /package/{dsh-mneme/lib → lib}/embedding.js +0 -0
- /package/{dsh-mneme/lib → lib}/entities/extractor.js +0 -0
- /package/{dsh-mneme/lib → lib}/local-embedder.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/adaptive.js +0 -0
- /package/{dsh-mneme/lib → lib}/search/bm25.js +0 -0
- /package/{dsh-mneme/lib → lib}/vector-index.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-embed.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-recall.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/benchmark-rerank.js +0 -0
- /package/{dsh-mneme/scripts → scripts}/stress-dsh.js +0 -0
- /package/{dsh-mneme/src → src}/commands.js +0 -0
- /package/{dsh-mneme/src → src}/dream/clustering.js +0 -0
- /package/{dsh-mneme/src → src}/embedding.js +0 -0
- /package/{dsh-mneme/src → src}/entities/extractor.js +0 -0
- /package/{dsh-mneme/src → src}/local-embedder.js +0 -0
- /package/{dsh-mneme/src → src}/search/adaptive.js +0 -0
- /package/{dsh-mneme/src → src}/search/bm25.js +0 -0
- /package/{dsh-mneme/src → src}/vector-index.js +0 -0
- /package/{dsh-mneme/test → test}/audit.test.js +0 -0
- /package/{dsh-mneme/test → test}/benchmark.test.js +0 -0
- /package/{dsh-mneme/test → test}/clustering.test.js +0 -0
- /package/{dsh-mneme/test → test}/commands.test.js +0 -0
- /package/{dsh-mneme/test → test}/conflict-freeze.test.js +0 -0
- /package/{dsh-mneme/test → test}/entities.test.js +0 -0
- /package/{dsh-mneme/test → test}/epistemic.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-0112.test.js +0 -0
- /package/{dsh-mneme/test → test}/fnew-03.test.js +0 -0
- /package/{dsh-mneme/test → test}/helpers/dream-mock.js +0 -0
- /package/{dsh-mneme/test → test}/local-embedder.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-dirty.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-edit-digest.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror-generation.test.js +0 -0
- /package/{dsh-mneme/test → test}/mirror.test.js +0 -0
- /package/{dsh-mneme/test → test}/peer-blockers.test.js +0 -0
- /package/{dsh-mneme/test → test}/policy-epoch.test.js +0 -0
- /package/{dsh-mneme/test → test}/quality-filter.test.js +0 -0
- /package/{dsh-mneme/test → test}/recall-evals.test.js +0 -0
- /package/{dsh-mneme/test → test}/receipt-chain.test.js +0 -0
- /package/{dsh-mneme/test → test}/reflection.test.js +0 -0
- /package/{dsh-mneme/test → test}/search-fusion.test.js +0 -0
- /package/{dsh-mneme/test → test}/semantic.test.js +0 -0
- /package/{dsh-mneme/test → test}/stress.test.js +0 -0
- /package/{dsh-mneme/test → test}/vector-index.test.js +0 -0
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { sanitizeTags } from "./parser/tag.js";
|
|
4
3
|
|
|
5
4
|
const SCHEMA = `
|
|
6
5
|
CREATE TABLE IF NOT EXISTS memories (
|
|
@@ -12,15 +11,12 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
12
11
|
importance INTEGER NOT NULL DEFAULT 3,
|
|
13
12
|
forgotten INTEGER NOT NULL DEFAULT 0,
|
|
14
13
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
15
|
-
session_disposed_at TEXT,
|
|
16
14
|
source TEXT,
|
|
17
|
-
session_id TEXT,
|
|
18
15
|
content_history TEXT,
|
|
19
16
|
embedding TEXT,
|
|
20
17
|
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
21
18
|
last_accessed_at TEXT,
|
|
22
19
|
_full_content TEXT,
|
|
23
|
-
metadata TEXT, -- JSON: free-form extras (e.g. sleep entity_extracted_at)
|
|
24
20
|
created_at TEXT NOT NULL,
|
|
25
21
|
updated_at TEXT NOT NULL
|
|
26
22
|
);
|
|
@@ -243,7 +239,7 @@ CREATE TABLE IF NOT EXISTS mirror_state (
|
|
|
243
239
|
);
|
|
244
240
|
`;
|
|
245
241
|
|
|
246
|
-
const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"
|
|
242
|
+
const TYPES = new Set(["preference", "project", "decision", "history", "summary", "pattern"]);
|
|
247
243
|
|
|
248
244
|
// Epistemic status: what kind of evidence a memory rests on. Defaults to
|
|
249
245
|
// 'subjective' so legacy rows (and rows without any signal) stay compatible.
|
|
@@ -315,14 +311,6 @@ function parseTags(raw) {
|
|
|
315
311
|
|
|
316
312
|
function toRow(row) {
|
|
317
313
|
if (!row) return undefined;
|
|
318
|
-
let metadata;
|
|
319
|
-
if (row.metadata != null) {
|
|
320
|
-
try {
|
|
321
|
-
metadata = JSON.parse(row.metadata);
|
|
322
|
-
} catch {
|
|
323
|
-
metadata = row.metadata;
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
314
|
return {
|
|
327
315
|
id: row.id,
|
|
328
316
|
type: row.type,
|
|
@@ -332,13 +320,10 @@ function toRow(row) {
|
|
|
332
320
|
importance: row.importance,
|
|
333
321
|
forgotten: row.forgotten === 1,
|
|
334
322
|
archived: row.archived === 1,
|
|
335
|
-
session_disposed_at: row.session_disposed_at ?? undefined,
|
|
336
323
|
source: row.source ?? undefined,
|
|
337
|
-
session_id: row.session_id ?? undefined,
|
|
338
324
|
content_history: parseJsonArray(row.content_history),
|
|
339
325
|
quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
|
|
340
326
|
epistemic_status: row.epistemic_status ?? "subjective",
|
|
341
|
-
metadata,
|
|
342
327
|
created_at: row.created_at,
|
|
343
328
|
updated_at: row.updated_at,
|
|
344
329
|
last_accessed_at: row.last_accessed_at ?? undefined,
|
|
@@ -563,17 +548,6 @@ export function createStore(path) {
|
|
|
563
548
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
564
549
|
db.exec(SCHEMA);
|
|
565
550
|
|
|
566
|
-
// Wiki-link dedup (v0.6.1): a (from_entity, to_entity) pair is unique only for
|
|
567
|
-
// relation_type='links_to'. This is a PARTIAL index scoped to links_to, so the
|
|
568
|
-
// append-only semantics of all other relation types (uses/depends_on/part_of/
|
|
569
|
-
// related_to/supersedes — the extractor and autoDream write these per-run
|
|
570
|
-
// without global dedup, and supersedes rows carry distinct metadata like
|
|
571
|
-
// attr_key/old_value) are preserved. Idempotent (IF NOT EXISTS), atomic, and
|
|
572
|
-
// race-safe. Legacy DBs have no links_to rows yet, so the index builds cleanly
|
|
573
|
-
// everywhere and never breaks plugin startup (a full-table UNIQUE index would
|
|
574
|
-
// fail on legacy duplicates).
|
|
575
|
-
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'");
|
|
576
|
-
|
|
577
551
|
// Schema migrations for legacy databases (idempotent). Each ADD COLUMN is
|
|
578
552
|
// also race-safe: two concurrently-opening processes can both pass the
|
|
579
553
|
// PRAGMA table_info check before either ALTERs, so the ALTER itself is
|
|
@@ -591,21 +565,12 @@ export function createStore(path) {
|
|
|
591
565
|
};
|
|
592
566
|
|
|
593
567
|
addColumn("memories", "archived", "ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
|
|
594
|
-
addColumn("memories", "session_disposed_at", "ALTER TABLE memories ADD COLUMN session_disposed_at TEXT");
|
|
595
568
|
addColumn("memories", "embedding", "ALTER TABLE memories ADD COLUMN embedding TEXT");
|
|
596
569
|
addColumn("memories", "last_accessed_at", "ALTER TABLE memories ADD COLUMN last_accessed_at TEXT");
|
|
597
570
|
addColumn("memories", "_full_content", "ALTER TABLE memories ADD COLUMN _full_content TEXT");
|
|
598
571
|
addColumn("memories", "epistemic_status", "ALTER TABLE memories ADD COLUMN epistemic_status TEXT NOT NULL DEFAULT 'subjective'");
|
|
599
572
|
addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
|
|
600
573
|
addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
|
|
601
|
-
addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
|
|
602
|
-
addColumn("memories", "metadata", "ALTER TABLE memories ADD COLUMN metadata TEXT");
|
|
603
|
-
|
|
604
|
-
// Composite index for session-lifecycle queries (dispose/restore/listBySession).
|
|
605
|
-
// Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
|
|
606
|
-
// ADD COLUMN above, so the index would fail at db.exec(SCHEMA) time. CREATE
|
|
607
|
-
// INDEX IF NOT EXISTS is atomic, so the two-process race is safe here.
|
|
608
|
-
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id, session_disposed_at)");
|
|
609
574
|
|
|
610
575
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
611
576
|
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
@@ -650,98 +615,37 @@ export function createStore(path) {
|
|
|
650
615
|
return ts;
|
|
651
616
|
}
|
|
652
617
|
|
|
653
|
-
function count(type, {
|
|
618
|
+
function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false } = {}) {
|
|
654
619
|
const clauses = [];
|
|
655
620
|
const params = [];
|
|
656
621
|
if (type !== undefined) {
|
|
657
622
|
clauses.push("type = ?");
|
|
658
623
|
params.push(type);
|
|
659
624
|
}
|
|
625
|
+
// Same filters as list() so a paged caller's total matches its rows.
|
|
626
|
+
if (minImportance != null) {
|
|
627
|
+
clauses.push("importance >= ?");
|
|
628
|
+
params.push(minImportance);
|
|
629
|
+
}
|
|
630
|
+
if (source != null) {
|
|
631
|
+
clauses.push("source = ?");
|
|
632
|
+
params.push(source);
|
|
633
|
+
}
|
|
660
634
|
if (!includeForgotten) {
|
|
661
635
|
clauses.push("forgotten = 0");
|
|
662
636
|
}
|
|
663
637
|
if (!includeArchived) {
|
|
664
638
|
clauses.push("archived = 0");
|
|
665
639
|
}
|
|
666
|
-
if (!includeDisposed) {
|
|
667
|
-
clauses.push("session_disposed_at IS NULL");
|
|
668
|
-
}
|
|
669
640
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
670
641
|
return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
|
|
671
642
|
}
|
|
672
643
|
|
|
673
|
-
/**
|
|
674
|
-
* Aggregated stats for the Web panel layered overview: per-type distribution
|
|
675
|
-
* plus a recent daily creation trend. Counts live (non-forgotten /
|
|
676
|
-
* non-archived / not session-disposed) memories only, matching `count`.
|
|
677
|
-
* created_at is ISO TEXT, so date strings compare lexicographically.
|
|
678
|
-
*/
|
|
679
|
-
function stats({ days = 7 } = {}) {
|
|
680
|
-
const LIVE = "forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL";
|
|
681
|
-
// Clamp + integer-coerce so fractional strings (e.g. ?days=7.5) can't
|
|
682
|
-
// produce ragged trend windows (kimi-k2.7-code 复验 S2).
|
|
683
|
-
days = Math.min(30, Math.max(1, Math.floor(Number(days) || 7)));
|
|
684
|
-
const now = Date.now(); // single clock read so the window doesn't span a day boundary
|
|
685
|
-
const byType = db.prepare(
|
|
686
|
-
`SELECT type, count(*) AS c FROM memories WHERE ${LIVE} GROUP BY type`
|
|
687
|
-
).all();
|
|
688
|
-
const byTypeCounts = {};
|
|
689
|
-
for (const row of byType) byTypeCounts[row.type] = row.c;
|
|
690
|
-
const since = new Date(now - (days - 1) * 86400000).toISOString().slice(0, 10);
|
|
691
|
-
const trend = db.prepare(
|
|
692
|
-
`SELECT substr(created_at, 1, 10) AS d, count(*) AS c FROM memories
|
|
693
|
-
WHERE ${LIVE} AND created_at >= ? GROUP BY d ORDER BY d`
|
|
694
|
-
).all(since);
|
|
695
|
-
const trendCounts = {};
|
|
696
|
-
for (const row of trend) trendCounts[row.d] = row.c;
|
|
697
|
-
const recent = [];
|
|
698
|
-
for (let i = days - 1; i >= 0; i--) {
|
|
699
|
-
const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
|
|
700
|
-
recent.push({ date: d, count: trendCounts[d] ?? 0 });
|
|
701
|
-
}
|
|
702
|
-
const total = Object.values(byTypeCounts).reduce((s, n) => s + n, 0);
|
|
703
|
-
return { byType: byTypeCounts, total, recent, days };
|
|
704
|
-
}
|
|
705
|
-
|
|
706
644
|
function getById(id) {
|
|
707
645
|
const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
708
646
|
return toRow(row);
|
|
709
647
|
}
|
|
710
648
|
|
|
711
|
-
/**
|
|
712
|
-
* issue #48: resolve a truncated id — as can leak through an agent's context
|
|
713
|
-
* window when list/search output is shortened — by matching it as a prefix of
|
|
714
|
-
* the id PRIMARY KEY. Exact lookups keep using getById; this only serves
|
|
715
|
-
* resolving a *candidate* id. Returns up to 51 rows so the caller can tell
|
|
716
|
-
* "unique" from "ambiguous" without a second query. LIKE wildcards are
|
|
717
|
-
* stripped from the input (a valid id fragment is hex/UUID text, never % or
|
|
718
|
-
* _). Prefix over a PK stays an index scan, so this is cheap even at scale.
|
|
719
|
-
*/
|
|
720
|
-
function listByIdPrefix(idPrefix) {
|
|
721
|
-
if (typeof idPrefix !== "string" || !idPrefix.trim()) return [];
|
|
722
|
-
// LIKE 通配符不参与 id 匹配,一律剥掉。若剥完为空(如 id="%"),
|
|
723
|
-
// 不能让 SQL 退化成 `LIKE '%'` 全表命中——那会让单条记忆被误删,
|
|
724
|
-
// 一律视为无匹配返回。
|
|
725
|
-
const safe = idPrefix.replace(/[\\%_]/g, "");
|
|
726
|
-
if (!safe) return [];
|
|
727
|
-
const rows = db.prepare("SELECT * FROM memories WHERE id LIKE ? LIMIT 51")
|
|
728
|
-
.all(`${safe}%`);
|
|
729
|
-
return rows.map(toRow);
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/**
|
|
733
|
-
* Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
|
|
734
|
-
* folds ASCII case (CJK titles are inherently case-free, so they match
|
|
735
|
-
* verbatim). Returns the first matching memory or undefined. Best-effort —
|
|
736
|
-
* used by wiki-link target resolution and the read APIs.
|
|
737
|
-
*/
|
|
738
|
-
function findByTitle(title) {
|
|
739
|
-
if (typeof title !== "string" || !title.trim()) return undefined;
|
|
740
|
-
return toRow(db.prepare(
|
|
741
|
-
"SELECT * FROM memories WHERE title = ? COLLATE NOCASE LIMIT 1"
|
|
742
|
-
).get(title.trim()));
|
|
743
|
-
}
|
|
744
|
-
|
|
745
649
|
function save(memory) {
|
|
746
650
|
const id = memory.id ?? randomUUID();
|
|
747
651
|
const type = memory.type;
|
|
@@ -762,8 +666,8 @@ export function createStore(path) {
|
|
|
762
666
|
: inferEpistemicStatus(memory);
|
|
763
667
|
runAtomically(() => {
|
|
764
668
|
db.prepare(
|
|
765
|
-
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source,
|
|
766
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?,
|
|
669
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, created_at, updated_at)
|
|
670
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
767
671
|
).run(
|
|
768
672
|
id,
|
|
769
673
|
type,
|
|
@@ -773,7 +677,6 @@ export function createStore(path) {
|
|
|
773
677
|
importance,
|
|
774
678
|
memory.archived ? 1 : 0,
|
|
775
679
|
memory.source ?? null,
|
|
776
|
-
memory.session_id ?? null,
|
|
777
680
|
JSON.stringify(memory.content_history ?? []),
|
|
778
681
|
Number.isFinite(memory.quality_score) ? memory.quality_score : null,
|
|
779
682
|
embedding,
|
|
@@ -790,9 +693,6 @@ export function createStore(path) {
|
|
|
790
693
|
return getById(id);
|
|
791
694
|
}
|
|
792
695
|
|
|
793
|
-
// v0.7.0 语义:updated_at 只记录内容变更,绝不当访问(与 last_accessed_at
|
|
794
|
-
// 正交,sleep/heat 永不 fallback 到它)。合并/更新刷 updated_at 不应
|
|
795
|
-
// 重置热度时钟 —— 防 autoDream 合并动作伪装成"刚被召回"。
|
|
796
696
|
function update(id, patch) {
|
|
797
697
|
const existing = getById(id);
|
|
798
698
|
if (!existing) throw new Error(`memory not found: ${id}`);
|
|
@@ -845,50 +745,6 @@ export function createStore(path) {
|
|
|
845
745
|
});
|
|
846
746
|
}
|
|
847
747
|
|
|
848
|
-
/**
|
|
849
|
-
* Bounded scan for sleep's entity-extraction phase: oldest memories without
|
|
850
|
-
* an entity_extracted_at stamp (row-level filters only — attr presence is a
|
|
851
|
-
* cross-table check, so the caller filters the returned page via
|
|
852
|
-
* getAttrsByMemory). Caller pages with {limit, offset}; a full-table scan +
|
|
853
|
-
* JS filter would be O(N) per sleep cycle.
|
|
854
|
-
*/
|
|
855
|
-
function listForEntityExtraction({ limit = 50, offset = 0 } = {}) {
|
|
856
|
-
// node:sqlite has no StatementSync#pluck; all() returns row objects.
|
|
857
|
-
return db
|
|
858
|
-
.prepare(`
|
|
859
|
-
SELECT id FROM memories
|
|
860
|
-
WHERE (archived = 0 OR archived IS NULL)
|
|
861
|
-
AND session_disposed_at IS NULL
|
|
862
|
-
AND (forgotten = 0 OR forgotten IS NULL)
|
|
863
|
-
AND type != 'summary'
|
|
864
|
-
AND content IS NOT NULL AND content != ''
|
|
865
|
-
AND (metadata IS NULL OR json_extract(metadata, '$.entity_extracted_at') IS NULL)
|
|
866
|
-
ORDER BY created_at ASC
|
|
867
|
-
LIMIT ? OFFSET ?
|
|
868
|
-
`)
|
|
869
|
-
.all(limit, offset)
|
|
870
|
-
.map((row) => getById(row.id));
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
/**
|
|
874
|
-
* Lightweight metadata-only update (used by sleep's batch entity extraction
|
|
875
|
-
* to stamp `entity_extracted_at` without disturbing title/content/embedding
|
|
876
|
-
* or the normal update semantics). Does not bump updated_at — metadata
|
|
877
|
-
* stamps are bookkeeping, not content, so they must not fake freshness.
|
|
878
|
-
* Incoming fields are MERGED into the existing metadata object so a stamp
|
|
879
|
-
* never clobbers metadata another path just wrote.
|
|
880
|
-
*/
|
|
881
|
-
function setMemoryMetadata(id, metadata) {
|
|
882
|
-
const existing = getById(id);
|
|
883
|
-
if (!existing) throw new Error(`memory not found: ${id}`);
|
|
884
|
-
const patch = typeof metadata === "string"
|
|
885
|
-
? JSON.parse(metadata)
|
|
886
|
-
: (metadata ?? {});
|
|
887
|
-
const merged = { ...(existing.metadata ?? {}), ...patch };
|
|
888
|
-
db.prepare("UPDATE memories SET metadata = ? WHERE id = ?").run(JSON.stringify(merged), id);
|
|
889
|
-
return getById(id);
|
|
890
|
-
}
|
|
891
|
-
|
|
892
748
|
/**
|
|
893
749
|
* Atomic compare-and-set update: applies `patch` only when the row still
|
|
894
750
|
* carries `expectedUpdatedAt` (the version token read by the caller). Returns
|
|
@@ -969,45 +825,6 @@ export function createStore(path) {
|
|
|
969
825
|
return getById(id);
|
|
970
826
|
}
|
|
971
827
|
|
|
972
|
-
// --- session lifecycle (v0.6.0) ------------------------------------------
|
|
973
|
-
// Session dispose is orthogonal to `archived`: memory_archive is the user/AI
|
|
974
|
-
// choosing to keep an entry long-term-but-quiet, while session_disposed_at
|
|
975
|
-
// marks entries hidden because the session they were born in was deleted
|
|
976
|
-
// (a reversible "undo" — restoreBySession clears it). They never clobber each
|
|
977
|
-
// other: restoreBySession must not resurrect user-archived memories.
|
|
978
|
-
// Mirrors list/search: disposed rows are hidden by default. A consumer that
|
|
979
|
-
// needs to see the full picture (e.g. a restore flow that tells the user
|
|
980
|
-
// "these N entries were hidden") opts in via includeDisposed.
|
|
981
|
-
function listBySession(sessionId, { includeDisposed = false } = {}) {
|
|
982
|
-
const disposedFilter = includeDisposed ? "" : "AND session_disposed_at IS NULL";
|
|
983
|
-
const rows = db.prepare(
|
|
984
|
-
`SELECT * FROM memories WHERE session_id = ? ${disposedFilter} ORDER BY updated_at DESC`
|
|
985
|
-
).all(sessionId);
|
|
986
|
-
return rows.map(toRow);
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// Idempotent by state guard, not timestamp compare (nowIso() differs every
|
|
990
|
-
// call, so a fresh-timestamp re-dispose would spuriously count): dispose only
|
|
991
|
-
// touches rows that are NOT yet disposed; restore only touches rows that ARE.
|
|
992
|
-
// updated_at is deliberately left alone — this is a lifecycle flag, not
|
|
993
|
-
// content — so a true flip is the sole trigger for a mirror generation.
|
|
994
|
-
function setDisposedBySession(sessionId, disposed) {
|
|
995
|
-
const at = disposed ? nowIso() : null;
|
|
996
|
-
let affected = 0;
|
|
997
|
-
runAtomically(() => {
|
|
998
|
-
const result = disposed
|
|
999
|
-
? db.prepare(
|
|
1000
|
-
"UPDATE memories SET session_disposed_at = ? WHERE session_id = ? AND session_disposed_at IS NULL"
|
|
1001
|
-
).run(at, sessionId)
|
|
1002
|
-
: db.prepare(
|
|
1003
|
-
"UPDATE memories SET session_disposed_at = NULL WHERE session_id = ? AND session_disposed_at IS NOT NULL"
|
|
1004
|
-
).run(sessionId);
|
|
1005
|
-
affected = result.changes;
|
|
1006
|
-
if (affected > 0) incrementGeneration();
|
|
1007
|
-
});
|
|
1008
|
-
return affected;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
828
|
// --- sleep-mode storage support (v0.4.0) ---------------------------------
|
|
1012
829
|
// touchLastAccess stamps the read time on recall/inject paths. It deliberately
|
|
1013
830
|
// does NOT bump the mirror generation: reads must not mark the mirror dirty.
|
|
@@ -1064,7 +881,6 @@ export function createStore(path) {
|
|
|
1064
881
|
const rows = db.prepare(
|
|
1065
882
|
`SELECT * FROM memories
|
|
1066
883
|
WHERE forgotten = 0 AND archived = 0
|
|
1067
|
-
AND session_disposed_at IS NULL
|
|
1068
884
|
AND (last_accessed_at IS NULL OR last_accessed_at < ?)
|
|
1069
885
|
ORDER BY COALESCE(last_accessed_at, created_at) ASC, id
|
|
1070
886
|
LIMIT ?`
|
|
@@ -1072,26 +888,37 @@ export function createStore(path) {
|
|
|
1072
888
|
return rows.map(toRow);
|
|
1073
889
|
}
|
|
1074
890
|
|
|
1075
|
-
function list({ type, limit = 50, offset = 0, includeForgotten = false, includeArchived = false,
|
|
891
|
+
function list({ type, limit = 50, offset = 0, order = "importance", includeForgotten = false, includeArchived = false, minImportance = null, source = null } = {}) {
|
|
1076
892
|
const clauses = [];
|
|
1077
893
|
const params = [];
|
|
1078
894
|
if (type) {
|
|
1079
895
|
clauses.push("type = ?");
|
|
1080
896
|
params.push(type);
|
|
1081
897
|
}
|
|
898
|
+
// Optional server-side filters: importance floor and exact source match.
|
|
899
|
+
// Both stay out of the query when unset so existing callers are unaffected.
|
|
900
|
+
if (minImportance != null) {
|
|
901
|
+
clauses.push("importance >= ?");
|
|
902
|
+
params.push(minImportance);
|
|
903
|
+
}
|
|
904
|
+
if (source) {
|
|
905
|
+
clauses.push("source = ?");
|
|
906
|
+
params.push(source);
|
|
907
|
+
}
|
|
1082
908
|
if (!includeForgotten) {
|
|
1083
909
|
clauses.push("forgotten = 0");
|
|
1084
910
|
}
|
|
1085
911
|
if (!includeArchived) {
|
|
1086
912
|
clauses.push("archived = 0");
|
|
1087
913
|
}
|
|
1088
|
-
if (!includeDisposed) {
|
|
1089
|
-
clauses.push("session_disposed_at IS NULL");
|
|
1090
|
-
}
|
|
1091
914
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
1092
915
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
916
|
+
// "chrono" is pure newest-first — the stable order paged browsing (month
|
|
917
|
+
// tree, infinite scroll) needs; importance ordering would interleave
|
|
918
|
+
// months across pages.
|
|
919
|
+
const orderBy = order === "chrono" ? "updated_at DESC, id DESC" : "importance DESC, updated_at DESC, id";
|
|
1093
920
|
const rows = db.prepare(
|
|
1094
|
-
`SELECT * FROM memories ${where} ORDER BY
|
|
921
|
+
`SELECT * FROM memories ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`
|
|
1095
922
|
).all(...params, lim, off);
|
|
1096
923
|
return rows.map(toRow);
|
|
1097
924
|
}
|
|
@@ -1145,7 +972,7 @@ export function createStore(path) {
|
|
|
1145
972
|
).all(limit);
|
|
1146
973
|
}
|
|
1147
974
|
|
|
1148
|
-
function search(query, { limit = 20, includeArchived = false
|
|
975
|
+
function search(query, { limit = 20, includeArchived = false } = {}) {
|
|
1149
976
|
const q = String(query).trim();
|
|
1150
977
|
if (!q) return [];
|
|
1151
978
|
// Plain LIKE substring scan over title/content/tags (wildcards escaped so
|
|
@@ -1154,10 +981,9 @@ export function createStore(path) {
|
|
|
1154
981
|
const like = `%${escapeLike(q)}%`;
|
|
1155
982
|
const { limit: lim } = sanitizePage(limit, 0, 20);
|
|
1156
983
|
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
1157
|
-
const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
|
|
1158
984
|
const rows = db.prepare(
|
|
1159
985
|
`SELECT * FROM memories
|
|
1160
|
-
WHERE ${archivedFilter}
|
|
986
|
+
WHERE ${archivedFilter}forgotten = 0 AND (title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')
|
|
1161
987
|
ORDER BY
|
|
1162
988
|
CASE WHEN title LIKE ? ESCAPE '\\' THEN 0 ELSE 1 END,
|
|
1163
989
|
importance DESC,
|
|
@@ -1188,13 +1014,12 @@ export function createStore(path) {
|
|
|
1188
1014
|
* Brute-force cosine similarity over embedded rows. Returns rows decorated
|
|
1189
1015
|
* with a `score` (0..1). Only rows with a stored embedding participate.
|
|
1190
1016
|
*/
|
|
1191
|
-
function searchVector(vector, { limit = 20, includeArchived = false,
|
|
1017
|
+
function searchVector(vector, { limit = 20, includeArchived = false, threshold = 0 } = {}) {
|
|
1192
1018
|
if (!Array.isArray(vector) || !vector.length) return [];
|
|
1193
1019
|
const archivedFilter = includeArchived ? "" : "archived = 0 AND ";
|
|
1194
|
-
const disposedFilter = includeDisposed ? "" : "session_disposed_at IS NULL AND ";
|
|
1195
1020
|
const rows = db.prepare(
|
|
1196
1021
|
`SELECT * FROM memories
|
|
1197
|
-
WHERE ${archivedFilter}
|
|
1022
|
+
WHERE ${archivedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
|
|
1198
1023
|
).all();
|
|
1199
1024
|
const scored = [];
|
|
1200
1025
|
for (const row of rows) {
|
|
@@ -1400,19 +1225,6 @@ export function createStore(path) {
|
|
|
1400
1225
|
return rows.map(toRecallRun);
|
|
1401
1226
|
}
|
|
1402
1227
|
|
|
1403
|
-
/**
|
|
1404
|
-
* Rolling purge of the recall_runs production audit (v0.7.0): with recordRecall
|
|
1405
|
-
* defaulting ON, the table would otherwise grow unboundedly on a busy store.
|
|
1406
|
-
* Drop rows older than `days` (config.recallRetentionDays, default 90).
|
|
1407
|
-
* created_at is an ISO TEXT string, so the ISO bound compares lexicographically.
|
|
1408
|
-
* Returns the number of rows deleted (bookkeeping — never throws on empty).
|
|
1409
|
-
*/
|
|
1410
|
-
function purgeRecallRunsOlderThan(days) {
|
|
1411
|
-
const cutoff = new Date(Date.now() - days * 86400000).toISOString();
|
|
1412
|
-
const result = db.prepare("DELETE FROM recall_runs WHERE created_at < ?").run(cutoff);
|
|
1413
|
-
return result.changes;
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
1228
|
// --- recall evaluation trail (方案 B: separate from the production audit) -
|
|
1417
1229
|
|
|
1418
1230
|
/**
|
|
@@ -1810,173 +1622,9 @@ export function createStore(path) {
|
|
|
1810
1622
|
return memories;
|
|
1811
1623
|
}
|
|
1812
1624
|
|
|
1813
|
-
// --- tag storage (v0.6.2) ------------------------------------------------
|
|
1814
|
-
// Tags ride the snapshot-style entity_attrs table (attr_key='tags'), so one
|
|
1815
|
-
// memory has exactly one live tags row; setMemoryTags invalidates any prior
|
|
1816
|
-
// live row and inserts a fresh one (idempotent overwrite). entity_id is the
|
|
1817
|
-
// memory id itself (the memory is its own tag entity), memory_id is kept so
|
|
1818
|
-
// the existing memory-scoped attr queries (getAttrsByMemory / findMemoriesByAttr)
|
|
1819
|
-
// and the bulk tag map all work without a special path.
|
|
1820
|
-
|
|
1821
|
-
/** Normalize an arbitrary tags input to a deduplicated string array.
|
|
1822
|
-
* Delegates to parser/tag.js sanitizeTags (shared validation with parseTags
|
|
1823
|
-
* and the autoDream tag-extractor): strips a leading `#`, trims, drops
|
|
1824
|
-
* non-strings/blanks/over-long/illegal-char tags. Kept as a thin alias so
|
|
1825
|
-
* the tag write path validates identically to the parser path. */
|
|
1826
|
-
function normalizeTags(tags) {
|
|
1827
|
-
return sanitizeTags(tags);
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
/**
|
|
1831
|
-
* Set (overwrite) the live tag set for a memory. Exactly one tags row stays
|
|
1832
|
-
* live per memory: any prior live row is invalidated first, then one fresh
|
|
1833
|
-
* row is written (no-op when tags is empty — the invalidated row is removed
|
|
1834
|
-
* so "clear tags" = no live row). Returns the stored tag array.
|
|
1835
|
-
*/
|
|
1836
|
-
function setMemoryTags(memoryId, tags) {
|
|
1837
|
-
const arr = normalizeTags(tags);
|
|
1838
|
-
const now = nowIso();
|
|
1839
|
-
// Atomic: the invalidation and the fresh row must land together, so a
|
|
1840
|
-
// mid-write crash never leaves the old live row gone without a replacement.
|
|
1841
|
-
// SAVEPOINT (not BEGIN) so this nests safely inside service.transaction().
|
|
1842
|
-
db.exec("SAVEPOINT set_memory_tags");
|
|
1843
|
-
try {
|
|
1844
|
-
db.prepare(
|
|
1845
|
-
`UPDATE entity_attrs SET valid_until = ?
|
|
1846
|
-
WHERE attr_key = 'tags' AND memory_id = ? AND valid_until IS NULL`
|
|
1847
|
-
).run(now, memoryId);
|
|
1848
|
-
if (arr.length) {
|
|
1849
|
-
const id = randomUUID();
|
|
1850
|
-
db.prepare(
|
|
1851
|
-
`INSERT INTO entity_attrs (id, entity_id, attr_key, attr_value, memory_id, valid_from, valid_until, confidence, source)
|
|
1852
|
-
VALUES (?, ?, 'tags', ?, ?, ?, NULL, 1.0, 'manual')`
|
|
1853
|
-
).run(id, memoryId, JSON.stringify(arr), memoryId, now);
|
|
1854
|
-
}
|
|
1855
|
-
// Keep memories.tags (the display/search column) as a mirror of the
|
|
1856
|
-
// entity_attrs source of truth, so API rows and keyword search never
|
|
1857
|
-
// drift from the directory/tag store (issue #31).
|
|
1858
|
-
db.prepare("UPDATE memories SET tags = ?, updated_at = ? WHERE id = ?")
|
|
1859
|
-
.run(JSON.stringify(arr), now, memoryId);
|
|
1860
|
-
db.exec("RELEASE set_memory_tags");
|
|
1861
|
-
} catch (e) {
|
|
1862
|
-
db.exec("ROLLBACK TO set_memory_tags");
|
|
1863
|
-
db.exec("RELEASE set_memory_tags");
|
|
1864
|
-
throw e;
|
|
1865
|
-
}
|
|
1866
|
-
return arr;
|
|
1867
|
-
}
|
|
1868
|
-
|
|
1869
|
-
/** Live tags for a memory ([] when none / unknown). */
|
|
1870
|
-
function getMemoryTags(memoryId) {
|
|
1871
|
-
const row = db.prepare(
|
|
1872
|
-
`SELECT attr_value FROM entity_attrs
|
|
1873
|
-
WHERE attr_key = 'tags' AND memory_id = ? AND valid_until IS NULL
|
|
1874
|
-
ORDER BY valid_from DESC LIMIT 1`
|
|
1875
|
-
).get(memoryId);
|
|
1876
|
-
if (!row) return [];
|
|
1877
|
-
try {
|
|
1878
|
-
const arr = JSON.parse(row.attr_value);
|
|
1879
|
-
return Array.isArray(arr) ? arr : [];
|
|
1880
|
-
} catch {
|
|
1881
|
-
return [];
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
/** Bulk live-tags lookup for mirror rendering. Returns Map<memoryId, string[]>. */
|
|
1886
|
-
function getMemoryTagsMap(ids) {
|
|
1887
|
-
const out = new Map();
|
|
1888
|
-
const list = (Array.isArray(ids) ? ids : []).filter(Boolean);
|
|
1889
|
-
for (let i = 0; i < list.length; i += 100) {
|
|
1890
|
-
const chunk = list.slice(i, i + 100);
|
|
1891
|
-
const rows = db.prepare(
|
|
1892
|
-
`SELECT memory_id, attr_value FROM entity_attrs
|
|
1893
|
-
WHERE attr_key = 'tags' AND valid_until IS NULL
|
|
1894
|
-
AND memory_id IN (${chunk.map(() => "?").join(",")})`
|
|
1895
|
-
).all(...chunk);
|
|
1896
|
-
for (const row of rows) {
|
|
1897
|
-
try {
|
|
1898
|
-
const arr = JSON.parse(row.attr_value);
|
|
1899
|
-
if (Array.isArray(arr) && arr.length) out.set(row.memory_id, arr);
|
|
1900
|
-
} catch { /* corrupt row: skip */ }
|
|
1901
|
-
}
|
|
1902
|
-
}
|
|
1903
|
-
return out;
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
/**
|
|
1907
|
-
* Memories carrying a live tags row that contains EVERY requested tag
|
|
1908
|
-
* (AND semantics for a multi-tag query). attr_value is a JSON array, so the
|
|
1909
|
-
* match uses quoted `"tag"` substrings — `tag:lin` never collides with
|
|
1910
|
-
* `linux` because JSON array elements are quote-delimited. Only live rows
|
|
1911
|
-
* (valid_until IS NULL) with a memory reference participate; each memory
|
|
1912
|
-
* appears once.
|
|
1913
|
-
*/
|
|
1914
|
-
function findMemoriesByTags(tags) {
|
|
1915
|
-
const list = normalizeTags(tags);
|
|
1916
|
-
if (!list.length) return [];
|
|
1917
|
-
const where = list.map(() => `attr_value LIKE ? ESCAPE '\\'`).join(" AND ");
|
|
1918
|
-
const params = list.map((t) => `%"${escapeLike(t)}"%`);
|
|
1919
|
-
const rows = db.prepare(
|
|
1920
|
-
`SELECT DISTINCT memory_id FROM entity_attrs
|
|
1921
|
-
WHERE attr_key = 'tags' AND valid_until IS NULL
|
|
1922
|
-
AND memory_id IS NOT NULL AND memory_id != ''
|
|
1923
|
-
AND (${where})`
|
|
1924
|
-
).all(...params);
|
|
1925
|
-
// Same live-memory filter as getDirectory/store.search: forgotten/archived/
|
|
1926
|
-
// session-disposed memories are invisible to `tag:` recall.
|
|
1927
|
-
const stmt = db.prepare(
|
|
1928
|
-
"SELECT * FROM memories WHERE id = ? AND forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL"
|
|
1929
|
-
);
|
|
1930
|
-
const memories = [];
|
|
1931
|
-
for (const { memory_id } of rows) {
|
|
1932
|
-
const row = stmt.get(memory_id);
|
|
1933
|
-
if (row) memories.push(toRow(row));
|
|
1934
|
-
}
|
|
1935
|
-
return memories;
|
|
1936
|
-
}
|
|
1937
|
-
|
|
1938
|
-
/**
|
|
1939
|
-
* Directory view (v0.6.3): group live memories by their entity_attrs-backed
|
|
1940
|
-
* tag set. A memory carrying N tags appears under all N tag folders; a memory
|
|
1941
|
-
* with no live tags lands in `untagged`. Only live rows participate —
|
|
1942
|
-
* forgotten, archived and session-disposed memories are excluded. Groups are
|
|
1943
|
-
* ordered by tag (locale-aware), group members and untagged follow the
|
|
1944
|
-
* canonical memory order (importance DESC, updated_at DESC, id).
|
|
1945
|
-
* @returns {{groups: {tag: string, memories: object[]}[], untagged: object[]}}
|
|
1946
|
-
*/
|
|
1947
|
-
function getDirectory() {
|
|
1948
|
-
const rows = db.prepare(
|
|
1949
|
-
`SELECT * FROM memories
|
|
1950
|
-
WHERE forgotten = 0 AND archived = 0 AND session_disposed_at IS NULL
|
|
1951
|
-
ORDER BY importance DESC, updated_at DESC, id`
|
|
1952
|
-
).all();
|
|
1953
|
-
const memories = rows.map(toRow);
|
|
1954
|
-
const tagMap = getMemoryTagsMap(memories.map((m) => m.id));
|
|
1955
|
-
const byTag = new Map(); // tag -> memory[]
|
|
1956
|
-
const untagged = [];
|
|
1957
|
-
for (const m of memories) {
|
|
1958
|
-
const tags = tagMap.get(m.id);
|
|
1959
|
-
if (!tags || tags.length === 0) {
|
|
1960
|
-
untagged.push(m);
|
|
1961
|
-
continue;
|
|
1962
|
-
}
|
|
1963
|
-
for (const tag of tags) {
|
|
1964
|
-
if (!byTag.has(tag)) byTag.set(tag, []);
|
|
1965
|
-
byTag.get(tag).push(m);
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
const groups = [...byTag.entries()]
|
|
1969
|
-
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
1970
|
-
.map(([tag, ms]) => ({ tag, memories: ms }));
|
|
1971
|
-
return { groups, untagged };
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
1625
|
/**
|
|
1975
1626
|
* Record a typed relation between two entities. metadata (optional) is a
|
|
1976
|
-
* free-form JSON blob describing the relation. Relations are append-only
|
|
1977
|
-
* callers that need idempotency (e.g. wiki-links, via saveWikiLinks) guard
|
|
1978
|
-
* with their own existence check plus the partial links_to unique index
|
|
1979
|
-
* (idx_relations_wikilink) as a race backstop.
|
|
1627
|
+
* free-form JSON blob describing the relation. Relations are append-only.
|
|
1980
1628
|
*/
|
|
1981
1629
|
function saveRelation({ from_entity, to_entity, relation_type, memory_id, metadata }) {
|
|
1982
1630
|
const id = randomUUID();
|
|
@@ -1990,56 +1638,7 @@ export function createStore(path) {
|
|
|
1990
1638
|
`INSERT INTO entity_relations (id, from_entity, to_entity, relation_type, memory_id, created_at, metadata)
|
|
1991
1639
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
1992
1640
|
).run(id, from_entity, to_entity, relation_type, memory_id ?? null, now, metaStr);
|
|
1993
|
-
return toRelation(db.prepare(
|
|
1994
|
-
"SELECT * FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ? LIMIT 1"
|
|
1995
|
-
).get(from_entity, to_entity, relation_type));
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
/**
|
|
1999
|
-
* Record wiki-link relations (v0.6.1). For each target title, resolve the
|
|
2000
|
-
* target memory (case-insensitive title match via findByTitle) and write a
|
|
2001
|
-
* links_to relation:
|
|
2002
|
-
* from_entity = source memory title, to_entity = canonical target memory
|
|
2003
|
-
* title, relation_type = 'links_to', memory_id = source memory id.
|
|
2004
|
-
* Using the canonical resolved title keeps the graph case-consistent
|
|
2005
|
-
* ([[beta]] and [[Beta]] collapse onto the same to_entity), so backlink
|
|
2006
|
-
* lookups never fight the way a target was typed.
|
|
2007
|
-
* Fail-safe: a target with no matching memory is skipped (never an error).
|
|
2008
|
-
* Idempotent: an already-existing triple is a silent no-op (existence check
|
|
2009
|
-
* here + the idx_relations_wikilink partial unique index as a race backstop),
|
|
2010
|
-
* so `saved` only counts newly written relations. Returns { saved, skipped }.
|
|
2011
|
-
*/
|
|
2012
|
-
function saveWikiLinks({ memoryId, title, targets }) {
|
|
2013
|
-
const saved = [];
|
|
2014
|
-
const skipped = [];
|
|
2015
|
-
const seen = new Set(); // canonical (lowercased) targets already handled
|
|
2016
|
-
const existsStmt = db.prepare(
|
|
2017
|
-
"SELECT id FROM entity_relations WHERE from_entity = ? AND to_entity = ? AND relation_type = ?"
|
|
2018
|
-
);
|
|
2019
|
-
const list = Array.isArray(targets)
|
|
2020
|
-
? targets.filter((t) => typeof t === "string" && t.trim())
|
|
2021
|
-
: [];
|
|
2022
|
-
for (const raw of list) {
|
|
2023
|
-
const target = raw.trim();
|
|
2024
|
-
const key = target.toLowerCase();
|
|
2025
|
-
if (seen.has(key)) continue; // dedupe within a single call (case-insensitive)
|
|
2026
|
-
seen.add(key);
|
|
2027
|
-
const targetMem = findByTitle(target);
|
|
2028
|
-
if (!targetMem) {
|
|
2029
|
-
skipped.push(target); // 目标不存在 → 跳过(Fail-safe)
|
|
2030
|
-
continue;
|
|
2031
|
-
}
|
|
2032
|
-
const toEntity = targetMem.title;
|
|
2033
|
-
if (existsStmt.get(title, toEntity, "links_to")) continue; // already linked → no-op
|
|
2034
|
-
saved.push(saveRelation({
|
|
2035
|
-
from_entity: title,
|
|
2036
|
-
to_entity: toEntity,
|
|
2037
|
-
relation_type: "links_to",
|
|
2038
|
-
memory_id: memoryId,
|
|
2039
|
-
metadata: { target_memory_id: targetMem.id }
|
|
2040
|
-
}));
|
|
2041
|
-
}
|
|
2042
|
-
return { saved, skipped };
|
|
1641
|
+
return toRelation(db.prepare("SELECT * FROM entity_relations WHERE id = ?").get(id));
|
|
2043
1642
|
}
|
|
2044
1643
|
|
|
2045
1644
|
/**
|
|
@@ -2284,19 +1883,13 @@ export function createStore(path) {
|
|
|
2284
1883
|
return {
|
|
2285
1884
|
db,
|
|
2286
1885
|
count,
|
|
2287
|
-
stats,
|
|
2288
1886
|
getById,
|
|
2289
|
-
listByIdPrefix,
|
|
2290
1887
|
save,
|
|
2291
1888
|
update,
|
|
2292
|
-
listForEntityExtraction,
|
|
2293
|
-
setMemoryMetadata,
|
|
2294
1889
|
compareAndUpdate,
|
|
2295
1890
|
remove,
|
|
2296
1891
|
setForget,
|
|
2297
1892
|
setArchived,
|
|
2298
|
-
listBySession,
|
|
2299
|
-
setDisposedBySession,
|
|
2300
1893
|
touchLastAccess,
|
|
2301
1894
|
demoteToSummary,
|
|
2302
1895
|
restoreContent,
|
|
@@ -2319,7 +1912,6 @@ export function createStore(path) {
|
|
|
2319
1912
|
saveRecallRun,
|
|
2320
1913
|
getRecallRun,
|
|
2321
1914
|
listRecallRuns,
|
|
2322
|
-
purgeRecallRunsOlderThan,
|
|
2323
1915
|
saveRecallEval,
|
|
2324
1916
|
getRecallEval,
|
|
2325
1917
|
listRecallEvals,
|
|
@@ -2347,14 +1939,7 @@ export function createStore(path) {
|
|
|
2347
1939
|
getAttrHistory,
|
|
2348
1940
|
getAttrsByMemory,
|
|
2349
1941
|
findMemoriesByAttr,
|
|
2350
|
-
setMemoryTags,
|
|
2351
|
-
getMemoryTags,
|
|
2352
|
-
getMemoryTagsMap,
|
|
2353
|
-
findMemoriesByTags,
|
|
2354
|
-
getDirectory,
|
|
2355
1942
|
saveRelation,
|
|
2356
|
-
saveWikiLinks,
|
|
2357
|
-
findByTitle,
|
|
2358
1943
|
migrateAttrsToMemory,
|
|
2359
1944
|
getRelations,
|
|
2360
1945
|
setMirrorState,
|