devlensio 0.2.0

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 (136) hide show
  1. package/LICENSE +674 -0
  2. package/dist/clustering/index.d.ts +27 -0
  3. package/dist/clustering/index.js +149 -0
  4. package/dist/config/index.d.ts +10 -0
  5. package/dist/config/index.js +78 -0
  6. package/dist/config/providers/file.d.ts +19 -0
  7. package/dist/config/providers/file.js +215 -0
  8. package/dist/config/providers/request.d.ts +2 -0
  9. package/dist/config/providers/request.js +72 -0
  10. package/dist/config/types.d.ts +46 -0
  11. package/dist/config/types.js +81 -0
  12. package/dist/config/writer.d.ts +29 -0
  13. package/dist/config/writer.js +103 -0
  14. package/dist/filesystem/appRouter.d.ts +2 -0
  15. package/dist/filesystem/appRouter.js +126 -0
  16. package/dist/filesystem/backendRoutes.d.ts +2 -0
  17. package/dist/filesystem/backendRoutes.js +161 -0
  18. package/dist/filesystem/index.d.ts +2 -0
  19. package/dist/filesystem/index.js +28 -0
  20. package/dist/filesystem/index.test.d.ts +1 -0
  21. package/dist/filesystem/index.test.js +178 -0
  22. package/dist/filesystem/pagesRouter.d.ts +2 -0
  23. package/dist/filesystem/pagesRouter.js +109 -0
  24. package/dist/fingerprint/detectors.d.ts +8 -0
  25. package/dist/fingerprint/detectors.js +174 -0
  26. package/dist/fingerprint/index.d.ts +2 -0
  27. package/dist/fingerprint/index.js +41 -0
  28. package/dist/fingerprint/index.test.d.ts +1 -0
  29. package/dist/fingerprint/index.test.js +148 -0
  30. package/dist/graph/buildLookup.d.ts +10 -0
  31. package/dist/graph/buildLookup.js +32 -0
  32. package/dist/graph/edges/callEdges.d.ts +7 -0
  33. package/dist/graph/edges/callEdges.js +145 -0
  34. package/dist/graph/edges/eventEdges.d.ts +7 -0
  35. package/dist/graph/edges/eventEdges.js +203 -0
  36. package/dist/graph/edges/guardEdges.d.ts +3 -0
  37. package/dist/graph/edges/guardEdges.js +232 -0
  38. package/dist/graph/edges/hookEdges.d.ts +3 -0
  39. package/dist/graph/edges/hookEdges.js +54 -0
  40. package/dist/graph/edges/importEdges.d.ts +8 -0
  41. package/dist/graph/edges/importEdges.js +224 -0
  42. package/dist/graph/edges/propEdges.d.ts +3 -0
  43. package/dist/graph/edges/propEdges.js +142 -0
  44. package/dist/graph/edges/routeEdge.d.ts +3 -0
  45. package/dist/graph/edges/routeEdge.js +124 -0
  46. package/dist/graph/edges/stateEdges.d.ts +3 -0
  47. package/dist/graph/edges/stateEdges.js +206 -0
  48. package/dist/graph/edges/testEdges.d.ts +3 -0
  49. package/dist/graph/edges/testEdges.js +143 -0
  50. package/dist/graph/edges/utils.d.ts +2 -0
  51. package/dist/graph/edges/utils.js +25 -0
  52. package/dist/graph/index.d.ts +6 -0
  53. package/dist/graph/index.js +65 -0
  54. package/dist/graph/index.test.d.ts +1 -0
  55. package/dist/graph/index.test.js +542 -0
  56. package/dist/graph/thirdPartyLibs.d.ts +8 -0
  57. package/dist/graph/thirdPartyLibs.js +162 -0
  58. package/dist/index.d.ts +15 -0
  59. package/dist/index.js +15 -0
  60. package/dist/jobs/index.d.ts +5 -0
  61. package/dist/jobs/index.js +11 -0
  62. package/dist/jobs/queue/interface.d.ts +13 -0
  63. package/dist/jobs/queue/interface.js +1 -0
  64. package/dist/jobs/queue/memory.d.ts +24 -0
  65. package/dist/jobs/queue/memory.js +291 -0
  66. package/dist/jobs/runner.d.ts +3 -0
  67. package/dist/jobs/runner.js +136 -0
  68. package/dist/jobs/types.d.ts +112 -0
  69. package/dist/jobs/types.js +33 -0
  70. package/dist/parser/directives.d.ts +4 -0
  71. package/dist/parser/directives.js +31 -0
  72. package/dist/parser/extractors/components.d.ts +5 -0
  73. package/dist/parser/extractors/components.js +240 -0
  74. package/dist/parser/extractors/functions.d.ts +4 -0
  75. package/dist/parser/extractors/functions.js +240 -0
  76. package/dist/parser/extractors/hooks.d.ts +4 -0
  77. package/dist/parser/extractors/hooks.js +128 -0
  78. package/dist/parser/extractors/stores.d.ts +3 -0
  79. package/dist/parser/extractors/stores.js +181 -0
  80. package/dist/parser/index.d.ts +14 -0
  81. package/dist/parser/index.js +168 -0
  82. package/dist/parser/index.test.d.ts +1 -0
  83. package/dist/parser/index.test.js +319 -0
  84. package/dist/parser/typeUtils.d.ts +9 -0
  85. package/dist/parser/typeUtils.js +46 -0
  86. package/dist/pipeline/index.d.ts +50 -0
  87. package/dist/pipeline/index.js +249 -0
  88. package/dist/scoring/connectionCounter.d.ts +28 -0
  89. package/dist/scoring/connectionCounter.js +134 -0
  90. package/dist/scoring/fileScorer.d.ts +2 -0
  91. package/dist/scoring/fileScorer.js +44 -0
  92. package/dist/scoring/index.d.ts +22 -0
  93. package/dist/scoring/index.js +130 -0
  94. package/dist/scoring/index.test.d.ts +1 -0
  95. package/dist/scoring/index.test.js +453 -0
  96. package/dist/scoring/nodeScorer.d.ts +3 -0
  97. package/dist/scoring/nodeScorer.js +108 -0
  98. package/dist/scoring/noiseFilter.d.ts +18 -0
  99. package/dist/scoring/noiseFilter.js +92 -0
  100. package/dist/storage/fileStorage.d.ts +117 -0
  101. package/dist/storage/fileStorage.js +616 -0
  102. package/dist/storage/index.d.ts +4 -0
  103. package/dist/storage/index.js +2 -0
  104. package/dist/storage/interface.d.ts +27 -0
  105. package/dist/storage/interface.js +1 -0
  106. package/dist/summarizer/checkpoint.d.ts +15 -0
  107. package/dist/summarizer/checkpoint.js +110 -0
  108. package/dist/summarizer/index.d.ts +2 -0
  109. package/dist/summarizer/index.js +281 -0
  110. package/dist/summarizer/mapreduce.d.ts +4 -0
  111. package/dist/summarizer/mapreduce.js +87 -0
  112. package/dist/summarizer/prompts.d.ts +22 -0
  113. package/dist/summarizer/prompts.js +205 -0
  114. package/dist/summarizer/providers/anthropic.d.ts +9 -0
  115. package/dist/summarizer/providers/anthropic.js +78 -0
  116. package/dist/summarizer/providers/gemini.d.ts +9 -0
  117. package/dist/summarizer/providers/gemini.js +79 -0
  118. package/dist/summarizer/providers/index.d.ts +3 -0
  119. package/dist/summarizer/providers/index.js +43 -0
  120. package/dist/summarizer/providers/ollama.d.ts +9 -0
  121. package/dist/summarizer/providers/ollama.js +23 -0
  122. package/dist/summarizer/providers/openRouter.d.ts +9 -0
  123. package/dist/summarizer/providers/openRouter.js +19 -0
  124. package/dist/summarizer/providers/openai.d.ts +9 -0
  125. package/dist/summarizer/providers/openai.js +72 -0
  126. package/dist/summarizer/providers/types.d.ts +32 -0
  127. package/dist/summarizer/providers/types.js +1 -0
  128. package/dist/summarizer/retry.d.ts +7 -0
  129. package/dist/summarizer/retry.js +51 -0
  130. package/dist/summarizer/topological.d.ts +3 -0
  131. package/dist/summarizer/topological.js +105 -0
  132. package/dist/summarizer/types.d.ts +57 -0
  133. package/dist/summarizer/types.js +17 -0
  134. package/dist/types.d.ts +78 -0
  135. package/dist/types.js +1 -0
  136. package/package.json +48 -0
@@ -0,0 +1,27 @@
1
+ import type { CodeEdge, CodeNode } from "../types.js";
2
+ export interface ClusterNode {
3
+ nodeId: string;
4
+ rank: number;
5
+ }
6
+ export interface ClusterFile {
7
+ filePath: string;
8
+ nodeIds: ClusterNode[];
9
+ }
10
+ export interface Cluster {
11
+ id: string;
12
+ label: string;
13
+ files: ClusterFile[];
14
+ nodeCount: number;
15
+ topNodes: string[];
16
+ }
17
+ export interface InterClusterEdge {
18
+ from: string;
19
+ to: string;
20
+ weight: number;
21
+ }
22
+ export interface ClusterResult {
23
+ clusters: Cluster[];
24
+ interClusterEdges: InterClusterEdge[];
25
+ clusterMembership: Record<string, string>;
26
+ }
27
+ export declare function computeClusters(allNodes: CodeNode[], allEdges: CodeEdge[], nodeScores: Record<string, number>): ClusterResult;
@@ -0,0 +1,149 @@
1
+ //Ranked BFS with Shared Node isolation
2
+ const BFS_TYPES = new Set(["CALLS", "PROP_PASS", "GUARDS", "EMITS", "LISTENS"]);
3
+ export function computeClusters(allNodes, allEdges, nodeScores) {
4
+ const adjList = new Map();
5
+ const inDegree = new Map();
6
+ const nodeMap = new Map();
7
+ const fileContentMap = new Map();
8
+ // 1. Initialize Maps and File-to-Node relationships
9
+ for (const node of allNodes) {
10
+ adjList.set(node.id, []);
11
+ inDegree.set(node.id, 0);
12
+ nodeMap.set(node.id, node);
13
+ const path = node.filePath || "external-dependencies";
14
+ if (!fileContentMap.has(path))
15
+ fileContentMap.set(path, []);
16
+ fileContentMap.get(path).push(node.id);
17
+ }
18
+ // 2. Build Adjacency List for Bloom Traversal
19
+ for (const edge of allEdges) {
20
+ if (!BFS_TYPES.has(edge.type))
21
+ continue;
22
+ adjList.get(edge.from)?.push(edge.to);
23
+ inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
24
+ }
25
+ const membership = new Map(); // nodeId -> clusterId
26
+ const ranks = new Map(); // nodeId -> rank
27
+ const queue = [];
28
+ let clusterPosition = 1;
29
+ // Helper: Register a node and all its file-siblings into a cluster
30
+ const startClusterFromNode = (rootId, manualClusterId) => {
31
+ if (membership.has(rootId))
32
+ return;
33
+ const clusterId = manualClusterId || `CLUSTER_${clusterPosition++}`;
34
+ const rootNode = nodeMap.get(rootId);
35
+ // STICKY LOGIC: Draft all nodes in the same file to keep file-integrity
36
+ const siblings = fileContentMap.get(rootNode?.filePath || "") || [];
37
+ const targets = siblings.length > 0 ? siblings : [rootId];
38
+ for (const id of targets) {
39
+ if (!membership.has(id)) {
40
+ membership.set(id, clusterId);
41
+ ranks.set(id, 0);
42
+ queue.push({ nodeId: id, clusterId, rank: 0 });
43
+ }
44
+ }
45
+ console.log(clusterId, rootId);
46
+ };
47
+ // 3. PHASE 1: Seed Entry Points (Structural Entry)
48
+ allNodes
49
+ .filter((n) => inDegree.get(n.id) === 0)
50
+ .sort((a, b) => (nodeScores[b.id] ?? 0) - (nodeScores[a.id] ?? 0))
51
+ .forEach((n) => startClusterFromNode(n.id));
52
+ const maxClusterLevel = 4;
53
+ // 4. PHASE 2: Main BFS Propagation (The Bloom)
54
+ while (queue.length > 0) {
55
+ const { nodeId, clusterId, rank } = queue.shift();
56
+ const currentNode = nodeMap.get(nodeId);
57
+ for (const childId of adjList.get(nodeId) ?? []) {
58
+ const childNode = nodeMap.get(childId);
59
+ const existingCluster = membership.get(childId);
60
+ const isFileBoundary = currentNode?.type === "FILE" || childNode?.type === "FILE";
61
+ const nextRank = (rank >= maxClusterLevel || isFileBoundary) ? rank : rank + 1;
62
+ if (!existingCluster) {
63
+ membership.set(childId, clusterId);
64
+ ranks.set(childId, nextRank);
65
+ queue.push({ nodeId: childId, clusterId, rank: nextRank });
66
+ }
67
+ else if (existingCluster !== clusterId && existingCluster !== "SHARED_CORE" && clusterId !== "SHARED_CORE") {
68
+ membership.set(childId, "SHARED_CORE");
69
+ ranks.set(childId, nextRank);
70
+ queue.push({ nodeId: childId, clusterId: "SHARED_CORE", rank: nextRank });
71
+ }
72
+ }
73
+ }
74
+ // 6. Assemble Temporary Groups
75
+ const tempGroups = new Map(); // clusterId -> path -> nodes
76
+ for (const node of allNodes) {
77
+ const cId = membership.get(node.id) || "MISC";
78
+ if (!tempGroups.has(cId))
79
+ tempGroups.set(cId, new Map());
80
+ const fileMap = tempGroups.get(cId);
81
+ const path = node.filePath || "external";
82
+ if (!fileMap.has(path))
83
+ fileMap.set(path, []);
84
+ fileMap.get(path).push({ nodeId: node.id, rank: ranks.get(node.id) ?? 0 });
85
+ }
86
+ // 7. PHASE 4: Refine & Consolidate (Remove single-node utility clusters)
87
+ const finalClusters = [];
88
+ const miscFiles = [];
89
+ const MIN_CLUSTER_SIZE = 3;
90
+ for (const [cId, fileMap] of tempGroups) {
91
+ const clusterFiles = [];
92
+ let totalNodesInCluster = 0;
93
+ for (const [path, nodes] of fileMap) {
94
+ clusterFiles.push({
95
+ filePath: path,
96
+ nodeIds: nodes.sort((a, b) => a.rank - b.rank)
97
+ });
98
+ totalNodesInCluster += nodes.length;
99
+ }
100
+ const isUtility = totalNodesInCluster < MIN_CLUSTER_SIZE && cId !== "SHARED_CORE";
101
+ if (isUtility) {
102
+ // Re-route membership to MISC cluster
103
+ clusterFiles.forEach(cf => cf.nodeIds.forEach(n => membership.set(n.nodeId, "UTILS_STORES")));
104
+ miscFiles.push(...clusterFiles);
105
+ }
106
+ else {
107
+ const allIds = clusterFiles.flatMap(f => f.nodeIds.map(n => n.nodeId));
108
+ const sortedByScore = allIds.sort((a, b) => (nodeScores[b] ?? 0) - (nodeScores[a] ?? 0));
109
+ finalClusters.push({
110
+ id: cId,
111
+ label: cId === "SHARED_CORE" ? "Shared Core" : (nodeMap.get(sortedByScore[0])?.name || cId),
112
+ files: clusterFiles,
113
+ nodeCount: totalNodesInCluster,
114
+ topNodes: sortedByScore.slice(0, 5),
115
+ });
116
+ }
117
+ }
118
+ // Add consolidated Utility cluster
119
+ if (miscFiles.length > 0) {
120
+ const miscIds = miscFiles.flatMap(f => f.nodeIds.map(n => n.nodeId));
121
+ finalClusters.push({
122
+ id: "UTILS_STORES",
123
+ label: "Singletons, Utilities & Global Stores",
124
+ files: miscFiles,
125
+ nodeCount: miscIds.length,
126
+ topNodes: miscIds.sort((a, b) => (nodeScores[b] ?? 0) - (nodeScores[a] ?? 0)).slice(0, 5)
127
+ });
128
+ }
129
+ // 8. Inter-Cluster Edges
130
+ const interClusterEdges = [];
131
+ const edgeTracker = new Map();
132
+ for (const edge of allEdges) {
133
+ const fromC = membership.get(edge.from);
134
+ const toC = membership.get(edge.to);
135
+ if (fromC && toC && fromC !== toC) {
136
+ const key = `${fromC}->${toC}`;
137
+ edgeTracker.set(key, (edgeTracker.get(key) ?? 0) + 1);
138
+ }
139
+ }
140
+ edgeTracker.forEach((weight, key) => {
141
+ const [from, to] = key.split("->");
142
+ interClusterEdges.push({ from, to, weight });
143
+ });
144
+ return {
145
+ clusters: finalClusters,
146
+ interClusterEdges,
147
+ clusterMembership: Object.fromEntries(membership),
148
+ };
149
+ }
@@ -0,0 +1,10 @@
1
+ import { type DevLensConfig } from "./types.js";
2
+ export declare function detectOllama(): Promise<boolean>;
3
+ export declare function initConfig(): Promise<void>;
4
+ export declare function resolveConfig(req?: Request): DevLensConfig;
5
+ export type { DevLensConfig } from "./types.js";
6
+ export type { SafeConfig } from "./writer.js";
7
+ export { maskConfig, writeConfig } from "./writer.js";
8
+ export { CONFIG_FILE, CONFIG_DIR, ENV } from "./providers/file.js";
9
+ export { sanitizeHeaders, CONFIG_HEADERS } from "./types.js";
10
+ export { OLLAMA_DEFAULTS, ANTHROPIC_DEFAULTS } from "./types.js";
@@ -0,0 +1,78 @@
1
+ import { OLLAMA_DEFAULTS, ANTHROPIC_DEFAULTS } from "./types.js";
2
+ import { loadFileConfig } from "./providers/file.js";
3
+ import { applyRequestHeaders } from "./providers/request.js";
4
+ // Ollama Detection
5
+ //
6
+ // Pings Ollama's default endpoint at server startup.
7
+ // Used by resolveConfig() to choose which defaults to fall back to:
8
+ // - Ollama running → OLLAMA_DEFAULTS (free, private, zero API cost)
9
+ // - Ollama absent → ANTHROPIC_DEFAULTS (user must set apiKey)
10
+ //
11
+ // Uses a short timeout — we don't want server startup to hang for 30 seconds
12
+ // if Ollama is not installed. 2 seconds is enough for a local HTTP ping.
13
+ //
14
+ // Called ONCE at startup and the result is cached — see `cachedDefaults` below.
15
+ const OLLAMA_PING_URL = "http://localhost:11434";
16
+ const OLLAMA_PING_TIMEOUT_MS = 2000;
17
+ export async function detectOllama() {
18
+ try {
19
+ const controller = new AbortController();
20
+ const timeout = setTimeout(() => controller.abort(), OLLAMA_PING_TIMEOUT_MS);
21
+ const res = await fetch(OLLAMA_PING_URL, {
22
+ signal: controller.signal,
23
+ method: "GET",
24
+ });
25
+ clearTimeout(timeout);
26
+ return res.ok;
27
+ }
28
+ catch {
29
+ // Ollama not running, not installed, or timed out — all treated the same
30
+ return false;
31
+ }
32
+ }
33
+ // Startup Initialization
34
+ //
35
+ // detectOllama() is called once when the server starts (in server/index.ts).
36
+ // The result is stored here so resolveConfig() doesn't ping Ollama on
37
+ // every single request — that would be slow and noisy.
38
+ //
39
+ // initConfig() must be called before any request is handled.
40
+ // Until it is called, resolveConfig() falls back to ANTHROPIC_DEFAULTS safely.
41
+ let cachedDefaults = ANTHROPIC_DEFAULTS;
42
+ let initialized = false;
43
+ export async function initConfig() {
44
+ if (initialized)
45
+ return;
46
+ const ollamaRunning = await detectOllama();
47
+ if (ollamaRunning) {
48
+ cachedDefaults = OLLAMA_DEFAULTS;
49
+ console.log("⚡ Ollama detected — using local LLM defaults");
50
+ console.log(` Summarization: ${OLLAMA_DEFAULTS.summarization.model}`);
51
+ console.log(` Embedding: ${OLLAMA_DEFAULTS.embedding.model}`);
52
+ }
53
+ else {
54
+ cachedDefaults = ANTHROPIC_DEFAULTS;
55
+ console.log("☁️ Ollama not detected — using Anthropic defaults");
56
+ console.log(" Add an apiKey to ~/.devlens/config.json to enable summarization");
57
+ console.log(` Or set ${(await import("./providers/file.js")).ENV.LLM_KEY}=your-key`);
58
+ }
59
+ initialized = true;
60
+ }
61
+ // This function reads config.json fresh on every call —
62
+ // so if the user edits settings in the UI, the next job picks up the change
63
+ // without requiring a server restart.
64
+ export function resolveConfig(req) {
65
+ // Step 1 — load file config merged with detected defaults + env vars
66
+ const fileConfig = loadFileConfig(cachedDefaults);
67
+ if (!req)
68
+ return fileConfig;
69
+ // In local mode, ignore headers even if present
70
+ if (fileConfig.deploymentMode !== "cloud")
71
+ return fileConfig;
72
+ // Step 4 — apply header overrides for cloud users
73
+ return applyRequestHeaders(fileConfig, req);
74
+ }
75
+ export { maskConfig, writeConfig } from "./writer.js";
76
+ export { CONFIG_FILE, CONFIG_DIR, ENV } from "./providers/file.js";
77
+ export { sanitizeHeaders, CONFIG_HEADERS } from "./types.js";
78
+ export { OLLAMA_DEFAULTS, ANTHROPIC_DEFAULTS } from "./types.js";
@@ -0,0 +1,19 @@
1
+ import { type DevLensConfig } from "../types.js";
2
+ export declare const CONFIG_DIR: string;
3
+ export declare const CONFIG_FILE: string;
4
+ export declare const ENV: {
5
+ readonly LLM_PROVIDER: "DEVLENS_LLM_PROVIDER";
6
+ readonly LLM_MODEL: "DEVLENS_LLM_MODEL";
7
+ readonly LLM_KEY: "DEVLENS_LLM_KEY";
8
+ readonly LLM_BASE_URL: "DEVLENS_LLM_BASE_URL";
9
+ readonly BATCH_SIZE: "DEVLENS_BATCH_SIZE";
10
+ readonly EMBED_PROVIDER: "DEVLENS_EMBED_PROVIDER";
11
+ readonly EMBED_MODEL: "DEVLENS_EMBED_MODEL";
12
+ readonly EMBED_KEY: "DEVLENS_EMBED_KEY";
13
+ readonly EMBED_BASE_URL: "DEVLENS_EMBED_BASE_URL";
14
+ readonly NEO4J_URL: "DEVLENS_NEO4J_URL";
15
+ readonly NEO4J_USER: "DEVLENS_NEO4J_USER";
16
+ readonly NEO4J_PASSWORD: "DEVLENS_NEO4J_PASSWORD";
17
+ readonly NEO4J_STORECODE: "NEO4J_STORE_CODE";
18
+ };
19
+ export declare function loadFileConfig(defaults?: DevLensConfig): DevLensConfig;
@@ -0,0 +1,215 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { ANTHROPIC_DEFAULTS, } from "../types.js";
5
+ // ─── Constants ────────────────────────────────────────────────────────────────
6
+ export const CONFIG_DIR = path.join(os.homedir(), ".devlens");
7
+ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
8
+ // ─── Environment Variable Names ───────────────────────────────────────────────
9
+ //
10
+ // For Docker users who prefer env vars over config files.
11
+ // A Docker user running Ollama in the same network would set:
12
+ // DEVLENS_LLM_PROVIDER=ollama
13
+ // DEVLENS_LLM_BASE_URL=http://ollama:11434
14
+ //
15
+ // Priority: config file wins over env vars.
16
+ // Env vars only fill fields that the config file left empty.
17
+ export const ENV = {
18
+ // Summarization
19
+ LLM_PROVIDER: "DEVLENS_LLM_PROVIDER", //here DEVLENS_LLM_PROVIDER is the actual env variable
20
+ LLM_MODEL: "DEVLENS_LLM_MODEL",
21
+ LLM_KEY: "DEVLENS_LLM_KEY",
22
+ LLM_BASE_URL: "DEVLENS_LLM_BASE_URL",
23
+ BATCH_SIZE: "DEVLENS_BATCH_SIZE",
24
+ // Embedding
25
+ EMBED_PROVIDER: "DEVLENS_EMBED_PROVIDER",
26
+ EMBED_MODEL: "DEVLENS_EMBED_MODEL",
27
+ EMBED_KEY: "DEVLENS_EMBED_KEY",
28
+ EMBED_BASE_URL: "DEVLENS_EMBED_BASE_URL",
29
+ // Neo4j
30
+ NEO4J_URL: "DEVLENS_NEO4J_URL",
31
+ NEO4J_USER: "DEVLENS_NEO4J_USER",
32
+ NEO4J_PASSWORD: "DEVLENS_NEO4J_PASSWORD",
33
+ NEO4J_STORECODE: "NEO4J_STORE_CODE"
34
+ };
35
+ // ─── Deep Merge ───────────────────────────────────────────────────────────────
36
+ //
37
+ // Merges user's partial config on top of defaults.
38
+ // Does NOT mutate either argument — returns a new object.
39
+ //
40
+ // Example:
41
+ // base: { summarization: { provider: "anthropic", model: "haiku", batchSize: 50 } }
42
+ // partial: { summarization: { apiKey: "sk-ant-..." } }
43
+ // result: { summarization: { provider: "anthropic", model: "haiku",
44
+ // batchSize: 50, apiKey: "sk-ant-..." } }
45
+ function deepMerge(base, partial) {
46
+ return {
47
+ deploymentMode: partial.deploymentMode ?? base.deploymentMode,
48
+ summarization: {
49
+ ...base.summarization,
50
+ ...partial.summarization,
51
+ },
52
+ embedding: {
53
+ ...base.embedding,
54
+ ...partial.embedding,
55
+ },
56
+ // Neo4j: if user provides any neo4j fields, merge on top of base.
57
+ // If user provides nothing, keep base (which may be undefined).
58
+ neo4j: partial.neo4j
59
+ ? { ...(base.neo4j ?? {}), ...partial.neo4j }
60
+ : base.neo4j,
61
+ };
62
+ }
63
+ // ─── Env Var Application ──────────────────────────────────────────────────────
64
+ //
65
+ // Applies environment variables onto an already-merged config.
66
+ // Only fills fields that are still empty after the file merge.
67
+ // Config file always wins — env vars never override explicit file values.
68
+ //
69
+ // This runs AFTER deepMerge so the priority is:
70
+ // config file > env vars > defaults
71
+ function applyEnvVars(config) {
72
+ const s = config.summarization;
73
+ const e = config.embedding;
74
+ return {
75
+ ...config,
76
+ summarization: {
77
+ ...s,
78
+ // Only inject env var if config file didn't already set this field
79
+ provider: s.provider !== ANTHROPIC_DEFAULTS.summarization.provider
80
+ ? s.provider
81
+ : process.env[ENV.LLM_PROVIDER] ?? s.provider,
82
+ model: s.model ?? process.env[ENV.LLM_MODEL],
83
+ apiKey: s.apiKey ?? process.env[ENV.LLM_KEY],
84
+ baseUrl: s.baseUrl ?? process.env[ENV.LLM_BASE_URL],
85
+ batchSize: s.batchSize ?? (parseInt(process.env[ENV.BATCH_SIZE] ?? "", 10) ?? s.batchSize),
86
+ },
87
+ embedding: {
88
+ ...e,
89
+ provider: e.provider !== ANTHROPIC_DEFAULTS.embedding.provider
90
+ ? e.provider
91
+ : process.env[ENV.EMBED_PROVIDER] ?? e.provider,
92
+ model: e.model ?? process.env[ENV.EMBED_MODEL],
93
+ apiKey: e.apiKey ?? process.env[ENV.EMBED_KEY],
94
+ baseUrl: e.baseUrl ?? process.env[ENV.EMBED_BASE_URL],
95
+ },
96
+ // Neo4j: only build from env vars if config file didn't set it
97
+ // AND all three required env vars are present
98
+ neo4j: config.neo4j ?? buildNeo4jFromEnv(),
99
+ };
100
+ }
101
+ // Builds a Neo4jConfig purely from env vars.
102
+ // Returns undefined if any of the three required vars is missing —
103
+ // we never create a partial Neo4j config.
104
+ function buildNeo4jFromEnv() {
105
+ const url = process.env[ENV.NEO4J_URL];
106
+ const username = process.env[ENV.NEO4J_USER];
107
+ const password = process.env[ENV.NEO4J_PASSWORD];
108
+ const storeRawCode = process.env[ENV.NEO4J_STORECODE] == "true";
109
+ if (!url || !username || !password)
110
+ return undefined;
111
+ return { url, username, password, storeRawCode };
112
+ }
113
+ // ─── Validation ───────────────────────────────────────────────────────────────
114
+ //
115
+ // Only validates what cannot have a sensible default.
116
+ // apiKey is required for all cloud providers (anthropic, openai, openrouter, gemini).
117
+ // ollama needs no apiKey — it uses baseUrl.
118
+ // managed never needs an apiKey — platform provides it via request headers.
119
+ //
120
+ // Error messages are actionable — they tell the user exactly how to fix the problem.
121
+ const PROVIDERS_NEEDING_KEY = new Set([
122
+ "anthropic",
123
+ "openai",
124
+ "openrouter",
125
+ "gemini",
126
+ ]);
127
+ function validate(config) {
128
+ const { summarization, embedding } = config;
129
+ // Summarization apiKey
130
+ if (PROVIDERS_NEEDING_KEY.has(summarization.provider) &&
131
+ !summarization.apiKey) {
132
+ throw new Error(`DevLens config error: summarization.apiKey is required when provider is "${summarization.provider}".\n` +
133
+ ` Fix option 1 — add to ${CONFIG_FILE}:\n` +
134
+ ` { "summarization": { "apiKey": "your-key-here" } }\n` +
135
+ ` Fix option 2 — set environment variable:\n` +
136
+ ` ${ENV.LLM_KEY}=your-key-here \n` +
137
+ `Fix option 3 - Skip Summarization`);
138
+ }
139
+ // Embedding apiKey — only validate if the user explicitly configured embedding.
140
+ // If the user only set summarization, embedding may still be at default (openai
141
+ // with no key) which is fine — embedding is only needed for vector search (cloud).
142
+ const rawFile = readFileConfig();
143
+ const userSetEmbedding = !!rawFile.embedding?.provider;
144
+ if (userSetEmbedding &&
145
+ PROVIDERS_NEEDING_KEY.has(embedding.provider) &&
146
+ !embedding.apiKey) {
147
+ throw new Error(`DevLens config error: embedding.apiKey is required when provider is "${embedding.provider}".\n` +
148
+ ` Fix option 1 — add to ${CONFIG_FILE}:\n` +
149
+ ` { "embedding": { "apiKey": "your-key-here" } }\n` +
150
+ ` Fix option 2 — set environment variable:\n` +
151
+ ` ${ENV.EMBED_KEY}=your-key-here`);
152
+ }
153
+ // Neo4j — if any field is provided, all three must be present
154
+ if (config.neo4j) {
155
+ const { url, username, password } = config.neo4j;
156
+ if (!url || !username || !password) {
157
+ throw new Error(`DevLens config error: neo4j config is incomplete.\n` +
158
+ ` All three fields are required: url, username, password.\n` +
159
+ ` Fix option 1 — update ${CONFIG_FILE}:\n` +
160
+ ` { "neo4j": { "url": "bolt://localhost:7687", "username": "neo4j", "password": "..." } }\n` +
161
+ ` Fix option 2 — set environment variables:\n` +
162
+ ` ${ENV.NEO4J_URL}=bolt://localhost:7687\n` +
163
+ ` ${ENV.NEO4J_USER}=neo4j\n` +
164
+ ` ${ENV.NEO4J_PASSWORD}=your-password`);
165
+ }
166
+ }
167
+ // Ollama baseUrl format
168
+ if (summarization.provider === "ollama") {
169
+ const base = summarization.baseUrl ?? "http://localhost:11434";
170
+ if (!base.startsWith("http://") && !base.startsWith("https://")) {
171
+ throw new Error(`DevLens config error: summarization.baseUrl must start with http:// or https://.\n` +
172
+ ` Got: "${base}"`);
173
+ }
174
+ }
175
+ }
176
+ // ─── readFileConfig ───────────────────────────────────────────────────────────
177
+ //
178
+ // Reads ~/.devlens/config.json and returns a PartialConfig.
179
+ // Returns empty object if file doesn't exist — first run, caller uses defaults.
180
+ // Throws a clear parse error if file exists but contains invalid JSON.
181
+ function readFileConfig() {
182
+ if (!fs.existsSync(CONFIG_FILE)) {
183
+ return {}; // first run — no config file yet
184
+ }
185
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
186
+ try {
187
+ return JSON.parse(raw);
188
+ }
189
+ catch {
190
+ throw new Error(`DevLens config error: ${CONFIG_FILE} contains invalid JSON.\n` +
191
+ ` Fix the syntax and restart DevLens.\n` +
192
+ ` Tip: use a JSON validator at https://jsonlint.com`);
193
+ }
194
+ }
195
+ // ─── loadFileConfig ───────────────────────────────────────────────────────────
196
+ //
197
+ // Public entry point — called by resolveConfig() in config/index.ts.
198
+ //
199
+ // Takes the active defaults (chosen by detectOllama() in index.ts):
200
+ // - OLLAMA_DEFAULTS if Ollama is running at startup
201
+ // - ANTHROPIC_DEFAULTS if Ollama is not detected
202
+ //
203
+ // Steps:
204
+ // 1. Read ~/.devlens/config.json (partial — only what user set)
205
+ // 2. Deep merge onto provided defaults
206
+ // 3. Apply env vars for any still-missing fields
207
+ // 4. Validate — throw clear errors for anything missing or invalid
208
+ // 5. Return fully resolved DevLensConfig — never partial, never undefined fields
209
+ export function loadFileConfig(defaults = ANTHROPIC_DEFAULTS) {
210
+ const partial = readFileConfig();
211
+ const merged = deepMerge(defaults, partial);
212
+ const withEnv = applyEnvVars(merged);
213
+ validate(withEnv);
214
+ return withEnv;
215
+ }
@@ -0,0 +1,2 @@
1
+ import { type DevLensConfig } from "../types.js";
2
+ export declare function applyRequestHeaders(base: DevLensConfig, req: Request): DevLensConfig;
@@ -0,0 +1,72 @@
1
+ import { CONFIG_HEADERS } from "../types.js";
2
+ // ─ applyRequestHeaders
3
+ //
4
+ // Public — called by resolveConfig() in config/index.ts AFTER loadFileConfig().
5
+ //
6
+ // Takes the fully resolved file config as a base and overrides only the fields
7
+ // that are explicitly present in the request headers.
8
+ //
9
+ // Headers that are absent = keep the file config value unchanged.
10
+ // Headers that are present = override the file config value.
11
+ //
12
+ // This means a cloud user who only sends x-llm-key gets everything else
13
+ // (model, batchSize, etc.) from their file config or defaults.
14
+ //
15
+ // IMPORTANT: This function must never be called in local deploymentMode.
16
+ // The guard lives in resolveConfig() in index.ts — not here.
17
+ // This function trusts that its caller checked deploymentMode first.
18
+ //
19
+ // Priority after this runs:
20
+ // request headers > file config > env vars > defaults
21
+ export function applyRequestHeaders(base, req) {
22
+ const h = req.headers;
23
+ // Helper — reads a header, returns undefined if absent or empty string
24
+ function get(name) {
25
+ const val = h.get(name);
26
+ return (val && val.trim() !== "") ? val.trim() : undefined;
27
+ }
28
+ // Summarization overrides ─
29
+ const provider = get(CONFIG_HEADERS.PROVIDER);
30
+ const model = get(CONFIG_HEADERS.MODEL);
31
+ const apiKey = get(CONFIG_HEADERS.API_KEY);
32
+ const baseUrl = get(CONFIG_HEADERS.BASE_URL);
33
+ const batchSize = get(CONFIG_HEADERS.BATCH_SIZE);
34
+ // Embedding overrides ─
35
+ const embedProvider = get(CONFIG_HEADERS.EMBED_PROVIDER);
36
+ const embedModel = get(CONFIG_HEADERS.EMBED_MODEL);
37
+ const embedKey = get(CONFIG_HEADERS.EMBED_KEY);
38
+ const embedBaseUrl = get(CONFIG_HEADERS.EMBED_BASE_URL);
39
+ // Neo4j overrides ─
40
+ const neo4jUrl = get(CONFIG_HEADERS.NEO4J_URL);
41
+ const neo4jUser = get(CONFIG_HEADERS.NEO4J_USER);
42
+ const neo4jPassword = get(CONFIG_HEADERS.NEO4J_PASSWORD);
43
+ const neo4jStoreCode = get(CONFIG_HEADERS.NEO4J_STORECODE) == "true";
44
+ // Build the final config — only override what headers explicitly provided
45
+ const result = {
46
+ // deploymentMode is never overridden by headers —
47
+ // it is set by the server at startup, not by the cloud backend
48
+ deploymentMode: base.deploymentMode,
49
+ summarization: {
50
+ ...base.summarization,
51
+ ...(provider && { provider: provider }),
52
+ ...(model && { model }),
53
+ ...(apiKey && { apiKey }),
54
+ ...(baseUrl && { baseUrl }),
55
+ ...(batchSize && { batchSize: parseInt(batchSize, 10) }),
56
+ },
57
+ embedding: {
58
+ ...base.embedding,
59
+ ...(embedProvider && { provider: embedProvider }),
60
+ ...(embedModel && { model: embedModel }),
61
+ ...(embedKey && { apiKey: embedKey }),
62
+ ...(embedBaseUrl && { baseUrl: embedBaseUrl }),
63
+ },
64
+ // Neo4j: only override if ALL THREE headers are present
65
+ // Partial Neo4j config from headers is never valid —
66
+ // if only one header is sent we keep the base neo4j config untouched
67
+ neo4j: (neo4jUrl && neo4jUser && neo4jPassword)
68
+ ? { url: neo4jUrl, username: neo4jUser, password: neo4jPassword, storeRawCode: neo4jStoreCode }
69
+ : base.neo4j,
70
+ };
71
+ return result;
72
+ }
@@ -0,0 +1,46 @@
1
+ export type LLMProvider = "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" | "managed";
2
+ export type EmbeddingProvider = "openai" | "anthropic" | "openrouter" | "gemini" | "ollama" | "managed";
3
+ export type DeploymentMode = "local" | "cloud";
4
+ export interface SummarizationConfig {
5
+ provider: LLMProvider;
6
+ model: string;
7
+ apiKey?: string;
8
+ baseUrl?: string;
9
+ batchSize: number;
10
+ }
11
+ export interface EmbeddingConfig {
12
+ provider: EmbeddingProvider;
13
+ model: string;
14
+ apiKey?: string;
15
+ baseUrl?: string;
16
+ }
17
+ export interface Neo4jConfig {
18
+ url: string;
19
+ username: string;
20
+ password: string;
21
+ storeRawCode: boolean;
22
+ }
23
+ export interface DevLensConfig {
24
+ deploymentMode: DeploymentMode;
25
+ summarization: SummarizationConfig;
26
+ embedding: EmbeddingConfig;
27
+ neo4j?: Neo4jConfig;
28
+ }
29
+ export declare const OLLAMA_DEFAULTS: DevLensConfig;
30
+ export declare const ANTHROPIC_DEFAULTS: DevLensConfig;
31
+ export declare const CONFIG_HEADERS: {
32
+ readonly PROVIDER: "x-llm-provider";
33
+ readonly MODEL: "x-llm-model";
34
+ readonly API_KEY: "x-llm-key";
35
+ readonly BASE_URL: "x-llm-base-url";
36
+ readonly BATCH_SIZE: "x-batch-size";
37
+ readonly EMBED_PROVIDER: "x-embed-provider";
38
+ readonly EMBED_MODEL: "x-embed-model";
39
+ readonly EMBED_KEY: "x-embed-key";
40
+ readonly EMBED_BASE_URL: "x-embed-base-url";
41
+ readonly NEO4J_URL: "x-neo4j-url";
42
+ readonly NEO4J_USER: "x-neo4j-user";
43
+ readonly NEO4J_PASSWORD: "x-neo4j-password";
44
+ readonly NEO4J_STORECODE: "false";
45
+ };
46
+ export declare function sanitizeHeaders(headers: Record<string, string>): Record<string, string>;