memorix 1.2.4 → 1.2.5
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 +13 -0
- package/dist/cli/index.js +147 -61
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +92 -55
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.js +79 -35
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +13 -0
- package/dist/sdk.d.ts +4 -2
- package/dist/sdk.js +93 -55
- package/dist/sdk.js.map +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.js.map +1 -1
- package/docs/1.2.5-RETRIEVAL-PERFORMANCE.md +85 -0
- package/docs/PERFORMANCE.md +22 -1
- package/docs/dev-log/progress.txt +33 -8
- package/package.json +2 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/operator-shared.ts +74 -2
- package/src/cli/index.ts +2 -0
- package/src/config/resolved-config.ts +29 -4
- package/src/runtime/project-maintenance.ts +1 -14
- package/src/sdk.ts +4 -0
- package/src/server.ts +8 -18
- package/src/store/orama-store.ts +39 -18
- package/src/timeout.ts +23 -0
- package/src/types.ts +8 -0
package/src/server.ts
CHANGED
|
@@ -50,23 +50,13 @@ import type { ExistingMemory } from './llm/memory-manager.js';
|
|
|
50
50
|
import { runFormation, getMetricsSummary, getBeforeAfterMetrics } from './memory/formation/index.js';
|
|
51
51
|
import type { FormationConfig, SearchHit, FormedMemory, FormationStage, FormationStageEvent } from './memory/formation/types.js';
|
|
52
52
|
import { parseFormationTimeoutMs } from './server/formation-timeout.js';
|
|
53
|
+
import { withTimeout } from './timeout.js';
|
|
53
54
|
|
|
54
55
|
// ── Timeout budgets for LLM-heavy paths ──────────────────────────
|
|
55
56
|
const FORMATION_TIMEOUT_MS = parseFormationTimeoutMs(process.env.MEMORIX_FORMATION_TIMEOUT_MS); // Formation pipeline (extract+resolve+evaluate)
|
|
56
57
|
const COMPACT_ON_WRITE_TIMEOUT_MS = 12_000; // Legacy compact-on-write fallback path
|
|
57
58
|
const COMPRESSION_TIMEOUT_MS = 5_000; // Narrative compression
|
|
58
59
|
|
|
59
|
-
/** Race a promise against a timeout. Rejects with a descriptive Error on timeout. */
|
|
60
|
-
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
61
|
-
let timer: ReturnType<typeof setTimeout>;
|
|
62
|
-
return Promise.race([
|
|
63
|
-
promise,
|
|
64
|
-
new Promise<never>((_, reject) => {
|
|
65
|
-
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
66
|
-
}),
|
|
67
|
-
]).finally(() => clearTimeout(timer!));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
60
|
function formatFormationStageDurations(stageDurationsMs: Partial<Record<FormationStage, number>>): string {
|
|
71
61
|
const orderedStages: FormationStage[] = ['extract', 'resolve', 'evaluate'];
|
|
72
62
|
const parts = orderedStages
|
|
@@ -1310,6 +1300,9 @@ export async function createMemorixServer(
|
|
|
1310
1300
|
source: z.enum(['agent', 'git', 'manual']).optional().describe(
|
|
1311
1301
|
'Filter by memory source. "git" returns only commit-derived ground truth memories. Omit for all sources.',
|
|
1312
1302
|
),
|
|
1303
|
+
quality: z.enum(['fast', 'balanced', 'thorough']).optional().default('balanced').describe(
|
|
1304
|
+
'Retrieval profile: fast stays local, balanced uses configured embeddings, thorough explicitly permits optional LLM refinement.',
|
|
1305
|
+
),
|
|
1313
1306
|
purpose: z.string().optional().describe(
|
|
1314
1307
|
'Why this must expand beyond the latest Autopilot brief. Name the missing fact or the user\'s explicit request.',
|
|
1315
1308
|
),
|
|
@@ -1318,7 +1311,7 @@ export async function createMemorixServer(
|
|
|
1318
1311
|
),
|
|
1319
1312
|
},
|
|
1320
1313
|
},
|
|
1321
|
-
async ({ query, limit, type, maxTokens, scope, since, until, status, source, purpose, force }) => {
|
|
1314
|
+
async ({ query, limit, type, maxTokens, scope, since, until, status, source, quality, purpose, force }) => {
|
|
1322
1315
|
if (scope !== 'global') {
|
|
1323
1316
|
const unresolved = requireResolvedProject('search the current project');
|
|
1324
1317
|
if (unresolved) return unresolved;
|
|
@@ -1346,18 +1339,15 @@ export async function createMemorixServer(
|
|
|
1346
1339
|
projectId: scope === 'global' ? undefined : project.id,
|
|
1347
1340
|
status: (status as 'active' | 'resolved' | 'archived' | 'all') ?? 'active',
|
|
1348
1341
|
source: source as 'agent' | 'git' | 'manual' | undefined,
|
|
1342
|
+
quality: quality as 'fast' | 'balanced' | 'thorough',
|
|
1349
1343
|
reader: getObservationReader(scope === 'global' ? 'global' : 'project'),
|
|
1350
1344
|
});
|
|
1351
1345
|
|
|
1352
|
-
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
1353
|
-
setTimeout(() => reject(new Error(`Search timeout after ${TIMEOUT_MS}ms`)), TIMEOUT_MS)
|
|
1354
|
-
);
|
|
1355
|
-
|
|
1356
1346
|
let result;
|
|
1357
1347
|
try {
|
|
1358
|
-
result = await
|
|
1348
|
+
result = await withTimeout(searchPromise, TIMEOUT_MS, 'Search');
|
|
1359
1349
|
} catch (error) {
|
|
1360
|
-
if (error instanceof Error && error.message
|
|
1350
|
+
if (error instanceof Error && /\btimeout\b|timed out/i.test(error.message)) {
|
|
1361
1351
|
// Timeout: return empty result with error message
|
|
1362
1352
|
return {
|
|
1363
1353
|
content: [
|
package/src/store/orama-store.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { getEmbeddingProvider, type EmbeddingProvider } from '../embedding/provi
|
|
|
17
17
|
import { calculateProjectAffinity, extractProjectKeywords, type AffinityContext, type MemoryContent } from './project-affinity.js';
|
|
18
18
|
import { detectQueryIntent, applyIntentBoost } from '../search/intent-detector.js';
|
|
19
19
|
import { maybeExpandSearchQuery } from '../search/query-expansion.js';
|
|
20
|
+
import { withTimeout } from '../timeout.js';
|
|
20
21
|
|
|
21
22
|
let db: AnyOrama | null = null;
|
|
22
23
|
let dbInitPromise: Promise<AnyOrama> | null = null;
|
|
@@ -501,8 +502,12 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
501
502
|
const t0 = perf ? performance.now() : 0;
|
|
502
503
|
const mark = (label: string) => { if (perf) { const now = performance.now(); process.stderr.write(` [search-perf] ${label}: ${(now - t0).toFixed(0)}ms\n`); } };
|
|
503
504
|
const modeKey = options.projectId ?? SEARCH_MODE_DEFAULT_KEY;
|
|
504
|
-
|
|
505
|
+
const quality = options.quality ?? 'balanced';
|
|
505
506
|
const database = await getDb();
|
|
507
|
+
lastSearchModeByProject.set(
|
|
508
|
+
modeKey,
|
|
509
|
+
quality === 'fast' ? 'fulltext (fast profile)' : embeddingEnabled ? 'hybrid' : 'fulltext',
|
|
510
|
+
);
|
|
506
511
|
|
|
507
512
|
// Resolve project aliases — safety net for observations not yet migrated to canonical ID.
|
|
508
513
|
// After migration, this is typically a single-element array matching options.projectId.
|
|
@@ -531,17 +536,28 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
531
536
|
// Determine search mode: hybrid (with vector) or fulltext (default)
|
|
532
537
|
const hasQuery = options.query && options.query.trim().length > 0;
|
|
533
538
|
const originalQuery = options.query;
|
|
534
|
-
|
|
539
|
+
// The fast profile is a bounded local probe, not a lower-quality spelling
|
|
540
|
+
// corrector. Skip query classification and the expensive recall enrichments
|
|
541
|
+
// it would otherwise inherit from the balanced path.
|
|
542
|
+
const tier = quality === 'fast' ? 'fast' as QueryTier : hasQuery ? classifyQueryTier(originalQuery!) : 'fast' as QueryTier;
|
|
535
543
|
mark(`tier=${tier}`);
|
|
536
544
|
|
|
537
|
-
//
|
|
538
|
-
|
|
545
|
+
// LLM quality work is explicit. It never runs on the default search path.
|
|
546
|
+
if (quality === 'thorough' && hasQuery) {
|
|
547
|
+
const { initLLM, isLLMEnabled } = await import('../llm/provider.js');
|
|
548
|
+
if (!isLLMEnabled()) initLLM();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Query expansion: only for thorough heavy-tier (CJK) searches.
|
|
552
|
+
const expandedEmbeddingQuery = quality === 'thorough' && tier === 'heavy'
|
|
553
|
+
? await maybeExpandSearchQuery(options.query!)
|
|
554
|
+
: options.query;
|
|
539
555
|
mark('queryExpansion');
|
|
540
556
|
|
|
541
557
|
// ── Intent-Aware Recall ──────────────────────────────────────
|
|
542
558
|
// Detect query intent (why/when/how/what/problem) and adjust
|
|
543
559
|
// field weights and type boosting accordingly.
|
|
544
|
-
const intentResult = hasQuery ? detectQueryIntent(originalQuery!)
|
|
560
|
+
const intentResult = quality === 'fast' || !hasQuery ? null : detectQueryIntent(originalQuery!);
|
|
545
561
|
|
|
546
562
|
// Orama's vector/hybrid search can leak cross-project hits even when `where`
|
|
547
563
|
// is present, so always keep enough headroom for a deterministic post-filter.
|
|
@@ -565,20 +581,24 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
565
581
|
let searchParams: Record<string, unknown> = {
|
|
566
582
|
term: originalQuery,
|
|
567
583
|
limit: requestLimit,
|
|
568
|
-
|
|
584
|
+
// Fast search never uses vectors, so returning them only adds work and
|
|
585
|
+
// payload pressure to an explicitly latency-sensitive path.
|
|
586
|
+
includeVectors: quality !== 'fast',
|
|
569
587
|
...(Object.keys(filters).length > 0 ? { where: filters } : {}),
|
|
570
588
|
// Search specific fields (not tokens, accessCount, etc.)
|
|
571
589
|
properties: ['title', 'entityName', 'narrative', 'facts', 'concepts', 'filesModified'],
|
|
572
590
|
// Field boosting: intent-aware or default
|
|
573
591
|
boost: fieldBoost,
|
|
574
592
|
// Fuzzy tolerance: allow 1-char typos for short queries, 2 for longer
|
|
575
|
-
|
|
593
|
+
// Fuzzy matching is useful for ordinary recall, but grows sharply with
|
|
594
|
+
// corpus size. The fast profile deliberately uses exact lexical matching.
|
|
595
|
+
...(hasQuery && quality !== 'fast' ? { tolerance: originalQuery!.length > 6 ? 2 : 1 } : {}),
|
|
576
596
|
};
|
|
577
597
|
|
|
578
598
|
// If embedding provider is available and query tier warrants it, use hybrid search
|
|
579
599
|
// Fast-tier queries skip embedding entirely (fulltext is sufficient)
|
|
580
600
|
let queryVector: number[] | null = null;
|
|
581
|
-
if (embeddingEnabled && hasQuery && tier !== 'fast') {
|
|
601
|
+
if (quality !== 'fast' && embeddingEnabled && hasQuery && tier !== 'fast') {
|
|
582
602
|
try {
|
|
583
603
|
const provider = await getEmbeddingProvider();
|
|
584
604
|
if (provider) {
|
|
@@ -594,11 +614,11 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
594
614
|
} else {
|
|
595
615
|
// Embedding timeout: 15 seconds
|
|
596
616
|
const EMBEDDING_TIMEOUT_MS = 15000;
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
617
|
+
queryVector = await withTimeout(
|
|
618
|
+
provider.embed(expandedEmbeddingQuery!),
|
|
619
|
+
EMBEDDING_TIMEOUT_MS,
|
|
620
|
+
'Embedding',
|
|
600
621
|
);
|
|
601
|
-
queryVector = await Promise.race([embedPromise, timeoutPromise]);
|
|
602
622
|
mark('embedding');
|
|
603
623
|
// Detect CJK-heavy queries: BM25 can't tokenize Chinese/Japanese/Korean well
|
|
604
624
|
const cjkRatio = (originalQuery!.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/g) || []).length / originalQuery!.length;
|
|
@@ -946,9 +966,10 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
946
966
|
|
|
947
967
|
// ── LLM Reranking (heavy-tier only) ────────────────────────────
|
|
948
968
|
// Only triggered for heavy-tier queries with ambiguous top results.
|
|
949
|
-
//
|
|
969
|
+
// Non-thorough profiles, fast, and standard tiers skip entirely.
|
|
950
970
|
// Ambiguity check: top-2 scores within 30% → results are uncertain.
|
|
951
|
-
const shouldRerank =
|
|
971
|
+
const shouldRerank = quality === 'thorough'
|
|
972
|
+
&& tier === 'heavy'
|
|
952
973
|
&& hasQuery
|
|
953
974
|
&& intermediate.length > 2
|
|
954
975
|
&& (() => {
|
|
@@ -979,11 +1000,11 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
|
|
|
979
1000
|
// LLM rerank timeout: configurable via MEMORIX_RERANK_TIMEOUT_MS, default 5s
|
|
980
1001
|
const _parsedRerank = parseInt(process.env.MEMORIX_RERANK_TIMEOUT_MS || '', 10);
|
|
981
1002
|
const RERANK_TIMEOUT_MS = Number.isFinite(_parsedRerank) && _parsedRerank > 0 ? _parsedRerank : 5000;
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
1003
|
+
const { reranked, usedLLM } = await withTimeout(
|
|
1004
|
+
rerankResults(originalQuery!, candidates),
|
|
1005
|
+
RERANK_TIMEOUT_MS,
|
|
1006
|
+
'LLM rerank',
|
|
985
1007
|
);
|
|
986
|
-
const { reranked, usedLLM } = await Promise.race([rerankPromise, timeoutPromise]);
|
|
987
1008
|
mark(`rerank(usedLLM=${usedLLM})`);
|
|
988
1009
|
|
|
989
1010
|
if (usedLLM) {
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run an operation with a bounded wait without leaving its watchdog alive.
|
|
3
|
+
*
|
|
4
|
+
* The underlying operation is not force-cancelled because many existing
|
|
5
|
+
* callers do not expose an AbortSignal. Its settlement is still observed so a
|
|
6
|
+
* late rejection is handled, while the caller receives the timeout promptly.
|
|
7
|
+
*/
|
|
8
|
+
export function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
9
|
+
return new Promise<T>((resolve, reject) => {
|
|
10
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
11
|
+
|
|
12
|
+
promise.then(
|
|
13
|
+
(value) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
resolve(value);
|
|
16
|
+
},
|
|
17
|
+
(error) => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
reject(error);
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -239,6 +239,9 @@ export interface TimelineContext {
|
|
|
239
239
|
after: IndexEntry[];
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/** Retrieval work allowed for a search request. */
|
|
243
|
+
export type RetrievalQuality = 'fast' | 'balanced' | 'thorough';
|
|
244
|
+
|
|
242
245
|
/** Search options for the compact engine */
|
|
243
246
|
export interface SearchOptions {
|
|
244
247
|
query: string;
|
|
@@ -257,6 +260,11 @@ export interface SearchOptions {
|
|
|
257
260
|
trackAccess?: boolean;
|
|
258
261
|
/** Internal reader context for agent-facing visibility filtering. */
|
|
259
262
|
reader?: ObservationReader;
|
|
263
|
+
/**
|
|
264
|
+
* `fast` stays fully local, `balanced` (default) permits embeddings, and
|
|
265
|
+
* `thorough` explicitly permits optional LLM query rewrite and reranking.
|
|
266
|
+
*/
|
|
267
|
+
quality?: RetrievalQuality;
|
|
260
268
|
}
|
|
261
269
|
|
|
262
270
|
/** Topic key family heuristics for suggesting stable topic keys */
|