@mnemoai/core 1.1.0 → 1.1.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/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +7 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.d.ts.map +1 -0
- package/{index.ts → dist/index.js} +526 -1333
- package/dist/index.js.map +7 -0
- package/dist/src/access-tracker.d.ts +97 -0
- package/dist/src/access-tracker.d.ts.map +1 -0
- package/dist/src/access-tracker.js +184 -0
- package/dist/src/access-tracker.js.map +7 -0
- package/dist/src/adapters/chroma.d.ts +31 -0
- package/dist/src/adapters/chroma.d.ts.map +1 -0
- package/{src/adapters/chroma.ts → dist/src/adapters/chroma.js} +45 -107
- package/dist/src/adapters/chroma.js.map +7 -0
- package/dist/src/adapters/lancedb.d.ts +29 -0
- package/dist/src/adapters/lancedb.d.ts.map +1 -0
- package/{src/adapters/lancedb.ts → dist/src/adapters/lancedb.js} +41 -109
- package/dist/src/adapters/lancedb.js.map +7 -0
- package/dist/src/adapters/pgvector.d.ts +33 -0
- package/dist/src/adapters/pgvector.d.ts.map +1 -0
- package/{src/adapters/pgvector.ts → dist/src/adapters/pgvector.js} +42 -104
- package/dist/src/adapters/pgvector.js.map +7 -0
- package/dist/src/adapters/qdrant.d.ts +34 -0
- package/dist/src/adapters/qdrant.d.ts.map +1 -0
- package/dist/src/adapters/qdrant.js +132 -0
- package/dist/src/adapters/qdrant.js.map +7 -0
- package/dist/src/adaptive-retrieval.d.ts +14 -0
- package/dist/src/adaptive-retrieval.d.ts.map +1 -0
- package/dist/src/adaptive-retrieval.js +52 -0
- package/dist/src/adaptive-retrieval.js.map +7 -0
- package/dist/src/audit-log.d.ts +56 -0
- package/dist/src/audit-log.d.ts.map +1 -0
- package/dist/src/audit-log.js +139 -0
- package/dist/src/audit-log.js.map +7 -0
- package/dist/src/chunker.d.ts +45 -0
- package/dist/src/chunker.d.ts.map +1 -0
- package/dist/src/chunker.js +157 -0
- package/dist/src/chunker.js.map +7 -0
- package/dist/src/config.d.ts +70 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +142 -0
- package/dist/src/config.js.map +7 -0
- package/dist/src/decay-engine.d.ts +73 -0
- package/dist/src/decay-engine.d.ts.map +1 -0
- package/dist/src/decay-engine.js +119 -0
- package/dist/src/decay-engine.js.map +7 -0
- package/dist/src/embedder.d.ts +94 -0
- package/dist/src/embedder.d.ts.map +1 -0
- package/{src/embedder.ts → dist/src/embedder.js} +119 -317
- package/dist/src/embedder.js.map +7 -0
- package/dist/src/extraction-prompts.d.ts +12 -0
- package/dist/src/extraction-prompts.d.ts.map +1 -0
- package/dist/src/extraction-prompts.js +311 -0
- package/dist/src/extraction-prompts.js.map +7 -0
- package/dist/src/license.d.ts +29 -0
- package/dist/src/license.d.ts.map +1 -0
- package/{src/license.ts → dist/src/license.js} +42 -113
- package/dist/src/license.js.map +7 -0
- package/dist/src/llm-client.d.ts +23 -0
- package/dist/src/llm-client.d.ts.map +1 -0
- package/{src/llm-client.ts → dist/src/llm-client.js} +22 -55
- package/dist/src/llm-client.js.map +7 -0
- package/dist/src/logger.d.ts +33 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +35 -0
- package/dist/src/logger.js.map +7 -0
- package/dist/src/mcp-server.d.ts +16 -0
- package/dist/src/mcp-server.d.ts.map +1 -0
- package/{src/mcp-server.ts → dist/src/mcp-server.js} +81 -181
- package/dist/src/mcp-server.js.map +7 -0
- package/dist/src/memory-categories.d.ts +40 -0
- package/dist/src/memory-categories.d.ts.map +1 -0
- package/dist/src/memory-categories.js +33 -0
- package/dist/src/memory-categories.js.map +7 -0
- package/dist/src/memory-upgrader.d.ts +71 -0
- package/dist/src/memory-upgrader.d.ts.map +1 -0
- package/dist/src/memory-upgrader.js +238 -0
- package/dist/src/memory-upgrader.js.map +7 -0
- package/dist/src/migrate.d.ts +47 -0
- package/dist/src/migrate.d.ts.map +1 -0
- package/{src/migrate.ts → dist/src/migrate.js} +57 -165
- package/dist/src/migrate.js.map +7 -0
- package/dist/src/mnemo.d.ts +67 -0
- package/dist/src/mnemo.d.ts.map +1 -0
- package/dist/src/mnemo.js +66 -0
- package/dist/src/mnemo.js.map +7 -0
- package/dist/src/noise-filter.d.ts +23 -0
- package/dist/src/noise-filter.d.ts.map +1 -0
- package/dist/src/noise-filter.js +62 -0
- package/dist/src/noise-filter.js.map +7 -0
- package/dist/src/noise-prototypes.d.ts +40 -0
- package/dist/src/noise-prototypes.d.ts.map +1 -0
- package/dist/src/noise-prototypes.js +116 -0
- package/dist/src/noise-prototypes.js.map +7 -0
- package/dist/src/observability.d.ts +16 -0
- package/dist/src/observability.d.ts.map +1 -0
- package/dist/src/observability.js +53 -0
- package/dist/src/observability.js.map +7 -0
- package/dist/src/query-tracker.d.ts +27 -0
- package/dist/src/query-tracker.d.ts.map +1 -0
- package/dist/src/query-tracker.js +32 -0
- package/dist/src/query-tracker.js.map +7 -0
- package/dist/src/reflection-event-store.d.ts +44 -0
- package/dist/src/reflection-event-store.d.ts.map +1 -0
- package/dist/src/reflection-event-store.js +50 -0
- package/dist/src/reflection-event-store.js.map +7 -0
- package/dist/src/reflection-item-store.d.ts +58 -0
- package/dist/src/reflection-item-store.d.ts.map +1 -0
- package/dist/src/reflection-item-store.js +69 -0
- package/dist/src/reflection-item-store.js.map +7 -0
- package/dist/src/reflection-mapped-metadata.d.ts +47 -0
- package/dist/src/reflection-mapped-metadata.d.ts.map +1 -0
- package/dist/src/reflection-mapped-metadata.js +40 -0
- package/dist/src/reflection-mapped-metadata.js.map +7 -0
- package/dist/src/reflection-metadata.d.ts +11 -0
- package/dist/src/reflection-metadata.d.ts.map +1 -0
- package/dist/src/reflection-metadata.js +24 -0
- package/dist/src/reflection-metadata.js.map +7 -0
- package/dist/src/reflection-ranking.d.ts +13 -0
- package/dist/src/reflection-ranking.d.ts.map +1 -0
- package/{src/reflection-ranking.ts → dist/src/reflection-ranking.js} +12 -21
- package/dist/src/reflection-ranking.js.map +7 -0
- package/dist/src/reflection-retry.d.ts +30 -0
- package/dist/src/reflection-retry.d.ts.map +1 -0
- package/{src/reflection-retry.ts → dist/src/reflection-retry.js} +24 -64
- package/dist/src/reflection-retry.js.map +7 -0
- package/dist/src/reflection-slices.d.ts +42 -0
- package/dist/src/reflection-slices.d.ts.map +1 -0
- package/{src/reflection-slices.ts → dist/src/reflection-slices.js} +60 -136
- package/dist/src/reflection-slices.js.map +7 -0
- package/dist/src/reflection-store.d.ts +85 -0
- package/dist/src/reflection-store.d.ts.map +1 -0
- package/dist/src/reflection-store.js +407 -0
- package/dist/src/reflection-store.js.map +7 -0
- package/dist/src/resonance-state.d.ts +19 -0
- package/dist/src/resonance-state.d.ts.map +1 -0
- package/{src/resonance-state.ts → dist/src/resonance-state.js} +13 -42
- package/dist/src/resonance-state.js.map +7 -0
- package/dist/src/retriever.d.ts +228 -0
- package/dist/src/retriever.d.ts.map +1 -0
- package/dist/src/retriever.js +1006 -0
- package/dist/src/retriever.js.map +7 -0
- package/dist/src/scopes.d.ts +58 -0
- package/dist/src/scopes.d.ts.map +1 -0
- package/dist/src/scopes.js +252 -0
- package/dist/src/scopes.js.map +7 -0
- package/dist/src/self-improvement-files.d.ts +20 -0
- package/dist/src/self-improvement-files.d.ts.map +1 -0
- package/{src/self-improvement-files.ts → dist/src/self-improvement-files.js} +24 -49
- package/dist/src/self-improvement-files.js.map +7 -0
- package/dist/src/semantic-gate.d.ts +24 -0
- package/dist/src/semantic-gate.d.ts.map +1 -0
- package/dist/src/semantic-gate.js +86 -0
- package/dist/src/semantic-gate.js.map +7 -0
- package/dist/src/session-recovery.d.ts +9 -0
- package/dist/src/session-recovery.d.ts.map +1 -0
- package/{src/session-recovery.ts → dist/src/session-recovery.js} +40 -57
- package/dist/src/session-recovery.js.map +7 -0
- package/dist/src/smart-extractor.d.ts +107 -0
- package/dist/src/smart-extractor.d.ts.map +1 -0
- package/{src/smart-extractor.ts → dist/src/smart-extractor.js} +130 -383
- package/dist/src/smart-extractor.js.map +7 -0
- package/dist/src/smart-metadata.d.ts +103 -0
- package/dist/src/smart-metadata.d.ts.map +1 -0
- package/dist/src/smart-metadata.js +361 -0
- package/dist/src/smart-metadata.js.map +7 -0
- package/dist/src/storage-adapter.d.ts +102 -0
- package/dist/src/storage-adapter.d.ts.map +1 -0
- package/dist/src/storage-adapter.js +22 -0
- package/dist/src/storage-adapter.js.map +7 -0
- package/dist/src/store.d.ts +108 -0
- package/dist/src/store.d.ts.map +1 -0
- package/dist/src/store.js +939 -0
- package/dist/src/store.js.map +7 -0
- package/dist/src/tier-manager.d.ts +57 -0
- package/dist/src/tier-manager.d.ts.map +1 -0
- package/dist/src/tier-manager.js +80 -0
- package/dist/src/tier-manager.js.map +7 -0
- package/dist/src/tools.d.ts +43 -0
- package/dist/src/tools.d.ts.map +1 -0
- package/dist/src/tools.js +1075 -0
- package/dist/src/tools.js.map +7 -0
- package/dist/src/wal-recovery.d.ts +30 -0
- package/dist/src/wal-recovery.d.ts.map +1 -0
- package/{src/wal-recovery.ts → dist/src/wal-recovery.js} +26 -79
- package/dist/src/wal-recovery.js.map +7 -0
- package/package.json +21 -2
- package/openclaw.plugin.json +0 -815
- package/src/access-tracker.ts +0 -341
- package/src/adapters/README.md +0 -78
- package/src/adapters/qdrant.ts +0 -191
- package/src/adaptive-retrieval.ts +0 -90
- package/src/audit-log.ts +0 -238
- package/src/chunker.ts +0 -254
- package/src/config.ts +0 -271
- package/src/decay-engine.ts +0 -238
- package/src/extraction-prompts.ts +0 -339
- package/src/memory-categories.ts +0 -71
- package/src/memory-upgrader.ts +0 -388
- package/src/mnemo.ts +0 -142
- package/src/noise-filter.ts +0 -97
- package/src/noise-prototypes.ts +0 -164
- package/src/observability.ts +0 -81
- package/src/query-tracker.ts +0 -57
- package/src/reflection-event-store.ts +0 -98
- package/src/reflection-item-store.ts +0 -112
- package/src/reflection-mapped-metadata.ts +0 -84
- package/src/reflection-metadata.ts +0 -23
- package/src/reflection-store.ts +0 -602
- package/src/retriever.ts +0 -1510
- package/src/scopes.ts +0 -375
- package/src/semantic-gate.ts +0 -121
- package/src/smart-metadata.ts +0 -561
- package/src/storage-adapter.ts +0 -153
- package/src/store.ts +0 -1330
- package/src/tier-manager.ts +0 -189
- package/src/tools.ts +0 -1292
- package/test/core.test.mjs +0 -301
package/src/store.ts
DELETED
|
@@ -1,1330 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
/**
|
|
3
|
-
* LanceDB Storage Layer with Multi-Scope Support
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type * as LanceDB from "@lancedb/lancedb";
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
8
|
-
import {
|
|
9
|
-
existsSync,
|
|
10
|
-
accessSync,
|
|
11
|
-
constants,
|
|
12
|
-
mkdirSync,
|
|
13
|
-
realpathSync,
|
|
14
|
-
lstatSync,
|
|
15
|
-
} from "node:fs";
|
|
16
|
-
import { dirname } from "node:path";
|
|
17
|
-
import { buildSmartMetadata, parseSmartMetadata, stringifySmartMetadata } from "./smart-metadata.js";
|
|
18
|
-
import type { SemanticGate } from "./semantic-gate.js";
|
|
19
|
-
import { requirePro } from "./license.js";
|
|
20
|
-
|
|
21
|
-
// Pro: Audit log — record all CRUD operations for GDPR/compliance
|
|
22
|
-
let _auditCreate: any = null;
|
|
23
|
-
let _auditUpdate: any = null;
|
|
24
|
-
let _auditDelete: any = null;
|
|
25
|
-
let _auditExpire: any = null;
|
|
26
|
-
|
|
27
|
-
if (requirePro("audit-log")) {
|
|
28
|
-
import("./audit-log.js").then((mod) => {
|
|
29
|
-
_auditCreate = mod.auditCreate;
|
|
30
|
-
_auditUpdate = mod.auditUpdate;
|
|
31
|
-
_auditDelete = mod.auditDelete;
|
|
32
|
-
_auditExpire = mod.auditExpire;
|
|
33
|
-
}).catch(() => {});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Pro: WAL (Write-Ahead Log) — graceful degradation without license
|
|
37
|
-
let walAppend: ((...args: any[]) => Promise<void>) | null = null;
|
|
38
|
-
let walMarkCommitted: ((...args: any[]) => Promise<void>) | null = null;
|
|
39
|
-
let walMarkFailed: ((...args: any[]) => Promise<void>) | null = null;
|
|
40
|
-
|
|
41
|
-
if (requirePro("wal")) {
|
|
42
|
-
import("./wal-recovery.js").then((mod) => {
|
|
43
|
-
walAppend = mod.walAppend;
|
|
44
|
-
walMarkCommitted = mod.walMarkCommitted;
|
|
45
|
-
walMarkFailed = mod.walMarkFailed;
|
|
46
|
-
}).catch(() => {});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ============================================================================
|
|
50
|
-
// Types
|
|
51
|
-
// ============================================================================
|
|
52
|
-
|
|
53
|
-
export interface MemoryEntry {
|
|
54
|
-
id: string;
|
|
55
|
-
text: string;
|
|
56
|
-
vector: number[];
|
|
57
|
-
category: "preference" | "fact" | "decision" | "entity" | "other" | "reflection";
|
|
58
|
-
scope: string;
|
|
59
|
-
importance: number;
|
|
60
|
-
timestamp: number;
|
|
61
|
-
metadata?: string; // JSON string for extensible metadata
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface MemorySearchResult {
|
|
65
|
-
entry: MemoryEntry;
|
|
66
|
-
score: number;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface StoreConfig {
|
|
70
|
-
dbPath: string;
|
|
71
|
-
vectorDim: number;
|
|
72
|
-
/** Enable near-duplicate detection before writing (default: true) */
|
|
73
|
-
deduplication?: boolean;
|
|
74
|
-
/** Enable semantic noise gate to filter fragments (default: true) */
|
|
75
|
-
semanticGate?: boolean;
|
|
76
|
-
/** Storage backend: "lancedb" (default), "qdrant", "chroma", "pgvector" */
|
|
77
|
-
storageBackend?: string;
|
|
78
|
-
/** Backend-specific config (url, connectionString, etc.) */
|
|
79
|
-
storageConfig?: Record<string, unknown>;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const DEDUP_SIMILARITY_THRESHOLD = 0.92;
|
|
83
|
-
const CONFLICT_SIMILARITY_THRESHOLD = 0.70;
|
|
84
|
-
|
|
85
|
-
export interface MetadataPatch {
|
|
86
|
-
[key: string]: unknown;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ============================================================================
|
|
90
|
-
// Storage Adapter Support (multi-backend)
|
|
91
|
-
// ============================================================================
|
|
92
|
-
|
|
93
|
-
import type { StorageAdapter } from "./storage-adapter.js";
|
|
94
|
-
import { createAdapter, listAdapters } from "./storage-adapter.js";
|
|
95
|
-
|
|
96
|
-
// Auto-register adapters on first import
|
|
97
|
-
let _adaptersLoaded = false;
|
|
98
|
-
async function ensureAdaptersLoaded(): Promise<void> {
|
|
99
|
-
if (_adaptersLoaded) return;
|
|
100
|
-
_adaptersLoaded = true;
|
|
101
|
-
// Dynamic imports — only loads the adapter that's actually needed
|
|
102
|
-
const dbg = !!process.env.MNEMO_DEBUG;
|
|
103
|
-
try { await import("./adapters/lancedb.js"); } catch (e) { if (dbg) console.debug("[mnemo] adapter lancedb not available:", e); }
|
|
104
|
-
try { await import("./adapters/qdrant.js"); } catch (e) { if (dbg) console.debug("[mnemo] adapter qdrant not available:", e); }
|
|
105
|
-
try { await import("./adapters/chroma.js"); } catch (e) { if (dbg) console.debug("[mnemo] adapter chroma not available:", e); }
|
|
106
|
-
try { await import("./adapters/pgvector.js"); } catch (e) { if (dbg) console.debug("[mnemo] adapter pgvector not available:", e); }
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ============================================================================
|
|
110
|
-
// LanceDB Dynamic Import (legacy path — used when storageBackend is unset or "lancedb")
|
|
111
|
-
// ============================================================================
|
|
112
|
-
|
|
113
|
-
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null =
|
|
114
|
-
null;
|
|
115
|
-
|
|
116
|
-
export const loadLanceDB = async (): Promise<
|
|
117
|
-
typeof import("@lancedb/lancedb")
|
|
118
|
-
> => {
|
|
119
|
-
if (!lancedbImportPromise) {
|
|
120
|
-
lancedbImportPromise = import("@lancedb/lancedb");
|
|
121
|
-
}
|
|
122
|
-
try {
|
|
123
|
-
return await lancedbImportPromise;
|
|
124
|
-
} catch (err) {
|
|
125
|
-
throw new Error(
|
|
126
|
-
`mnemo: failed to load LanceDB. ${String(err)}`,
|
|
127
|
-
{ cause: err },
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
// ============================================================================
|
|
133
|
-
// Utility Functions
|
|
134
|
-
// ============================================================================
|
|
135
|
-
|
|
136
|
-
function clampInt(value: number, min: number, max: number): number {
|
|
137
|
-
if (!Number.isFinite(value)) return min;
|
|
138
|
-
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Sanitize a string for use in SQL WHERE clauses.
|
|
143
|
-
* Strips everything except alphanumeric, dash, underscore, dot, colon, and space.
|
|
144
|
-
* This is stricter than SQL escaping — it prevents injection by allowlist.
|
|
145
|
-
*/
|
|
146
|
-
function escapeSqlLiteral(value: string): string {
|
|
147
|
-
if (typeof value !== "string") return "";
|
|
148
|
-
// Allowlist: only safe chars for IDs, scopes, categories
|
|
149
|
-
return value.replace(/[^a-zA-Z0-9\-_.:@ \u4e00-\u9fff\u3400-\u4dbf]/g, "");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function normalizeSearchText(value: string): string {
|
|
153
|
-
return value.toLowerCase().trim();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function scoreLexicalHit(query: string, candidates: Array<{ text: string; weight: number }>): number {
|
|
157
|
-
const normalizedQuery = normalizeSearchText(query);
|
|
158
|
-
if (!normalizedQuery) return 0;
|
|
159
|
-
|
|
160
|
-
let score = 0;
|
|
161
|
-
for (const candidate of candidates) {
|
|
162
|
-
const normalized = normalizeSearchText(candidate.text);
|
|
163
|
-
if (!normalized) continue;
|
|
164
|
-
if (normalized.includes(normalizedQuery)) {
|
|
165
|
-
score = Math.max(score, Math.min(0.95, 0.72 + normalizedQuery.length * 0.02) * candidate.weight);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return score;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ============================================================================
|
|
173
|
-
// Storage Path Validation
|
|
174
|
-
// ============================================================================
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Validate and prepare the storage directory before LanceDB connection.
|
|
178
|
-
* Resolves symlinks, creates missing directories, and checks write permissions.
|
|
179
|
-
* Returns the resolved absolute path on success, or throws a descriptive error.
|
|
180
|
-
*/
|
|
181
|
-
export function validateStoragePath(dbPath: string): string {
|
|
182
|
-
let resolvedPath = dbPath;
|
|
183
|
-
|
|
184
|
-
// Resolve symlinks (including dangling symlinks)
|
|
185
|
-
try {
|
|
186
|
-
const stats = lstatSync(dbPath);
|
|
187
|
-
if (stats.isSymbolicLink()) {
|
|
188
|
-
try {
|
|
189
|
-
resolvedPath = realpathSync(dbPath);
|
|
190
|
-
} catch (err: any) {
|
|
191
|
-
throw new Error(
|
|
192
|
-
`dbPath "${dbPath}" is a symlink whose target does not exist.\n` +
|
|
193
|
-
` Fix: Create the target directory, or update the symlink to point to a valid path.\n` +
|
|
194
|
-
` Details: ${err.code || ""} ${err.message}`,
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
} catch (err: any) {
|
|
199
|
-
// Missing path is OK (it will be created below)
|
|
200
|
-
if (err?.code === "ENOENT") {
|
|
201
|
-
// no-op
|
|
202
|
-
} else if (
|
|
203
|
-
typeof err?.message === "string" &&
|
|
204
|
-
err.message.includes("symlink whose target does not exist")
|
|
205
|
-
) {
|
|
206
|
-
throw err;
|
|
207
|
-
} else {
|
|
208
|
-
// Other lstat failures — continue with original path
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Create directory if it doesn't exist
|
|
213
|
-
if (!existsSync(resolvedPath)) {
|
|
214
|
-
try {
|
|
215
|
-
mkdirSync(resolvedPath, { recursive: true });
|
|
216
|
-
} catch (err: any) {
|
|
217
|
-
throw new Error(
|
|
218
|
-
`Failed to create dbPath directory "${resolvedPath}".\n` +
|
|
219
|
-
` Fix: Ensure the parent directory "${dirname(resolvedPath)}" exists and is writable,\n` +
|
|
220
|
-
` or create it manually: mkdir -p "${resolvedPath}"\n` +
|
|
221
|
-
` Details: ${err.code || ""} ${err.message}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Check write permissions
|
|
227
|
-
try {
|
|
228
|
-
accessSync(resolvedPath, constants.W_OK);
|
|
229
|
-
} catch (err: any) {
|
|
230
|
-
throw new Error(
|
|
231
|
-
`dbPath directory "${resolvedPath}" is not writable.\n` +
|
|
232
|
-
` Fix: Check permissions with: ls -la "${dirname(resolvedPath)}"\n` +
|
|
233
|
-
` Or grant write access: chmod u+w "${resolvedPath}"\n` +
|
|
234
|
-
` Details: ${err.code || ""} ${err.message}`,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return resolvedPath;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ============================================================================
|
|
242
|
-
// Memory Store
|
|
243
|
-
// ============================================================================
|
|
244
|
-
|
|
245
|
-
const TABLE_NAME = "memories";
|
|
246
|
-
|
|
247
|
-
export class MemoryStore {
|
|
248
|
-
private db: LanceDB.Connection | null = null;
|
|
249
|
-
private table: LanceDB.Table | null = null;
|
|
250
|
-
private initPromise: Promise<void> | null = null;
|
|
251
|
-
private ftsIndexCreated = false;
|
|
252
|
-
private updateQueue: Promise<void> = Promise.resolve();
|
|
253
|
-
private semanticGateInstance: SemanticGate | null = null;
|
|
254
|
-
|
|
255
|
-
/** When using a non-LanceDB adapter, this holds the active adapter instance */
|
|
256
|
-
private _adapter: StorageAdapter | null = null;
|
|
257
|
-
|
|
258
|
-
/** True when using the adapter path (non-LanceDB backends) */
|
|
259
|
-
get usingAdapter(): boolean { return this._adapter !== null; }
|
|
260
|
-
|
|
261
|
-
constructor(private readonly config: StoreConfig) { }
|
|
262
|
-
|
|
263
|
-
/** Inject a SemanticGate instance (created externally with an Embedder). */
|
|
264
|
-
setSemanticGate(gate: SemanticGate): void {
|
|
265
|
-
this.semanticGateInstance = gate;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
get dbPath(): string {
|
|
269
|
-
return this.config.dbPath;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Get the active adapter (null if using legacy LanceDB path) */
|
|
273
|
-
get adapter(): StorageAdapter | null {
|
|
274
|
-
return this._adapter;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Whether BM25 full-text search is available */
|
|
278
|
-
get hasFtsSupport(): boolean {
|
|
279
|
-
if (this._adapter) return this._adapter.hasFullTextSearch();
|
|
280
|
-
return this.ftsIndexCreated;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
private async ensureInitialized(): Promise<void> {
|
|
284
|
-
if (this.table || this._adapter) {
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
if (this.initPromise) {
|
|
288
|
-
return this.initPromise;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
this.initPromise = this.doInitialize().catch((err) => {
|
|
292
|
-
this.initPromise = null;
|
|
293
|
-
throw err;
|
|
294
|
-
});
|
|
295
|
-
return this.initPromise;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
private async doInitialize(): Promise<void> {
|
|
299
|
-
// ── Adapter path: non-LanceDB backends (qdrant, chroma, pgvector) ──
|
|
300
|
-
const backend = this.config.storageBackend;
|
|
301
|
-
if (backend && backend !== "lancedb") {
|
|
302
|
-
await ensureAdaptersLoaded();
|
|
303
|
-
const available = listAdapters();
|
|
304
|
-
if (!available.includes(backend)) {
|
|
305
|
-
throw new Error(
|
|
306
|
-
`Storage backend "${backend}" not available. Installed: ${available.join(", ")}. ` +
|
|
307
|
-
`Check that the adapter is properly imported.`
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
this._adapter = createAdapter(backend, this.config.storageConfig);
|
|
311
|
-
await this._adapter.connect(this.config.dbPath);
|
|
312
|
-
await this._adapter.ensureTable(this.config.vectorDim);
|
|
313
|
-
this.ftsIndexCreated = this._adapter.hasFullTextSearch();
|
|
314
|
-
return; // Skip LanceDB initialization
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// ── Legacy LanceDB path (default) ──
|
|
318
|
-
const lancedb = await loadLanceDB();
|
|
319
|
-
|
|
320
|
-
let db: LanceDB.Connection;
|
|
321
|
-
try {
|
|
322
|
-
db = await lancedb.connect(this.config.dbPath);
|
|
323
|
-
} catch (err: any) {
|
|
324
|
-
const code = err.code || "";
|
|
325
|
-
const message = err.message || String(err);
|
|
326
|
-
throw new Error(
|
|
327
|
-
`Failed to open LanceDB at "${this.config.dbPath}": ${code} ${message}\n` +
|
|
328
|
-
` Fix: Verify the path exists and is writable. Check parent directory permissions.`,
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
let table: LanceDB.Table;
|
|
333
|
-
|
|
334
|
-
// Idempotent table init: try openTable first, create only if missing,
|
|
335
|
-
// and handle the race where tableNames() misses an existing table but
|
|
336
|
-
// createTable then sees it (LanceDB eventual consistency).
|
|
337
|
-
try {
|
|
338
|
-
table = await db.openTable(TABLE_NAME);
|
|
339
|
-
|
|
340
|
-
// Check if we need to add scope column for backward compatibility
|
|
341
|
-
try {
|
|
342
|
-
const sample = await table.query().limit(1).toArray();
|
|
343
|
-
if (sample.length > 0 && !("scope" in sample[0])) {
|
|
344
|
-
console.warn(
|
|
345
|
-
"Adding scope column for backward compatibility with existing data",
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
} catch (err) {
|
|
349
|
-
console.warn("Could not check table schema:", err);
|
|
350
|
-
}
|
|
351
|
-
} catch (_openErr) {
|
|
352
|
-
// Table doesn't exist yet — create it
|
|
353
|
-
const schemaEntry: MemoryEntry = {
|
|
354
|
-
id: "__schema__",
|
|
355
|
-
text: "",
|
|
356
|
-
vector: Array.from({ length: this.config.vectorDim }).fill(
|
|
357
|
-
0,
|
|
358
|
-
) as number[],
|
|
359
|
-
category: "other",
|
|
360
|
-
scope: "global",
|
|
361
|
-
importance: 0,
|
|
362
|
-
timestamp: 0,
|
|
363
|
-
metadata: "{}",
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
try {
|
|
367
|
-
table = await db.createTable(TABLE_NAME, [schemaEntry]);
|
|
368
|
-
await table.delete('id = "__schema__"');
|
|
369
|
-
} catch (createErr) {
|
|
370
|
-
// Race: another caller (or eventual consistency) created the table
|
|
371
|
-
// between our failed openTable and this createTable — just open it.
|
|
372
|
-
if (String(createErr).includes("already exists")) {
|
|
373
|
-
table = await db.openTable(TABLE_NAME);
|
|
374
|
-
} else {
|
|
375
|
-
throw createErr;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Validate vector dimensions
|
|
381
|
-
// Note: LanceDB returns Arrow Vector objects, not plain JS arrays.
|
|
382
|
-
// Array.isArray() returns false for Arrow Vectors, so use .length instead.
|
|
383
|
-
const sample = await table.query().limit(1).toArray();
|
|
384
|
-
if (sample.length > 0 && sample[0]?.vector?.length) {
|
|
385
|
-
const existingDim = sample[0].vector.length;
|
|
386
|
-
if (existingDim !== this.config.vectorDim) {
|
|
387
|
-
throw new Error(
|
|
388
|
-
`Vector dimension mismatch: table=${existingDim}, config=${this.config.vectorDim}. Create a new table/dbPath or set matching embedding.dimensions.`,
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Create FTS index for BM25 search (graceful fallback if unavailable)
|
|
394
|
-
try {
|
|
395
|
-
await this.createFtsIndex(table);
|
|
396
|
-
this.ftsIndexCreated = true;
|
|
397
|
-
} catch (err) {
|
|
398
|
-
console.warn(
|
|
399
|
-
"Failed to create FTS index, falling back to vector-only search:",
|
|
400
|
-
err,
|
|
401
|
-
);
|
|
402
|
-
this.ftsIndexCreated = false;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
this.db = db;
|
|
406
|
-
this.table = table;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
private async createFtsIndex(table: LanceDB.Table): Promise<void> {
|
|
410
|
-
try {
|
|
411
|
-
// Check if FTS index already exists
|
|
412
|
-
const indices = await table.listIndices();
|
|
413
|
-
const hasFtsIndex = indices?.some(
|
|
414
|
-
(idx: any) => idx.indexType === "FTS" || idx.columns?.includes("text"),
|
|
415
|
-
);
|
|
416
|
-
|
|
417
|
-
if (!hasFtsIndex) {
|
|
418
|
-
// LanceDB @lancedb/lancedb >=0.26: use Index.fts() config
|
|
419
|
-
const lancedb = await loadLanceDB();
|
|
420
|
-
await table.createIndex("text", {
|
|
421
|
-
config: (lancedb as any).Index.fts(),
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
} catch (err) {
|
|
425
|
-
throw new Error(
|
|
426
|
-
`FTS index creation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
async store(
|
|
432
|
-
entry: Omit<MemoryEntry, "id" | "timestamp">,
|
|
433
|
-
): Promise<MemoryEntry> {
|
|
434
|
-
await this.ensureInitialized();
|
|
435
|
-
|
|
436
|
-
// ── Step 0: Semantic noise gate (before dedup) ──
|
|
437
|
-
if (this.config.semanticGate !== false && this.semanticGateInstance) {
|
|
438
|
-
try {
|
|
439
|
-
const passed = await this.semanticGateInstance.shouldPass(entry.vector, entry.text);
|
|
440
|
-
if (!passed) {
|
|
441
|
-
// Return a synthetic entry with a marker id so callers know it was filtered
|
|
442
|
-
return {
|
|
443
|
-
...entry,
|
|
444
|
-
id: "__filtered__",
|
|
445
|
-
timestamp: Date.now(),
|
|
446
|
-
metadata: entry.metadata || "{}",
|
|
447
|
-
};
|
|
448
|
-
}
|
|
449
|
-
} catch {
|
|
450
|
-
// Gate failure → pass through
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// ── Step 1: Near-duplicate detection ──
|
|
455
|
-
if (this.config.deduplication !== false && entry.vector && entry.vector.length > 0) {
|
|
456
|
-
try {
|
|
457
|
-
const scopeFilter = entry.scope ? [entry.scope] : undefined;
|
|
458
|
-
const similar = await this.vectorSearch(entry.vector, 3, 0.3, scopeFilter);
|
|
459
|
-
|
|
460
|
-
for (const match of similar) {
|
|
461
|
-
// Convert LanceDB distance-based score to cosine similarity
|
|
462
|
-
// vectorSearch returns score = 1 / (1 + distance), so:
|
|
463
|
-
// cosine_similarity ≈ 1 - distance = (2*score - 1) / score ... but simpler:
|
|
464
|
-
// For cosine distance: similarity = 1 - distance
|
|
465
|
-
// score = 1/(1+d) → d = 1/score - 1 → similarity = 1 - d = 2 - 1/score
|
|
466
|
-
const cosineSim = 2 - 1 / match.score;
|
|
467
|
-
if (cosineSim > DEDUP_SIMILARITY_THRESHOLD) {
|
|
468
|
-
// Duplicate found — update existing entry instead of creating new
|
|
469
|
-
const existingMeta = parseSmartMetadata(match.entry.metadata, match.entry);
|
|
470
|
-
const accessCount = (existingMeta.access_count ?? 0) + 1;
|
|
471
|
-
|
|
472
|
-
const updates: {
|
|
473
|
-
text?: string;
|
|
474
|
-
importance?: number;
|
|
475
|
-
metadata?: string;
|
|
476
|
-
} = {};
|
|
477
|
-
|
|
478
|
-
// If new text is longer or importance is higher, update
|
|
479
|
-
if (entry.text.length > match.entry.text.length) {
|
|
480
|
-
updates.text = entry.text;
|
|
481
|
-
}
|
|
482
|
-
if (entry.importance > match.entry.importance) {
|
|
483
|
-
updates.importance = entry.importance;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Always update accessCount and updatedAt
|
|
487
|
-
const patchedMeta = {
|
|
488
|
-
...existingMeta,
|
|
489
|
-
access_count: accessCount,
|
|
490
|
-
updatedAt: Date.now(),
|
|
491
|
-
};
|
|
492
|
-
updates.metadata = stringifySmartMetadata(patchedMeta);
|
|
493
|
-
|
|
494
|
-
await this.update(match.entry.id, updates, scopeFilter);
|
|
495
|
-
|
|
496
|
-
// Return existing entry id so caller knows dedup happened
|
|
497
|
-
return {
|
|
498
|
-
...match.entry,
|
|
499
|
-
id: match.entry.id,
|
|
500
|
-
timestamp: match.entry.timestamp,
|
|
501
|
-
metadata: updates.metadata,
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
} catch {
|
|
506
|
-
// Dedup failure → proceed with normal write
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// ── Step 1b: Conflict detection (mid-similarity range) ──
|
|
511
|
-
// If the new entry is similar but not identical to an existing one,
|
|
512
|
-
// check if it's a contradiction/update and demote the old entry.
|
|
513
|
-
// Search without scope filter to catch conflicts across scopes.
|
|
514
|
-
if (this.config.deduplication !== false && entry.vector && entry.vector.length > 0) {
|
|
515
|
-
try {
|
|
516
|
-
const similar = await this.vectorSearch(entry.vector, 3, 0.3);
|
|
517
|
-
|
|
518
|
-
for (const match of similar) {
|
|
519
|
-
const cosineSim = 2 - 1 / match.score;
|
|
520
|
-
if (cosineSim > CONFLICT_SIMILARITY_THRESHOLD && cosineSim <= DEDUP_SIMILARITY_THRESHOLD) {
|
|
521
|
-
// Mid-range similarity: might be a contradiction or update
|
|
522
|
-
// Heuristic: if both texts are about the same topic but have different
|
|
523
|
-
// values/numbers/states, it's likely a contradiction
|
|
524
|
-
const oldText = match.entry.text || "";
|
|
525
|
-
const newText = entry.text || "";
|
|
526
|
-
|
|
527
|
-
// Quick contradiction signals:
|
|
528
|
-
// 1. Both mention the same entity but with different numbers
|
|
529
|
-
// 2. Negation patterns (不/没/no/not + similar keywords)
|
|
530
|
-
// 3. New text explicitly says "changed to" / "改成" / "updated"
|
|
531
|
-
const hasContradictionSignal =
|
|
532
|
-
/改成|变成|更新为|换成|不再|取消了|changed to|updated to|no longer|switched to/i.test(newText) ||
|
|
533
|
-
(oldText.match(/\d+/) && newText.match(/\d+/) && cosineSim > 0.80);
|
|
534
|
-
|
|
535
|
-
if (hasContradictionSignal) {
|
|
536
|
-
// Audit: record contradiction-based expiration (version history)
|
|
537
|
-
_auditExpire?.(match.entry.id, match.entry.scope || "global", "contradiction",
|
|
538
|
-
`old: "${match.entry.text?.slice(0, 100)}" → new: "${newText.slice(0, 100)}"`);
|
|
539
|
-
|
|
540
|
-
// Demote old entry
|
|
541
|
-
const existingMeta = parseSmartMetadata(match.entry.metadata, match.entry);
|
|
542
|
-
const oldImportance = match.entry.importance ?? 0.7;
|
|
543
|
-
existingMeta.expired_at = new Date().toISOString();
|
|
544
|
-
existingMeta.expired_reason = `superseded: ${newText.slice(0, 80)}`;
|
|
545
|
-
await this.update(
|
|
546
|
-
match.entry.id,
|
|
547
|
-
{
|
|
548
|
-
importance: Math.max(0.05, oldImportance * 0.1),
|
|
549
|
-
metadata: stringifySmartMetadata(existingMeta),
|
|
550
|
-
},
|
|
551
|
-
);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
} catch {
|
|
556
|
-
// Conflict check failure → proceed with normal write
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// ── Step 2: Normal write ──
|
|
561
|
-
const fullEntry: MemoryEntry = {
|
|
562
|
-
...entry,
|
|
563
|
-
id: randomUUID(),
|
|
564
|
-
timestamp: Date.now(),
|
|
565
|
-
metadata: entry.metadata || "{}",
|
|
566
|
-
};
|
|
567
|
-
|
|
568
|
-
try {
|
|
569
|
-
if (this._adapter) {
|
|
570
|
-
await this._adapter.add([fullEntry as any]);
|
|
571
|
-
} else {
|
|
572
|
-
await this.table!.add([fullEntry]);
|
|
573
|
-
}
|
|
574
|
-
} catch (err: any) {
|
|
575
|
-
const code = err.code || "";
|
|
576
|
-
const message = err.message || String(err);
|
|
577
|
-
throw new Error(
|
|
578
|
-
`Failed to store memory in "${this.config.dbPath}": ${code} ${message}`,
|
|
579
|
-
);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// Audit: record creation
|
|
583
|
-
_auditCreate?.(fullEntry.id, fullEntry.scope, fullEntry.scope, "store", fullEntry.text?.slice(0, 200));
|
|
584
|
-
|
|
585
|
-
// ── Step 3: Graphiti 时序图谱双写 with WAL ──
|
|
586
|
-
const textLen = (fullEntry.text || "").length;
|
|
587
|
-
const importance = typeof fullEntry.importance === "number" ? fullEntry.importance : 0.7;
|
|
588
|
-
if (process.env.GRAPHITI_ENABLED === "true" && importance >= 0.5 && textLen >= 20) {
|
|
589
|
-
const graphitiBase = process.env.GRAPHITI_BASE_URL || "http://127.0.0.1:18799";
|
|
590
|
-
const scope = fullEntry.scope || "default";
|
|
591
|
-
const groupId = scope.startsWith("agent:") ? scope.split(":")[1] || "default" : "default";
|
|
592
|
-
const walTs = new Date(fullEntry.timestamp).toISOString();
|
|
593
|
-
|
|
594
|
-
// Write WAL pending entry before Graphiti call (Pro feature)
|
|
595
|
-
if (walAppend) {
|
|
596
|
-
walAppend({
|
|
597
|
-
ts: walTs,
|
|
598
|
-
action: "write",
|
|
599
|
-
text: fullEntry.text,
|
|
600
|
-
scope,
|
|
601
|
-
category: fullEntry.category || "fact",
|
|
602
|
-
groupId,
|
|
603
|
-
importance,
|
|
604
|
-
status: "pending",
|
|
605
|
-
}).catch(() => {});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Async Graphiti write (WAL tracking is Pro, Graphiti write is Core)
|
|
609
|
-
fetch(`${graphitiBase}/episodes`, {
|
|
610
|
-
method: "POST",
|
|
611
|
-
headers: { "Content-Type": "application/json" },
|
|
612
|
-
body: JSON.stringify({
|
|
613
|
-
text: `[${fullEntry.category || "fact"}] ${fullEntry.text}`,
|
|
614
|
-
group_id: groupId,
|
|
615
|
-
reference_time: walTs,
|
|
616
|
-
source: `lancedb-pro-store-${groupId}`,
|
|
617
|
-
category: fullEntry.category || "fact",
|
|
618
|
-
}),
|
|
619
|
-
signal: AbortSignal.timeout(15000),
|
|
620
|
-
})
|
|
621
|
-
.then(() => {
|
|
622
|
-
walMarkCommitted?.(walTs).catch(() => {});
|
|
623
|
-
})
|
|
624
|
-
.catch((err) => {
|
|
625
|
-
walMarkFailed?.(walTs, String(err)).catch(() => {});
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
return fullEntry;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
/**
|
|
633
|
-
* Import a pre-built entry while preserving its id/timestamp.
|
|
634
|
-
* Used for re-embedding / migration / A/B testing across embedding models.
|
|
635
|
-
* Intentionally separate from `store()` to keep normal writes simple.
|
|
636
|
-
*/
|
|
637
|
-
async importEntry(entry: MemoryEntry): Promise<MemoryEntry> {
|
|
638
|
-
await this.ensureInitialized();
|
|
639
|
-
|
|
640
|
-
if (!entry.id || typeof entry.id !== "string") {
|
|
641
|
-
throw new Error("importEntry requires a stable id");
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
const vector = entry.vector || [];
|
|
645
|
-
if (!Array.isArray(vector) || vector.length !== this.config.vectorDim) {
|
|
646
|
-
throw new Error(
|
|
647
|
-
`Vector dimension mismatch: expected ${this.config.vectorDim}, got ${Array.isArray(vector) ? vector.length : "non-array"}`,
|
|
648
|
-
);
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
const full: MemoryEntry = {
|
|
652
|
-
...entry,
|
|
653
|
-
scope: entry.scope || "global",
|
|
654
|
-
importance: Number.isFinite(entry.importance) ? entry.importance : 0.7,
|
|
655
|
-
timestamp: Number.isFinite(entry.timestamp)
|
|
656
|
-
? entry.timestamp
|
|
657
|
-
: Date.now(),
|
|
658
|
-
metadata: entry.metadata || "{}",
|
|
659
|
-
};
|
|
660
|
-
|
|
661
|
-
await this.table!.add([full]);
|
|
662
|
-
return full;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
async hasId(id: string): Promise<boolean> {
|
|
666
|
-
await this.ensureInitialized();
|
|
667
|
-
if (this._adapter) {
|
|
668
|
-
const results = await this._adapter.query({ where: `id = '${escapeSqlLiteral(id)}'`, limit: 1 });
|
|
669
|
-
return results.length > 0;
|
|
670
|
-
}
|
|
671
|
-
const safeId = escapeSqlLiteral(id);
|
|
672
|
-
const res = await this.table!.query()
|
|
673
|
-
.select(["id"])
|
|
674
|
-
.where(`id = '${safeId}'`)
|
|
675
|
-
.limit(1)
|
|
676
|
-
.toArray();
|
|
677
|
-
return res.length > 0;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
async getById(id: string, scopeFilter?: string[]): Promise<MemoryEntry | null> {
|
|
681
|
-
await this.ensureInitialized();
|
|
682
|
-
|
|
683
|
-
if (this._adapter) {
|
|
684
|
-
const results = await this._adapter.query({ where: `id = '${escapeSqlLiteral(id)}'`, limit: 1 });
|
|
685
|
-
if (results.length === 0) return null;
|
|
686
|
-
const r = results[0];
|
|
687
|
-
return { id: r.id, text: r.text, vector: r.vector, category: r.category as any, scope: r.scope, importance: r.importance, timestamp: r.timestamp, metadata: r.metadata } as MemoryEntry;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
const safeId = escapeSqlLiteral(id);
|
|
691
|
-
const rows = await this.table!
|
|
692
|
-
.query()
|
|
693
|
-
.where(`id = '${safeId}'`)
|
|
694
|
-
.limit(1)
|
|
695
|
-
.toArray();
|
|
696
|
-
|
|
697
|
-
if (rows.length === 0) return null;
|
|
698
|
-
|
|
699
|
-
const row = rows[0];
|
|
700
|
-
const rowScope = (row.scope as string | undefined) ?? "global";
|
|
701
|
-
if (scopeFilter && scopeFilter.length > 0 && !scopeFilter.includes(rowScope)) {
|
|
702
|
-
return null;
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
return {
|
|
706
|
-
id: row.id as string,
|
|
707
|
-
text: row.text as string,
|
|
708
|
-
vector: Array.from(row.vector as Iterable<number>),
|
|
709
|
-
category: row.category as MemoryEntry["category"],
|
|
710
|
-
scope: rowScope,
|
|
711
|
-
importance: Number(row.importance),
|
|
712
|
-
timestamp: Number(row.timestamp),
|
|
713
|
-
metadata: (row.metadata as string) || "{}",
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
async vectorSearch(vector: number[], limit = 5, minScore = 0.3, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
|
|
718
|
-
await this.ensureInitialized();
|
|
719
|
-
|
|
720
|
-
// Adapter path: delegate to backend
|
|
721
|
-
if (this._adapter) {
|
|
722
|
-
const results = await this._adapter.vectorSearch(vector, limit, minScore, scopeFilter);
|
|
723
|
-
return results.map(r => ({
|
|
724
|
-
entry: { id: r.record.id, text: r.record.text, vector: r.record.vector, category: r.record.category as any, scope: r.record.scope, importance: r.record.importance, timestamp: r.record.timestamp, metadata: r.record.metadata } as MemoryEntry,
|
|
725
|
-
score: r.score,
|
|
726
|
-
}));
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
const safeLimit = clampInt(limit, 1, 20);
|
|
730
|
-
const fetchLimit = Math.min(safeLimit * 10, 200); // Over-fetch for scope filtering
|
|
731
|
-
|
|
732
|
-
let query = this.table!.vectorSearch(vector).distanceType('cosine').limit(fetchLimit);
|
|
733
|
-
|
|
734
|
-
// Apply scope filter if provided
|
|
735
|
-
if (scopeFilter && scopeFilter.length > 0) {
|
|
736
|
-
const scopeConditions = scopeFilter
|
|
737
|
-
.map((scope) => `scope = '${escapeSqlLiteral(scope)}'`)
|
|
738
|
-
.join(" OR ");
|
|
739
|
-
query = query.where(`(${scopeConditions}) OR scope IS NULL`); // NULL for backward compatibility
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
const results = await query.toArray();
|
|
743
|
-
const mapped: MemorySearchResult[] = [];
|
|
744
|
-
|
|
745
|
-
for (const row of results) {
|
|
746
|
-
const distance = Number(row._distance ?? 0);
|
|
747
|
-
const score = 1 / (1 + distance);
|
|
748
|
-
|
|
749
|
-
if (score < minScore) continue;
|
|
750
|
-
|
|
751
|
-
const rowScope = (row.scope as string | undefined) ?? "global";
|
|
752
|
-
|
|
753
|
-
// Double-check scope filter in application layer
|
|
754
|
-
if (
|
|
755
|
-
scopeFilter &&
|
|
756
|
-
scopeFilter.length > 0 &&
|
|
757
|
-
!scopeFilter.includes(rowScope)
|
|
758
|
-
) {
|
|
759
|
-
continue;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
mapped.push({
|
|
763
|
-
entry: {
|
|
764
|
-
id: row.id as string,
|
|
765
|
-
text: row.text as string,
|
|
766
|
-
vector: row.vector as number[],
|
|
767
|
-
category: row.category as MemoryEntry["category"],
|
|
768
|
-
scope: rowScope,
|
|
769
|
-
importance: Number(row.importance),
|
|
770
|
-
timestamp: Number(row.timestamp),
|
|
771
|
-
metadata: (row.metadata as string) || "{}",
|
|
772
|
-
},
|
|
773
|
-
score,
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
if (mapped.length >= safeLimit) break;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
return mapped;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async bm25Search(
|
|
783
|
-
query: string,
|
|
784
|
-
limit = 5,
|
|
785
|
-
scopeFilter?: string[],
|
|
786
|
-
): Promise<MemorySearchResult[]> {
|
|
787
|
-
await this.ensureInitialized();
|
|
788
|
-
|
|
789
|
-
// Adapter path
|
|
790
|
-
if (this._adapter) {
|
|
791
|
-
const results = await this._adapter.fullTextSearch(query, limit, scopeFilter);
|
|
792
|
-
return results.map(r => ({
|
|
793
|
-
entry: { id: r.record.id, text: r.record.text, vector: r.record.vector, category: r.record.category as any, scope: r.record.scope, importance: r.record.importance, timestamp: r.record.timestamp, metadata: r.record.metadata } as MemoryEntry,
|
|
794
|
-
score: r.score,
|
|
795
|
-
}));
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const safeLimit = clampInt(limit, 1, 20);
|
|
799
|
-
|
|
800
|
-
if (!this.ftsIndexCreated) {
|
|
801
|
-
return this.lexicalFallbackSearch(query, safeLimit, scopeFilter);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
try {
|
|
805
|
-
// Use FTS query type explicitly
|
|
806
|
-
let searchQuery = this.table!.search(query, "fts").limit(safeLimit);
|
|
807
|
-
|
|
808
|
-
// Apply scope filter if provided
|
|
809
|
-
if (scopeFilter && scopeFilter.length > 0) {
|
|
810
|
-
const scopeConditions = scopeFilter
|
|
811
|
-
.map((scope) => `scope = '${escapeSqlLiteral(scope)}'`)
|
|
812
|
-
.join(" OR ");
|
|
813
|
-
searchQuery = searchQuery.where(
|
|
814
|
-
`(${scopeConditions}) OR scope IS NULL`,
|
|
815
|
-
);
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
const results = await searchQuery.toArray();
|
|
819
|
-
const mapped: MemorySearchResult[] = [];
|
|
820
|
-
|
|
821
|
-
for (const row of results) {
|
|
822
|
-
const rowScope = (row.scope as string | undefined) ?? "global";
|
|
823
|
-
|
|
824
|
-
// Double-check scope filter in application layer
|
|
825
|
-
if (
|
|
826
|
-
scopeFilter &&
|
|
827
|
-
scopeFilter.length > 0 &&
|
|
828
|
-
!scopeFilter.includes(rowScope)
|
|
829
|
-
) {
|
|
830
|
-
continue;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// LanceDB FTS _score is raw BM25 (unbounded). Normalize with sigmoid.
|
|
834
|
-
// LanceDB may return BigInt for numeric columns; coerce safely.
|
|
835
|
-
const rawScore = row._score != null ? Number(row._score) : 0;
|
|
836
|
-
const normalizedScore =
|
|
837
|
-
rawScore > 0 ? 1 / (1 + Math.exp(-rawScore / 5)) : 0.5;
|
|
838
|
-
|
|
839
|
-
mapped.push({
|
|
840
|
-
entry: {
|
|
841
|
-
id: row.id as string,
|
|
842
|
-
text: row.text as string,
|
|
843
|
-
vector: row.vector as number[],
|
|
844
|
-
category: row.category as MemoryEntry["category"],
|
|
845
|
-
scope: rowScope,
|
|
846
|
-
importance: Number(row.importance),
|
|
847
|
-
timestamp: Number(row.timestamp),
|
|
848
|
-
metadata: (row.metadata as string) || "{}",
|
|
849
|
-
},
|
|
850
|
-
score: normalizedScore,
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
if (mapped.length > 0) {
|
|
855
|
-
return mapped;
|
|
856
|
-
}
|
|
857
|
-
return this.lexicalFallbackSearch(query, safeLimit, scopeFilter);
|
|
858
|
-
} catch (err) {
|
|
859
|
-
console.warn("BM25 search failed, falling back to empty results:", err);
|
|
860
|
-
return this.lexicalFallbackSearch(query, safeLimit, scopeFilter);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
private async lexicalFallbackSearch(query: string, limit: number, scopeFilter?: string[]): Promise<MemorySearchResult[]> {
|
|
865
|
-
const trimmedQuery = query.trim();
|
|
866
|
-
if (!trimmedQuery) return [];
|
|
867
|
-
|
|
868
|
-
let searchQuery = this.table!.query().select([
|
|
869
|
-
"id",
|
|
870
|
-
"text",
|
|
871
|
-
"vector",
|
|
872
|
-
"category",
|
|
873
|
-
"scope",
|
|
874
|
-
"importance",
|
|
875
|
-
"timestamp",
|
|
876
|
-
"metadata",
|
|
877
|
-
]);
|
|
878
|
-
|
|
879
|
-
if (scopeFilter && scopeFilter.length > 0) {
|
|
880
|
-
const scopeConditions = scopeFilter
|
|
881
|
-
.map(scope => `scope = '${escapeSqlLiteral(scope)}'`)
|
|
882
|
-
.join(" OR ");
|
|
883
|
-
searchQuery = searchQuery.where(`(${scopeConditions}) OR scope IS NULL`);
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
const rows = await searchQuery.toArray();
|
|
887
|
-
const matches: MemorySearchResult[] = [];
|
|
888
|
-
|
|
889
|
-
for (const row of rows) {
|
|
890
|
-
const rowScope = (row.scope as string | undefined) ?? "global";
|
|
891
|
-
if (scopeFilter && scopeFilter.length > 0 && !scopeFilter.includes(rowScope)) {
|
|
892
|
-
continue;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const entry: MemoryEntry = {
|
|
896
|
-
id: row.id as string,
|
|
897
|
-
text: row.text as string,
|
|
898
|
-
vector: row.vector as number[],
|
|
899
|
-
category: row.category as MemoryEntry["category"],
|
|
900
|
-
scope: rowScope,
|
|
901
|
-
importance: Number(row.importance),
|
|
902
|
-
timestamp: Number(row.timestamp),
|
|
903
|
-
metadata: (row.metadata as string) || "{}",
|
|
904
|
-
};
|
|
905
|
-
|
|
906
|
-
const metadata = parseSmartMetadata(entry.metadata, entry);
|
|
907
|
-
const score = scoreLexicalHit(trimmedQuery, [
|
|
908
|
-
{ text: entry.text, weight: 1 },
|
|
909
|
-
{ text: metadata.l0_abstract, weight: 0.98 },
|
|
910
|
-
{ text: metadata.l1_overview, weight: 0.92 },
|
|
911
|
-
{ text: metadata.l2_content, weight: 0.96 },
|
|
912
|
-
]);
|
|
913
|
-
|
|
914
|
-
if (score <= 0) continue;
|
|
915
|
-
matches.push({ entry, score });
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
return matches
|
|
919
|
-
.sort((a, b) => b.score - a.score || b.entry.timestamp - a.entry.timestamp)
|
|
920
|
-
.slice(0, limit);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
async delete(id: string, scopeFilter?: string[]): Promise<boolean> {
|
|
924
|
-
await this.ensureInitialized();
|
|
925
|
-
|
|
926
|
-
// Support both full UUID and short prefix (8+ hex chars)
|
|
927
|
-
const uuidRegex =
|
|
928
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
929
|
-
const prefixRegex = /^[0-9a-f]{8,}$/i;
|
|
930
|
-
const isFullId = uuidRegex.test(id);
|
|
931
|
-
const isPrefix = !isFullId && prefixRegex.test(id);
|
|
932
|
-
|
|
933
|
-
if (!isFullId && !isPrefix) {
|
|
934
|
-
throw new Error(`Invalid memory ID format: ${id}`);
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
let candidates: any[];
|
|
938
|
-
if (isFullId) {
|
|
939
|
-
candidates = await this.table!.query()
|
|
940
|
-
.where(`id = '${id}'`)
|
|
941
|
-
.limit(1)
|
|
942
|
-
.toArray();
|
|
943
|
-
} else {
|
|
944
|
-
// Prefix match: fetch candidates and filter in app layer
|
|
945
|
-
const all = await this.table!.query()
|
|
946
|
-
.select(["id", "scope"])
|
|
947
|
-
.limit(1000)
|
|
948
|
-
.toArray();
|
|
949
|
-
candidates = all.filter((r: any) => (r.id as string).startsWith(id));
|
|
950
|
-
if (candidates.length > 1) {
|
|
951
|
-
throw new Error(
|
|
952
|
-
`Ambiguous prefix "${id}" matches ${candidates.length} memories. Use a longer prefix or full ID.`,
|
|
953
|
-
);
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
if (candidates.length === 0) {
|
|
957
|
-
return false;
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
const resolvedId = candidates[0].id as string;
|
|
961
|
-
const rowScope = (candidates[0].scope as string | undefined) ?? "global";
|
|
962
|
-
|
|
963
|
-
// Check scope permissions
|
|
964
|
-
if (
|
|
965
|
-
scopeFilter &&
|
|
966
|
-
scopeFilter.length > 0 &&
|
|
967
|
-
!scopeFilter.includes(rowScope)
|
|
968
|
-
) {
|
|
969
|
-
throw new Error(`Memory ${resolvedId} is outside accessible scopes`);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// Audit: record deletion with old value for version history
|
|
973
|
-
_auditDelete?.([resolvedId], rowScope, "user-request");
|
|
974
|
-
|
|
975
|
-
if (this._adapter) {
|
|
976
|
-
await this._adapter.delete(`id = '${escapeSqlLiteral(resolvedId)}'`);
|
|
977
|
-
} else {
|
|
978
|
-
await this.table!.delete(`id = '${resolvedId}'`);
|
|
979
|
-
}
|
|
980
|
-
return true;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
async list(
|
|
984
|
-
scopeFilter?: string[],
|
|
985
|
-
category?: string,
|
|
986
|
-
limit = 20,
|
|
987
|
-
offset = 0,
|
|
988
|
-
): Promise<MemoryEntry[]> {
|
|
989
|
-
await this.ensureInitialized();
|
|
990
|
-
|
|
991
|
-
let query = this.table!.query();
|
|
992
|
-
|
|
993
|
-
// Build where conditions
|
|
994
|
-
const conditions: string[] = [];
|
|
995
|
-
|
|
996
|
-
if (scopeFilter && scopeFilter.length > 0) {
|
|
997
|
-
const scopeConditions = scopeFilter
|
|
998
|
-
.map((scope) => `scope = '${escapeSqlLiteral(scope)}'`)
|
|
999
|
-
.join(" OR ");
|
|
1000
|
-
conditions.push(`((${scopeConditions}) OR scope IS NULL)`);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
if (category) {
|
|
1004
|
-
conditions.push(`category = '${escapeSqlLiteral(category)}'`);
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
if (conditions.length > 0) {
|
|
1008
|
-
query = query.where(conditions.join(" AND "));
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// Fetch all matching rows (no pre-limit) so app-layer sort is correct across full dataset
|
|
1012
|
-
const results = await query
|
|
1013
|
-
.select([
|
|
1014
|
-
"id",
|
|
1015
|
-
"text",
|
|
1016
|
-
"category",
|
|
1017
|
-
"scope",
|
|
1018
|
-
"importance",
|
|
1019
|
-
"timestamp",
|
|
1020
|
-
"metadata",
|
|
1021
|
-
])
|
|
1022
|
-
.toArray();
|
|
1023
|
-
|
|
1024
|
-
return results
|
|
1025
|
-
.map(
|
|
1026
|
-
(row): MemoryEntry => ({
|
|
1027
|
-
id: row.id as string,
|
|
1028
|
-
text: row.text as string,
|
|
1029
|
-
vector: [], // Don't include vectors in list results for performance
|
|
1030
|
-
category: row.category as MemoryEntry["category"],
|
|
1031
|
-
scope: (row.scope as string | undefined) ?? "global",
|
|
1032
|
-
importance: Number(row.importance),
|
|
1033
|
-
timestamp: Number(row.timestamp),
|
|
1034
|
-
metadata: (row.metadata as string) || "{}",
|
|
1035
|
-
}),
|
|
1036
|
-
)
|
|
1037
|
-
.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0))
|
|
1038
|
-
.slice(offset, offset + limit);
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
async stats(scopeFilter?: string[]): Promise<{
|
|
1042
|
-
totalCount: number;
|
|
1043
|
-
scopeCounts: Record<string, number>;
|
|
1044
|
-
categoryCounts: Record<string, number>;
|
|
1045
|
-
}> {
|
|
1046
|
-
await this.ensureInitialized();
|
|
1047
|
-
|
|
1048
|
-
let query = this.table!.query();
|
|
1049
|
-
|
|
1050
|
-
if (scopeFilter && scopeFilter.length > 0) {
|
|
1051
|
-
const scopeConditions = scopeFilter
|
|
1052
|
-
.map((scope) => `scope = '${escapeSqlLiteral(scope)}'`)
|
|
1053
|
-
.join(" OR ");
|
|
1054
|
-
query = query.where(`((${scopeConditions}) OR scope IS NULL)`);
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
const results = await query.select(["scope", "category"]).toArray();
|
|
1058
|
-
|
|
1059
|
-
const scopeCounts: Record<string, number> = {};
|
|
1060
|
-
const categoryCounts: Record<string, number> = {};
|
|
1061
|
-
|
|
1062
|
-
for (const row of results) {
|
|
1063
|
-
const scope = (row.scope as string | undefined) ?? "global";
|
|
1064
|
-
const category = row.category as string;
|
|
1065
|
-
|
|
1066
|
-
scopeCounts[scope] = (scopeCounts[scope] || 0) + 1;
|
|
1067
|
-
categoryCounts[category] = (categoryCounts[category] || 0) + 1;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
return {
|
|
1071
|
-
totalCount: results.length,
|
|
1072
|
-
scopeCounts,
|
|
1073
|
-
categoryCounts,
|
|
1074
|
-
};
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
async update(
|
|
1078
|
-
id: string,
|
|
1079
|
-
updates: {
|
|
1080
|
-
text?: string;
|
|
1081
|
-
vector?: number[];
|
|
1082
|
-
importance?: number;
|
|
1083
|
-
category?: MemoryEntry["category"];
|
|
1084
|
-
metadata?: string;
|
|
1085
|
-
},
|
|
1086
|
-
scopeFilter?: string[],
|
|
1087
|
-
): Promise<MemoryEntry | null> {
|
|
1088
|
-
await this.ensureInitialized();
|
|
1089
|
-
|
|
1090
|
-
return this.runSerializedUpdate(async () => {
|
|
1091
|
-
// Support both full UUID and short prefix (8+ hex chars), same as delete()
|
|
1092
|
-
const uuidRegex =
|
|
1093
|
-
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1094
|
-
const prefixRegex = /^[0-9a-f]{8,}$/i;
|
|
1095
|
-
const isFullId = uuidRegex.test(id);
|
|
1096
|
-
const isPrefix = !isFullId && prefixRegex.test(id);
|
|
1097
|
-
|
|
1098
|
-
if (!isFullId && !isPrefix) {
|
|
1099
|
-
throw new Error(`Invalid memory ID format: ${id}`);
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
let rows: any[];
|
|
1103
|
-
if (isFullId) {
|
|
1104
|
-
const safeId = escapeSqlLiteral(id);
|
|
1105
|
-
rows = await this.table!.query()
|
|
1106
|
-
.where(`id = '${safeId}'`)
|
|
1107
|
-
.limit(1)
|
|
1108
|
-
.toArray();
|
|
1109
|
-
} else {
|
|
1110
|
-
// Prefix match
|
|
1111
|
-
const all = await this.table!.query()
|
|
1112
|
-
.select([
|
|
1113
|
-
"id",
|
|
1114
|
-
"text",
|
|
1115
|
-
"vector",
|
|
1116
|
-
"category",
|
|
1117
|
-
"scope",
|
|
1118
|
-
"importance",
|
|
1119
|
-
"timestamp",
|
|
1120
|
-
"metadata",
|
|
1121
|
-
])
|
|
1122
|
-
.limit(1000)
|
|
1123
|
-
.toArray();
|
|
1124
|
-
rows = all.filter((r: any) => (r.id as string).startsWith(id));
|
|
1125
|
-
if (rows.length > 1) {
|
|
1126
|
-
throw new Error(
|
|
1127
|
-
`Ambiguous prefix "${id}" matches ${rows.length} memories. Use a longer prefix or full ID.`,
|
|
1128
|
-
);
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
if (rows.length === 0) return null;
|
|
1133
|
-
|
|
1134
|
-
const row = rows[0];
|
|
1135
|
-
const rowScope = (row.scope as string | undefined) ?? "global";
|
|
1136
|
-
|
|
1137
|
-
// Check scope permissions
|
|
1138
|
-
if (
|
|
1139
|
-
scopeFilter &&
|
|
1140
|
-
scopeFilter.length > 0 &&
|
|
1141
|
-
!scopeFilter.includes(rowScope)
|
|
1142
|
-
) {
|
|
1143
|
-
throw new Error(`Memory ${id} is outside accessible scopes`);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
const original: MemoryEntry = {
|
|
1147
|
-
id: row.id as string,
|
|
1148
|
-
text: row.text as string,
|
|
1149
|
-
vector: Array.from(row.vector as Iterable<number>),
|
|
1150
|
-
category: row.category as MemoryEntry["category"],
|
|
1151
|
-
scope: rowScope,
|
|
1152
|
-
importance: Number(row.importance),
|
|
1153
|
-
timestamp: Number(row.timestamp),
|
|
1154
|
-
metadata: (row.metadata as string) || "{}",
|
|
1155
|
-
};
|
|
1156
|
-
|
|
1157
|
-
// Build updated entry, preserving original timestamp
|
|
1158
|
-
const updated: MemoryEntry = {
|
|
1159
|
-
...original,
|
|
1160
|
-
text: updates.text ?? original.text,
|
|
1161
|
-
vector: updates.vector ?? original.vector,
|
|
1162
|
-
category: updates.category ?? original.category,
|
|
1163
|
-
scope: rowScope,
|
|
1164
|
-
importance: updates.importance ?? original.importance,
|
|
1165
|
-
timestamp: original.timestamp, // preserve original
|
|
1166
|
-
metadata: updates.metadata ?? original.metadata,
|
|
1167
|
-
};
|
|
1168
|
-
|
|
1169
|
-
// Audit: record update with old value snapshot (version history)
|
|
1170
|
-
_auditUpdate?.(original.id, rowScope, "memory-update",
|
|
1171
|
-
JSON.stringify({
|
|
1172
|
-
old: { text: original.text?.slice(0, 200), importance: original.importance, category: original.category },
|
|
1173
|
-
new: { text: updated.text?.slice(0, 200), importance: updated.importance, category: updated.category },
|
|
1174
|
-
})
|
|
1175
|
-
);
|
|
1176
|
-
|
|
1177
|
-
// LanceDB doesn't support in-place update; delete + re-add.
|
|
1178
|
-
// Serialize updates per store instance to avoid stale rollback races.
|
|
1179
|
-
// If the add fails after delete, attempt best-effort recovery without
|
|
1180
|
-
// overwriting a newer concurrent successful update.
|
|
1181
|
-
const rollbackCandidate =
|
|
1182
|
-
(await this.getById(original.id).catch(() => null)) ?? original;
|
|
1183
|
-
const resolvedId = escapeSqlLiteral(row.id as string);
|
|
1184
|
-
await this.table!.delete(`id = '${resolvedId}'`);
|
|
1185
|
-
try {
|
|
1186
|
-
await this.table!.add([updated]);
|
|
1187
|
-
} catch (addError) {
|
|
1188
|
-
const current = await this.getById(original.id).catch(() => null);
|
|
1189
|
-
if (current) {
|
|
1190
|
-
throw new Error(
|
|
1191
|
-
`Failed to update memory ${id}: write failed after delete, but an existing record was preserved. ` +
|
|
1192
|
-
`Write error: ${addError instanceof Error ? addError.message : String(addError)}`,
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
try {
|
|
1197
|
-
await this.table!.add([rollbackCandidate]);
|
|
1198
|
-
} catch (rollbackError) {
|
|
1199
|
-
throw new Error(
|
|
1200
|
-
`Failed to update memory ${id}: write failed after delete, and rollback also failed. ` +
|
|
1201
|
-
`Write error: ${addError instanceof Error ? addError.message : String(addError)}. ` +
|
|
1202
|
-
`Rollback error: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`,
|
|
1203
|
-
);
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
throw new Error(
|
|
1207
|
-
`Failed to update memory ${id}: write failed after delete, latest available record restored. ` +
|
|
1208
|
-
`Write error: ${addError instanceof Error ? addError.message : String(addError)}`,
|
|
1209
|
-
);
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
return updated;
|
|
1213
|
-
});
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
private async runSerializedUpdate<T>(action: () => Promise<T>): Promise<T> {
|
|
1217
|
-
const previous = this.updateQueue;
|
|
1218
|
-
let release: (() => void) | undefined;
|
|
1219
|
-
const lock = new Promise<void>((resolve) => {
|
|
1220
|
-
release = resolve;
|
|
1221
|
-
});
|
|
1222
|
-
this.updateQueue = previous.then(() => lock);
|
|
1223
|
-
|
|
1224
|
-
await previous;
|
|
1225
|
-
try {
|
|
1226
|
-
return await action();
|
|
1227
|
-
} finally {
|
|
1228
|
-
release?.();
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
async patchMetadata(
|
|
1233
|
-
id: string,
|
|
1234
|
-
patch: MetadataPatch,
|
|
1235
|
-
scopeFilter?: string[],
|
|
1236
|
-
): Promise<MemoryEntry | null> {
|
|
1237
|
-
const existing = await this.getById(id, scopeFilter);
|
|
1238
|
-
if (!existing) return null;
|
|
1239
|
-
|
|
1240
|
-
const metadata = buildSmartMetadata(existing, patch);
|
|
1241
|
-
return this.update(
|
|
1242
|
-
id,
|
|
1243
|
-
{ metadata: stringifySmartMetadata(metadata) },
|
|
1244
|
-
scopeFilter,
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
async bulkDelete(scopeFilter: string[], beforeTimestamp?: number): Promise<number> {
|
|
1249
|
-
await this.ensureInitialized();
|
|
1250
|
-
|
|
1251
|
-
const conditions: string[] = [];
|
|
1252
|
-
|
|
1253
|
-
if (scopeFilter.length > 0) {
|
|
1254
|
-
const scopeConditions = scopeFilter
|
|
1255
|
-
.map((scope) => `scope = '${escapeSqlLiteral(scope)}'`)
|
|
1256
|
-
.join(" OR ");
|
|
1257
|
-
conditions.push(`(${scopeConditions})`);
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
if (beforeTimestamp) {
|
|
1261
|
-
conditions.push(`timestamp < ${beforeTimestamp}`);
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
if (conditions.length === 0) {
|
|
1265
|
-
throw new Error(
|
|
1266
|
-
"Bulk delete requires at least scope or timestamp filter for safety",
|
|
1267
|
-
);
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
const whereClause = conditions.join(" AND ");
|
|
1271
|
-
|
|
1272
|
-
// Count first
|
|
1273
|
-
const countResults = await this.table!.query().where(whereClause).toArray();
|
|
1274
|
-
const deleteCount = countResults.length;
|
|
1275
|
-
|
|
1276
|
-
// Then delete
|
|
1277
|
-
if (deleteCount > 0) {
|
|
1278
|
-
await this.table!.delete(whereClause);
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
return deleteCount;
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
get hasFtsSupport(): boolean {
|
|
1285
|
-
return this.ftsIndexCreated;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
/** Last FTS error for diagnostics */
|
|
1289
|
-
private _lastFtsError: string | null = null;
|
|
1290
|
-
|
|
1291
|
-
get lastFtsError(): string | null {
|
|
1292
|
-
return this._lastFtsError;
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
/** Get FTS index health status */
|
|
1296
|
-
getFtsStatus(): { available: boolean; lastError: string | null } {
|
|
1297
|
-
return {
|
|
1298
|
-
available: this.ftsIndexCreated,
|
|
1299
|
-
lastError: this._lastFtsError,
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
/** Rebuild FTS index (drops and recreates). Useful for recovery after corruption. */
|
|
1304
|
-
async rebuildFtsIndex(): Promise<{ success: boolean; error?: string }> {
|
|
1305
|
-
await this.ensureInitialized();
|
|
1306
|
-
try {
|
|
1307
|
-
// Drop existing FTS index if any
|
|
1308
|
-
const indices = await this.table!.listIndices();
|
|
1309
|
-
for (const idx of indices) {
|
|
1310
|
-
if (idx.indexType === "FTS" || idx.columns?.includes("text")) {
|
|
1311
|
-
try {
|
|
1312
|
-
await this.table!.dropIndex((idx as any).name || "text");
|
|
1313
|
-
} catch (err) {
|
|
1314
|
-
console.warn(`mnemo: dropIndex(${(idx as any).name || "text"}) failed:`, err);
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
// Recreate
|
|
1319
|
-
await this.createFtsIndex(this.table!);
|
|
1320
|
-
this.ftsIndexCreated = true;
|
|
1321
|
-
this._lastFtsError = null;
|
|
1322
|
-
return { success: true };
|
|
1323
|
-
} catch (err) {
|
|
1324
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1325
|
-
this._lastFtsError = msg;
|
|
1326
|
-
this.ftsIndexCreated = false;
|
|
1327
|
-
return { success: false, error: msg };
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
}
|