memorix 1.9.0 → 1.9.2
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 +39 -0
- package/README.md +22 -5
- package/README.zh-CN.md +9 -5
- package/dist/cli/index.js +2635 -1942
- package/dist/index.js +2445 -1766
- package/dist/maintenance-runner.js +1696 -1036
- package/dist/media-audio-runner.js +2197 -1537
- package/dist/media-video-runner.js +2195 -1535
- package/dist/memcode-runtime/CHANGELOG.md +39 -0
- package/dist/sdk.js +2507 -1764
- package/dist/vector-backfill-runner.js +2193 -1533
- package/docs/CONFIGURATION.md +22 -0
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/INTEGRATIONS.md +4 -0
- package/docs/PERFORMANCE.md +77 -20
- package/docs/README.md +1 -1
- package/docs/SETUP.md +5 -0
- package/package.json +2 -2
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/memory.ts +2 -1
- package/src/cli/default-mode.ts +15 -0
- package/src/cli/index.ts +10 -1
- package/src/codegraph/lite-provider.ts +10 -1
- package/src/compact/engine.ts +14 -4
- package/src/dashboard/maintenance.ts +11 -2
- package/src/memory/consolidation.ts +15 -6
- package/src/memory/freshness.ts +7 -4
- package/src/memory/observations.ts +118 -6
- package/src/runtime/vector-backfill-runner.ts +1 -1
- package/src/sdk.ts +13 -6
- package/src/search/semantic-index.ts +340 -0
- package/src/server.ts +1 -1
- package/src/store/obs-store.ts +54 -2
- package/src/store/orama-store.ts +300 -12
- package/src/store/sqlite-db.ts +6 -0
- package/src/store/sqlite-fts.ts +164 -0
- package/src/store/sqlite-store.ts +83 -6
- package/assets/star-history-dark.svg +0 -30
- package/assets/star-history-light.svg +0 -30
- package/assets/star-history.json +0 -712
package/src/store/obs-store.ts
CHANGED
|
@@ -45,6 +45,20 @@ export interface StoreTransaction {
|
|
|
45
45
|
getGeneration(): Promise<number>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export interface LexicalSearchOptions {
|
|
49
|
+
query: string;
|
|
50
|
+
projectId?: string | string[];
|
|
51
|
+
status?: string | 'all';
|
|
52
|
+
type?: string;
|
|
53
|
+
source?: string;
|
|
54
|
+
limit?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LexicalSearchHit {
|
|
58
|
+
observation: Observation;
|
|
59
|
+
score: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
export interface ObservationStore {
|
|
49
63
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
|
50
64
|
|
|
@@ -63,7 +77,7 @@ export interface ObservationStore {
|
|
|
63
77
|
): Promise<Observation[]>;
|
|
64
78
|
|
|
65
79
|
/** Count one project's observations without materializing the rows. */
|
|
66
|
-
countByProject(projectId: string, options?: { status?: string }): Promise<number>;
|
|
80
|
+
countByProject(projectId: string, options?: { status?: string; visibility?: 'project' }): Promise<number>;
|
|
67
81
|
|
|
68
82
|
/** Load one observation by its globally unique ID. */
|
|
69
83
|
getById(id: number): Promise<Observation | undefined>;
|
|
@@ -71,6 +85,24 @@ export interface ObservationStore {
|
|
|
71
85
|
/** Load the current next-ID counter value. */
|
|
72
86
|
loadIdCounter(): Promise<number>;
|
|
73
87
|
|
|
88
|
+
/** Count all durable observations without materializing their bodies. */
|
|
89
|
+
countAll?(): Promise<number>;
|
|
90
|
+
|
|
91
|
+
/** List project scopes without materializing observation bodies. */
|
|
92
|
+
listProjectIds?(): Promise<string[]>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Search the persistent lexical index without materializing the corpus.
|
|
96
|
+
* Backends without FTS5 return an empty result and callers use the fallback.
|
|
97
|
+
*/
|
|
98
|
+
searchLexical?(options: LexicalSearchOptions): Promise<LexicalSearchHit[]>;
|
|
99
|
+
|
|
100
|
+
/** Whether a persistent lexical index is available for this backend. */
|
|
101
|
+
hasLexicalIndex?(): boolean;
|
|
102
|
+
|
|
103
|
+
/** Rebuild the derived lexical index from durable observations. */
|
|
104
|
+
rebuildLexicalIndex?(): Promise<boolean>;
|
|
105
|
+
|
|
74
106
|
// ── Write — single mutations ───────────────────────────────────────
|
|
75
107
|
|
|
76
108
|
/** Insert a new observation. Bumps generation (if applicable). */
|
|
@@ -235,7 +267,7 @@ export class DegradedBackend implements ObservationStore {
|
|
|
235
267
|
return [];
|
|
236
268
|
}
|
|
237
269
|
|
|
238
|
-
async countByProject(_projectId: string, _options?: { status?: string }): Promise<number> {
|
|
270
|
+
async countByProject(_projectId: string, _options?: { status?: string; visibility?: 'project' }): Promise<number> {
|
|
239
271
|
return 0;
|
|
240
272
|
}
|
|
241
273
|
|
|
@@ -247,6 +279,26 @@ export class DegradedBackend implements ObservationStore {
|
|
|
247
279
|
return 1;
|
|
248
280
|
}
|
|
249
281
|
|
|
282
|
+
async countAll(): Promise<number> {
|
|
283
|
+
return 0;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async listProjectIds(): Promise<string[]> {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async searchLexical(_options: LexicalSearchOptions): Promise<LexicalSearchHit[]> {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
hasLexicalIndex(): boolean {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async rebuildLexicalIndex(): Promise<boolean> {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
250
302
|
async insert(_obs: Observation): Promise<void> {
|
|
251
303
|
throw new Error('[memorix] Cannot write observations: SQLite backend unavailable (degraded mode)');
|
|
252
304
|
}
|
package/src/store/orama-store.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { create, insert, insertMultiple, search, remove, update, count, getByID, type AnyOrama } from '@orama/orama';
|
|
12
|
-
import type { MemorixDocument, SearchOptions, IndexEntry, KnowledgeLayer, ObservationReader } from '../types.js';
|
|
12
|
+
import type { MemorixDocument, SearchOptions, IndexEntry, KnowledgeLayer, Observation, ObservationReader } from '../types.js';
|
|
13
13
|
import { OBSERVATION_ICONS, type ObservationType } from '../types.js';
|
|
14
14
|
import { resolveKnowledgeLayer } from '../skills/mini-skills.js';
|
|
15
15
|
import { canReadObservation } from '../memory/visibility.js';
|
|
@@ -25,6 +25,14 @@ import { detectQueryIntent, applyIntentBoost } from '../search/intent-detector.j
|
|
|
25
25
|
import { maybeExpandSearchQuery } from '../search/query-expansion.js';
|
|
26
26
|
import { parseRerankTimeoutMs, shouldAttemptHttpRerank, shouldAttemptLlmRerank } from '../rerank/policy.js';
|
|
27
27
|
import { withTimeout, withTimeoutSignal } from '../timeout.js';
|
|
28
|
+
import { getObservationStore } from './obs-store.js';
|
|
29
|
+
import {
|
|
30
|
+
createSemanticIndexProfile,
|
|
31
|
+
searchSemanticVectors,
|
|
32
|
+
upsertSemanticVectors,
|
|
33
|
+
type SemanticIndexProfile,
|
|
34
|
+
type SemanticVectorRecord,
|
|
35
|
+
} from '../search/semantic-index.js';
|
|
28
36
|
|
|
29
37
|
let db: AnyOrama | null = null;
|
|
30
38
|
let dbInitPromise: Promise<AnyOrama> | null = null;
|
|
@@ -49,6 +57,30 @@ function rememberSearchMode(key: string, value: string): void {
|
|
|
49
57
|
export function getLastSearchMode(projectId?: string): string {
|
|
50
58
|
return lastSearchModeByProject.get(projectId ?? SEARCH_MODE_DEFAULT_KEY) ?? 'fulltext';
|
|
51
59
|
}
|
|
60
|
+
|
|
61
|
+
function getOptionalObservationStore(): ReturnType<typeof getObservationStore> | null {
|
|
62
|
+
try {
|
|
63
|
+
return getObservationStore();
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function shouldUsePersistentSearch(
|
|
70
|
+
projectIds: string[] | null,
|
|
71
|
+
projectId?: string,
|
|
72
|
+
): Promise<boolean> {
|
|
73
|
+
const store = getOptionalObservationStore();
|
|
74
|
+
if (!store?.hasLexicalIndex?.()) return false;
|
|
75
|
+
const rawThreshold = Number.parseInt(process.env.MEMORIX_ORAMA_HYDRATION_THRESHOLD ?? '', 10);
|
|
76
|
+
const threshold = Number.isSafeInteger(rawThreshold) && rawThreshold > 0 ? rawThreshold : 10_000;
|
|
77
|
+
const ids = projectIds ?? (projectId ? [projectId] : null);
|
|
78
|
+
if (ids) {
|
|
79
|
+
const count = (await Promise.all(ids.map((id) => store.countByProject(id)))).reduce((sum, value) => sum + value, 0);
|
|
80
|
+
return count >= threshold;
|
|
81
|
+
}
|
|
82
|
+
return (await store.countAll?.() ?? 0) >= threshold;
|
|
83
|
+
}
|
|
52
84
|
// Hard filter: titles starting with these are command execution logs, not knowledge.
|
|
53
85
|
// They are excluded from results entirely (not just demoted) unless the query is command-like.
|
|
54
86
|
const COMMAND_LOG_TITLE = /^(Ran:|Command:|Executed:)\s/i;
|
|
@@ -80,6 +112,185 @@ function rememberObservationDoc(doc: MemorixDocument): MemorixDocument {
|
|
|
80
112
|
return publicDoc;
|
|
81
113
|
}
|
|
82
114
|
|
|
115
|
+
function observationToSearchDocument(observation: Observation): MemorixDocument {
|
|
116
|
+
return {
|
|
117
|
+
id: makeOramaObservationId(observation.projectId, observation.id),
|
|
118
|
+
observationId: observation.id,
|
|
119
|
+
entityName: observation.entityName,
|
|
120
|
+
type: observation.type,
|
|
121
|
+
title: observation.title,
|
|
122
|
+
narrative: observation.narrative,
|
|
123
|
+
facts: observation.facts.join(' '),
|
|
124
|
+
filesModified: observation.filesModified.join(' '),
|
|
125
|
+
concepts: observation.concepts.join(', '),
|
|
126
|
+
attachments: (observation.attachments ?? []).map((attachment) => [
|
|
127
|
+
attachment.name,
|
|
128
|
+
attachment.modality,
|
|
129
|
+
attachment.mimeType,
|
|
130
|
+
attachment.url,
|
|
131
|
+
].filter(Boolean).join(' ')).join('\n'),
|
|
132
|
+
tokens: observation.tokens,
|
|
133
|
+
createdAt: observation.createdAt,
|
|
134
|
+
projectId: observation.projectId,
|
|
135
|
+
accessCount: 0,
|
|
136
|
+
lastAccessedAt: '',
|
|
137
|
+
status: observation.status ?? 'active',
|
|
138
|
+
source: observation.source ?? 'agent',
|
|
139
|
+
sourceDetail: observation.sourceDetail ?? '',
|
|
140
|
+
valueCategory: observation.valueCategory ?? '',
|
|
141
|
+
admissionState: observation.admissionState ?? '',
|
|
142
|
+
admissionReason: observation.admissionReason ?? '',
|
|
143
|
+
visibility: observation.visibility ?? 'project',
|
|
144
|
+
createdByAgentId: observation.createdByAgentId ?? '',
|
|
145
|
+
sharedWithAgentIds: JSON.stringify(observation.sharedWithAgentIds ?? []),
|
|
146
|
+
documentType: 'observation',
|
|
147
|
+
knowledgeLayer: resolveKnowledgeLayer('observation', observation.sourceDetail, observation.source),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type SearchHitLike = {
|
|
152
|
+
id: string;
|
|
153
|
+
score: number;
|
|
154
|
+
document: MemorixDocument;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
type SearchResultLike = {
|
|
158
|
+
count: number;
|
|
159
|
+
hits: SearchHitLike[];
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Use the durable SQLite FTS5 index for lexical-only queries. Returning a
|
|
164
|
+
* search-result-shaped value lets the existing visibility, intent, provenance,
|
|
165
|
+
* token-budget, and access-tracking pipeline remain shared with Orama.
|
|
166
|
+
*/
|
|
167
|
+
async function searchPersistentLexically(
|
|
168
|
+
options: SearchOptions,
|
|
169
|
+
projectIds: string[] | null,
|
|
170
|
+
limit: number,
|
|
171
|
+
): Promise<SearchResultLike | null> {
|
|
172
|
+
try {
|
|
173
|
+
const store = getObservationStore();
|
|
174
|
+
if (!store.hasLexicalIndex?.() || !store.searchLexical) return null;
|
|
175
|
+
const hits = await store.searchLexical({
|
|
176
|
+
query: options.query ?? '',
|
|
177
|
+
projectId: projectIds ?? options.projectId,
|
|
178
|
+
status: options.status === 'all' ? 'all' : (options.status ?? 'active'),
|
|
179
|
+
type: options.type,
|
|
180
|
+
source: options.source,
|
|
181
|
+
limit,
|
|
182
|
+
});
|
|
183
|
+
return {
|
|
184
|
+
count: hits.length,
|
|
185
|
+
hits: hits.map((hit) => ({
|
|
186
|
+
id: makeOramaObservationId(hit.observation.projectId, hit.observation.id),
|
|
187
|
+
score: hit.score,
|
|
188
|
+
document: observationToSearchDocument(hit.observation),
|
|
189
|
+
})),
|
|
190
|
+
};
|
|
191
|
+
} catch {
|
|
192
|
+
// The derived FTS path is optional. A provider/runtime-specific failure
|
|
193
|
+
// must fall back to the established Orama search path.
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fuse the persistent lexical candidate list with the local LanceDB semantic
|
|
200
|
+
* candidate list. The vector index is a derived accelerator; authoritative
|
|
201
|
+
* observation rows still come from SQLite before visibility is applied.
|
|
202
|
+
*/
|
|
203
|
+
async function searchPersistentSemantically(
|
|
204
|
+
options: SearchOptions,
|
|
205
|
+
projectIds: string[] | null,
|
|
206
|
+
limit: number,
|
|
207
|
+
queryVector: number[],
|
|
208
|
+
provider: EmbeddingProvider,
|
|
209
|
+
): Promise<SearchResultLike | null> {
|
|
210
|
+
try {
|
|
211
|
+
const { getObservationDataDir } = await import('../memory/observations.js');
|
|
212
|
+
const dataDir = getObservationDataDir();
|
|
213
|
+
if (!dataDir) return null;
|
|
214
|
+
|
|
215
|
+
const profile = createSemanticIndexProfile(provider);
|
|
216
|
+
const vectorHits = await searchSemanticVectors({
|
|
217
|
+
dataDir,
|
|
218
|
+
profile,
|
|
219
|
+
vector: queryVector,
|
|
220
|
+
projectId: projectIds ?? options.projectId,
|
|
221
|
+
status: 'all',
|
|
222
|
+
limit: Math.min(10_000, limit * (options.reader ? 8 : 2)),
|
|
223
|
+
});
|
|
224
|
+
if (vectorHits === null) return null;
|
|
225
|
+
|
|
226
|
+
const lexical = await searchPersistentLexically(options, projectIds, limit);
|
|
227
|
+
const store = getObservationStore();
|
|
228
|
+
const documents = new Map<string, MemorixDocument>();
|
|
229
|
+
const addObservation = (observation: Observation): void => {
|
|
230
|
+
const document = observationToSearchDocument(observation);
|
|
231
|
+
documents.set(makeEntryKey(document.projectId, document.observationId), document);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
for (const hit of lexical?.hits ?? []) {
|
|
235
|
+
documents.set(makeEntryKey(hit.document.projectId, hit.document.observationId), hit.document);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const vectorObservations = await Promise.all(vectorHits.map(async (hit) => {
|
|
239
|
+
const observation = await store.getById(hit.observationId);
|
|
240
|
+
if (!observation) return null;
|
|
241
|
+
if (projectIds && !projectIds.includes(observation.projectId)) return null;
|
|
242
|
+
if (options.status !== 'all' && (observation.status ?? 'active') !== (options.status ?? 'active')) return null;
|
|
243
|
+
if (options.type && observation.type !== options.type) return null;
|
|
244
|
+
if (options.source && observation.source !== options.source) return null;
|
|
245
|
+
addObservation(observation);
|
|
246
|
+
return observation;
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
// Reciprocal Rank Fusion keeps lexical identifier matches and semantic
|
|
250
|
+
// paraphrases comparable without pretending their raw score scales match.
|
|
251
|
+
const RRF_K = 60;
|
|
252
|
+
const scores = new Map<string, number>();
|
|
253
|
+
for (const [index, hit] of (lexical?.hits ?? []).entries()) {
|
|
254
|
+
const key = makeEntryKey(hit.document.projectId, hit.document.observationId);
|
|
255
|
+
scores.set(key, (scores.get(key) ?? 0) + 0.6 / (RRF_K + index + 1));
|
|
256
|
+
}
|
|
257
|
+
for (const [index, hit] of vectorHits.entries()) {
|
|
258
|
+
const key = makeEntryKey(hit.projectId, hit.observationId);
|
|
259
|
+
if (!documents.has(key)) continue;
|
|
260
|
+
scores.set(key, (scores.get(key) ?? 0) + 0.4 / (RRF_K + index + 1));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const hits = [...scores.entries()]
|
|
264
|
+
.sort((left, right) => right[1] - left[1])
|
|
265
|
+
.slice(0, limit)
|
|
266
|
+
.map(([key, score]) => ({
|
|
267
|
+
id: documents.get(key)!.id,
|
|
268
|
+
score,
|
|
269
|
+
document: documents.get(key)!,
|
|
270
|
+
}));
|
|
271
|
+
// Keep a vector-only result set useful when the FTS tokenizer cannot see
|
|
272
|
+
// the query language (for example a paraphrase or CJK text).
|
|
273
|
+
if (hits.length === 0 && vectorObservations.some(Boolean)) {
|
|
274
|
+
return {
|
|
275
|
+
count: vectorHits.length,
|
|
276
|
+
hits: vectorHits
|
|
277
|
+
.map((hit, index) => {
|
|
278
|
+
const key = makeEntryKey(hit.projectId, hit.observationId);
|
|
279
|
+
const document = documents.get(key);
|
|
280
|
+
return document ? { id: document.id, score: 0.4 / (RRF_K + index + 1), document } : null;
|
|
281
|
+
})
|
|
282
|
+
.filter((hit): hit is SearchHitLike => hit !== null)
|
|
283
|
+
.slice(0, limit),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return { count: hits.length, hits };
|
|
287
|
+
} catch {
|
|
288
|
+
// LanceDB is a derived optional accelerator. If it is unavailable or its
|
|
289
|
+
// table is mid-rebuild, the caller retains the Orama fallback.
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
83
294
|
function isCommandLikeQuery(query: string): boolean {
|
|
84
295
|
return COMMAND_LIKE_QUERY.test(query);
|
|
85
296
|
}
|
|
@@ -267,6 +478,29 @@ export function getVectorDimensions(): number | null {
|
|
|
267
478
|
return embeddingEnabled ? embeddingDimensions : null;
|
|
268
479
|
}
|
|
269
480
|
|
|
481
|
+
/** Current derived semantic-index profile, if an embedding provider is active. */
|
|
482
|
+
export function getActiveSemanticIndexProfile(): SemanticIndexProfile | null {
|
|
483
|
+
return indexEmbeddingProvider ? createSemanticIndexProfile(indexEmbeddingProvider) : null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Queue one derived vector without delaying the durable observation write. */
|
|
487
|
+
export async function queueSemanticVector(
|
|
488
|
+
dataDir: string,
|
|
489
|
+
record: Omit<SemanticVectorRecord, 'vector'> & { vector: number[] },
|
|
490
|
+
): Promise<void> {
|
|
491
|
+
// A large-corpus writer can reset the in-memory Orama state after crossing
|
|
492
|
+
// the hydration threshold. Resolve the provider again so that reset does
|
|
493
|
+
// not strand newly generated vectors outside the persistent shadow index.
|
|
494
|
+
const provider = indexEmbeddingProvider ?? await getEmbeddingProvider();
|
|
495
|
+
const profile = provider ? createSemanticIndexProfile(provider) : null;
|
|
496
|
+
if (!profile) return;
|
|
497
|
+
try {
|
|
498
|
+
await upsertSemanticVectors(dataDir, profile, [record]);
|
|
499
|
+
} catch {
|
|
500
|
+
// The vector index is rebuildable; lexical memory remains authoritative.
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
270
504
|
/**
|
|
271
505
|
* Generate embedding for text content using the available provider.
|
|
272
506
|
* Returns null if no provider is available.
|
|
@@ -366,6 +600,8 @@ async function attachCachedVectors(
|
|
|
366
600
|
database: AnyOrama,
|
|
367
601
|
observations: any[],
|
|
368
602
|
): Promise<void> {
|
|
603
|
+
const semanticDataDir = (await import('../memory/observations.js')).getObservationDataDir();
|
|
604
|
+
const semanticProfile = getActiveSemanticIndexProfile();
|
|
369
605
|
const batchSize = 200;
|
|
370
606
|
for (let start = 0; start < observations.length; start += batchSize) {
|
|
371
607
|
// Keep cache lookup and the temporary vector array bounded to one batch.
|
|
@@ -376,9 +612,20 @@ async function attachCachedVectors(
|
|
|
376
612
|
// Do not attach stale vectors to that new index.
|
|
377
613
|
if (db !== database) return;
|
|
378
614
|
|
|
615
|
+
const semanticRecords: SemanticVectorRecord[] = [];
|
|
379
616
|
for (let index = 0; index < batch.length; index++) {
|
|
380
617
|
const vector = cachedVectors[index];
|
|
381
618
|
if (!isCompatibleCachedVector(vector)) continue;
|
|
619
|
+
const observation = batch[index];
|
|
620
|
+
if (semanticDataDir && semanticProfile) {
|
|
621
|
+
semanticRecords.push({
|
|
622
|
+
observationId: observation.id,
|
|
623
|
+
projectId: observation.projectId,
|
|
624
|
+
status: observation.status ?? 'active',
|
|
625
|
+
visibility: observation.visibility ?? 'project',
|
|
626
|
+
vector,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
382
629
|
const id = makeOramaObservationId(batch[index].projectId, batch[index].id);
|
|
383
630
|
const existing = getByID(database, id) as MemorixDocument | undefined;
|
|
384
631
|
if (!existing || documentEmbeddingText(existing) !== observationEmbeddingText(batch[index])) continue;
|
|
@@ -388,6 +635,11 @@ async function attachCachedVectors(
|
|
|
388
635
|
// Vector cache hydration is best-effort. The normal backfill lane owns misses.
|
|
389
636
|
}
|
|
390
637
|
}
|
|
638
|
+
if (semanticDataDir && semanticProfile && semanticRecords.length > 0) {
|
|
639
|
+
void upsertSemanticVectors(semanticDataDir, semanticProfile, semanticRecords).catch(() => {
|
|
640
|
+
// A rebuildable semantic index must never make startup hydration fail.
|
|
641
|
+
});
|
|
642
|
+
}
|
|
391
643
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
392
644
|
}
|
|
393
645
|
}
|
|
@@ -656,10 +908,12 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
656
908
|
// If embedding provider is available and query tier warrants it, use hybrid search
|
|
657
909
|
// Fast-tier queries skip embedding entirely (fulltext is sufficient)
|
|
658
910
|
let queryVector: number[] | null = null;
|
|
911
|
+
let embeddingProviderForSearch: EmbeddingProvider | null = null;
|
|
659
912
|
if (quality !== 'fast' && embeddingEnabled && hasQuery && tier !== 'fast') {
|
|
660
913
|
try {
|
|
661
914
|
const provider = await getEmbeddingProvider();
|
|
662
915
|
if (provider) {
|
|
916
|
+
embeddingProviderForSearch = provider;
|
|
663
917
|
const activeVectorDimensions = getVectorDimensions();
|
|
664
918
|
if (activeVectorDimensions !== null && provider.dimensions !== activeVectorDimensions) {
|
|
665
919
|
rememberSearchMode(
|
|
@@ -716,19 +970,47 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
716
970
|
}
|
|
717
971
|
|
|
718
972
|
mark('preSearch');
|
|
719
|
-
let results
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
973
|
+
let results: SearchResultLike | Awaited<ReturnType<typeof search>>;
|
|
974
|
+
const persistentCorpusEligible = await shouldUsePersistentSearch(projectIds, options.projectId);
|
|
975
|
+
const canUsePersistentLexical = persistentCorpusEligible && Boolean(hasQuery) && !queryVector &&
|
|
976
|
+
(quality === 'fast' || tier === 'fast' || !embeddingEnabled);
|
|
977
|
+
let persistent = persistentCorpusEligible && queryVector && embeddingProviderForSearch
|
|
978
|
+
? await searchPersistentSemantically(options, projectIds, requestLimit, queryVector, embeddingProviderForSearch)
|
|
979
|
+
: canUsePersistentLexical
|
|
980
|
+
? await searchPersistentLexically(options, projectIds, requestLimit)
|
|
981
|
+
: null;
|
|
982
|
+
|
|
983
|
+
// In lazy large-corpus mode Orama is intentionally empty. If a semantic
|
|
984
|
+
// shadow index has not been built yet, retain a correct lexical answer
|
|
985
|
+
// instead of returning no memories while background indexing catches up.
|
|
986
|
+
const durableStore = getOptionalObservationStore();
|
|
987
|
+
if (!persistent && queryVector && durableStore?.hasLexicalIndex?.() && count(database) === 0) {
|
|
988
|
+
persistent = await searchPersistentLexically(options, projectIds, requestLimit);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (persistent) {
|
|
992
|
+
results = persistent;
|
|
993
|
+
// Keep the public mode label compatible with existing integrations while
|
|
994
|
+
// exposing the concrete accelerator through performance marks/status.
|
|
995
|
+
rememberSearchMode(
|
|
996
|
+
modeKey,
|
|
997
|
+
queryVector ? 'hybrid' : quality === 'fast' ? 'fulltext (fast profile)' : 'fulltext',
|
|
998
|
+
);
|
|
999
|
+
mark(queryVector ? 'persistentHybridSearch' : 'sqliteFtsSearch');
|
|
1000
|
+
} else {
|
|
1001
|
+
try {
|
|
1002
|
+
results = await search(database, searchParams);
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
if (queryVector && isVectorDimensionMismatchError(error)) {
|
|
1005
|
+
rememberSearchMode(modeKey, 'fulltext (embedding dimension mismatch)');
|
|
1006
|
+
console.error('[memorix] Vector search dimension mismatch detected, retrying without embeddings');
|
|
1007
|
+
results = await search(database, stripVectorSearchParams(searchParams));
|
|
1008
|
+
} else {
|
|
1009
|
+
throw error;
|
|
1010
|
+
}
|
|
729
1011
|
}
|
|
1012
|
+
mark('oramaSearch');
|
|
730
1013
|
}
|
|
731
|
-
mark('oramaSearch');
|
|
732
1014
|
|
|
733
1015
|
// Fallback: if hybrid returned nothing but we have a vector, retry with vector-only
|
|
734
1016
|
if (results.count === 0 && queryVector && embeddingEnabled) {
|
|
@@ -1345,6 +1627,12 @@ function applyTokenBudget(entries: IndexEntry[], maxTokens: number): IndexEntry[
|
|
|
1345
1627
|
* Get total observation count, optionally filtered by project.
|
|
1346
1628
|
*/
|
|
1347
1629
|
export async function getObservationCount(projectId?: string): Promise<number> {
|
|
1630
|
+
const durableStore = getObservationStore();
|
|
1631
|
+
if (durableStore.countAll && durableStore.countByProject) {
|
|
1632
|
+
return projectId
|
|
1633
|
+
? durableStore.countByProject(projectId)
|
|
1634
|
+
: durableStore.countAll();
|
|
1635
|
+
}
|
|
1348
1636
|
const database = await getDb();
|
|
1349
1637
|
if (!projectId) {
|
|
1350
1638
|
return await count(database);
|
package/src/store/sqlite-db.ts
CHANGED
|
@@ -17,6 +17,7 @@ import path from 'node:path';
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import { createDatabase, loadSqlite } from './bun-sqlite-compat.js';
|
|
19
19
|
import { assertNotHomeDataDir } from '../project/launch-root.js';
|
|
20
|
+
import { initializeObservationLexicalIndex } from './sqlite-fts.js';
|
|
20
21
|
|
|
21
22
|
// Dynamic require for SQLite (better-sqlite3, node:sqlite, or bun:sqlite)
|
|
22
23
|
let BetterSqlite3: any;
|
|
@@ -57,6 +58,7 @@ CREATE TABLE IF NOT EXISTS observations (
|
|
|
57
58
|
commitHash TEXT,
|
|
58
59
|
relatedCommits TEXT,
|
|
59
60
|
relatedEntities TEXT,
|
|
61
|
+
attachments TEXT,
|
|
60
62
|
sourceDetail TEXT,
|
|
61
63
|
valueCategory TEXT,
|
|
62
64
|
admissionState TEXT,
|
|
@@ -1177,6 +1179,10 @@ export function getDatabase(dataDir: string): any {
|
|
|
1177
1179
|
// Create indexes AFTER all ALTER TABLE migrations so referenced columns exist
|
|
1178
1180
|
db.exec(CREATE_INDEXES);
|
|
1179
1181
|
|
|
1182
|
+
// FTS5 is a derived accelerator. It is initialized after all migrations so
|
|
1183
|
+
// old databases have the attachment column before triggers are created.
|
|
1184
|
+
initializeObservationLexicalIndex(db);
|
|
1185
|
+
|
|
1180
1186
|
// Seed meta defaults
|
|
1181
1187
|
db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('storage_generation', '0')`).run();
|
|
1182
1188
|
db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('next_id', '1')`).run();
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { Observation } from '../types.js';
|
|
2
|
+
|
|
3
|
+
/** Rows returned by the derived FTS5 index before normal Observation parsing. */
|
|
4
|
+
export interface SqliteLexicalHit {
|
|
5
|
+
row: Record<string, unknown>;
|
|
6
|
+
score: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ftsAvailability = new WeakMap<object, boolean>();
|
|
10
|
+
const FTS_TABLE = 'observations_fts';
|
|
11
|
+
|
|
12
|
+
const CREATE_OBSERVATION_FTS = `
|
|
13
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
|
|
14
|
+
entityName,
|
|
15
|
+
title,
|
|
16
|
+
narrative,
|
|
17
|
+
facts,
|
|
18
|
+
filesModified,
|
|
19
|
+
concepts,
|
|
20
|
+
attachments,
|
|
21
|
+
content='observations',
|
|
22
|
+
content_rowid='id',
|
|
23
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TRIGGER IF NOT EXISTS observations_fts_ai AFTER INSERT ON observations BEGIN
|
|
27
|
+
INSERT INTO observations_fts(rowid, entityName, title, narrative, facts, filesModified, concepts, attachments)
|
|
28
|
+
VALUES (new.id, new.entityName, new.title, new.narrative, new.facts, new.filesModified, new.concepts, new.attachments);
|
|
29
|
+
END;
|
|
30
|
+
|
|
31
|
+
CREATE TRIGGER IF NOT EXISTS observations_fts_ad AFTER DELETE ON observations BEGIN
|
|
32
|
+
INSERT INTO observations_fts(observations_fts, rowid, entityName, title, narrative, facts, filesModified, concepts, attachments)
|
|
33
|
+
VALUES ('delete', old.id, old.entityName, old.title, old.narrative, old.facts, old.filesModified, old.concepts, old.attachments);
|
|
34
|
+
END;
|
|
35
|
+
|
|
36
|
+
CREATE TRIGGER IF NOT EXISTS observations_fts_au AFTER UPDATE ON observations BEGIN
|
|
37
|
+
INSERT INTO observations_fts(observations_fts, rowid, entityName, title, narrative, facts, filesModified, concepts, attachments)
|
|
38
|
+
VALUES ('delete', old.id, old.entityName, old.title, old.narrative, old.facts, old.filesModified, old.concepts, old.attachments);
|
|
39
|
+
INSERT INTO observations_fts(rowid, entityName, title, narrative, facts, filesModified, concepts, attachments)
|
|
40
|
+
VALUES (new.id, new.entityName, new.title, new.narrative, new.facts, new.filesModified, new.concepts, new.attachments);
|
|
41
|
+
END;
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
function normalizeFtsToken(token: string): string {
|
|
45
|
+
return token.replace(/"/g, '""');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Turn user text into a parameterized FTS query. Every token is quoted so
|
|
50
|
+
* punctuation such as `OR`, `NEAR`, and quotes remains user data.
|
|
51
|
+
*/
|
|
52
|
+
export function buildFtsQuery(query: string): string | null {
|
|
53
|
+
const tokens = query.normalize('NFKC').match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
54
|
+
const unique = [...new Set(tokens.map((token) => token.trim()).filter(Boolean))].slice(0, 64);
|
|
55
|
+
if (unique.length === 0) return null;
|
|
56
|
+
return unique.map((token) => `"${normalizeFtsToken(token)}"`).join(' OR ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create and reconcile the external-content FTS5 index for one SQLite handle.
|
|
61
|
+
* FTS5 is an optional SQLite capability, so an unavailable extension is a
|
|
62
|
+
* normal fallback rather than a reason to make the durable store unusable.
|
|
63
|
+
*/
|
|
64
|
+
export function initializeObservationLexicalIndex(db: any): boolean {
|
|
65
|
+
const known = ftsAvailability.get(db);
|
|
66
|
+
if (known !== undefined) return known;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
db.exec(CREATE_OBSERVATION_FTS);
|
|
70
|
+
const observationCount = Number(db.prepare('SELECT COUNT(*) AS count FROM observations').get()?.count ?? 0);
|
|
71
|
+
const indexedCount = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${FTS_TABLE}`).get()?.count ?? 0);
|
|
72
|
+
if (observationCount !== indexedCount) {
|
|
73
|
+
db.exec(`INSERT INTO ${FTS_TABLE}(${FTS_TABLE}) VALUES ('rebuild')`);
|
|
74
|
+
}
|
|
75
|
+
ftsAvailability.set(db, true);
|
|
76
|
+
return true;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
ftsAvailability.set(db, false);
|
|
79
|
+
console.warn(
|
|
80
|
+
`[memorix] SQLite FTS5 unavailable; using the existing search fallback: ${error instanceof Error ? error.message : String(error)}`,
|
|
81
|
+
);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isObservationLexicalIndexEnabled(db: any): boolean {
|
|
87
|
+
return ftsAvailability.get(db) === true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function rebuildObservationLexicalIndex(db: any): boolean {
|
|
91
|
+
if (!isObservationLexicalIndexEnabled(db)) return false;
|
|
92
|
+
try {
|
|
93
|
+
db.exec(`INSERT INTO ${FTS_TABLE}(${FTS_TABLE}) VALUES ('rebuild')`);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface SqliteLexicalSearchOptions {
|
|
101
|
+
query: string;
|
|
102
|
+
projectId?: string | string[];
|
|
103
|
+
status?: string | 'all';
|
|
104
|
+
type?: string;
|
|
105
|
+
source?: string;
|
|
106
|
+
limit?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Search only the bounded lexical candidate set. The full observation body is
|
|
111
|
+
* fetched from SQLite for those candidates, never by loading the corpus.
|
|
112
|
+
*/
|
|
113
|
+
export function searchObservationLexically(db: any, options: SqliteLexicalSearchOptions): SqliteLexicalHit[] {
|
|
114
|
+
if (!isObservationLexicalIndexEnabled(db)) return [];
|
|
115
|
+
const match = buildFtsQuery(options.query);
|
|
116
|
+
if (!match) return [];
|
|
117
|
+
|
|
118
|
+
const projectIds = options.projectId == null
|
|
119
|
+
? undefined
|
|
120
|
+
: (Array.isArray(options.projectId) ? options.projectId : [options.projectId]);
|
|
121
|
+
if (projectIds && projectIds.length === 0) return [];
|
|
122
|
+
|
|
123
|
+
const where: string[] = [`${FTS_TABLE} MATCH ?`];
|
|
124
|
+
const params: unknown[] = [match];
|
|
125
|
+
if (projectIds) {
|
|
126
|
+
where.push(`o.projectId IN (${projectIds.map(() => '?').join(', ')})`);
|
|
127
|
+
params.push(...projectIds);
|
|
128
|
+
}
|
|
129
|
+
if (options.status && options.status !== 'all') {
|
|
130
|
+
where.push('o.status = ?');
|
|
131
|
+
params.push(options.status);
|
|
132
|
+
}
|
|
133
|
+
if (options.type) {
|
|
134
|
+
where.push('o.type = ?');
|
|
135
|
+
params.push(options.type);
|
|
136
|
+
}
|
|
137
|
+
if (options.source) {
|
|
138
|
+
where.push('o.source = ?');
|
|
139
|
+
params.push(options.source);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const requestedLimit = Number.isFinite(options.limit) ? Math.floor(options.limit!) : 20;
|
|
143
|
+
// This is a per-query working-set guard, not a corpus limit. Large callers
|
|
144
|
+
// can page or request up to 10k candidates without changing durable data.
|
|
145
|
+
const limit = Math.max(1, Math.min(requestedLimit, 10_000));
|
|
146
|
+
params.push(limit);
|
|
147
|
+
|
|
148
|
+
const rows = db.prepare(`
|
|
149
|
+
SELECT o.*, -bm25(${FTS_TABLE}, 2.0, 4.0, 1.0, 0.7, 0.5, 1.5, 0.5) AS lexical_score
|
|
150
|
+
FROM ${FTS_TABLE}
|
|
151
|
+
JOIN observations AS o ON o.id = ${FTS_TABLE}.rowid
|
|
152
|
+
WHERE ${where.join(' AND ')}
|
|
153
|
+
ORDER BY bm25(${FTS_TABLE}, 2.0, 4.0, 1.0, 0.7, 0.5, 1.5, 0.5) ASC, o.id DESC
|
|
154
|
+
LIMIT ?
|
|
155
|
+
`).all(...params) as Array<Record<string, unknown>>;
|
|
156
|
+
|
|
157
|
+
return rows.map((row) => ({
|
|
158
|
+
row,
|
|
159
|
+
score: Math.max(0, Number(row.lexical_score ?? 0)),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Keep the import visible to TypeScript consumers that build store adapters. */
|
|
164
|
+
export type SqliteLexicalObservation = Observation;
|