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.
- package/LICENSE +674 -0
- package/dist/clustering/index.d.ts +27 -0
- package/dist/clustering/index.js +149 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +78 -0
- package/dist/config/providers/file.d.ts +19 -0
- package/dist/config/providers/file.js +215 -0
- package/dist/config/providers/request.d.ts +2 -0
- package/dist/config/providers/request.js +72 -0
- package/dist/config/types.d.ts +46 -0
- package/dist/config/types.js +81 -0
- package/dist/config/writer.d.ts +29 -0
- package/dist/config/writer.js +103 -0
- package/dist/filesystem/appRouter.d.ts +2 -0
- package/dist/filesystem/appRouter.js +126 -0
- package/dist/filesystem/backendRoutes.d.ts +2 -0
- package/dist/filesystem/backendRoutes.js +161 -0
- package/dist/filesystem/index.d.ts +2 -0
- package/dist/filesystem/index.js +28 -0
- package/dist/filesystem/index.test.d.ts +1 -0
- package/dist/filesystem/index.test.js +178 -0
- package/dist/filesystem/pagesRouter.d.ts +2 -0
- package/dist/filesystem/pagesRouter.js +109 -0
- package/dist/fingerprint/detectors.d.ts +8 -0
- package/dist/fingerprint/detectors.js +174 -0
- package/dist/fingerprint/index.d.ts +2 -0
- package/dist/fingerprint/index.js +41 -0
- package/dist/fingerprint/index.test.d.ts +1 -0
- package/dist/fingerprint/index.test.js +148 -0
- package/dist/graph/buildLookup.d.ts +10 -0
- package/dist/graph/buildLookup.js +32 -0
- package/dist/graph/edges/callEdges.d.ts +7 -0
- package/dist/graph/edges/callEdges.js +145 -0
- package/dist/graph/edges/eventEdges.d.ts +7 -0
- package/dist/graph/edges/eventEdges.js +203 -0
- package/dist/graph/edges/guardEdges.d.ts +3 -0
- package/dist/graph/edges/guardEdges.js +232 -0
- package/dist/graph/edges/hookEdges.d.ts +3 -0
- package/dist/graph/edges/hookEdges.js +54 -0
- package/dist/graph/edges/importEdges.d.ts +8 -0
- package/dist/graph/edges/importEdges.js +224 -0
- package/dist/graph/edges/propEdges.d.ts +3 -0
- package/dist/graph/edges/propEdges.js +142 -0
- package/dist/graph/edges/routeEdge.d.ts +3 -0
- package/dist/graph/edges/routeEdge.js +124 -0
- package/dist/graph/edges/stateEdges.d.ts +3 -0
- package/dist/graph/edges/stateEdges.js +206 -0
- package/dist/graph/edges/testEdges.d.ts +3 -0
- package/dist/graph/edges/testEdges.js +143 -0
- package/dist/graph/edges/utils.d.ts +2 -0
- package/dist/graph/edges/utils.js +25 -0
- package/dist/graph/index.d.ts +6 -0
- package/dist/graph/index.js +65 -0
- package/dist/graph/index.test.d.ts +1 -0
- package/dist/graph/index.test.js +542 -0
- package/dist/graph/thirdPartyLibs.d.ts +8 -0
- package/dist/graph/thirdPartyLibs.js +162 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/jobs/index.d.ts +5 -0
- package/dist/jobs/index.js +11 -0
- package/dist/jobs/queue/interface.d.ts +13 -0
- package/dist/jobs/queue/interface.js +1 -0
- package/dist/jobs/queue/memory.d.ts +24 -0
- package/dist/jobs/queue/memory.js +291 -0
- package/dist/jobs/runner.d.ts +3 -0
- package/dist/jobs/runner.js +136 -0
- package/dist/jobs/types.d.ts +112 -0
- package/dist/jobs/types.js +33 -0
- package/dist/parser/directives.d.ts +4 -0
- package/dist/parser/directives.js +31 -0
- package/dist/parser/extractors/components.d.ts +5 -0
- package/dist/parser/extractors/components.js +240 -0
- package/dist/parser/extractors/functions.d.ts +4 -0
- package/dist/parser/extractors/functions.js +240 -0
- package/dist/parser/extractors/hooks.d.ts +4 -0
- package/dist/parser/extractors/hooks.js +128 -0
- package/dist/parser/extractors/stores.d.ts +3 -0
- package/dist/parser/extractors/stores.js +181 -0
- package/dist/parser/index.d.ts +14 -0
- package/dist/parser/index.js +168 -0
- package/dist/parser/index.test.d.ts +1 -0
- package/dist/parser/index.test.js +319 -0
- package/dist/parser/typeUtils.d.ts +9 -0
- package/dist/parser/typeUtils.js +46 -0
- package/dist/pipeline/index.d.ts +50 -0
- package/dist/pipeline/index.js +249 -0
- package/dist/scoring/connectionCounter.d.ts +28 -0
- package/dist/scoring/connectionCounter.js +134 -0
- package/dist/scoring/fileScorer.d.ts +2 -0
- package/dist/scoring/fileScorer.js +44 -0
- package/dist/scoring/index.d.ts +22 -0
- package/dist/scoring/index.js +130 -0
- package/dist/scoring/index.test.d.ts +1 -0
- package/dist/scoring/index.test.js +453 -0
- package/dist/scoring/nodeScorer.d.ts +3 -0
- package/dist/scoring/nodeScorer.js +108 -0
- package/dist/scoring/noiseFilter.d.ts +18 -0
- package/dist/scoring/noiseFilter.js +92 -0
- package/dist/storage/fileStorage.d.ts +117 -0
- package/dist/storage/fileStorage.js +616 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/interface.d.ts +27 -0
- package/dist/storage/interface.js +1 -0
- package/dist/summarizer/checkpoint.d.ts +15 -0
- package/dist/summarizer/checkpoint.js +110 -0
- package/dist/summarizer/index.d.ts +2 -0
- package/dist/summarizer/index.js +281 -0
- package/dist/summarizer/mapreduce.d.ts +4 -0
- package/dist/summarizer/mapreduce.js +87 -0
- package/dist/summarizer/prompts.d.ts +22 -0
- package/dist/summarizer/prompts.js +205 -0
- package/dist/summarizer/providers/anthropic.d.ts +9 -0
- package/dist/summarizer/providers/anthropic.js +78 -0
- package/dist/summarizer/providers/gemini.d.ts +9 -0
- package/dist/summarizer/providers/gemini.js +79 -0
- package/dist/summarizer/providers/index.d.ts +3 -0
- package/dist/summarizer/providers/index.js +43 -0
- package/dist/summarizer/providers/ollama.d.ts +9 -0
- package/dist/summarizer/providers/ollama.js +23 -0
- package/dist/summarizer/providers/openRouter.d.ts +9 -0
- package/dist/summarizer/providers/openRouter.js +19 -0
- package/dist/summarizer/providers/openai.d.ts +9 -0
- package/dist/summarizer/providers/openai.js +72 -0
- package/dist/summarizer/providers/types.d.ts +32 -0
- package/dist/summarizer/providers/types.js +1 -0
- package/dist/summarizer/retry.d.ts +7 -0
- package/dist/summarizer/retry.js +51 -0
- package/dist/summarizer/topological.d.ts +3 -0
- package/dist/summarizer/topological.js +105 -0
- package/dist/summarizer/types.d.ts +57 -0
- package/dist/summarizer/types.js +17 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +1 -0
- 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,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>;
|