memorix 1.8.8 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +15 -1
- package/README.zh-CN.md +15 -1
- package/dist/cli/index.js +3722 -1599
- package/dist/cli/memcode.js +0 -0
- package/dist/index.js +634 -363
- package/dist/maintenance-runner.js +285 -103
- package/dist/media-audio-runner.js +285 -103
- package/dist/media-video-runner.js +285 -103
- package/dist/memcode-runtime/CHANGELOG.md +36 -0
- package/dist/sdk.js +627 -356
- package/dist/vector-backfill-runner.d.ts +4 -1
- package/dist/vector-backfill-runner.js +288 -104
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -2
- package/docs/API_REFERENCE.md +14 -0
- package/docs/CONFIGURATION.md +46 -0
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/INTEGRATIONS.md +1 -0
- package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +344 -0
- package/docs/PERFORMANCE.md +32 -0
- package/docs/README.md +4 -2
- package/docs/hooks-architecture.md +18 -13
- package/llms-full.txt +4 -1
- package/llms.txt +3 -1
- package/package.json +2 -1
- 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/command-guide.ts +9 -1
- package/src/cli/commands/agent-integrations.ts +2 -1
- package/src/cli/commands/hooks-install.ts +2 -0
- package/src/cli/commands/integrate.ts +2 -0
- package/src/cli/commands/serve-http.ts +11 -0
- package/src/cli/commands/setup.ts +11 -4
- package/src/cli/commands/sync-store.ts +191 -0
- package/src/cli/commands/sync.ts +8 -0
- package/src/hooks/handler.ts +6 -0
- package/src/hooks/installers/index.ts +181 -6
- package/src/hooks/normalizer.ts +60 -1
- package/src/hooks/types.ts +3 -1
- package/src/integrations/registry.ts +9 -0
- package/src/memory/observations.ts +15 -2
- package/src/runtime/maintenance-targets.ts +6 -0
- package/src/runtime/vector-backfill-runner.ts +185 -7
- package/src/store/orama-store.ts +85 -102
- package/src/sync/adapters/fs.ts +179 -0
- package/src/sync/adapters/github.ts +258 -0
- package/src/sync/adapters/object-store.ts +267 -0
- package/src/sync/adapters/postgres.ts +269 -0
- package/src/sync/engine.ts +353 -0
- package/src/sync/journal.ts +162 -0
- package/src/sync/merge.ts +116 -0
- package/src/sync/namespace.ts +15 -0
- package/src/sync/paging.ts +45 -0
- package/src/sync/policy.ts +37 -0
- package/src/sync/remote-factory.ts +49 -0
- package/src/sync/store-port.ts +288 -0
- package/src/sync/types.ts +183 -0
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
3
12
|
import path from 'node:path';
|
|
4
13
|
import { fileURLToPath } from 'node:url';
|
|
5
14
|
|
|
@@ -11,8 +20,12 @@ import { initObservationStore } from '../store/obs-store.js';
|
|
|
11
20
|
import { getDeferredCachedVectorHydration } from '../store/orama-store.js';
|
|
12
21
|
import { closeAllDatabases } from '../store/sqlite-db.js';
|
|
13
22
|
import { MaintenanceJobStore, MaintenanceJobWorker } from './maintenance-jobs.js';
|
|
23
|
+
import { MaintenanceTargetStore, type MaintenanceTarget } from './maintenance-targets.js';
|
|
14
24
|
import { createProjectMaintenanceHandler } from './project-maintenance.js';
|
|
15
25
|
|
|
26
|
+
const VECTOR_BACKFILL_LOCK_FILE = '.memorix-vector-backfill.lock';
|
|
27
|
+
const STALE_LOCK_RECOVERY_MS = 24 * 60 * 60 * 1_000;
|
|
28
|
+
|
|
16
29
|
export interface VectorBackfillRequest {
|
|
17
30
|
projectId: string;
|
|
18
31
|
projectRoot: string;
|
|
@@ -23,6 +36,20 @@ export interface VectorBackfillLauncherOptions {
|
|
|
23
36
|
runnerPath?: string;
|
|
24
37
|
exists?: (path: string) => boolean;
|
|
25
38
|
spawn?: typeof spawn;
|
|
39
|
+
lockPath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface VectorBackfillLock {
|
|
43
|
+
parentPid: number;
|
|
44
|
+
childPid?: number;
|
|
45
|
+
startedAt: number;
|
|
46
|
+
projectId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface VectorBackfillLockHandle {
|
|
50
|
+
path: string;
|
|
51
|
+
setChildPid(pid: number): void;
|
|
52
|
+
release(): void;
|
|
26
53
|
}
|
|
27
54
|
|
|
28
55
|
function isNonEmptyString(value: unknown): value is string {
|
|
@@ -68,6 +95,111 @@ export function parseVectorBackfillRequest(raw: string): VectorBackfillRequest {
|
|
|
68
95
|
};
|
|
69
96
|
}
|
|
70
97
|
|
|
98
|
+
export function vectorBackfillLockPath(dataDir: string): string {
|
|
99
|
+
return path.join(dataDir, VECTOR_BACKFILL_LOCK_FILE);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function processIsAlive(pid: number | undefined): boolean {
|
|
103
|
+
if (!Number.isSafeInteger(pid) || pid == null || pid <= 0) return false;
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// EPERM means the process exists but this process cannot signal it.
|
|
109
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readVectorBackfillLock(lockPath: string): VectorBackfillLock | undefined {
|
|
114
|
+
try {
|
|
115
|
+
const value = JSON.parse(readFileSync(lockPath, 'utf8')) as Partial<VectorBackfillLock>;
|
|
116
|
+
if (
|
|
117
|
+
typeof value.parentPid !== 'number'
|
|
118
|
+
|| !Number.isSafeInteger(value.parentPid)
|
|
119
|
+
|| typeof value.startedAt !== 'number'
|
|
120
|
+
|| !Number.isFinite(value.startedAt)
|
|
121
|
+
|| typeof value.projectId !== 'string'
|
|
122
|
+
) return undefined;
|
|
123
|
+
return {
|
|
124
|
+
parentPid: value.parentPid,
|
|
125
|
+
...(typeof value.childPid === 'number' ? { childPid: value.childPid } : {}),
|
|
126
|
+
startedAt: value.startedAt,
|
|
127
|
+
projectId: value.projectId,
|
|
128
|
+
};
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeVectorBackfillLock(lockPath: string, value: VectorBackfillLock): void {
|
|
135
|
+
writeFileSync(lockPath, `${JSON.stringify(value)}\n`, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function acquireVectorBackfillLock(
|
|
139
|
+
dataDir: string,
|
|
140
|
+
projectId: string,
|
|
141
|
+
preferredPath?: string,
|
|
142
|
+
): VectorBackfillLockHandle | undefined {
|
|
143
|
+
const lockPath = preferredPath ?? vectorBackfillLockPath(dataDir);
|
|
144
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
145
|
+
const initial: VectorBackfillLock = {
|
|
146
|
+
parentPid: process.pid,
|
|
147
|
+
startedAt: Date.now(),
|
|
148
|
+
projectId,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
152
|
+
try {
|
|
153
|
+
const fd = openSync(lockPath, 'wx');
|
|
154
|
+
try {
|
|
155
|
+
writeFileSync(fd, `${JSON.stringify(initial)}\n`, 'utf8');
|
|
156
|
+
} finally {
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
path: lockPath,
|
|
161
|
+
setChildPid(pid: number) {
|
|
162
|
+
try {
|
|
163
|
+
writeVectorBackfillLock(lockPath, { ...initial, childPid: pid });
|
|
164
|
+
} catch {
|
|
165
|
+
// The child can still release the lock on normal shutdown; a dead
|
|
166
|
+
// owner is recoverable on the next launch.
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
release() {
|
|
170
|
+
try { unlinkSync(lockPath); } catch { /* already released */ }
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') return undefined;
|
|
175
|
+
const existing = readVectorBackfillLock(lockPath);
|
|
176
|
+
const ownerAlive = existing && (processIsAlive(existing.childPid) || processIsAlive(existing.parentPid));
|
|
177
|
+
if (ownerAlive) return undefined;
|
|
178
|
+
|
|
179
|
+
// A malformed lock is not deleted immediately: another process may be
|
|
180
|
+
// between create and write. A dead, well-formed owner is safe to reap;
|
|
181
|
+
// an unreadable lock needs a long recovery window so it cannot cause a
|
|
182
|
+
// second heavy worker during a slow filesystem operation.
|
|
183
|
+
let stale = false;
|
|
184
|
+
try {
|
|
185
|
+
stale = Date.now() - statSync(lockPath).mtimeMs >= STALE_LOCK_RECOVERY_MS;
|
|
186
|
+
} catch {
|
|
187
|
+
stale = false;
|
|
188
|
+
}
|
|
189
|
+
if (!existing && !stale) return undefined;
|
|
190
|
+
try { unlinkSync(lockPath); } catch { return undefined; }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function releaseVectorBackfillLock(lockPath = process.env.MEMORIX_VECTOR_BACKFILL_LOCK): void {
|
|
197
|
+
if (!lockPath) return;
|
|
198
|
+
const lock = readVectorBackfillLock(lockPath);
|
|
199
|
+
if (lock && lock.childPid !== process.pid) return;
|
|
200
|
+
try { unlinkSync(lockPath); } catch { /* already released */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
71
203
|
/**
|
|
72
204
|
* Start a detached one-shot worker. The caller has already persisted the
|
|
73
205
|
* observation and durable vector job, so failure to start is recoverable by a
|
|
@@ -81,6 +213,9 @@ export function launchDetachedVectorBackfill(
|
|
|
81
213
|
const exists = options.exists ?? existsSync;
|
|
82
214
|
if (!exists(runnerPath)) return false;
|
|
83
215
|
|
|
216
|
+
const lock = acquireVectorBackfillLock(request.dataDir, request.projectId, options.lockPath);
|
|
217
|
+
if (!lock) return false;
|
|
218
|
+
|
|
84
219
|
try {
|
|
85
220
|
const child = (options.spawn ?? spawn)(process.execPath, [runnerPath], {
|
|
86
221
|
cwd: request.projectRoot,
|
|
@@ -90,13 +225,24 @@ export function launchDetachedVectorBackfill(
|
|
|
90
225
|
stdio: 'ignore',
|
|
91
226
|
// The request contains only local project metadata, never credentials.
|
|
92
227
|
// Environment transport avoids a live stdin pipe keeping the CLI alive.
|
|
93
|
-
env: {
|
|
228
|
+
env: {
|
|
229
|
+
...process.env,
|
|
230
|
+
MEMORIX_VECTOR_BACKFILL_REQUEST: JSON.stringify(request),
|
|
231
|
+
MEMORIX_VECTOR_BACKFILL_LOCK: lock.path,
|
|
232
|
+
},
|
|
94
233
|
windowsHide: true,
|
|
95
234
|
}) as ChildProcess;
|
|
235
|
+
if (child.pid) lock.setChildPid(child.pid);
|
|
236
|
+
else lock.release();
|
|
96
237
|
child.once?.('error', () => {});
|
|
238
|
+
child.once?.('close', () => lock.release());
|
|
239
|
+
// Test doubles and unusual spawn adapters may not expose ChildProcess
|
|
240
|
+
// events. Do not leave a lock behind when there is no lifecycle to own it.
|
|
241
|
+
if (typeof child.once !== 'function') lock.release();
|
|
97
242
|
child.unref();
|
|
98
|
-
return
|
|
243
|
+
return Boolean(child.pid);
|
|
99
244
|
} catch {
|
|
245
|
+
lock.release();
|
|
100
246
|
return false;
|
|
101
247
|
}
|
|
102
248
|
}
|
|
@@ -110,12 +256,43 @@ export async function executeVectorBackfill(request: VectorBackfillRequest) {
|
|
|
110
256
|
await prepareSearchIndex();
|
|
111
257
|
await getDeferredCachedVectorHydration()?.catch(() => {});
|
|
112
258
|
|
|
259
|
+
const queue = new MaintenanceJobStore(request.dataDir);
|
|
260
|
+
const targets = new MaintenanceTargetStore(request.dataDir);
|
|
261
|
+
let preparedRoot = request.projectRoot;
|
|
262
|
+
const prepareTarget = async (target: MaintenanceTarget): Promise<void> => {
|
|
263
|
+
if (target.projectRoot === preparedRoot) return;
|
|
264
|
+
initProjectRoot(target.projectRoot);
|
|
265
|
+
loadDotenv(target.projectRoot);
|
|
266
|
+
preparedRoot = target.projectRoot;
|
|
267
|
+
// The data directory is shared across projects. The in-process index is
|
|
268
|
+
// intentionally global, so switching the target only changes the config
|
|
269
|
+
// and the job scope; it does not create another corpus/index copy.
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const handler = async (job: Parameters<ReturnType<typeof createProjectMaintenanceHandler>>[0]) => {
|
|
273
|
+
const target = targets.get(job.projectId)
|
|
274
|
+
?? (job.projectId === request.projectId
|
|
275
|
+
? { projectId: request.projectId, projectRoot: request.projectRoot, dataDir: request.dataDir, updatedAt: Date.now() }
|
|
276
|
+
: undefined);
|
|
277
|
+
if (!target) return { action: 'reschedule' as const, delayMs: 30_000 };
|
|
278
|
+
await prepareTarget(target);
|
|
279
|
+
return createProjectMaintenanceHandler(job.projectId, target.dataDir, target.projectRoot)(job);
|
|
280
|
+
};
|
|
281
|
+
|
|
113
282
|
const worker = new MaintenanceJobWorker(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
{
|
|
283
|
+
queue,
|
|
284
|
+
handler,
|
|
285
|
+
{ kinds: ['vector-backfill'] },
|
|
117
286
|
);
|
|
118
|
-
|
|
287
|
+
// Drain currently due vector jobs while this one process owns the lock. A
|
|
288
|
+
// hook storm therefore creates at most one heavy process, and pending jobs
|
|
289
|
+
// for other projects are picked up without requiring another hook event.
|
|
290
|
+
let result: Awaited<ReturnType<typeof worker.runOnce>> = { state: 'idle' };
|
|
291
|
+
while (true) {
|
|
292
|
+
result = await worker.runOnce();
|
|
293
|
+
if (result.state === 'idle') break;
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
119
296
|
}
|
|
120
297
|
|
|
121
298
|
async function readStdin(): Promise<string> {
|
|
@@ -137,6 +314,7 @@ export async function main(): Promise<void> {
|
|
|
137
314
|
process.stderr.write(`[memorix] vector backfill worker failed: ${detail}\n`);
|
|
138
315
|
process.exitCode = 1;
|
|
139
316
|
} finally {
|
|
317
|
+
releaseVectorBackfillLock();
|
|
140
318
|
closeAllDatabases();
|
|
141
319
|
}
|
|
142
320
|
}
|
package/src/store/orama-store.ts
CHANGED
|
@@ -33,10 +33,19 @@ let embeddingEnabled = false;
|
|
|
33
33
|
let embeddingDimensions: number | null = null;
|
|
34
34
|
let indexEmbeddingProvider: EmbeddingProvider | null = null;
|
|
35
35
|
let deferredCachedVectorHydration: Promise<void> | null = null;
|
|
36
|
-
const docByObservationKey = new Map<string, MemorixDocument>();
|
|
37
36
|
const NON_CJK_HYBRID_SIMILARITY = 0.45;
|
|
38
37
|
const lastSearchModeByProject = new Map<string, string>();
|
|
39
38
|
const SEARCH_MODE_DEFAULT_KEY = '__global__';
|
|
39
|
+
const MAX_SEARCH_MODE_KEYS = 4096;
|
|
40
|
+
|
|
41
|
+
function rememberSearchMode(key: string, value: string): void {
|
|
42
|
+
if (!lastSearchModeByProject.has(key) && lastSearchModeByProject.size >= MAX_SEARCH_MODE_KEYS) {
|
|
43
|
+
const oldest = lastSearchModeByProject.keys().next().value;
|
|
44
|
+
if (oldest !== undefined) lastSearchModeByProject.delete(oldest);
|
|
45
|
+
}
|
|
46
|
+
lastSearchModeByProject.delete(key);
|
|
47
|
+
lastSearchModeByProject.set(key, value);
|
|
48
|
+
}
|
|
40
49
|
export function getLastSearchMode(projectId?: string): string {
|
|
41
50
|
return lastSearchModeByProject.get(projectId ?? SEARCH_MODE_DEFAULT_KEY) ?? 'fulltext';
|
|
42
51
|
}
|
|
@@ -68,9 +77,6 @@ function makeEntryKey(projectId: string | undefined, observationId: number): str
|
|
|
68
77
|
function rememberObservationDoc(doc: MemorixDocument): MemorixDocument {
|
|
69
78
|
const publicDoc = { ...doc };
|
|
70
79
|
delete publicDoc.embedding;
|
|
71
|
-
if (doc.projectId && typeof doc.observationId === 'number') {
|
|
72
|
-
docByObservationKey.set(makeEntryKey(doc.projectId, doc.observationId), publicDoc);
|
|
73
|
-
}
|
|
74
80
|
return publicDoc;
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -244,7 +250,6 @@ export async function resetDb(): Promise<void> {
|
|
|
244
250
|
indexEmbeddingProvider = null;
|
|
245
251
|
deferredCachedVectorHydration = null;
|
|
246
252
|
lastSearchModeByProject.clear();
|
|
247
|
-
docByObservationKey.clear();
|
|
248
253
|
}
|
|
249
254
|
|
|
250
255
|
/**
|
|
@@ -336,8 +341,6 @@ export interface HydrateIndexOptions {
|
|
|
336
341
|
skipCachedVectors?: boolean;
|
|
337
342
|
}
|
|
338
343
|
|
|
339
|
-
type HydrationCandidate = { observation: any; id: string };
|
|
340
|
-
|
|
341
344
|
function observationEmbeddingText(observation: any): string {
|
|
342
345
|
return [
|
|
343
346
|
observation.title ?? '',
|
|
@@ -361,29 +364,31 @@ function isCompatibleCachedVector(vector: number[] | null): vector is number[] {
|
|
|
361
364
|
|
|
362
365
|
async function attachCachedVectors(
|
|
363
366
|
database: AnyOrama,
|
|
364
|
-
|
|
367
|
+
observations: any[],
|
|
365
368
|
): Promise<void> {
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
+
const batchSize = 200;
|
|
370
|
+
for (let start = 0; start < observations.length; start += batchSize) {
|
|
371
|
+
// Keep cache lookup and the temporary vector array bounded to one batch.
|
|
372
|
+
const batch = observations.slice(start, start + batchSize);
|
|
373
|
+
const cachedVectors = await getCachedEmbeddings(batch.map(observationEmbeddingText));
|
|
369
374
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
375
|
+
// A reset or project switch created a new index while the cache was loading.
|
|
376
|
+
// Do not attach stale vectors to that new index.
|
|
377
|
+
if (db !== database) return;
|
|
373
378
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
379
|
+
for (let index = 0; index < batch.length; index++) {
|
|
380
|
+
const vector = cachedVectors[index];
|
|
381
|
+
if (!isCompatibleCachedVector(vector)) continue;
|
|
382
|
+
const id = makeOramaObservationId(batch[index].projectId, batch[index].id);
|
|
383
|
+
const existing = getByID(database, id) as MemorixDocument | undefined;
|
|
384
|
+
if (!existing || documentEmbeddingText(existing) !== observationEmbeddingText(batch[index])) continue;
|
|
385
|
+
try {
|
|
386
|
+
await update(database, id, { ...existing, embedding: vector });
|
|
387
|
+
} catch {
|
|
388
|
+
// Vector cache hydration is best-effort. The normal backfill lane owns misses.
|
|
389
|
+
}
|
|
386
390
|
}
|
|
391
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
387
392
|
}
|
|
388
393
|
}
|
|
389
394
|
|
|
@@ -392,62 +397,54 @@ export async function hydrateIndex(
|
|
|
392
397
|
options: HydrateIndexOptions = {},
|
|
393
398
|
): Promise<number> {
|
|
394
399
|
const database = await getDb({ allowNetworkProbe: options.allowNetworkProbe });
|
|
395
|
-
|
|
396
|
-
const candidates: HydrationCandidate[] = [];
|
|
397
|
-
for (const observation of observations) {
|
|
398
|
-
if (!observation || !observation.id || !observation.projectId) continue;
|
|
399
|
-
const id = makeOramaObservationId(observation.projectId, observation.id);
|
|
400
|
-
if (getByID(database, id)) continue;
|
|
401
|
-
candidates.push({ observation, id });
|
|
402
|
-
}
|
|
403
|
-
|
|
404
400
|
const deferCachedVectors = options.deferCachedVectors === true && Boolean(indexEmbeddingProvider?.getCachedEmbeddings);
|
|
405
|
-
const cachedVectors = deferCachedVectors
|
|
406
|
-
? candidates.map(() => null)
|
|
407
|
-
: await getCachedEmbeddings(candidates.map(({ observation }) => observationEmbeddingText(observation)));
|
|
408
|
-
|
|
409
|
-
const documents: MemorixDocument[] = [];
|
|
410
|
-
for (let index = 0; index < candidates.length; index++) {
|
|
411
|
-
const { observation: obs, id } = candidates[index];
|
|
412
|
-
try {
|
|
413
|
-
const vector = cachedVectors[index];
|
|
414
|
-
const compatibleVector = isCompatibleCachedVector(vector) ? vector : null;
|
|
415
|
-
const doc: MemorixDocument = {
|
|
416
|
-
id,
|
|
417
|
-
observationId: obs.id,
|
|
418
|
-
entityName: obs.entityName || '',
|
|
419
|
-
type: obs.type || 'discovery',
|
|
420
|
-
title: obs.title || '',
|
|
421
|
-
narrative: obs.narrative || '',
|
|
422
|
-
facts: Array.isArray(obs.facts) ? obs.facts.join(' ') : '',
|
|
423
|
-
filesModified: Array.isArray(obs.filesModified) ? obs.filesModified.join(' ') : '',
|
|
424
|
-
concepts: Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '',
|
|
425
|
-
tokens: obs.tokens ?? 0,
|
|
426
|
-
createdAt: obs.createdAt || '',
|
|
427
|
-
projectId: obs.projectId,
|
|
428
|
-
accessCount: obs.accessCount ?? 0,
|
|
429
|
-
lastAccessedAt: obs.lastAccessedAt || '',
|
|
430
|
-
status: obs.status ?? 'active',
|
|
431
|
-
source: obs.source || 'agent',
|
|
432
|
-
sourceDetail: obs.sourceDetail ?? '',
|
|
433
|
-
valueCategory: obs.valueCategory ?? '',
|
|
434
|
-
admissionState: obs.admissionState ?? '',
|
|
435
|
-
admissionReason: obs.admissionReason ?? '',
|
|
436
|
-
visibility: obs.visibility ?? 'project',
|
|
437
|
-
createdByAgentId: obs.createdByAgentId ?? '',
|
|
438
|
-
sharedWithAgentIds: JSON.stringify(obs.sharedWithAgentIds ?? []),
|
|
439
|
-
documentType: 'observation',
|
|
440
|
-
knowledgeLayer: resolveKnowledgeLayer('observation', obs.sourceDetail, obs.source),
|
|
441
|
-
...(compatibleVector ? { embedding: compatibleVector } : {}),
|
|
442
|
-
};
|
|
443
|
-
documents.push(doc);
|
|
444
|
-
} catch { /* skip malformed entries */ }
|
|
445
|
-
}
|
|
446
|
-
|
|
447
401
|
let inserted = 0;
|
|
448
402
|
const hydrationBatchSize = 200;
|
|
449
|
-
for (let start = 0; start <
|
|
450
|
-
const
|
|
403
|
+
for (let start = 0; start < observations.length; start += hydrationBatchSize) {
|
|
404
|
+
const batchObservations = observations.slice(start, start + hydrationBatchSize)
|
|
405
|
+
.filter((observation) => observation && observation.id && observation.projectId)
|
|
406
|
+
.filter((observation) => !getByID(database, makeOramaObservationId(observation.projectId, observation.id)));
|
|
407
|
+
if (batchObservations.length === 0) continue;
|
|
408
|
+
|
|
409
|
+
const cachedVectors = deferCachedVectors || options.skipCachedVectors
|
|
410
|
+
? batchObservations.map(() => null)
|
|
411
|
+
: await getCachedEmbeddings(batchObservations.map(observationEmbeddingText));
|
|
412
|
+
const batch: MemorixDocument[] = [];
|
|
413
|
+
for (let index = 0; index < batchObservations.length; index++) {
|
|
414
|
+
const obs = batchObservations[index];
|
|
415
|
+
try {
|
|
416
|
+
const compatibleVector = isCompatibleCachedVector(cachedVectors[index]) ? cachedVectors[index] : null;
|
|
417
|
+
batch.push({
|
|
418
|
+
id: makeOramaObservationId(obs.projectId, obs.id),
|
|
419
|
+
observationId: obs.id,
|
|
420
|
+
entityName: obs.entityName || '',
|
|
421
|
+
type: obs.type || 'discovery',
|
|
422
|
+
title: obs.title || '',
|
|
423
|
+
narrative: obs.narrative || '',
|
|
424
|
+
facts: Array.isArray(obs.facts) ? obs.facts.join(' ') : '',
|
|
425
|
+
filesModified: Array.isArray(obs.filesModified) ? obs.filesModified.join(' ') : '',
|
|
426
|
+
concepts: Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '',
|
|
427
|
+
tokens: obs.tokens ?? 0,
|
|
428
|
+
createdAt: obs.createdAt || '',
|
|
429
|
+
projectId: obs.projectId,
|
|
430
|
+
accessCount: obs.accessCount ?? 0,
|
|
431
|
+
lastAccessedAt: obs.lastAccessedAt || '',
|
|
432
|
+
status: obs.status ?? 'active',
|
|
433
|
+
source: obs.source || 'agent',
|
|
434
|
+
sourceDetail: obs.sourceDetail ?? '',
|
|
435
|
+
valueCategory: obs.valueCategory ?? '',
|
|
436
|
+
admissionState: obs.admissionState ?? '',
|
|
437
|
+
admissionReason: obs.admissionReason ?? '',
|
|
438
|
+
visibility: obs.visibility ?? 'project',
|
|
439
|
+
createdByAgentId: obs.createdByAgentId ?? '',
|
|
440
|
+
sharedWithAgentIds: JSON.stringify(obs.sharedWithAgentIds ?? []),
|
|
441
|
+
documentType: 'observation',
|
|
442
|
+
knowledgeLayer: resolveKnowledgeLayer('observation', obs.sourceDetail, obs.source),
|
|
443
|
+
...(compatibleVector ? { embedding: compatibleVector } : {}),
|
|
444
|
+
});
|
|
445
|
+
} catch { /* skip malformed entries */ }
|
|
446
|
+
}
|
|
447
|
+
if (batch.length === 0) continue;
|
|
451
448
|
try {
|
|
452
449
|
// Orama can rebalance its indexes once per batch instead of once per
|
|
453
450
|
// document. Keep batches bounded so HTTP health remains responsive.
|
|
@@ -476,7 +473,7 @@ export async function hydrateIndex(
|
|
|
476
473
|
deferredCachedVectorHydration = options.skipCachedVectors
|
|
477
474
|
? null
|
|
478
475
|
: deferCachedVectors
|
|
479
|
-
? attachCachedVectors(database,
|
|
476
|
+
? attachCachedVectors(database, observations).catch(() => {})
|
|
480
477
|
: null;
|
|
481
478
|
return inserted;
|
|
482
479
|
}
|
|
@@ -548,12 +545,6 @@ export async function updateObservationMetadata(
|
|
|
548
545
|
export async function removeObservation(oramaId: string): Promise<void> {
|
|
549
546
|
const database = await getDb();
|
|
550
547
|
await remove(database, oramaId);
|
|
551
|
-
for (const [key, doc] of docByObservationKey.entries()) {
|
|
552
|
-
if (doc.id === oramaId) {
|
|
553
|
-
docByObservationKey.delete(key);
|
|
554
|
-
break;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
548
|
}
|
|
558
549
|
|
|
559
550
|
/**
|
|
@@ -569,7 +560,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
569
560
|
const modeKey = options.projectId ?? SEARCH_MODE_DEFAULT_KEY;
|
|
570
561
|
const quality = options.quality ?? 'balanced';
|
|
571
562
|
const database = await getDb();
|
|
572
|
-
|
|
563
|
+
rememberSearchMode(
|
|
573
564
|
modeKey,
|
|
574
565
|
quality === 'fast' ? 'fulltext (fast profile)' : embeddingEnabled ? 'hybrid' : 'fulltext',
|
|
575
566
|
);
|
|
@@ -671,7 +662,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
671
662
|
if (provider) {
|
|
672
663
|
const activeVectorDimensions = getVectorDimensions();
|
|
673
664
|
if (activeVectorDimensions !== null && provider.dimensions !== activeVectorDimensions) {
|
|
674
|
-
|
|
665
|
+
rememberSearchMode(
|
|
675
666
|
modeKey,
|
|
676
667
|
`fulltext (embedding dimension mismatch: provider ${provider.dimensions}d vs index ${activeVectorDimensions}d)`,
|
|
677
668
|
);
|
|
@@ -700,7 +691,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
700
691
|
// Detect CJK-heavy queries: BM25 can't tokenize Chinese/Japanese/Korean well
|
|
701
692
|
const cjkRatio = (originalQuery!.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []).length / originalQuery!.length;
|
|
702
693
|
const isCJKHeavy = cjkRatio > 0.3;
|
|
703
|
-
|
|
694
|
+
rememberSearchMode(modeKey, 'hybrid');
|
|
704
695
|
searchParams = {
|
|
705
696
|
...searchParams,
|
|
706
697
|
mode: 'hybrid',
|
|
@@ -719,7 +710,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
719
710
|
}
|
|
720
711
|
} catch (error) {
|
|
721
712
|
// Fallback to fulltext if embedding fails or times out
|
|
722
|
-
|
|
713
|
+
rememberSearchMode(modeKey, 'fulltext (embedding unavailable)');
|
|
723
714
|
console.error('[memorix] Embedding failed or timed out, falling back to fulltext search');
|
|
724
715
|
}
|
|
725
716
|
}
|
|
@@ -730,7 +721,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
730
721
|
results = await search(database, searchParams);
|
|
731
722
|
} catch (error) {
|
|
732
723
|
if (queryVector && isVectorDimensionMismatchError(error)) {
|
|
733
|
-
|
|
724
|
+
rememberSearchMode(modeKey, 'fulltext (embedding dimension mismatch)');
|
|
734
725
|
console.error('[memorix] Vector search dimension mismatch detected, retrying without embeddings');
|
|
735
726
|
results = await search(database, stripVectorSearchParams(searchParams));
|
|
736
727
|
} else {
|
|
@@ -754,7 +745,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
754
745
|
},
|
|
755
746
|
similarity: 0.25,
|
|
756
747
|
};
|
|
757
|
-
|
|
748
|
+
rememberSearchMode(modeKey, 'vector-only (hybrid empty fallback)');
|
|
758
749
|
results = await search(database, vectorOnlyParams);
|
|
759
750
|
} catch {
|
|
760
751
|
// Keep original empty results
|
|
@@ -1106,7 +1097,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
1106
1097
|
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
|
|
1107
1098
|
if (rerankedTop.length > 0) {
|
|
1108
1099
|
intermediate = [...rerankedTop, ...intermediate.slice(RERANK_TOP_K)];
|
|
1109
|
-
|
|
1100
|
+
rememberSearchMode(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + neural rerank');
|
|
1110
1101
|
mark('rerank(usedNeural=true)');
|
|
1111
1102
|
}
|
|
1112
1103
|
}
|
|
@@ -1123,7 +1114,7 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
1123
1114
|
);
|
|
1124
1115
|
mark(`rerank(usedLLM=${usedLLM})`);
|
|
1125
1116
|
if (usedLLM) {
|
|
1126
|
-
|
|
1117
|
+
rememberSearchMode(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + LLM rerank');
|
|
1127
1118
|
const rerankedTop = reranked
|
|
1128
1119
|
.map((row) => candidateMap.get(row.id))
|
|
1129
1120
|
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
|
|
@@ -1224,14 +1215,6 @@ export async function getObservationsByIds(
|
|
|
1224
1215
|
const results: MemorixDocument[] = [];
|
|
1225
1216
|
|
|
1226
1217
|
for (const id of ids) {
|
|
1227
|
-
if (projectId) {
|
|
1228
|
-
const cached = docByObservationKey.get(makeEntryKey(projectId, id));
|
|
1229
|
-
if (cached) {
|
|
1230
|
-
results.push(cached);
|
|
1231
|
-
continue;
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
1218
|
const searchResult = await search(database, {
|
|
1236
1219
|
term: '',
|
|
1237
1220
|
includeVectors: true,
|