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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "memorix",
3
3
  "mcpName": "io.github.AVIDS2/memorix",
4
- "version": "1.7.2",
4
+ "version": "1.7.3",
5
5
  "description": "Local-first shared memory layer for AI coding agents across MCP clients, Git history, reasoning context, and project sessions.",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "memorix",
4
- "version": "1.7.2",
4
+ "version": "1.7.3",
5
5
  "description": "Shared workspace memory for Claude Code and other AI coding agents.",
6
6
  "author": {
7
7
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Local-first project memory for CodeBuddy Code and other coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Shared workspace memory for Codex and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Shared local workspace memory for GitHub Copilot CLI and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "mcpServers": {
5
5
  "memorix": {
6
6
  "command": "memorix",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-omp-package",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Memorix shared workspace memory package for Oh-my-Pi.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Shared workspace memory for OpenClaw and other AI coding agents.",
5
5
  "author": {
6
6
  "name": "AVIDS2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memorix-pi-package",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Memorix shared workspace memory package for Pi coding agent.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
package/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/AVIDS2/memorix",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.7.2",
10
+ "version": "1.7.3",
11
11
  "websiteUrl": "https://github.com/AVIDS2/memorix#readme",
12
12
  "icons": [
13
13
  {
@@ -22,7 +22,7 @@
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "memorix",
25
- "version": "1.7.2",
25
+ "version": "1.7.3",
26
26
  "runtimeHint": "npx",
27
27
  "packageArguments": [
28
28
  {
@@ -69,6 +69,13 @@ export function serializeResolvedConfigToToml(
69
69
  dimensions: config.embedding.dimensions,
70
70
  });
71
71
 
72
+ writeSection(lines, 'rerank', {
73
+ provider: config.rerank.provider,
74
+ model: config.rerank.model,
75
+ base_url: config.rerank.baseUrl,
76
+ api_key: includeSecrets ? config.rerank.apiKey : undefined,
77
+ });
78
+
72
79
  writeSection(lines, 'memory', {
73
80
  inject: config.memory.inject,
74
81
  formation: config.memory.formation,
@@ -517,7 +517,11 @@ export default defineCommand({
517
517
  if (isLLMEnabled()) {
518
518
  const config = getLLMConfig();
519
519
  lines.push(ok(`Provider: ${config?.provider}/${config?.model}`));
520
- lines.push(info('Capabilities: fact extraction, auto-dedup, LLM rerank'));
520
+ const { isNeuralRerankEnabled } = await import('../../rerank/index.js');
521
+ const rerankCap = isNeuralRerankEnabled()
522
+ ? 'HTTP rerank'
523
+ : 'LLM rerank';
524
+ lines.push(info(`Capabilities: fact extraction, auto-dedup, ${rerankCap}`));
521
525
  report.llm = { enabled: true, provider: config?.provider, model: config?.model };
522
526
  } else {
523
527
  lines.push(info('LLM mode: off (heuristic dedup only)'));
@@ -183,6 +183,12 @@ export default defineCommand({
183
183
  envLines.push('');
184
184
  }
185
185
 
186
+ envLines.push('# Optional HTTP rerank (Cohere-compatible POST {base_url}/rerank)');
187
+ envLines.push('# MEMORIX_RERANK_PROVIDER=http');
188
+ envLines.push('# MEMORIX_RERANK_MODEL=rerank-model');
189
+ envLines.push('# MEMORIX_RERANK_BASE_URL=https://api.example.com/v1');
190
+ envLines.push('# MEMORIX_RERANK_API_KEY= # required when BASE_URL is not the memory LLM endpoint');
191
+ envLines.push('');
186
192
  envLines.push('# Optional memory LLM simple key (does not apply to embedding or agent lanes)');
187
193
  envLines.push('# MEMORIX_API_KEY=sk-your-key-here');
188
194
  envLines.push('');
@@ -310,6 +316,17 @@ export function buildInitTomlConfig(options: {
310
316
  }
311
317
  lines.push('');
312
318
 
319
+ lines.push('[rerank]');
320
+ lines.push('# Optional HTTP rerank against any Cohere-compatible /rerank API.');
321
+ lines.push('# provider = "http"');
322
+ lines.push('# model = "rerank-model"');
323
+ lines.push('# base_url inherits [memory.llm] when unset.');
324
+ if (isGlobal) {
325
+ lines.push('# Bearer inherits the memory LLM key only when that endpoint matches.');
326
+ lines.push('# A different rerank URL needs MEMORIX_RERANK_API_KEY.');
327
+ }
328
+ lines.push('');
329
+
313
330
  lines.push('[git]');
314
331
  lines.push(`auto_hook = ${options.gitAutoHook}`);
315
332
  lines.push('ingest_on_commit = true');
@@ -104,6 +104,7 @@ export default defineCommand({
104
104
  diagLines.push(` Agent lane: ${formatLane(agent.provider, agent.model, agent.baseUrl, agent.apiKey)}`);
105
105
  diagLines.push(` Memory LLM lane: ${formatLane(resolved.memory.llm.provider, resolved.memory.llm.model, resolved.memory.llm.baseUrl, resolved.memory.llm.apiKey)}`);
106
106
  diagLines.push(` Embedding lane: ${formatLane(resolved.embedding.provider, resolved.embedding.model, resolved.embedding.baseUrl, resolved.embedding.apiKey)}`);
107
+ diagLines.push(` Rerank lane: ${formatLane(resolved.rerank.provider, resolved.rerank.model, resolved.rerank.baseUrl, resolved.rerank.apiKey)}`);
107
108
  diagLines.push(` Memory behavior: inject=${resolved.memory.inject ?? 'default'}, formation=${resolved.memory.formation ?? 'default'}, autoCleanup=${resolved.memory.autoCleanup ?? 'default'}`);
108
109
  diagLines.push(` Git behavior: autoHook=${resolved.git.autoHook ?? 'default'}, ingestOnCommit=${resolved.git.ingestOnCommit ?? 'default'}, maxDiffSize=${resolved.git.maxDiffSize ?? 'default'}, skipMergeCommits=${resolved.git.skipMergeCommits ?? 'default'}`);
109
110
  diagLines.push(` Server: transport=${resolved.server.transport ?? 'default'}, dashboard=${resolved.server.dashboard ?? 'default'}, dashboardPort=${resolved.server.dashboardPort ?? 'default'}`);
@@ -141,6 +142,7 @@ export default defineCommand({
141
142
  agent: summarizeLane(agent.provider, agent.model, agent.baseUrl, agent.apiKey),
142
143
  memoryLlm: summarizeLane(resolved.memory.llm.provider, resolved.memory.llm.model, resolved.memory.llm.baseUrl, resolved.memory.llm.apiKey),
143
144
  embedding: summarizeLane(resolved.embedding.provider, resolved.embedding.model, resolved.embedding.baseUrl, resolved.embedding.apiKey),
145
+ rerank: summarizeLane(resolved.rerank.provider, resolved.rerank.model, resolved.rerank.baseUrl, resolved.rerank.apiKey),
144
146
  },
145
147
  memory: {
146
148
  inject: resolved.memory.inject ?? 'default',
@@ -9,8 +9,8 @@ import {
9
9
  getProjectYamlPath,
10
10
  } from './config-paths.js';
11
11
  import { loadFileConfig } from './legacy-loader.js';
12
- import { loadTomlConfig } from './toml-loader.js';
13
- import { loadYamlConfig } from './yaml-loader.js';
12
+ import { loadTomlConfig, type MemorixTomlConfig } from './toml-loader.js';
13
+ import { loadYamlConfig, type MemorixYamlConfig } from './yaml-loader.js';
14
14
  import { loadDotenv } from './dotenv-loader.js';
15
15
 
16
16
  export interface ResolvedLaneOptions {
@@ -44,6 +44,14 @@ export interface ResolvedMemorixConfig {
44
44
  apiKey?: string;
45
45
  dimensions?: number;
46
46
  };
47
+ rerank: {
48
+ provider: 'off' | 'http';
49
+ model?: string;
50
+ baseUrl?: string;
51
+ apiKey?: string;
52
+ /** False when a Memory LLM key exists and the rerank URL is a different host without a dedicated key. */
53
+ canSendRequest: boolean;
54
+ };
47
55
  git: {
48
56
  autoHook?: boolean;
49
57
  ingestOnCommit?: boolean;
@@ -109,6 +117,16 @@ export function getResolvedConfig(options: ResolvedLaneOptions = {}): ResolvedMe
109
117
  const openRouterMemoryLlmApiKey = isOpenRouterMemoryLane(memoryLlmProvider, memoryLlmBaseUrl)
110
118
  ? process.env.OPENROUTER_API_KEY
111
119
  : undefined;
120
+ const memoryLlmApiKey = first(
121
+ process.env.MEMORIX_LLM_API_KEY,
122
+ process.env.MEMORIX_API_KEY,
123
+ toml.memory?.llm?.api_key,
124
+ yaml.llm?.apiKey,
125
+ legacy.llm?.apiKey,
126
+ process.env.OPENAI_API_KEY,
127
+ process.env.ANTHROPIC_API_KEY,
128
+ openRouterMemoryLlmApiKey,
129
+ );
112
130
 
113
131
  const resolved: ResolvedMemorixConfig = {
114
132
  agent: {
@@ -150,16 +168,7 @@ export function getResolvedConfig(options: ResolvedLaneOptions = {}): ResolvedMe
150
168
  provider: memoryLlmProvider,
151
169
  model: memoryLlmModel,
152
170
  baseUrl: memoryLlmBaseUrl,
153
- apiKey: first(
154
- process.env.MEMORIX_LLM_API_KEY,
155
- process.env.MEMORIX_API_KEY,
156
- toml.memory?.llm?.api_key,
157
- yaml.llm?.apiKey,
158
- legacy.llm?.apiKey,
159
- process.env.OPENAI_API_KEY,
160
- process.env.ANTHROPIC_API_KEY,
161
- openRouterMemoryLlmApiKey,
162
- ),
171
+ apiKey: memoryLlmApiKey,
163
172
  },
164
173
  },
165
174
  embedding: {
@@ -169,6 +178,12 @@ export function getResolvedConfig(options: ResolvedLaneOptions = {}): ResolvedMe
169
178
  apiKey: first(process.env.MEMORIX_EMBEDDING_API_KEY, toml.embedding?.api_key, yaml.embedding?.apiKey, legacy.embeddingApi?.apiKey, openRouterEmbeddingApiKey),
170
179
  dimensions: firstNumber(parseNumber(process.env.MEMORIX_EMBEDDING_DIMENSIONS), toml.embedding?.dimensions, yaml.embedding?.dimensions, legacy.embeddingApi?.dimensions),
171
180
  },
181
+ rerank: resolveRerankLane({
182
+ toml,
183
+ yaml,
184
+ memoryLlmApiKey,
185
+ memoryLlmBaseUrl,
186
+ }),
172
187
  git: {
173
188
  autoHook: firstBool(toml.git?.auto_hook, yaml.git?.autoHook),
174
189
  ingestOnCommit: firstBool(toml.git?.ingest_on_commit, yaml.git?.ingestOnCommit),
@@ -244,6 +259,10 @@ export function getResolvedEmbeddingLane(options: ResolvedLaneOptions = {}): Res
244
259
  return getResolvedConfig(options).embedding;
245
260
  }
246
261
 
262
+ export function getResolvedRerankLane(options: ResolvedLaneOptions = {}): ResolvedMemorixConfig['rerank'] {
263
+ return getResolvedConfig(options).rerank;
264
+ }
265
+
247
266
  export function resetResolvedConfigCache(): void {
248
267
  // Kept as a public test helper. File-level caches live in individual loaders.
249
268
  }
@@ -294,6 +313,10 @@ function getEnvSourceNames(): string[] {
294
313
  'MEMORIX_EMBEDDING_BASE_URL',
295
314
  'MEMORIX_EMBEDDING_MODEL',
296
315
  'MEMORIX_EMBEDDING_DIMENSIONS',
316
+ 'MEMORIX_RERANK_PROVIDER',
317
+ 'MEMORIX_RERANK_MODEL',
318
+ 'MEMORIX_RERANK_BASE_URL',
319
+ 'MEMORIX_RERANK_API_KEY',
297
320
  'MEMORIX_CODEGRAPH_EXTERNAL_CONTEXT',
298
321
  'MEMORIX_CODEGRAPH_EXTERNAL_COMMAND',
299
322
  'MEMORIX_CODEGRAPH_EXTERNAL_TIMEOUT_MS',
@@ -320,3 +343,78 @@ function normalizeExternalContext(value: string | undefined): 'auto' | 'off' | u
320
343
  if (normalized === 'auto' || normalized === 'off') return normalized;
321
344
  return undefined;
322
345
  }
346
+
347
+ function normalizeRerankProvider(value: string | undefined): 'off' | 'http' {
348
+ const normalized = value?.trim().toLowerCase();
349
+ if (normalized === 'http') return 'http';
350
+ return 'off';
351
+ }
352
+
353
+ function resolveRerankLane(args: {
354
+ toml: MemorixTomlConfig;
355
+ yaml: MemorixYamlConfig;
356
+ memoryLlmApiKey?: string;
357
+ memoryLlmBaseUrl?: string;
358
+ }): ResolvedMemorixConfig['rerank'] {
359
+ const provider = normalizeRerankProvider(first(
360
+ process.env.MEMORIX_RERANK_PROVIDER,
361
+ args.toml.rerank?.provider,
362
+ args.yaml.rerank?.provider,
363
+ 'off',
364
+ ));
365
+ const model = first(
366
+ process.env.MEMORIX_RERANK_MODEL,
367
+ args.toml.rerank?.model,
368
+ args.yaml.rerank?.model,
369
+ );
370
+ const explicitBaseUrl = first(
371
+ process.env.MEMORIX_RERANK_BASE_URL,
372
+ args.toml.rerank?.base_url,
373
+ args.yaml.rerank?.baseUrl,
374
+ );
375
+ const baseUrl = provider === 'http'
376
+ ? first(explicitBaseUrl, args.memoryLlmBaseUrl)
377
+ : explicitBaseUrl;
378
+ const dedicatedApiKey = first(
379
+ process.env.MEMORIX_RERANK_API_KEY,
380
+ args.toml.rerank?.api_key,
381
+ args.yaml.rerank?.apiKey,
382
+ );
383
+ const sameEndpoint = provider === 'http' && sameTrustedEndpoint(baseUrl, args.memoryLlmBaseUrl);
384
+ const inheritedApiKey = sameEndpoint ? args.memoryLlmApiKey : undefined;
385
+ const apiKey = first(dedicatedApiKey, inheritedApiKey);
386
+ const canSendRequest = provider === 'http' && Boolean(baseUrl)
387
+ && (Boolean(apiKey) || !args.memoryLlmApiKey || sameEndpoint);
388
+
389
+ return { provider, model, baseUrl, apiKey, canSendRequest };
390
+ }
391
+
392
+ /**
393
+ * True when two API roots are the same host+path after trailing-slash
394
+ * and optional `/rerank` normalization. Used so a Memory LLM bearer is
395
+ * never sent to a different rerank provider.
396
+ */
397
+ function sameTrustedEndpoint(left?: string, right?: string): boolean {
398
+ const a = normalizeTrustedEndpoint(left);
399
+ const b = normalizeTrustedEndpoint(right);
400
+ return Boolean(a && b && a === b);
401
+ }
402
+
403
+ function normalizeTrustedEndpoint(value?: string): string | undefined {
404
+ const trimmed = value?.trim();
405
+ if (!trimmed) return undefined;
406
+ try {
407
+ const url = new URL(trimmed);
408
+ const protocol = url.protocol.toLowerCase();
409
+ const hostname = url.hostname.toLowerCase();
410
+ const port = url.port;
411
+ let pathname = url.pathname.replace(/\/+$/, '');
412
+ if (pathname.toLowerCase().endsWith('/rerank')) {
413
+ pathname = pathname.slice(0, -'/rerank'.length).replace(/\/+$/, '');
414
+ }
415
+ return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}`;
416
+ } catch {
417
+ const fallback = trimmed.replace(/\/+$/, '').replace(/\/rerank$/i, '').replace(/\/+$/, '');
418
+ return fallback.toLowerCase() || undefined;
419
+ }
420
+ }
@@ -28,6 +28,13 @@ export interface MemorixTomlConfig {
28
28
  api_key?: string;
29
29
  dimensions?: number;
30
30
  };
31
+ /** Optional HTTP rerank against a compatible /rerank API. */
32
+ rerank?: {
33
+ provider?: 'off' | 'http' | string;
34
+ model?: string;
35
+ base_url?: string;
36
+ api_key?: string;
37
+ };
31
38
  hooks?: {
32
39
  native_memcode?: boolean;
33
40
  external_agents?: boolean;
@@ -97,6 +104,7 @@ function mergeTomlConfig(base: MemorixTomlConfig, override: MemorixTomlConfig):
97
104
  llm: { ...base.memory?.llm, ...override.memory?.llm },
98
105
  },
99
106
  embedding: { ...base.embedding, ...override.embedding },
107
+ rerank: { ...base.rerank, ...override.rerank },
100
108
  hooks: { ...base.hooks, ...override.hooks },
101
109
  git: { ...base.git, ...override.git },
102
110
  codegraph: { ...base.codegraph, ...override.codegraph },
@@ -48,6 +48,14 @@ export interface MemorixYamlConfig {
48
48
  dimensions?: number;
49
49
  };
50
50
 
51
+ /** Remote neural rerank (HTTP only) */
52
+ rerank?: {
53
+ provider?: 'off' | 'http' | string;
54
+ model?: string;
55
+ apiKey?: string;
56
+ baseUrl?: string;
57
+ };
58
+
51
59
  /** Git-Memory pipeline configuration */
52
60
  git?: {
53
61
  /** Auto-install post-commit hook on first run (default: false) */
@@ -190,6 +198,7 @@ export function loadYamlConfig(projectRoot?: string | null): MemorixYamlConfig {
190
198
  llm: { ...userConfig.llm, ...projectConfig.llm },
191
199
  agent: { ...userConfig.agent, ...projectConfig.agent },
192
200
  embedding: { ...userConfig.embedding, ...projectConfig.embedding },
201
+ rerank: { ...userConfig.rerank, ...projectConfig.rerank },
193
202
  git: { ...userConfig.git, ...projectConfig.git },
194
203
  codegraph: { ...userConfig.codegraph, ...projectConfig.codegraph },
195
204
  behavior: { ...userConfig.behavior, ...projectConfig.behavior },
@@ -31,6 +31,36 @@ const LLM_TIMEOUT_MIN_MS = 1_000;
31
31
  const LLM_TIMEOUT_MAX_MS = 300_000;
32
32
  const MAX_LLM_RESPONSE_BYTES = 2 * 1024 * 1024;
33
33
 
34
+ const OPENAI_API_VERSION_SEGMENT = /^v\d+(?:[a-z][a-z0-9-]*)?$/i;
35
+
36
+ /**
37
+ * Normalize an OpenAI-compatible API root without rewriting an explicit
38
+ * provider version such as `/api/v3` or `/v1beta`.
39
+ *
40
+ * Bare hosts keep the historical convenience of receiving `/v1`. Once a
41
+ * caller supplies a versioned path, that path is authoritative.
42
+ */
43
+ export function normalizeOpenAICompatibleBaseUrl(baseUrl: string): string {
44
+ const base = baseUrl.trim().replace(/\/+$/, '');
45
+ if (!base) return base;
46
+
47
+ let pathname = '';
48
+ try {
49
+ pathname = new URL(base).pathname.replace(/\/+$/, '');
50
+ } catch {
51
+ pathname = base.split(/[?#]/, 1)[0].replace(/\/+$/, '');
52
+ }
53
+
54
+ const lastSegment = pathname.split('/').filter(Boolean).at(-1);
55
+ if (lastSegment && OPENAI_API_VERSION_SEGMENT.test(lastSegment)) {
56
+ return base;
57
+ }
58
+
59
+ const queryOrHash = base.match(/[?#].*$/)?.[0] ?? '';
60
+ const pathWithoutQuery = base.slice(0, base.length - queryOrHash.length).replace(/\/+$/, '');
61
+ return `${pathWithoutQuery}/v1${queryOrHash}`;
62
+ }
63
+
34
64
  /**
35
65
  * Parse and validate MEMORIX_LLM_TIMEOUT_MS environment variable.
36
66
  * - Must be a valid integer in the range 1000–300000ms.
@@ -259,9 +289,7 @@ async function callOpenAICompatible(
259
289
  signal?: AbortSignal,
260
290
  ): Promise<LLMResponse> {
261
291
  const config = currentConfig!;
262
- // Auto-fix: append /v1 if baseUrl doesn't end with it (common user mistake)
263
- let base = config.baseUrl!.replace(/\/+$/, '');
264
- if (!base.endsWith('/v1')) base += '/v1';
292
+ const base = normalizeOpenAICompatibleBaseUrl(config.baseUrl!);
265
293
  const url = `${base}/chat/completions`;
266
294
 
267
295
  const fetchSignal = signal
@@ -387,8 +415,7 @@ async function callOpenAIWithTools(
387
415
  signal?: AbortSignal,
388
416
  ): Promise<LLMToolResponse> {
389
417
  const config = currentConfig!;
390
- let base = config.baseUrl!.replace(/\/+$/, '');
391
- if (!base.endsWith('/v1')) base += '/v1';
418
+ const base = normalizeOpenAICompatibleBaseUrl(config.baseUrl!);
392
419
  const url = `${base}/chat/completions`;
393
420
 
394
421
  // Convert ChatMessage[] to OpenAI format
@@ -603,8 +630,7 @@ async function* callOpenAIWithToolsStream(
603
630
  tools: ToolDefinition[],
604
631
  ): AsyncGenerator<LLMStreamEvent, void, undefined> {
605
632
  const config = currentConfig!;
606
- let base = config.baseUrl!.replace(/\/+$/, '');
607
- if (!base.endsWith('/v1')) base += '/v1';
633
+ const base = normalizeOpenAICompatibleBaseUrl(config.baseUrl!);
608
634
  const url = `${base}/chat/completions`;
609
635
 
610
636
  const openaiMessages: Array<Record<string, unknown>> = messages.map((msg) => {
@@ -6,7 +6,11 @@
6
6
  */
7
7
 
8
8
  import { getLLMApiKey, getLLMBaseUrl, getLLMModel } from '../config.js';
9
- import { isLLMEnabled, getLLMConfig } from '../llm/provider.js';
9
+ import {
10
+ isLLMEnabled,
11
+ getLLMConfig,
12
+ normalizeOpenAICompatibleBaseUrl,
13
+ } from '../llm/provider.js';
10
14
  import { sanitizeCredentials } from '../memory/secret-filter.js';
11
15
  import {
12
16
  decodeBase64ImagePayload,
@@ -55,8 +59,7 @@ async function callVisionLLM(
55
59
  throw new Error('No LLM API key configured for image analysis.');
56
60
  }
57
61
 
58
- let baseUrl = getLLMBaseUrl('https://api.openai.com/v1').replace(/\/+$/, '');
59
- if (!baseUrl.endsWith('/v1')) baseUrl += '/v1';
62
+ const baseUrl = normalizeOpenAICompatibleBaseUrl(getLLMBaseUrl('https://api.openai.com/v1'));
60
63
  const model = getLLMModel('gpt-4o');
61
64
 
62
65
  const response = await fetch(`${baseUrl}/chat/completions`, {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Optional HTTP rerank client.
3
+ *
4
+ * Posts a Cohere-compatible `{model, query, documents}` body to
5
+ * `{base_url}/rerank` (or an explicit `/rerank` path). Compatible with
6
+ * any `/rerank` endpoint that accepts that body. The model id is
7
+ * whatever the user configured — Memorix does not rewrite it.
8
+ */
9
+
10
+ import { resolveRerankTimeoutMs } from './policy.js';
11
+
12
+ export type NeuralRerankProviderName = 'http';
13
+
14
+ export interface NeuralRerankRequestConfig {
15
+ provider: NeuralRerankProviderName;
16
+ model?: string;
17
+ baseUrl: string;
18
+ apiKey?: string;
19
+ timeoutMs?: number;
20
+ fetchImpl?: typeof fetch;
21
+ }
22
+
23
+ export interface RerankedDocument {
24
+ index: number;
25
+ score: number;
26
+ }
27
+
28
+ /**
29
+ * Join a provider base URL with `/rerank`, leaving an explicit path intact.
30
+ */
31
+ export function buildRerankUrl(baseUrl: string): string {
32
+ const trimmed = baseUrl.trim().replace(/\/+$/, '');
33
+ if (trimmed.endsWith('/rerank')) return trimmed;
34
+ return `${trimmed}/rerank`;
35
+ }
36
+
37
+ /**
38
+ * Accept Cohere-compatible `{results:[{index,relevance_score}]}` and TEI `[{index,score}]`.
39
+ */
40
+ export function parseRerankResponse(payload: unknown): RerankedDocument[] {
41
+ const rows = extractRows(payload);
42
+ if (rows.length === 0) {
43
+ throw new Error('Neural rerank response contained no ranked results');
44
+ }
45
+ return rows.sort((a, b) => b.score - a.score);
46
+ }
47
+
48
+ function extractRows(payload: unknown): RerankedDocument[] {
49
+ if (Array.isArray(payload)) {
50
+ return payload.map(rowFromUnknown).filter((row): row is RerankedDocument => row !== null);
51
+ }
52
+ if (payload && typeof payload === 'object' && 'results' in payload) {
53
+ const results = (payload as { results: unknown }).results;
54
+ if (Array.isArray(results)) {
55
+ return results.map(rowFromUnknown).filter((row): row is RerankedDocument => row !== null);
56
+ }
57
+ }
58
+ throw new Error('Neural rerank response was not Cohere or TEI shaped');
59
+ }
60
+
61
+ function rowFromUnknown(value: unknown): RerankedDocument | null {
62
+ if (!value || typeof value !== 'object') return null;
63
+ const row = value as { index?: unknown; relevance_score?: unknown; score?: unknown };
64
+ if (typeof row.index !== 'number' || !Number.isFinite(row.index)) return null;
65
+ const score = typeof row.relevance_score === 'number'
66
+ ? row.relevance_score
67
+ : typeof row.score === 'number'
68
+ ? row.score
69
+ : null;
70
+ if (score === null || !Number.isFinite(score)) return null;
71
+ return { index: row.index, score };
72
+ }
73
+
74
+ function buildRerankBody(query: string, documents: string[], model?: string): Record<string, unknown> {
75
+ const body: Record<string, unknown> = {
76
+ query,
77
+ documents,
78
+ top_n: documents.length,
79
+ return_documents: false,
80
+ };
81
+ const trimmed = model?.trim();
82
+ if (trimmed) body.model = trimmed;
83
+ return body;
84
+ }
85
+
86
+ /**
87
+ * POST query + documents to a compatible `/rerank` endpoint and return ranked indexes.
88
+ */
89
+ export async function rerankViaHttp(
90
+ query: string,
91
+ documents: string[],
92
+ config: NeuralRerankRequestConfig,
93
+ ): Promise<RerankedDocument[]> {
94
+ if (documents.length === 0) return [];
95
+
96
+ const url = buildRerankUrl(config.baseUrl);
97
+ const headers: Record<string, string> = {
98
+ 'content-type': 'application/json',
99
+ };
100
+ if (config.apiKey) {
101
+ headers.authorization = `Bearer ${config.apiKey}`;
102
+ }
103
+
104
+ const controller = new AbortController();
105
+ const timeoutMs = resolveRerankTimeoutMs(config.timeoutMs);
106
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
107
+
108
+ try {
109
+ const fetchImpl = config.fetchImpl ?? fetch;
110
+ const response = await fetchImpl(url, {
111
+ method: 'POST',
112
+ headers,
113
+ signal: controller.signal,
114
+ body: JSON.stringify(buildRerankBody(query, documents, config.model)),
115
+ });
116
+
117
+ if (!response.ok) {
118
+ throw new Error(`Neural rerank HTTP ${response.status} from ${url}`);
119
+ }
120
+
121
+ return parseRerankResponse(await response.json());
122
+ } finally {
123
+ clearTimeout(timer);
124
+ }
125
+ }