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/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 Promise.race([searchPromise, timeoutPromise]);
1348
+ result = await withTimeout(searchPromise, TIMEOUT_MS, 'Search');
1359
1349
  } catch (error) {
1360
- if (error instanceof Error && error.message.includes('timeout')) {
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: [
@@ -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
- lastSearchModeByProject.set(modeKey, embeddingEnabled ? 'hybrid' : 'fulltext');
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
- const tier = hasQuery ? classifyQueryTier(originalQuery!) : 'fast' as QueryTier;
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
- // Query expansion: only for heavy-tier (CJK) queries
538
- const expandedEmbeddingQuery = tier === 'heavy' ? await maybeExpandSearchQuery(options.query!) : options.query;
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!) : null;
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
- includeVectors: true,
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
- ...(hasQuery ? { tolerance: originalQuery!.length > 6 ? 2 : 1 } : {}),
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
- const embedPromise = provider.embed(expandedEmbeddingQuery!);
598
- const timeoutPromise = new Promise<never>((_, reject) =>
599
- setTimeout(() => reject(new Error(`Embedding timeout after ${EMBEDDING_TIMEOUT_MS}ms`)), EMBEDDING_TIMEOUT_MS)
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
- // Fast and standard tiers skip entirely.
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 = tier === 'heavy'
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 rerankPromise = rerankResults(originalQuery!, candidates);
983
- const timeoutPromise = new Promise<never>((_, reject) =>
984
- setTimeout(() => reject(new Error(`LLM rerank timeout after ${RERANK_TIMEOUT_MS}ms`)), RERANK_TIMEOUT_MS)
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 */