memorix 1.8.0 → 1.8.2

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 (45) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/cli/index.js +407 -130
  3. package/dist/dashboard/static/app.js +37 -23
  4. package/dist/dashboard/static/index.html +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +633 -391
  7. package/dist/maintenance-runner.js +31 -25
  8. package/dist/media-audio-runner.js +31 -25
  9. package/dist/media-video-runner.js +31 -25
  10. package/dist/memcode-runtime/CHANGELOG.md +29 -0
  11. package/dist/sdk.js +625 -390
  12. package/dist/vector-backfill-runner.js +31 -25
  13. package/docs/1.2.0-PROVIDER-QUALITY.md +15 -6
  14. package/docs/1.3.3-MULTIMODAL-MEDIA-AND-MCP-COMPAT.md +7 -6
  15. package/docs/1.8.0-RELEASE-CANDIDATE-SPEC.md +10 -8
  16. package/docs/API_REFERENCE.md +12 -8
  17. package/docs/ARCHITECTURE.md +3 -1
  18. package/docs/CONFIGURATION.md +2 -2
  19. package/docs/DASHBOARD.md +6 -0
  20. package/docs/DEVELOPMENT.md +2 -2
  21. package/docs/README.md +14 -8
  22. package/package.json +1 -1
  23. package/plugins/antigravity/memorix/plugin.json +2 -1
  24. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  25. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  26. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  27. package/plugins/copilot/memorix/plugin.json +1 -1
  28. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  29. package/plugins/hermes/memorix/plugin.yaml +1 -1
  30. package/plugins/omp/memorix/package.json +1 -1
  31. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  32. package/plugins/pi/memorix/package.json +1 -1
  33. package/server.json +3 -3
  34. package/src/cli/commands/dashboard.ts +1 -1
  35. package/src/cli/commands/serve-http.ts +38 -3
  36. package/src/codegraph/external-provider.ts +299 -19
  37. package/src/config/dotenv-loader.ts +9 -4
  38. package/src/config.ts +16 -16
  39. package/src/dashboard/maintenance.ts +4 -1
  40. package/src/dashboard/server.ts +85 -86
  41. package/src/dashboard/static/index.html +1 -1
  42. package/src/llm/provider.ts +17 -4
  43. package/src/server/mcp-compat.ts +82 -0
  44. package/src/server/mcp-diagnostics.ts +13 -4
  45. package/src/server.ts +9 -6
@@ -14,8 +14,8 @@ import type {
14
14
  } from './types.js';
15
15
 
16
16
  // The bundled Windows CodeGraph CLI takes roughly 600 ms just to start cold.
17
- // Keep the default useful in real sessions while still bounding a local call.
18
- export const DEFAULT_EXTERNAL_CODEGRAPH_TIMEOUT_MS = 1_200;
17
+ // Leave room for a cold project read while keeping each probe bounded.
18
+ export const DEFAULT_EXTERNAL_CODEGRAPH_TIMEOUT_MS = 3_000;
19
19
  export const MAX_EXTERNAL_CODEGRAPH_TIMEOUT_MS = 5_000;
20
20
  /** Explicit index maintenance is opt-in and may legitimately take longer. */
21
21
  export const EXTERNAL_CODEGRAPH_LIFECYCLE_TIMEOUT_MS = 30_000;
@@ -85,6 +85,28 @@ export interface ExternalCodeGraphContextResult extends InspectExternalCodeGraph
85
85
  outline?: ExternalCodeGraphOutline;
86
86
  /** Only present when a detected external graph could not safely contribute. */
87
87
  caution?: string;
88
+ diagnostics?: ExternalCodeGraphQueryDiagnostics;
89
+ }
90
+
91
+ export type ExternalCodeGraphQueryOutcome =
92
+ | 'not-attempted'
93
+ | 'success'
94
+ | 'failed'
95
+ | 'timed-out'
96
+ | 'output-limited'
97
+ | 'invalid-output';
98
+
99
+ export interface ExternalCodeGraphQueryDiagnostic {
100
+ command: 'context' | 'explore';
101
+ attempted: boolean;
102
+ outcome: ExternalCodeGraphQueryOutcome;
103
+ exitCode?: number;
104
+ reason?: string;
105
+ }
106
+
107
+ export interface ExternalCodeGraphQueryDiagnostics {
108
+ context: ExternalCodeGraphQueryDiagnostic;
109
+ explore: ExternalCodeGraphQueryDiagnostic;
88
110
  }
89
111
 
90
112
  /** Explicit lifecycle operations. Memorix never calls CodeGraph's `install`. */
@@ -410,6 +432,42 @@ function unavailableHealth(result: ExternalCodeGraphCommandResult): ExternalCode
410
432
  return { state: 'unavailable', reason: 'A local CodeGraph command did not complete.' };
411
433
  }
412
434
 
435
+ function notAttemptedDiagnostic(command: 'context' | 'explore'): ExternalCodeGraphQueryDiagnostic {
436
+ return { command, attempted: false, outcome: 'not-attempted' };
437
+ }
438
+
439
+ function commandDiagnostic(
440
+ command: 'context' | 'explore',
441
+ result: ExternalCodeGraphCommandResult,
442
+ outcome: ExternalCodeGraphQueryOutcome,
443
+ reason?: string,
444
+ ): ExternalCodeGraphQueryDiagnostic {
445
+ return {
446
+ command,
447
+ attempted: true,
448
+ outcome,
449
+ ...(typeof result.exitCode === 'number' ? { exitCode: result.exitCode } : {}),
450
+ ...(reason ? { reason: sanitizeCredentials(reason).slice(0, 240) } : {}),
451
+ };
452
+ }
453
+
454
+ function failedCommandDiagnostic(
455
+ command: 'context' | 'explore',
456
+ result: ExternalCodeGraphCommandResult,
457
+ ): ExternalCodeGraphQueryDiagnostic {
458
+ const outcome: ExternalCodeGraphQueryOutcome = result.timedOut
459
+ ? 'timed-out'
460
+ : result.outputLimited
461
+ ? 'output-limited'
462
+ : 'failed';
463
+ return commandDiagnostic(
464
+ command,
465
+ result,
466
+ outcome,
467
+ result.error || result.stderr || 'CodeGraph command did not complete.',
468
+ );
469
+ }
470
+
413
471
  async function runSafely(
414
472
  runner: ExternalCodeGraphRunner,
415
473
  input: ExternalCodeGraphCommandInput,
@@ -590,6 +648,7 @@ function parseOutline(value: unknown, projectRoot: string, exclude?: string[]):
590
648
  if (nodes.some(node => !node)) return undefined;
591
649
  const byId = new Map<string, ExternalCodeGraphSymbol>();
592
650
  for (const node of nodes as RawExternalNode[]) {
651
+ if (byId.has(node.id)) return undefined;
593
652
  byId.set(node.id, externalSymbol(node));
594
653
  }
595
654
  const entryPoints = value.entryPoints
@@ -609,7 +668,7 @@ function parseOutline(value: unknown, projectRoot: string, exclude?: string[]):
609
668
  if (!source || !target || !kind || (rawEdge.line !== undefined && (!line || line < 1))) return undefined;
610
669
  const from = byId.get(source);
611
670
  const to = byId.get(target);
612
- if (!from || !to) continue;
671
+ if (!from || !to) return undefined;
613
672
  relations.push({ from, to, kind, ...(line ? { line } : {}) });
614
673
  }
615
674
 
@@ -629,12 +688,202 @@ function parseOutline(value: unknown, projectRoot: string, exclude?: string[]):
629
688
  };
630
689
  }
631
690
 
691
+ function parseTextLocation(value: string): { filePath: string; line?: number } | undefined {
692
+ const match = value.trim().match(/^(.*?)(?::([1-9]\d*))?$/);
693
+ if (!match?.[1]) return undefined;
694
+ return {
695
+ filePath: match[1].trim(),
696
+ ...(match[2] ? { line: Number(match[2]) } : {}),
697
+ };
698
+ }
699
+
700
+ function parseExploreText(value: string, projectRoot: string, exclude?: string[]): ExternalCodeGraphOutline | undefined {
701
+ if (!value.trim() || Buffer.byteLength(value, 'utf8') > MAX_EXTERNAL_CODEGRAPH_OUTPUT_BYTES) return undefined;
702
+
703
+ const entryPoints: RawExternalNode[] = [];
704
+ const nodes: RawExternalNode[] = [];
705
+ const relatedFiles = new Set<string>();
706
+ const byName = new Map<string, RawExternalNode>();
707
+ let section: 'entry' | 'related' | 'relationships' | null = null;
708
+ let relationKind = 'references';
709
+
710
+ const addNode = (node: RawExternalNode, entry: boolean): void => {
711
+ const key = `${node.filePath}\0${node.name}`;
712
+ const existing = byName.get(key);
713
+ if (existing) return;
714
+ byName.set(key, node);
715
+ relatedFiles.add(node.filePath);
716
+ (entry ? entryPoints : nodes).push(node);
717
+ };
718
+
719
+ for (const rawLine of value.split(/\r?\n/)) {
720
+ const line = rawLine.trim();
721
+ const fileHeader = line.match(/^\*\*`([^`]+)`\*\*\s+(?:—|-)\s+(.+)$/);
722
+ if (fileHeader) {
723
+ const filePath = safeRelativePath(projectRoot, fileHeader[1], exclude);
724
+ if (!filePath) return undefined;
725
+ const symbolText = fileHeader[2].split(/\s+·\s+/, 1)[0] ?? '';
726
+ const symbols = [...symbolText.matchAll(/([^,]+?)\(([^()]+)\)/g)];
727
+ if (symbols.length === 0) return undefined;
728
+ const isFirstFile = entryPoints.length === 0 && nodes.length === 0;
729
+ for (const symbol of symbols.slice(0, MAX_EXTERNAL_NODE_COUNT)) {
730
+ const name = symbol[1]?.trim();
731
+ const kind = symbol[2]?.trim();
732
+ if (!name || !kind) return undefined;
733
+ addNode({
734
+ id: `explore:${filePath}:${name}`,
735
+ kind,
736
+ name,
737
+ filePath,
738
+ }, isFirstFile);
739
+ }
740
+ section = null;
741
+ continue;
742
+ }
743
+ if (/^#{2,3}\s+Entry Points?\b/i.test(line)) {
744
+ section = 'entry';
745
+ continue;
746
+ }
747
+ if (/^#{2,3}\s+Related Symbols?\b/i.test(line)) {
748
+ section = 'related';
749
+ continue;
750
+ }
751
+ if (/^\*\*Relationships\*\*$/i.test(line)) {
752
+ section = 'relationships';
753
+ continue;
754
+ }
755
+ if (/^\*\*(?:Source Code|Code)\*\*$/i.test(line) || /^#{2,3}\s+/i.test(line)) {
756
+ section = null;
757
+ continue;
758
+ }
759
+
760
+ if (section === 'entry') {
761
+ const match = line.match(/^-\s+\*\*(.+?)\*\*\s+\(([^)]+)\)\s+-\s+(.+)$/);
762
+ if (!match) continue;
763
+ const location = parseTextLocation(match[3]);
764
+ if (!location) return undefined;
765
+ const filePath = safeRelativePath(projectRoot, location.filePath, exclude);
766
+ if (!filePath) return undefined;
767
+ addNode({
768
+ id: `explore:${filePath}:${match[1]}`,
769
+ kind: match[2],
770
+ name: match[1],
771
+ filePath,
772
+ ...(location.line ? { startLine: location.line, endLine: location.line } : {}),
773
+ }, true);
774
+ continue;
775
+ }
776
+
777
+ if (section === 'related') {
778
+ const match = line.match(/^-\s+([^:]+):\s+(.+)$/);
779
+ if (!match) continue;
780
+ const filePath = safeRelativePath(projectRoot, match[1], exclude);
781
+ if (!filePath) return undefined;
782
+ relatedFiles.add(filePath);
783
+ for (const rawSymbol of match[2].split(',')) {
784
+ const symbol = rawSymbol.trim().match(/^(.+?)(?::([1-9]\d*))?$/);
785
+ if (!symbol?.[1]) return undefined;
786
+ addNode({
787
+ id: `explore:${filePath}:${symbol[1].trim()}`,
788
+ kind: 'unknown',
789
+ name: symbol[1].trim(),
790
+ filePath,
791
+ ...(symbol[2] ? { startLine: Number(symbol[2]), endLine: Number(symbol[2]) } : {}),
792
+ }, false);
793
+ }
794
+ continue;
795
+ }
796
+
797
+ if (section === 'relationships') {
798
+ const kind = line.match(/^\*\*([^*]+):\*\*$/);
799
+ if (kind?.[1]) {
800
+ relationKind = kind[1].trim();
801
+ continue;
802
+ }
803
+ }
804
+ }
805
+
806
+ const bySymbolName = new Map<string, RawExternalNode>();
807
+ for (const node of [...entryPoints, ...nodes]) {
808
+ if (!bySymbolName.has(node.name)) bySymbolName.set(node.name, node);
809
+ }
810
+ const relations: ExternalCodeGraphRelation[] = [];
811
+ if (section === 'relationships') {
812
+ for (const rawLine of value.split(/\r?\n/)) {
813
+ const line = rawLine.trim();
814
+ const match = line.match(/^-\s+(.+?)\s+(?:\u2192|->)\s+(.+)$/);
815
+ if (!match) continue;
816
+ const from = bySymbolName.get(match[1].trim());
817
+ const to = bySymbolName.get(match[2].trim());
818
+ if (!from || !to) continue;
819
+ relations.push({
820
+ from: externalSymbol(from),
821
+ to: externalSymbol(to),
822
+ kind: relationKind,
823
+ });
824
+ }
825
+ } else {
826
+ // The relationship section may be followed by source sections. Re-scan
827
+ // its bounded region so relation parsing does not depend on the final
828
+ // section value after the loop.
829
+ const relationshipStart = value.split(/\r?\n/).findIndex((line) => /^\s*\*\*Relationships\*\*\s*$/i.test(line));
830
+ if (relationshipStart >= 0) {
831
+ const relationshipLines = value.split(/\r?\n/).slice(relationshipStart + 1);
832
+ for (const rawLine of relationshipLines) {
833
+ const line = rawLine.trim();
834
+ if (/^\*\*(?:Source Code|Code)\*\*$/i.test(line) || /^#{2,3}\s+/i.test(line)) break;
835
+ const kind = line.match(/^\*\*([^*]+):\*\*$/);
836
+ if (kind?.[1]) {
837
+ relationKind = kind[1].trim();
838
+ continue;
839
+ }
840
+ const match = line.match(/^-\s+(.+?)\s+(?:\u2192|->)\s+(.+)$/);
841
+ if (!match) continue;
842
+ const from = bySymbolName.get(match[1].trim());
843
+ const to = bySymbolName.get(match[2].trim());
844
+ if (from && to) relations.push({ from: externalSymbol(from), to: externalSymbol(to), kind: relationKind });
845
+ }
846
+ }
847
+ }
848
+
849
+ const allNodes = [...entryPoints, ...nodes];
850
+ if (allNodes.length === 0) return undefined;
851
+ return {
852
+ provider: 'external',
853
+ entryPoints: entryPoints.map(externalSymbol).slice(0, 5),
854
+ relations: relations.slice(0, 6),
855
+ relatedFiles: [...relatedFiles].slice(0, 5),
856
+ stats: {
857
+ nodes: allNodes.length,
858
+ edges: relations.length,
859
+ files: relatedFiles.size,
860
+ },
861
+ };
862
+ }
863
+
864
+ function parseExplorePayload(value: unknown, projectRoot: string, exclude?: string[]): ExternalCodeGraphOutline | undefined {
865
+ if (typeof value === 'string') return parseExploreText(value, projectRoot, exclude);
866
+ if (isRecord(value) && isRecord(value.result)) value = value.result;
867
+ if (isRecord(value) && Array.isArray(value.content)) {
868
+ const text = value.content
869
+ .filter((item): item is Record<string, unknown> => isRecord(item) && item.type === 'text' && typeof item.text === 'string')
870
+ .map(item => item.text as string)
871
+ .join('\n');
872
+ return parseExploreText(text, projectRoot, exclude);
873
+ }
874
+ return parseOutline(value, projectRoot, exclude);
875
+ }
876
+
632
877
  function fallbackCaution(health: ExternalCodeGraphHealth): string | undefined {
633
878
  if (health.state === 'disabled' || health.state === 'not-detected'
634
879
  || health.state === 'not-initialized' || health.state === 'ready') return undefined;
635
880
  return 'External semantic CodeGraph is ' + health.state + '; using Lite structural evidence for this brief.';
636
881
  }
637
882
 
883
+ function queryFallbackCaution(diagnostics: ExternalCodeGraphQueryDiagnostics): string {
884
+ return `External semantic CodeGraph context was unavailable (context: ${diagnostics.context.outcome}; explore: ${diagnostics.explore.outcome}); using Lite structural evidence.`;
885
+ }
886
+
638
887
  export async function getExternalCodeGraphContext(input: ExternalCodeGraphContextInput): Promise<ExternalCodeGraphContextResult> {
639
888
  const inspected = await inspectExternalCodeGraph(input);
640
889
  if (inspected.health.state !== 'ready') {
@@ -642,34 +891,65 @@ export async function getExternalCodeGraphContext(input: ExternalCodeGraphContex
642
891
  }
643
892
 
644
893
  const mode = input.mode ?? 'auto';
645
- const result = await runSafely(input.runner ?? defaultRunner, {
894
+ const runner = input.runner ?? defaultRunner;
895
+ const contextResult = await runSafely(runner, {
646
896
  command: input.command,
647
897
  args: ['context', '--path', input.projectRoot, '--format', 'json', '--max-nodes', '8', '--no-code', input.task],
648
898
  cwd: input.projectRoot,
649
899
  timeoutMs: boundedTimeout(input.timeoutMs),
650
900
  maxOutputBytes: MAX_EXTERNAL_CODEGRAPH_OUTPUT_BYTES,
651
901
  });
652
- if (!result.ok) {
653
- const health = unavailableHealth(result);
902
+ const diagnostics: ExternalCodeGraphQueryDiagnostics = {
903
+ context: contextResult.ok
904
+ ? commandDiagnostic('context', contextResult, 'success')
905
+ : failedCommandDiagnostic('context', contextResult),
906
+ explore: notAttemptedDiagnostic('explore'),
907
+ };
908
+
909
+ const contextOutline = contextResult.ok
910
+ ? parseOutline(parseJson(contextResult.stdout), input.projectRoot, input.exclude)
911
+ : undefined;
912
+ if (contextOutline && (contextOutline.entryPoints.length > 0 || contextOutline.relations.length > 0 || contextOutline.relatedFiles.length > 0)) {
654
913
  return {
655
- health,
656
- quality: providerQuality({ mode, health }),
657
- caution: fallbackCaution(health),
914
+ ...inspected,
915
+ outline: contextOutline,
916
+ diagnostics,
917
+ quality: providerQuality({ mode, health: inspected.health, selected: 'external' }),
658
918
  };
659
919
  }
660
- const outline = parseOutline(parseJson(result.stdout), input.projectRoot, input.exclude);
661
- if (!outline) {
662
- const health: ExternalCodeGraphHealth = { state: 'invalid', reason: 'The local CodeGraph context payload failed validation.' };
920
+ if (contextResult.ok) diagnostics.context = commandDiagnostic('context', contextResult, 'invalid-output', 'The context payload failed validation or contained no safe outline.');
921
+
922
+ const exploreResult = await runSafely(runner, {
923
+ command: input.command,
924
+ args: ['explore', '--path', input.projectRoot, '--max-files', '4', input.task],
925
+ cwd: input.projectRoot,
926
+ timeoutMs: boundedTimeout(input.timeoutMs),
927
+ maxOutputBytes: MAX_EXTERNAL_CODEGRAPH_OUTPUT_BYTES,
928
+ });
929
+ diagnostics.explore = exploreResult.ok
930
+ ? commandDiagnostic('explore', exploreResult, 'success')
931
+ : failedCommandDiagnostic('explore', exploreResult);
932
+
933
+ const exploreOutline = exploreResult.ok
934
+ ? parseExplorePayload(parseJson(exploreResult.stdout) ?? exploreResult.stdout, input.projectRoot, input.exclude)
935
+ : undefined;
936
+ if (exploreOutline && (exploreOutline.entryPoints.length > 0 || exploreOutline.relations.length > 0 || exploreOutline.relatedFiles.length > 0)) {
663
937
  return {
664
- health,
665
- quality: providerQuality({ mode, health }),
666
- caution: fallbackCaution(health),
938
+ ...inspected,
939
+ outline: exploreOutline,
940
+ diagnostics,
941
+ quality: providerQuality({ mode, health: inspected.health, selected: 'external' }),
667
942
  };
668
943
  }
669
- const contributes = outline.entryPoints.length > 0 || outline.relations.length > 0 || outline.relatedFiles.length > 0;
944
+ if (exploreResult.ok) diagnostics.explore = commandDiagnostic('explore', exploreResult, 'invalid-output', 'The explore payload failed validation or contained no safe outline.');
945
+
946
+ const health = exploreResult.ok
947
+ ? { state: 'invalid' as const, reason: 'The local CodeGraph context and explore payloads failed validation.' }
948
+ : unavailableHealth(exploreResult);
670
949
  return {
671
- ...inspected,
672
- ...(contributes ? { outline } : {}),
673
- quality: providerQuality({ mode, health: inspected.health, ...(contributes ? { selected: 'external' as const } : {}) }),
950
+ health,
951
+ quality: providerQuality({ mode, health }),
952
+ diagnostics,
953
+ caution: queryFallbackCaution(diagnostics),
674
954
  };
675
955
  }
@@ -28,6 +28,7 @@ import { getGlobalDotenvPath, getProjectDotenvPath } from './config-paths.js';
28
28
 
29
29
  let dotenvLoaded = false;
30
30
  let dotenvProjectRoot: string | null = null;
31
+ let dotenvHomeDir: string | null = null;
31
32
 
32
33
  /** Track which .env files were loaded (for diagnostics) */
33
34
  const loadedEnvFiles: string[] = [];
@@ -65,9 +66,11 @@ interface DotenvLoadOptions {
65
66
  * @param projectRoot - Project root directory (for project-level .env)
66
67
  */
67
68
  export function loadDotenv(projectRoot?: string, options: DotenvLoadOptions = {}): void {
68
- if (dotenvLoaded && dotenvProjectRoot === (projectRoot ?? null)) return;
69
+ const userHomeDir = options.userHomeDir ?? homedir();
70
+ const normalizedProjectRoot = projectRoot ?? null;
71
+ if (dotenvLoaded && dotenvProjectRoot === normalizedProjectRoot && dotenvHomeDir === userHomeDir) return;
69
72
 
70
- if (dotenvLoaded && dotenvProjectRoot !== (projectRoot ?? null)) {
73
+ if (dotenvLoaded && (dotenvProjectRoot !== normalizedProjectRoot || dotenvHomeDir !== userHomeDir)) {
71
74
  resetDotenv();
72
75
  }
73
76
 
@@ -85,10 +88,11 @@ export function loadDotenv(projectRoot?: string, options: DotenvLoadOptions = {}
85
88
 
86
89
  // 2. User-level .env (~/.memorix/.env) — lowest .env priority, load second
87
90
  // (override: false means it only fills in keys not already set)
88
- loadEnvFile(getGlobalDotenvPath(options.userHomeDir ?? homedir()));
91
+ loadEnvFile(getGlobalDotenvPath(userHomeDir));
89
92
 
90
93
  dotenvLoaded = true;
91
- dotenvProjectRoot = projectRoot ?? null;
94
+ dotenvProjectRoot = normalizedProjectRoot;
95
+ dotenvHomeDir = userHomeDir;
92
96
  }
93
97
 
94
98
  /**
@@ -101,6 +105,7 @@ export function resetDotenv(): void {
101
105
  injectedKeys.clear();
102
106
  dotenvLoaded = false;
103
107
  dotenvProjectRoot = null;
108
+ dotenvHomeDir = null;
104
109
  loadedEnvFiles.length = 0;
105
110
  loadedEnvKeys.clear();
106
111
  }
package/src/config.ts CHANGED
@@ -23,13 +23,13 @@ export { loadFileConfig, resetConfigCache, type MemorixConfig } from './config/l
23
23
  // ─── Resolved Getters ────────────────────────────────────────────────
24
24
 
25
25
  /** Memory/background LLM API key: env > memorix.yml > config.json > provider env fallbacks */
26
- export function getLLMApiKey(): string | undefined {
27
- return getResolvedMemoryLane().llm.apiKey;
26
+ export function getLLMApiKey(options: ResolvedLaneOptions = {}): string | undefined {
27
+ return getResolvedMemoryLane(options).llm.apiKey;
28
28
  }
29
29
 
30
30
  /** LLM provider: env > memorix.yml > config.json > auto-detect */
31
- export function getLLMProvider(): string {
32
- const provider = getResolvedMemoryLane().llm.provider;
31
+ export function getLLMProvider(options: ResolvedLaneOptions = {}): string {
32
+ const provider = getResolvedMemoryLane(options).llm.provider;
33
33
  if (provider) return provider;
34
34
  // Auto-detect from env var names
35
35
  if (process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) return 'anthropic';
@@ -38,33 +38,33 @@ export function getLLMProvider(): string {
38
38
  }
39
39
 
40
40
  /** LLM model: env > memorix.yml > config.json > provider default */
41
- export function getLLMModel(providerDefault: string): string {
42
- return getResolvedMemoryLane().llm.model || providerDefault;
41
+ export function getLLMModel(providerDefault: string, options: ResolvedLaneOptions = {}): string {
42
+ return getResolvedMemoryLane(options).llm.model || providerDefault;
43
43
  }
44
44
 
45
45
  /** LLM base URL: env > memorix.yml > config.json > provider default */
46
- export function getLLMBaseUrl(providerDefault: string): string {
47
- return getResolvedMemoryLane().llm.baseUrl || providerDefault;
46
+ export function getLLMBaseUrl(providerDefault: string, options: ResolvedLaneOptions = {}): string {
47
+ return getResolvedMemoryLane(options).llm.baseUrl || providerDefault;
48
48
  }
49
49
 
50
50
  /** TUI/chat agent API key: agent env > agent config > memory LLM fallback */
51
- export function getAgentLLMApiKey(): string | undefined {
52
- return getResolvedAgentLane().apiKey;
51
+ export function getAgentLLMApiKey(options: ResolvedLaneOptions = {}): string | undefined {
52
+ return getResolvedAgentLane(options).apiKey;
53
53
  }
54
54
 
55
55
  /** TUI/chat agent provider: agent env > agent config > memory LLM fallback */
56
- export function getAgentLLMProvider(): string {
57
- return getResolvedAgentLane().provider || getLLMProvider();
56
+ export function getAgentLLMProvider(options: ResolvedLaneOptions = {}): string {
57
+ return getResolvedAgentLane(options).provider || getLLMProvider(options);
58
58
  }
59
59
 
60
60
  /** TUI/chat agent model: agent env > agent config > memory LLM fallback */
61
- export function getAgentLLMModel(providerDefault: string): string {
62
- return getResolvedAgentLane().model || getLLMModel(providerDefault);
61
+ export function getAgentLLMModel(providerDefault: string, options: ResolvedLaneOptions = {}): string {
62
+ return getResolvedAgentLane(options).model || getLLMModel(providerDefault, options);
63
63
  }
64
64
 
65
65
  /** TUI/chat agent base URL: agent env > agent config > memory LLM fallback */
66
- export function getAgentLLMBaseUrl(providerDefault: string): string {
67
- return getResolvedAgentLane().baseUrl || getLLMBaseUrl(providerDefault);
66
+ export function getAgentLLMBaseUrl(providerDefault: string, options: ResolvedLaneOptions = {}): string {
67
+ return getResolvedAgentLane(options).baseUrl || getLLMBaseUrl(providerDefault, options);
68
68
  }
69
69
 
70
70
  /** Embedding mode: env > memorix.yml > config.json > 'off' */
@@ -1,6 +1,6 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
 
3
- import { isLLMEnabled } from '../llm/provider.js';
3
+ import { initLLM, isLLMEnabled } from '../llm/provider.js';
4
4
  import { analyzeCleanupObservations, applyCleanupMutations } from '../memory/cleanup.js';
5
5
  import { findConsolidationCandidates, executeConsolidation } from '../memory/consolidation.js';
6
6
  import { applyDeduplicationPlan, planMemoryDeduplication } from '../memory/deduplication.js';
@@ -167,6 +167,9 @@ export async function executeCleanup(
167
167
  }
168
168
 
169
169
  export async function previewDeduplicate(context: DashboardMaintenanceContext) {
170
+ // Dashboard callers do not share the CLI/server bootstrap path. Resolve the
171
+ // memory lane here so standalone and HTTP previews use the selected project.
172
+ initLLM({ scope: 'memory', projectRoot: context.projectRoot });
170
173
  if (!isLLMEnabled()) {
171
174
  return {
172
175
  action: 'deduplicate',