recallnest 3.0.1 → 4.0.0
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/CHANGELOG.md +203 -0
- package/README.md +42 -17
- package/README_CN.md +31 -15
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +10 -2
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +434 -68
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/env-config.ts +118 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +69 -104
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +276 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +3 -4
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +6 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-output.ts +11 -0
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +57 -9
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- package/src/write-verifier.ts +16 -11
package/src/store.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { DurableMemoryCategory } from "./memory-schema.js";
|
|
|
12
12
|
import { matchesScopeFilter, type ScopeMatchMode } from "./scope-policy.js";
|
|
13
13
|
import { detectEmotionIfEnabled } from "./emotion-detector.js";
|
|
14
14
|
import { withWriteLock } from "./distill-lock.js";
|
|
15
|
+
import { STORE_WRITE_LOCK_OPTS, STORE_WRITE_LONG_LOCK_OPTS } from "./store-write-budget.js";
|
|
15
16
|
import { incrementWriteCount } from "./activity-counter.js";
|
|
16
17
|
import type { MemoryStorePort, MemoryStoreStats, MemoryStoreUpdate } from "./memory-store-port.js";
|
|
17
18
|
import { clearVersionGroupMetadata, planSingletonVersionGroupRepairs } from "./version-manager.js";
|
|
@@ -57,6 +58,19 @@ export interface MemorySearchResult {
|
|
|
57
58
|
score: number;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** replaceWithArchivedVersion 的 plan 在锁内给出的:要新写的历史行,以及对原行的改动 */
|
|
62
|
+
export interface ArchivedReplacePlan {
|
|
63
|
+
history: MemoryEntry;
|
|
64
|
+
updates: MemoryStoreUpdate;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type ArchivedReplaceResult =
|
|
68
|
+
/** 原行已不在(重读时没找到,或写回时已被删掉):库里什么都没留下 */
|
|
69
|
+
| { status: "missing" }
|
|
70
|
+
/** plan 返回 null:什么都没写,current 是锁内读到的行 */
|
|
71
|
+
| { status: "unchanged"; current: MemoryEntry }
|
|
72
|
+
| { status: "replaced"; entry: MemoryEntry; history: MemoryEntry };
|
|
73
|
+
|
|
60
74
|
export interface StoreConfig {
|
|
61
75
|
dbPath: string;
|
|
62
76
|
vectorDim: number;
|
|
@@ -86,6 +100,14 @@ export interface LegacyScopeAudit {
|
|
|
86
100
|
samples: LegacyScopeAuditSample[];
|
|
87
101
|
}
|
|
88
102
|
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// store-write lock options
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
// The store-write lock options live in store-write-budget.ts (the HTTP servers size their timeouts
|
|
108
|
+
// from them too); re-exported here for existing callers.
|
|
109
|
+
export { STORE_WRITE_LOCK_OPTS, STORE_WRITE_LONG_LOCK_OPTS };
|
|
110
|
+
|
|
89
111
|
// ============================================================================
|
|
90
112
|
// Deterministic ID
|
|
91
113
|
// ============================================================================
|
|
@@ -133,7 +155,7 @@ function clampInt(value: number, min: number, max: number): number {
|
|
|
133
155
|
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
134
156
|
}
|
|
135
157
|
|
|
136
|
-
function escapeSqlLiteral(value: string): string {
|
|
158
|
+
export function escapeSqlLiteral(value: string): string {
|
|
137
159
|
return value.replace(/'/g, "''");
|
|
138
160
|
}
|
|
139
161
|
|
|
@@ -232,7 +254,12 @@ const TABLE_NAME = "memories";
|
|
|
232
254
|
* 应用层双检 `matchesScopeFilter(row, filter, mode)` 必须传同一个 mode,否则 SQL 收紧了
|
|
233
255
|
* 应用层又放行(或反之),两层判据打架。
|
|
234
256
|
*/
|
|
235
|
-
|
|
257
|
+
/** `scope NOT IN (...)` — exact scope names, never prefix families. */
|
|
258
|
+
export function scopeExclusionClause(excludeScopes: string[]): string {
|
|
259
|
+
return `(scope NOT IN (${excludeScopes.map((scope) => `'${escapeSqlLiteral(scope)}'`).join(", ")}))`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function scopeWhereClause(scopeFilter: string[], mode: ScopeMatchMode = "family"): string {
|
|
236
263
|
return scopeFilter
|
|
237
264
|
.map(scope => {
|
|
238
265
|
const safe = escapeSqlLiteral(scope);
|
|
@@ -475,7 +502,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
475
502
|
// window a crash could tear). toWrite is already in-batch deduped by id above,
|
|
476
503
|
// so the merge key matches ≤1 existing row (given no pre-existing dup-id rows).
|
|
477
504
|
await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute(toWrite);
|
|
478
|
-
},
|
|
505
|
+
}, STORE_WRITE_LOCK_OPTS);
|
|
479
506
|
} catch (err: any) {
|
|
480
507
|
const code = err.code || "";
|
|
481
508
|
const message = err.message || String(err);
|
|
@@ -494,6 +521,75 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
494
521
|
return toWrite.length;
|
|
495
522
|
}
|
|
496
523
|
|
|
524
|
+
/**
|
|
525
|
+
* 只插入库里还不存在的 id,已存在的行一律不动(storeBatch 的 whenMatchedUpdateAll 会整行覆盖)。
|
|
526
|
+
*
|
|
527
|
+
* 给记忆文件对账用(memory-reconcile.ts):规划时确认过「这段文字库里没有」,但规划到写入之间
|
|
528
|
+
* 别的写方(MCP / API / dream)可能已写入同一个确定性 id;覆盖会冲掉那一行的 metadata 与 evolution。
|
|
529
|
+
* 写锁内先查已存在的 id,再以 whenNotMatchedInsertAll 写入——即便查询与写入之间有别的进程绕过
|
|
530
|
+
* 写锁插入同 id,mergeInsert 也不会覆盖它。
|
|
531
|
+
*/
|
|
532
|
+
async insertIfAbsent(
|
|
533
|
+
entries: (Omit<MemoryEntry, "id" | "timestamp"> & { id?: string })[],
|
|
534
|
+
): Promise<{ inserted: string[]; skippedExisting: string[] }> {
|
|
535
|
+
if (entries.length === 0) return { inserted: [], skippedExisting: [] };
|
|
536
|
+
await this.ensureInitialized();
|
|
537
|
+
|
|
538
|
+
const byId = new Map<string, MemoryEntry>();
|
|
539
|
+
for (const entry of entries) {
|
|
540
|
+
const full: MemoryEntry = {
|
|
541
|
+
...entry,
|
|
542
|
+
id: entry.id ?? deterministicId(entry.scope, entry.text),
|
|
543
|
+
timestamp: Date.now(),
|
|
544
|
+
metadata: entry.metadata || "{}",
|
|
545
|
+
language: entry.language || "en",
|
|
546
|
+
fts_text: entry.fts_text || entry.text,
|
|
547
|
+
};
|
|
548
|
+
const emotionResult = detectEmotionIfEnabled(full.text);
|
|
549
|
+
if (emotionResult) {
|
|
550
|
+
const meta = JSON.parse(full.metadata || "{}");
|
|
551
|
+
meta.emotion = emotionResult;
|
|
552
|
+
full.metadata = JSON.stringify(meta);
|
|
553
|
+
}
|
|
554
|
+
byId.set(full.id, full); // 同批同 id 只留最后一条
|
|
555
|
+
}
|
|
556
|
+
const candidates = [...byId.values()];
|
|
557
|
+
|
|
558
|
+
let result: { inserted: string[]; skippedExisting: string[] };
|
|
559
|
+
try {
|
|
560
|
+
result = await withWriteLock("store-write", async () => {
|
|
561
|
+
const conditions = candidates.map((e) => `id = '${escapeSqlLiteral(e.id)}'`).join(" OR ");
|
|
562
|
+
const existing = await this.table!.query().where(conditions).select(["id"]).limit(candidates.length).toArray();
|
|
563
|
+
const existingIds = new Set(existing.map((row) => row.id as string));
|
|
564
|
+
const fresh = candidates.filter((e) => !existingIds.has(e.id));
|
|
565
|
+
if (fresh.length > 0) {
|
|
566
|
+
await this.table!.mergeInsert("id").whenNotMatchedInsertAll().execute(fresh);
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
inserted: fresh.map((e) => e.id),
|
|
570
|
+
skippedExisting: candidates.filter((e) => existingIds.has(e.id)).map((e) => e.id),
|
|
571
|
+
};
|
|
572
|
+
}, STORE_WRITE_LOCK_OPTS);
|
|
573
|
+
} catch (err: any) {
|
|
574
|
+
const code = err.code || "";
|
|
575
|
+
const message = err.message || String(err);
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Failed to insert ${candidates.length} memories in "${this.config.dbPath}": ${code} ${message}` +
|
|
578
|
+
(this.schemaMigrationError ? `\n Schema migration failed at init: ${this.schemaMigrationError}` : "")
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
const statsPath = this.activityStatsPath();
|
|
583
|
+
const countByScope = new Map<string, number>();
|
|
584
|
+
for (const e of candidates) {
|
|
585
|
+
if (!result.inserted.includes(e.id)) continue;
|
|
586
|
+
countByScope.set(e.scope, (countByScope.get(e.scope) ?? 0) + 1);
|
|
587
|
+
}
|
|
588
|
+
for (const [scope, n] of countByScope) await incrementWriteCount(scope, n, { statsPath });
|
|
589
|
+
} catch { /* activity tracking is best-effort */ }
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
|
|
497
593
|
/**
|
|
498
594
|
* P0-1/P0-2/P1: Idempotent, crash-atomic write of a fully-formed entry, serialized
|
|
499
595
|
* across processes.
|
|
@@ -516,7 +612,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
516
612
|
await this.ensureInitialized();
|
|
517
613
|
await withWriteLock("store-write", async () => {
|
|
518
614
|
await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([entry]);
|
|
519
|
-
},
|
|
615
|
+
}, STORE_WRITE_LOCK_OPTS);
|
|
520
616
|
// Per-scope write count drives dream activation. Best-effort — never block a write.
|
|
521
617
|
try {
|
|
522
618
|
await incrementWriteCount(entry.scope, 1, { statsPath: this.activityStatsPath() });
|
|
@@ -524,6 +620,38 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
524
620
|
return entry;
|
|
525
621
|
}
|
|
526
622
|
|
|
623
|
+
/**
|
|
624
|
+
* Upsert that never moves a row back in time: a row already holding a newer timestamp is left
|
|
625
|
+
* as it is. The check is the merge's own condition, so it holds even when the caller's lock has
|
|
626
|
+
* expired and another writer got in first. Returns whether the row was inserted or updated.
|
|
627
|
+
* Used by the checkpoint mirror (src/checkpoint-mirror.ts), whose timestamp is the checkpoint's.
|
|
628
|
+
*/
|
|
629
|
+
async upsertUnlessNewer(entry: MemoryEntry): Promise<boolean> {
|
|
630
|
+
await this.ensureInitialized();
|
|
631
|
+
// Every column present, defaults as in store(): lance 2.0's conditional merge panics on a source
|
|
632
|
+
// batch missing columns ("Incorrect number of arrays for StructArray fields"), which the
|
|
633
|
+
// unconditional merge in upsert() tolerates.
|
|
634
|
+
const full: MemoryEntry = {
|
|
635
|
+
...entry,
|
|
636
|
+
metadata: entry.metadata || "{}",
|
|
637
|
+
language: entry.language || "en",
|
|
638
|
+
fts_text: entry.fts_text || entry.text,
|
|
639
|
+
};
|
|
640
|
+
const result = await withWriteLock("store-write", async () =>
|
|
641
|
+
this.table!.mergeInsert("id")
|
|
642
|
+
.whenMatchedUpdateAll({ where: "target.timestamp <= source.timestamp" })
|
|
643
|
+
.whenNotMatchedInsertAll()
|
|
644
|
+
.execute([full]),
|
|
645
|
+
STORE_WRITE_LOCK_OPTS);
|
|
646
|
+
const written = (result?.numInsertedRows ?? 0) + (result?.numUpdatedRows ?? 0) > 0;
|
|
647
|
+
if (written) {
|
|
648
|
+
try {
|
|
649
|
+
await incrementWriteCount(entry.scope, 1, { statsPath: this.activityStatsPath() });
|
|
650
|
+
} catch { /* activity tracking is best-effort */ }
|
|
651
|
+
}
|
|
652
|
+
return written;
|
|
653
|
+
}
|
|
654
|
+
|
|
527
655
|
/**
|
|
528
656
|
* Import a pre-built entry while preserving its id/timestamp.
|
|
529
657
|
* Used for re-embedding / migration / A/B testing across embedding models.
|
|
@@ -582,6 +710,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
582
710
|
minScore = 0.3,
|
|
583
711
|
scopeFilter?: string[],
|
|
584
712
|
scopeMatch: ScopeMatchMode = "family",
|
|
713
|
+
excludeScopes?: string[],
|
|
585
714
|
): Promise<MemorySearchResult[]> {
|
|
586
715
|
await this.ensureInitialized();
|
|
587
716
|
|
|
@@ -593,8 +722,13 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
593
722
|
// Apply scope filter if provided. Default mode "family" = 历史行为(含冒号精确 /
|
|
594
723
|
// 无冒号前缀);exact 由调用方显式声明。**必须下推到 SQL,不能事后在应用层滤**——
|
|
595
724
|
// top-k 在收窄前就被兄弟 scope 占满,滤完只剩残缺候选(2026-08-16 互审 C1)。
|
|
596
|
-
|
|
597
|
-
|
|
725
|
+
// Exclusions are pushed down for the same reason (resolveExcludedScopes in memory-boundaries.ts).
|
|
726
|
+
const where = [
|
|
727
|
+
...(scopeFilter && scopeFilter.length > 0 ? [`(${scopeWhereClause(scopeFilter, scopeMatch)})`] : []),
|
|
728
|
+
...(excludeScopes && excludeScopes.length > 0 ? [scopeExclusionClause(excludeScopes)] : []),
|
|
729
|
+
];
|
|
730
|
+
if (where.length > 0) {
|
|
731
|
+
query = query.where(where.join(" AND "));
|
|
598
732
|
}
|
|
599
733
|
|
|
600
734
|
const results = await query.toArray();
|
|
@@ -612,6 +746,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
612
746
|
if (!matchesScopeFilter(rowScope, scopeFilter, scopeMatch)) {
|
|
613
747
|
continue;
|
|
614
748
|
}
|
|
749
|
+
if (excludeScopes?.includes(rowScope)) continue;
|
|
615
750
|
|
|
616
751
|
mapped.push({
|
|
617
752
|
entry: {
|
|
@@ -633,7 +768,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
633
768
|
return mapped;
|
|
634
769
|
}
|
|
635
770
|
|
|
636
|
-
async bm25Search(query: string, limit = 5, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
|
|
771
|
+
async bm25Search(query: string, limit = 5, scopeFilter?: string[], excludeScopes?: string[]): Promise<MemorySearchResult[]> {
|
|
637
772
|
await this.ensureInitialized();
|
|
638
773
|
|
|
639
774
|
if (!this.ftsIndexCreated) {
|
|
@@ -647,8 +782,12 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
647
782
|
let searchQuery = this.table!.search(query, "fts").limit(safeLimit);
|
|
648
783
|
|
|
649
784
|
// Apply scope filter if provided (family mode, same as vectorSearch's default)
|
|
650
|
-
|
|
651
|
-
|
|
785
|
+
const where = [
|
|
786
|
+
...(scopeFilter && scopeFilter.length > 0 ? [`(${scopeWhereClause(scopeFilter)})`] : []),
|
|
787
|
+
...(excludeScopes && excludeScopes.length > 0 ? [scopeExclusionClause(excludeScopes)] : []),
|
|
788
|
+
];
|
|
789
|
+
if (where.length > 0) {
|
|
790
|
+
searchQuery = searchQuery.where(where.join(" AND "));
|
|
652
791
|
}
|
|
653
792
|
|
|
654
793
|
const results = await searchQuery.toArray();
|
|
@@ -661,6 +800,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
661
800
|
if (!matchesScopeFilter(rowScope, scopeFilter)) {
|
|
662
801
|
continue;
|
|
663
802
|
}
|
|
803
|
+
if (excludeScopes?.includes(rowScope)) continue;
|
|
664
804
|
|
|
665
805
|
// LanceDB FTS _score is raw BM25 (unbounded). Normalize with sigmoid.
|
|
666
806
|
// LanceDB may return BigInt for numeric columns; coerce safely.
|
|
@@ -743,6 +883,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
743
883
|
limit = 20,
|
|
744
884
|
offset = 0,
|
|
745
885
|
scopeMatch: ScopeMatchMode = "family",
|
|
886
|
+
excludeScopes?: string[],
|
|
746
887
|
): Promise<MemoryEntry[]> {
|
|
747
888
|
await this.ensureInitialized();
|
|
748
889
|
|
|
@@ -751,6 +892,10 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
751
892
|
// Build where conditions
|
|
752
893
|
const conditions: string[] = [];
|
|
753
894
|
|
|
895
|
+
if (excludeScopes && excludeScopes.length > 0) {
|
|
896
|
+
conditions.push(scopeExclusionClause(excludeScopes));
|
|
897
|
+
}
|
|
898
|
+
|
|
754
899
|
if (scopeFilter && scopeFilter.length > 0) {
|
|
755
900
|
// 收窄必须在 SQL 里:limit 是在这之后下推的,事后过滤等于先截断再筛,
|
|
756
901
|
// 目标 scope 实际拿到多少行不可控(2026-08-16 互审 C1/K3)。
|
|
@@ -805,15 +950,17 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
805
950
|
limit?: number;
|
|
806
951
|
offset?: number;
|
|
807
952
|
includeVector?: boolean;
|
|
953
|
+
/** 默认 family:无冒号的 scope 按前缀匹配("memory" 会带进 "memory:pivot");exact 只要全等。 */
|
|
954
|
+
scopeMatch?: ScopeMatchMode;
|
|
808
955
|
} = {}): Promise<MemoryEntry[]> {
|
|
809
956
|
await this.ensureInitialized();
|
|
810
|
-
const { scopeFilter, category, limit = 1000, offset = 0, includeVector = false } = opts;
|
|
957
|
+
const { scopeFilter, category, limit = 1000, offset = 0, includeVector = false, scopeMatch = "family" } = opts;
|
|
811
958
|
|
|
812
959
|
let query = this.table!.query();
|
|
813
960
|
|
|
814
961
|
const conditions: string[] = [];
|
|
815
962
|
if (scopeFilter && scopeFilter.length > 0) {
|
|
816
|
-
conditions.push(`((${scopeWhereClause(scopeFilter)}))`);
|
|
963
|
+
conditions.push(`((${scopeWhereClause(scopeFilter, scopeMatch)}))`);
|
|
817
964
|
}
|
|
818
965
|
if (category) {
|
|
819
966
|
conditions.push(`category = '${escapeSqlLiteral(category)}'`);
|
|
@@ -1053,30 +1200,118 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1053
1200
|
throw new Error(`Invalid memory ID format: ${id}`);
|
|
1054
1201
|
}
|
|
1055
1202
|
|
|
1056
|
-
|
|
1203
|
+
// 前缀先在锁外解析成完整 id(全表扫,不放进锁里拉长持锁时间);读整行与写回都在锁内按完整 id 做。
|
|
1204
|
+
let fullId: string;
|
|
1057
1205
|
if (isFullId) {
|
|
1058
|
-
|
|
1059
|
-
rows = await this.table!.query().where(`id = '${safeId}'`).limit(1).toArray();
|
|
1206
|
+
fullId = id;
|
|
1060
1207
|
} else {
|
|
1061
|
-
|
|
1062
|
-
const
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
throw new Error(`Ambiguous prefix "${id}" matches ${rows.length} memories. Use a longer prefix or full ID.`);
|
|
1208
|
+
const all = await this.table!.query().select(["id"]).toArray();
|
|
1209
|
+
const matches = all.filter((r) => (r.id as string).startsWith(id));
|
|
1210
|
+
if (matches.length > 1) {
|
|
1211
|
+
throw new Error(`Ambiguous prefix "${id}" matches ${matches.length} memories. Use a longer prefix or full ID.`);
|
|
1066
1212
|
}
|
|
1213
|
+
if (matches.length === 0) return null;
|
|
1214
|
+
fullId = matches[0].id as string;
|
|
1067
1215
|
}
|
|
1068
1216
|
|
|
1069
|
-
|
|
1217
|
+
// 写路径一律包在 store-write 锁里(2026-08-14 起;巩固的批量元数据改动现在走 patchMetadataBatch,不再经这里)。
|
|
1218
|
+
// 2026-10-17:读也挪进锁内、写回只更新已存在的行。原先锁外读整行、锁内 upsert,别的进程在这两步之间
|
|
1219
|
+
// 删掉这一行(forget 等,delete() 不拿写锁),会被按读到的旧样子插回来;在这两步之间改的元数据也会被旧读数盖掉。
|
|
1220
|
+
return withWriteLock("store-write", async () => {
|
|
1221
|
+
const rows = await this.table!.query().where(`id = '${escapeSqlLiteral(fullId)}'`).limit(1).toArray();
|
|
1222
|
+
if (rows.length === 0) return null;
|
|
1223
|
+
const row = rows[0] as Record<string, unknown>;
|
|
1224
|
+
if (!matchesScopeFilter((row.scope as string | undefined) ?? "", scopeFilter)) {
|
|
1225
|
+
throw new Error(`Memory ${id} is outside accessible scopes`);
|
|
1226
|
+
}
|
|
1227
|
+
const updated = this.buildUpdatedEntry(row, updates);
|
|
1228
|
+
// 只更新已存在的行:锁内读完之后被不拿锁的 delete() 删掉的行不插回来
|
|
1229
|
+
const res = await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute([updated]);
|
|
1230
|
+
return res.numUpdatedRows > 0 ? updated : null;
|
|
1231
|
+
}, STORE_WRITE_LOCK_OPTS);
|
|
1232
|
+
}
|
|
1070
1233
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1234
|
+
/**
|
|
1235
|
+
* 在同一次 store-write 持锁里:按完整 id 重读原行 → 不在就什么都不写 → 在就交给 plan 拿最新行算出
|
|
1236
|
+
* 历史行与对原行的改动,先写历史行、再只更新原行(2026-10-17,信念归档与覆盖不再是锁外两次写)。
|
|
1237
|
+
*
|
|
1238
|
+
* - plan 在锁内同步执行,只能做纯计算:取向量 / 调 embedder 这类慢操作由调用方在锁外先做好;
|
|
1239
|
+
* plan 里也不能调用会再拿 store-write 的公有方法(锁不可重入,会等自己到超时)。
|
|
1240
|
+
* - plan 返回 null:不归档也不改原行,结果为 unchanged(带上锁内读到的行)。
|
|
1241
|
+
* - 历史行按 plan 给的原样写入(language / fts_text 由 plan 负责,见 belief-history 的 replaceBeliefVersion)。
|
|
1242
|
+
* - 原行在重读时已不在,或在写历史行之后、更新原行之前被不拿锁的 delete() 删掉:撤掉本次写的历史行
|
|
1243
|
+
* (它在本次之前就已存在则不动),结果为 missing,库里不留下指向不存在原行的历史行;撤销本身失败只记日志,仍返回 missing。
|
|
1244
|
+
* - 更新原行抛错时同样撤掉本次写的历史行,再把错误抛出去。
|
|
1245
|
+
*
|
|
1246
|
+
* 已知局限:历史行 id 在本次之前就已存在时,这里照样整行覆盖它,失败时也不还原(undoHistory 只删本次新建的)。
|
|
1247
|
+
* 历史行 id 按内容寻址(原行 id、timestamp、正文),所以只有同一版本先前写到一半(历史行写了、原行没更新)
|
|
1248
|
+
* 之后重试才会撞上,覆盖进去的也是同一版本的内容。
|
|
1249
|
+
*/
|
|
1250
|
+
async replaceWithArchivedVersion(
|
|
1251
|
+
fullId: string,
|
|
1252
|
+
plan: (current: MemoryEntry) => ArchivedReplacePlan | null,
|
|
1253
|
+
scopeFilter?: string[],
|
|
1254
|
+
): Promise<ArchivedReplaceResult> {
|
|
1255
|
+
await this.ensureInitialized();
|
|
1073
1256
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1257
|
+
const result = await withWriteLock("store-write", async (): Promise<ArchivedReplaceResult> => {
|
|
1258
|
+
const rows = await this.table!.query().where(`id = '${escapeSqlLiteral(fullId)}'`).limit(1).toArray();
|
|
1259
|
+
if (rows.length === 0) return { status: "missing" };
|
|
1260
|
+
const row = rows[0] as Record<string, unknown>;
|
|
1261
|
+
if (!matchesScopeFilter((row.scope as string | undefined) ?? "", scopeFilter)) {
|
|
1262
|
+
throw new Error(`Memory ${fullId} is outside accessible scopes`);
|
|
1263
|
+
}
|
|
1264
|
+
const current = this.buildUpdatedEntry(row, {});
|
|
1265
|
+
const planned = plan(current);
|
|
1266
|
+
if (!planned) return { status: "unchanged", current };
|
|
1267
|
+
|
|
1268
|
+
// 历史行原样写入:language / fts_text 由 plan 按正文算好,这里不再用 "en" / 原文顶替
|
|
1269
|
+
const { history } = planned;
|
|
1270
|
+
if (history.id === fullId) {
|
|
1271
|
+
throw new Error(`History row id collided with canonical id ${fullId}`);
|
|
1272
|
+
}
|
|
1273
|
+
const updated = this.buildUpdatedEntry(row, planned.updates);
|
|
1274
|
+
const safeHistoryId = escapeSqlLiteral(history.id);
|
|
1275
|
+
const historyExisted = (await this.table!.query().select(["id"]).where(`id = '${safeHistoryId}'`).limit(1).toArray()).length > 0;
|
|
1276
|
+
const undoHistory = async (): Promise<void> => {
|
|
1277
|
+
if (!historyExisted) await this.table!.delete(`id = '${safeHistoryId}'`);
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
await this.table!.mergeInsert("id").whenMatchedUpdateAll().whenNotMatchedInsertAll().execute([history]);
|
|
1281
|
+
let numUpdatedRows: number;
|
|
1282
|
+
try {
|
|
1283
|
+
numUpdatedRows = (await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute([updated])).numUpdatedRows;
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
try {
|
|
1286
|
+
await undoHistory();
|
|
1287
|
+
} catch (undoErr) {
|
|
1288
|
+
console.error(`[replaceWithArchivedVersion] failed to remove history row ${history.id} after the canonical update failed: ${undoErr instanceof Error ? undoErr.message : String(undoErr)}`);
|
|
1289
|
+
}
|
|
1290
|
+
throw err;
|
|
1291
|
+
}
|
|
1292
|
+
if (numUpdatedRows === 0) {
|
|
1293
|
+
try {
|
|
1294
|
+
await undoHistory();
|
|
1295
|
+
} catch (undoErr) {
|
|
1296
|
+
console.error(`[replaceWithArchivedVersion] failed to remove history row ${history.id} after the canonical row vanished: ${undoErr instanceof Error ? undoErr.message : String(undoErr)}`);
|
|
1297
|
+
}
|
|
1298
|
+
return { status: "missing" };
|
|
1299
|
+
}
|
|
1300
|
+
return { status: "replaced", entry: updated, history };
|
|
1301
|
+
}, STORE_WRITE_LOCK_OPTS);
|
|
1302
|
+
|
|
1303
|
+
if (result.status === "replaced") {
|
|
1304
|
+
// 与 upsert() 一致:历史行算一次写入,驱动 dream 激活。尽力而为,不阻塞写入。
|
|
1305
|
+
try {
|
|
1306
|
+
await incrementWriteCount(result.history.scope, 1, { statsPath: this.activityStatsPath() });
|
|
1307
|
+
} catch { /* activity tracking is best-effort */ }
|
|
1077
1308
|
}
|
|
1309
|
+
return result;
|
|
1310
|
+
}
|
|
1078
1311
|
|
|
1079
|
-
|
|
1312
|
+
/** 由库里的一行加上改动拼出写回的整行,保留原 timestamp / language(update 与 patchMetadata 共用) */
|
|
1313
|
+
private buildUpdatedEntry(row: Record<string, unknown>, updates: MemoryStoreUpdate): MemoryEntry {
|
|
1314
|
+
const rowScope = (row.scope as string | undefined) ?? "";
|
|
1080
1315
|
const updated: MemoryEntry = {
|
|
1081
1316
|
id: row.id as string,
|
|
1082
1317
|
text: updates.text ?? (row.text as string),
|
|
@@ -1102,22 +1337,6 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1102
1337
|
updated.metadata = JSON.stringify(meta);
|
|
1103
1338
|
}
|
|
1104
1339
|
}
|
|
1105
|
-
|
|
1106
|
-
// Atomic upsert via mergeInsert — replaces the previous delete+add two-step,
|
|
1107
|
-
// which could lose the row entirely on a crash between steps and exposed a
|
|
1108
|
-
// window where concurrent reads saw the id momentarily vanish.
|
|
1109
|
-
//
|
|
1110
|
-
// 2026-08-14: 包进 store-write 锁与 upsert/storeBatch 对齐 —— 此前 update 是唯一
|
|
1111
|
-
// 裸奔的写路径(dream 的 3a/3b/auto-gc 全走它),与其他进程的 mergeInsert 并发时
|
|
1112
|
-
// 靠 LanceDB 乐观重试硬扛,是 commit conflict 的隐性源之一。
|
|
1113
|
-
await withWriteLock("store-write", async () => {
|
|
1114
|
-
await this.table!
|
|
1115
|
-
.mergeInsert("id")
|
|
1116
|
-
.whenMatchedUpdateAll()
|
|
1117
|
-
.whenNotMatchedInsertAll()
|
|
1118
|
-
.execute([updated]);
|
|
1119
|
-
}, { expireMs: 30_000 });
|
|
1120
|
-
|
|
1121
1340
|
return updated;
|
|
1122
1341
|
}
|
|
1123
1342
|
|
|
@@ -1134,11 +1353,14 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1134
1353
|
* metadata object. A throwing patchFn abandons that patch — the error
|
|
1135
1354
|
* propagates to the caller, later queued patches still run.
|
|
1136
1355
|
*
|
|
1137
|
-
* Scope of guarantee:
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1356
|
+
* Scope of guarantee: the per-id queue orders patches within this process;
|
|
1357
|
+
* since 2026-09-24 the read and the write both happen inside the
|
|
1358
|
+
* cross-process store-write lock, so a commit made by another process
|
|
1359
|
+
* between them (reconcile retirement, dream consolidation, GC archive) is
|
|
1360
|
+
* read and kept instead of overwritten. Direct update() callers that pass a
|
|
1361
|
+
* metadata string they read earlier can still overwrite — they are not
|
|
1362
|
+
* read-modify-write. Long-term direction: make this the only legal metadata
|
|
1363
|
+
* write path.
|
|
1142
1364
|
*/
|
|
1143
1365
|
async patchMetadata(
|
|
1144
1366
|
id: string,
|
|
@@ -1159,9 +1381,17 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1159
1381
|
fullId = resolved.id;
|
|
1160
1382
|
}
|
|
1161
1383
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1384
|
+
// 读与写都在 store-write 锁内(2026-09-24):原先在锁外 getById、再经 update() 写回,别的进程在这两步之间的提交
|
|
1385
|
+
// (对账下架、dream 合并、GC 归档)会被这里拿旧行算出的元数据整行盖掉。锁不可重入,所以锁内不调 update(),
|
|
1386
|
+
// 直接按同样的规则拼整行写回。
|
|
1387
|
+
const run = async (): Promise<MemoryEntry | null> => withWriteLock("store-write", async () => {
|
|
1388
|
+
const rows = await this.table!.query().where(`id = '${escapeSqlLiteral(fullId)}'`).limit(1).toArray();
|
|
1389
|
+
if (rows.length === 0) return null;
|
|
1390
|
+
const row = rows[0] as Record<string, unknown>;
|
|
1391
|
+
if (!matchesScopeFilter((row.scope as string | undefined) ?? "", scopeFilter)) {
|
|
1392
|
+
throw new Error(`Memory ${fullId} is outside accessible scopes`);
|
|
1393
|
+
}
|
|
1394
|
+
const entry = this.buildUpdatedEntry(row, {});
|
|
1165
1395
|
let meta: Record<string, unknown>;
|
|
1166
1396
|
try {
|
|
1167
1397
|
const parsed: unknown = JSON.parse(entry.metadata || "{}");
|
|
@@ -1172,8 +1402,12 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1172
1402
|
meta = {};
|
|
1173
1403
|
}
|
|
1174
1404
|
const patched = patchFn(meta, entry);
|
|
1175
|
-
|
|
1176
|
-
|
|
1405
|
+
const updated: MemoryEntry = { ...entry, metadata: JSON.stringify(patched) };
|
|
1406
|
+
// 只更新已存在的行:delete() 不拿 store-write 锁,锁内读完之后这一行可能已被删掉(比如 forget),
|
|
1407
|
+
// 不能把它插回来(2026-09-24 上线前代码单审)
|
|
1408
|
+
const res = await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute([updated]);
|
|
1409
|
+
return res.numUpdatedRows > 0 ? updated : null;
|
|
1410
|
+
}, STORE_WRITE_LOCK_OPTS);
|
|
1177
1411
|
|
|
1178
1412
|
const prev = this.metadataPatchQueues.get(fullId) ?? Promise.resolve();
|
|
1179
1413
|
// Isolate prior failures so one rejected patch can't wedge the queue.
|
|
@@ -1277,9 +1511,10 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1277
1511
|
}
|
|
1278
1512
|
}
|
|
1279
1513
|
if (toWrite.length === 0) return 0;
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1514
|
+
// 只更新已存在的行:锁内读完之后被不拿锁的 delete() 删掉的行不插回来,返回实际更新数(2026-09-24 上线前代码单审)
|
|
1515
|
+
const res = await this.table!.mergeInsert("id").whenMatchedUpdateAll().execute(toWrite);
|
|
1516
|
+
return res.numUpdatedRows;
|
|
1517
|
+
}, STORE_WRITE_LOCK_OPTS);
|
|
1283
1518
|
}
|
|
1284
1519
|
return written;
|
|
1285
1520
|
}
|
|
@@ -1296,7 +1531,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1296
1531
|
async repairSingletonVersionGroups(): Promise<number> {
|
|
1297
1532
|
await this.ensureInitialized();
|
|
1298
1533
|
|
|
1299
|
-
return withWriteLock("store-write", async () => {
|
|
1534
|
+
return withWriteLock("store-write", async (signal) => {
|
|
1300
1535
|
const membershipRows = await this.table!.query()
|
|
1301
1536
|
.select(["id", "metadata"])
|
|
1302
1537
|
.toArray();
|
|
@@ -1311,6 +1546,12 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1311
1546
|
let repaired = 0;
|
|
1312
1547
|
const BATCH = 200;
|
|
1313
1548
|
for (let i = 0; i < repairs.length; i += BATCH) {
|
|
1549
|
+
// Past its max hold: stop between batches (each batch is an independent, re-checked
|
|
1550
|
+
// commit) so the lock goes back to other writers; the rest is repaired next pass.
|
|
1551
|
+
if (signal.aborted) {
|
|
1552
|
+
logWarn(`[store] repairSingletonVersionGroups stopped after ${repaired} of ${repairs.length} repairs: held store-write past its max hold`);
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1314
1555
|
const batch = repairs.slice(i, i + BATCH);
|
|
1315
1556
|
const expectedById = new Map(batch.map(item => [item.id, item.groupId]));
|
|
1316
1557
|
const conditions = batch
|
|
@@ -1357,7 +1598,7 @@ export class MemoryStore implements MemoryStorePort {
|
|
|
1357
1598
|
repaired += result.numUpdatedRows;
|
|
1358
1599
|
}
|
|
1359
1600
|
return repaired;
|
|
1360
|
-
},
|
|
1601
|
+
}, STORE_WRITE_LONG_LOCK_OPTS);
|
|
1361
1602
|
}
|
|
1362
1603
|
|
|
1363
1604
|
async bulkDelete(scopeFilter: string[], beforeTimestamp?: number): Promise<number> {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 判重与对账共用的文本归一:去掉转录行首的「[用户]」「[助手]」标记,空白折叠。
|
|
5
|
+
* (原定义在 ingest.ts,2026-09-24 挪到这里,免得 forget 引擎为一个五行函数载入整个导入模块;ingest.ts 原样再导出,行为不变。)
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeDedupText(value: string): string {
|
|
8
|
+
return value
|
|
9
|
+
.replace(/^\[(用户|助手)\]\s*/gm, "")
|
|
10
|
+
.replace(/\s+/g, " ")
|
|
11
|
+
.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 已归一文本的指纹(sha256 前 16 位十六进制)。 */
|
|
15
|
+
export function fingerprintNormalized(norm: string): string {
|
|
16
|
+
return createHash("sha256").update(norm).digest("hex").slice(0, 16);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 文本指纹:先归一再取指纹。forget 的审计事件记它(`norm=<指纹>`),记忆文件对账据此认出「被 forget 的是这段文字」,
|
|
21
|
+
* 不管当时删的是同文的哪一行、原文空白差多少——只记 id 的话,删掉一行,同文的另一行照样会被恢复回来。
|
|
22
|
+
*/
|
|
23
|
+
export function textFingerprint(text: string): string {
|
|
24
|
+
return fingerprintNormalized(normalizeDedupText(text));
|
|
25
|
+
}
|