memorix 1.7.2 → 1.7.3

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 (39) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/assets/star-history-dark.svg +4 -4
  3. package/assets/star-history-light.svg +4 -4
  4. package/assets/star-history.json +47 -3
  5. package/dist/cli/index.js +396 -64
  6. package/dist/index.js +368 -62
  7. package/dist/maintenance-runner.js +365 -58
  8. package/dist/media-audio-runner.js +365 -58
  9. package/dist/media-video-runner.js +365 -58
  10. package/dist/memcode-runtime/CHANGELOG.md +27 -1
  11. package/dist/sdk.js +367 -61
  12. package/dist/vector-backfill-runner.js +365 -58
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/CONFIGURATION.md +57 -1
  15. package/docs/PERFORMANCE.md +3 -1
  16. package/docs/SETUP.md +2 -1
  17. package/package.json +1 -1
  18. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  19. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  20. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  21. package/plugins/copilot/memorix/plugin.json +1 -1
  22. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  23. package/plugins/omp/memorix/package.json +1 -1
  24. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/pi/memorix/package.json +1 -1
  26. package/server.json +2 -2
  27. package/src/cli/commands/config-migrate.ts +7 -0
  28. package/src/cli/commands/doctor.ts +5 -1
  29. package/src/cli/commands/init.ts +17 -0
  30. package/src/cli/commands/status.ts +2 -0
  31. package/src/config/resolved-config.ts +110 -12
  32. package/src/config/toml-loader.ts +8 -0
  33. package/src/config/yaml-loader.ts +9 -0
  34. package/src/llm/provider.ts +33 -7
  35. package/src/multimodal/image-loader.ts +6 -3
  36. package/src/rerank/http-provider.ts +125 -0
  37. package/src/rerank/index.ts +81 -0
  38. package/src/rerank/policy.ts +88 -0
  39. package/src/store/orama-store.ts +79 -53
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Optional HTTP rerank lane. LLM rerank stays in quality.ts when HTTP is off.
3
+ */
4
+
5
+ import { getResolvedRerankLane, type ResolvedLaneOptions } from '../config/resolved-config.js';
6
+ import { rerankViaHttp } from './http-provider.js';
7
+ import { resolveRerankTimeoutMs } from './policy.js';
8
+
9
+ export {
10
+ DEFAULT_RERANK_TIMEOUT_MS,
11
+ MIN_RERANK_CANDIDATES,
12
+ parseRerankTimeoutMs,
13
+ resolveRerankTimeoutMs,
14
+ shouldAttemptHttpRerank,
15
+ shouldAttemptLlmRerank,
16
+ } from './policy.js';
17
+
18
+ /** Minimal search hit the HTTP reranker can reorder. */
19
+ export interface NeuralRerankCandidate {
20
+ id: string;
21
+ title: string;
22
+ type: string;
23
+ score: number;
24
+ narrative?: string;
25
+ }
26
+
27
+ const MAX_RERANK = 10;
28
+
29
+ /**
30
+ * True when an HTTP `/rerank` endpoint is configured.
31
+ */
32
+ export function isNeuralRerankEnabled(options: ResolvedLaneOptions = {}): boolean {
33
+ const lane = getResolvedRerankLane(options);
34
+ return lane.provider === 'http' && Boolean(lane.baseUrl);
35
+ }
36
+
37
+ /**
38
+ * Reorder search candidates via the configured remote reranker.
39
+ * Returns null when the provider is off or the HTTP call cannot be used.
40
+ */
41
+ export async function neuralRerankCandidates(
42
+ query: string,
43
+ candidates: NeuralRerankCandidate[],
44
+ options: ResolvedLaneOptions = {},
45
+ ): Promise<NeuralRerankCandidate[] | null> {
46
+ if (!isNeuralRerankEnabled(options) || candidates.length === 0) return null;
47
+
48
+ const lane = getResolvedRerankLane(options);
49
+ if (!lane.canSendRequest || !lane.baseUrl) return null;
50
+ const toRerank = candidates.slice(0, MAX_RERANK);
51
+ const rest = candidates.slice(MAX_RERANK);
52
+ const documents = toRerank.map((candidate) => candidateText(candidate));
53
+
54
+ const ranked = await rerankViaHttp(query, documents, {
55
+ provider: 'http',
56
+ model: lane.model,
57
+ baseUrl: lane.baseUrl!,
58
+ apiKey: lane.apiKey,
59
+ timeoutMs: resolveRerankTimeoutMs(),
60
+ });
61
+
62
+ const seen = new Set<number>();
63
+ const reranked: NeuralRerankCandidate[] = [];
64
+ for (const row of ranked) {
65
+ const candidate = toRerank[row.index];
66
+ if (!candidate || seen.has(row.index)) continue;
67
+ reranked.push(candidate);
68
+ seen.add(row.index);
69
+ }
70
+ for (const [index, candidate] of toRerank.entries()) {
71
+ if (!seen.has(index)) reranked.push(candidate);
72
+ }
73
+ reranked.push(...rest);
74
+ return reranked;
75
+ }
76
+
77
+ function candidateText(candidate: NeuralRerankCandidate): string {
78
+ const narrative = candidate.narrative?.trim();
79
+ if (narrative) return `${candidate.title} — ${narrative.slice(0, 200)}`;
80
+ return candidate.title;
81
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Product rules for optional HTTP rerank and the existing LLM rerank path.
3
+ *
4
+ * HTTP rerank is opt-in. When it is configured, thorough search with enough
5
+ * candidates uses the HTTP lane only — a miss or timeout keeps the original
6
+ * order instead of falling through to LLM rerank.
7
+ */
8
+
9
+ export const DEFAULT_RERANK_TIMEOUT_MS = 30_000;
10
+ export const RERANK_TIMEOUT_MIN_MS = 1_000;
11
+ export const RERANK_TIMEOUT_MAX_MS = 300_000;
12
+ export const MIN_RERANK_CANDIDATES = 3;
13
+
14
+ /**
15
+ * Parse and validate MEMORIX_RERANK_TIMEOUT_MS.
16
+ *
17
+ * @param raw - Environment value, or undefined when unset
18
+ * @returns Timeout in milliseconds (default 30000)
19
+ */
20
+ export function parseRerankTimeoutMs(raw: string | undefined): number {
21
+ const value = raw?.trim();
22
+ if (!value) return DEFAULT_RERANK_TIMEOUT_MS;
23
+
24
+ const parsed = Number(value);
25
+ if (!Number.isInteger(parsed) || Number.isNaN(parsed)) {
26
+ console.warn(
27
+ `[memorix] MEMORIX_RERANK_TIMEOUT_MS="${raw}" is invalid (must be a positive integer between ${RERANK_TIMEOUT_MIN_MS}-${RERANK_TIMEOUT_MAX_MS}ms). Using default ${DEFAULT_RERANK_TIMEOUT_MS}ms.`,
28
+ );
29
+ return DEFAULT_RERANK_TIMEOUT_MS;
30
+ }
31
+
32
+ if (parsed < RERANK_TIMEOUT_MIN_MS) return RERANK_TIMEOUT_MIN_MS;
33
+ if (parsed > RERANK_TIMEOUT_MAX_MS) return RERANK_TIMEOUT_MAX_MS;
34
+ return parsed;
35
+ }
36
+
37
+ /**
38
+ * Resolve the rerank timeout from an explicit override or the environment.
39
+ *
40
+ * @param explicitTimeoutMs - Caller-supplied timeout, when present
41
+ * @returns Timeout in milliseconds
42
+ */
43
+ export function resolveRerankTimeoutMs(explicitTimeoutMs?: number): number {
44
+ if (typeof explicitTimeoutMs === 'number' && Number.isFinite(explicitTimeoutMs) && explicitTimeoutMs > 0) {
45
+ return explicitTimeoutMs;
46
+ }
47
+ return parseRerankTimeoutMs(process.env.MEMORIX_RERANK_TIMEOUT_MS);
48
+ }
49
+
50
+ /**
51
+ * True when thorough search has enough candidates for HTTP rerank.
52
+ *
53
+ * @param input - Retrieval quality, query presence, and hit count
54
+ * @returns Whether the HTTP rerank lane should run
55
+ */
56
+ export function shouldAttemptHttpRerank(input: {
57
+ quality?: string;
58
+ hasQuery: boolean;
59
+ candidateCount: number;
60
+ }): boolean {
61
+ return input.quality === 'thorough'
62
+ && input.hasQuery
63
+ && input.candidateCount >= MIN_RERANK_CANDIDATES;
64
+ }
65
+
66
+ /**
67
+ * True when the existing LLM rerank path should run.
68
+ *
69
+ * LLM rerank stays on the original thorough + heavy + close top-2 gate.
70
+ * It never runs when an HTTP rerank lane is configured.
71
+ *
72
+ * @param input - Search gate inputs plus whether HTTP rerank is configured
73
+ * @returns Whether LLM rerank should run
74
+ */
75
+ export function shouldAttemptLlmRerank(input: {
76
+ quality?: string;
77
+ tier: string;
78
+ hasQuery: boolean;
79
+ candidateCount: number;
80
+ topScore: number;
81
+ secondScore: number;
82
+ httpRerankConfigured: boolean;
83
+ }): boolean {
84
+ if (input.httpRerankConfigured) return false;
85
+ if (input.quality !== 'thorough' || input.tier !== 'heavy' || !input.hasQuery) return false;
86
+ if (input.candidateCount < MIN_RERANK_CANDIDATES) return false;
87
+ return input.topScore > 0 && input.secondScore / input.topScore > 0.7;
88
+ }
@@ -23,6 +23,7 @@ import {
23
23
  import { calculateProjectAffinity, extractProjectKeywords, type AffinityContext, type MemoryContent } from './project-affinity.js';
24
24
  import { detectQueryIntent, applyIntentBoost } from '../search/intent-detector.js';
25
25
  import { maybeExpandSearchQuery } from '../search/query-expansion.js';
26
+ import { parseRerankTimeoutMs, shouldAttemptHttpRerank, shouldAttemptLlmRerank } from '../rerank/policy.js';
26
27
  import { withTimeout, withTimeoutSignal } from '../timeout.js';
27
28
 
28
29
  let db: AnyOrama | null = null;
@@ -1054,62 +1055,87 @@ export async function searchObservations(options: SearchOptions): Promise<IndexE
1054
1055
  }
1055
1056
  }
1056
1057
 
1057
- // ── LLM Reranking (heavy-tier only) ────────────────────────────
1058
- // Only triggered for heavy-tier queries with ambiguous top results.
1059
- // Non-thorough profiles, fast, and standard tiers skip entirely.
1060
- // Ambiguity check: top-2 scores within 30% → results are uncertain.
1061
- const shouldRerank = quality === 'thorough'
1062
- && tier === 'heavy'
1063
- && hasQuery
1064
- && intermediate.length > 2
1065
- && (() => {
1066
- const top = intermediate[0]?.score ?? 0;
1067
- const second = intermediate[1]?.score ?? 0;
1068
- return top > 0 && second / top > 0.7; // top-2 within 30% = ambiguous
1069
- })();
1070
-
1071
- if (shouldRerank) {
1072
- try {
1073
- const { rerankResults } = await import('../llm/quality.js');
1074
- const narrativeMap = new Map<string, string>();
1075
- for (const hit of results.hits) {
1076
- const doc = hit.document as unknown as MemorixDocument;
1077
- narrativeMap.set(makeEntryKey(doc.projectId, doc.observationId), doc.narrative);
1058
+ // HTTP rerank: thorough search with enough candidates.
1059
+ // LLM rerank: original thorough + heavy + close top-2 gate, and only when
1060
+ // HTTP rerank is not configured. A configured HTTP miss keeps original order.
1061
+ const httpGate = shouldAttemptHttpRerank({
1062
+ quality,
1063
+ hasQuery: Boolean(hasQuery),
1064
+ candidateCount: intermediate.length,
1065
+ });
1066
+ const llmGateWithoutHttp = shouldAttemptLlmRerank({
1067
+ quality,
1068
+ tier,
1069
+ hasQuery: Boolean(hasQuery),
1070
+ candidateCount: intermediate.length,
1071
+ topScore: intermediate[0]?.score ?? 0,
1072
+ secondScore: intermediate[1]?.score ?? 0,
1073
+ httpRerankConfigured: false,
1074
+ });
1075
+
1076
+ if (httpGate || llmGateWithoutHttp) {
1077
+ const narrativeMap = new Map<string, string>();
1078
+ for (const hit of results.hits) {
1079
+ const doc = hit.document as unknown as MemorixDocument;
1080
+ narrativeMap.set(makeEntryKey(doc.projectId, doc.observationId), doc.narrative);
1081
+ }
1082
+ const RERANK_TOP_K = 5;
1083
+ const toRerank = intermediate.slice(0, RERANK_TOP_K);
1084
+ const candidates = toRerank.map((e, index) => ({
1085
+ id: `r${index + 1}`,
1086
+ title: e.title,
1087
+ type: e.type,
1088
+ score: e.score,
1089
+ narrative: narrativeMap.get(makeEntryKey(e.projectId, e.id)),
1090
+ }));
1091
+ const RERANK_TIMEOUT_MS = parseRerankTimeoutMs(process.env.MEMORIX_RERANK_TIMEOUT_MS);
1092
+ const candidateMap = new Map(candidates.map((candidate, index) => [candidate.id, toRerank[index]]));
1093
+ const { isNeuralRerankEnabled, neuralRerankCandidates } = await import('../rerank/index.js');
1094
+ const httpConfigured = isNeuralRerankEnabled();
1095
+
1096
+ if (httpConfigured && httpGate) {
1097
+ try {
1098
+ const neural = await withTimeout(
1099
+ neuralRerankCandidates(originalQuery!, candidates),
1100
+ RERANK_TIMEOUT_MS,
1101
+ 'neural rerank',
1102
+ );
1103
+ if (neural && neural.length > 0) {
1104
+ const rerankedTop = neural
1105
+ .map((row) => candidateMap.get(row.id))
1106
+ .filter((entry): entry is NonNullable<typeof entry> => entry != null);
1107
+ if (rerankedTop.length > 0) {
1108
+ intermediate = [...rerankedTop, ...intermediate.slice(RERANK_TOP_K)];
1109
+ lastSearchModeByProject.set(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + neural rerank');
1110
+ mark('rerank(usedNeural=true)');
1111
+ }
1112
+ }
1113
+ } catch {
1114
+ console.error('[memorix] neural rerank failed or timed out; keeping original order');
1078
1115
  }
1079
- // Rerank only top-5 (was 10) to save LLM tokens and latency
1080
- const RERANK_TOP_K = 5;
1081
- const toRerank = intermediate.slice(0, RERANK_TOP_K);
1082
- const candidates = toRerank.map((e, index) => ({
1083
- id: `r${index + 1}`,
1084
- title: e.title,
1085
- type: e.type,
1086
- score: e.score,
1087
- narrative: narrativeMap.get(makeEntryKey(e.projectId, e.id)),
1088
- }));
1089
-
1090
- // LLM rerank timeout: configurable via MEMORIX_RERANK_TIMEOUT_MS, default 5s
1091
- const _parsedRerank = parseInt(process.env.MEMORIX_RERANK_TIMEOUT_MS || '', 10);
1092
- const RERANK_TIMEOUT_MS = Number.isFinite(_parsedRerank) && _parsedRerank > 0 ? _parsedRerank : 5000;
1093
- const { reranked, usedLLM } = await withTimeout(
1094
- rerankResults(originalQuery!, candidates),
1095
- RERANK_TIMEOUT_MS,
1096
- 'LLM rerank',
1097
- );
1098
- mark(`rerank(usedLLM=${usedLLM})`);
1099
-
1100
- if (usedLLM) {
1101
- lastSearchModeByProject.set(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + LLM rerank');
1102
- const candidateMap = new Map(candidates.map((candidate, index) => [candidate.id, toRerank[index]]));
1103
- const rerankedTop = reranked
1104
- .map(r => candidateMap.get(r.id))
1105
- .filter((e): e is NonNullable<typeof e> => e != null);
1106
- if (rerankedTop.length > 0) {
1107
- intermediate = [...rerankedTop, ...intermediate.slice(RERANK_TOP_K)];
1116
+ } else if (!httpConfigured && llmGateWithoutHttp) {
1117
+ try {
1118
+ const { rerankResults } = await import('../llm/quality.js');
1119
+ const { reranked, usedLLM } = await withTimeout(
1120
+ rerankResults(originalQuery!, candidates),
1121
+ RERANK_TIMEOUT_MS,
1122
+ 'LLM rerank',
1123
+ );
1124
+ mark(`rerank(usedLLM=${usedLLM})`);
1125
+ if (usedLLM) {
1126
+ lastSearchModeByProject.set(modeKey, (lastSearchModeByProject.get(modeKey) ?? 'fulltext') + ' + LLM rerank');
1127
+ const rerankedTop = reranked
1128
+ .map((row) => candidateMap.get(row.id))
1129
+ .filter((entry): entry is NonNullable<typeof entry> => entry != null);
1130
+ if (rerankedTop.length > 0) {
1131
+ intermediate = [...rerankedTop, ...intermediate.slice(RERANK_TOP_K)];
1132
+ }
1108
1133
  }
1134
+ } catch {
1135
+ console.error('[memorix] LLM rerank failed or timed out, using original order');
1109
1136
  }
1110
- } catch (error) {
1111
- // Reranking is best-effort: fall back to original order on timeout or error
1112
- console.error('[memorix] LLM rerank failed or timed out, using original order');
1137
+ } else {
1138
+ mark(`rerank(skipped,tier=${tier})`);
1113
1139
  }
1114
1140
  } else {
1115
1141
  mark(`rerank(skipped,tier=${tier})`);