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.
Files changed (50) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +22 -5
  3. package/README.zh-CN.md +9 -5
  4. package/dist/cli/index.js +2635 -1942
  5. package/dist/index.js +2445 -1766
  6. package/dist/maintenance-runner.js +1696 -1036
  7. package/dist/media-audio-runner.js +2197 -1537
  8. package/dist/media-video-runner.js +2195 -1535
  9. package/dist/memcode-runtime/CHANGELOG.md +39 -0
  10. package/dist/sdk.js +2507 -1764
  11. package/dist/vector-backfill-runner.js +2193 -1533
  12. package/docs/CONFIGURATION.md +22 -0
  13. package/docs/DEVELOPMENT.md +2 -2
  14. package/docs/INTEGRATIONS.md +4 -0
  15. package/docs/PERFORMANCE.md +77 -20
  16. package/docs/README.md +1 -1
  17. package/docs/SETUP.md +5 -0
  18. package/package.json +2 -2
  19. package/plugins/antigravity/memorix/plugin.json +1 -1
  20. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  21. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  22. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  23. package/plugins/copilot/memorix/plugin.json +1 -1
  24. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  25. package/plugins/hermes/memorix/plugin.yaml +1 -1
  26. package/plugins/omp/memorix/package.json +1 -1
  27. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  28. package/plugins/pi/memorix/package.json +1 -1
  29. package/server.json +2 -2
  30. package/src/cli/commands/memory.ts +2 -1
  31. package/src/cli/default-mode.ts +15 -0
  32. package/src/cli/index.ts +10 -1
  33. package/src/codegraph/lite-provider.ts +10 -1
  34. package/src/compact/engine.ts +14 -4
  35. package/src/dashboard/maintenance.ts +11 -2
  36. package/src/memory/consolidation.ts +15 -6
  37. package/src/memory/freshness.ts +7 -4
  38. package/src/memory/observations.ts +118 -6
  39. package/src/runtime/vector-backfill-runner.ts +1 -1
  40. package/src/sdk.ts +13 -6
  41. package/src/search/semantic-index.ts +340 -0
  42. package/src/server.ts +1 -1
  43. package/src/store/obs-store.ts +54 -2
  44. package/src/store/orama-store.ts +300 -12
  45. package/src/store/sqlite-db.ts +6 -0
  46. package/src/store/sqlite-fts.ts +164 -0
  47. package/src/store/sqlite-store.ts +83 -6
  48. package/assets/star-history-dark.svg +0 -30
  49. package/assets/star-history-light.svg +0 -30
  50. package/assets/star-history.json +0 -712
@@ -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
  }
@@ -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
- try {
721
- results = await search(database, searchParams);
722
- } catch (error) {
723
- if (queryVector && isVectorDimensionMismatchError(error)) {
724
- rememberSearchMode(modeKey, 'fulltext (embedding dimension mismatch)');
725
- console.error('[memorix] Vector search dimension mismatch detected, retrying without embeddings');
726
- results = await search(database, stripVectorSearchParams(searchParams));
727
- } else {
728
- throw error;
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);
@@ -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;