zencommit 0.1.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 (43) hide show
  1. package/README.md +421 -0
  2. package/bin/zencommit.js +37 -0
  3. package/package.json +68 -0
  4. package/scripts/install.mjs +146 -0
  5. package/scripts/platform.mjs +34 -0
  6. package/src/auth/secrets.ts +234 -0
  7. package/src/commands/auth.ts +138 -0
  8. package/src/commands/config.ts +83 -0
  9. package/src/commands/default.ts +322 -0
  10. package/src/commands/models.ts +67 -0
  11. package/src/config/load.test.ts +47 -0
  12. package/src/config/load.ts +118 -0
  13. package/src/config/merge.test.ts +25 -0
  14. package/src/config/merge.ts +30 -0
  15. package/src/config/types.ts +119 -0
  16. package/src/config/validate.ts +139 -0
  17. package/src/git/commit.ts +17 -0
  18. package/src/git/diff.ts +89 -0
  19. package/src/git/repo.ts +10 -0
  20. package/src/index.ts +207 -0
  21. package/src/llm/generate.ts +188 -0
  22. package/src/llm/prompt-template.ts +44 -0
  23. package/src/llm/prompt.ts +83 -0
  24. package/src/llm/prompts/base.md +119 -0
  25. package/src/llm/prompts/conventional.md +123 -0
  26. package/src/llm/prompts/gitmoji.md +212 -0
  27. package/src/llm/prompts/system.md +21 -0
  28. package/src/llm/providers.ts +102 -0
  29. package/src/llm/tokens.test.ts +22 -0
  30. package/src/llm/tokens.ts +46 -0
  31. package/src/llm/truncate.test.ts +60 -0
  32. package/src/llm/truncate.ts +552 -0
  33. package/src/metadata/cache.ts +28 -0
  34. package/src/metadata/index.ts +94 -0
  35. package/src/metadata/providers/local.ts +66 -0
  36. package/src/metadata/providers/modelsdev.ts +145 -0
  37. package/src/metadata/types.ts +20 -0
  38. package/src/ui/editor.ts +33 -0
  39. package/src/ui/prompts.ts +99 -0
  40. package/src/util/exec.ts +57 -0
  41. package/src/util/fs.ts +46 -0
  42. package/src/util/logger.ts +50 -0
  43. package/src/util/redact.ts +30 -0
@@ -0,0 +1,552 @@
1
+ import type { Tiktoken } from '@dqbd/tiktoken';
2
+ import type { DiffConfig } from '../config/types.js';
3
+ import { countTokens } from './tokens.js';
4
+
5
+ export type TruncateMode = 'full' | 'byFile' | 'smart' | 'summaryOnly';
6
+
7
+ export interface TruncateResult {
8
+ text: string;
9
+ usedTokens: number;
10
+ truncated: boolean;
11
+ mode: TruncateMode;
12
+ }
13
+
14
+ interface DiffHunk {
15
+ header: string;
16
+ lines: string[];
17
+ addedLines: string[];
18
+ removedLines: string[];
19
+ definitionLines: string[];
20
+ }
21
+
22
+ interface DiffFile {
23
+ path: string;
24
+ headerLines: string[];
25
+ hunks: DiffHunk[];
26
+ isBinary: boolean;
27
+ }
28
+
29
+ const TRUNCATION_MARKER = '... truncated';
30
+ const OMITTED_MARKER = (added: number, removed: number): string =>
31
+ `... omitted (+${added} additions, -${removed} deletions)`;
32
+
33
+ const isDefinitionLine = (line: string): boolean => {
34
+ if (!line.startsWith('+') || line.startsWith('+++')) {
35
+ return false;
36
+ }
37
+ const content = line.slice(1).trim();
38
+ return (
39
+ /^(export\s+)?(async\s+)?(function|class|interface|type)\b/.test(content) ||
40
+ /^def\s+/.test(content) ||
41
+ /^fn\s+/.test(content) ||
42
+ /^struct\s+/.test(content)
43
+ );
44
+ };
45
+
46
+ const parseDiff = (diffText: string): DiffFile[] => {
47
+ const lines = diffText.split(/\r?\n/);
48
+ const files: DiffFile[] = [];
49
+ let currentFile: DiffFile | null = null;
50
+ let currentHunk: DiffHunk | null = null;
51
+
52
+ const finalizeFile = () => {
53
+ if (currentFile) {
54
+ files.push(currentFile);
55
+ }
56
+ };
57
+
58
+ for (const line of lines) {
59
+ if (line.startsWith('diff --git')) {
60
+ finalizeFile();
61
+ const match = /^diff --git\s+(\S+)\s+(\S+)/.exec(line);
62
+ const pathRaw = match?.[2] ?? match?.[1] ?? 'unknown';
63
+ const path = pathRaw.replace(/^a\//, '').replace(/^b\//, '');
64
+ currentFile = {
65
+ path,
66
+ headerLines: [line],
67
+ hunks: [],
68
+ isBinary: false,
69
+ };
70
+ currentHunk = null;
71
+ continue;
72
+ }
73
+
74
+ if (!currentFile) {
75
+ continue;
76
+ }
77
+
78
+ if (line.startsWith('index ')) {
79
+ continue;
80
+ }
81
+
82
+ if (line.startsWith('Binary files')) {
83
+ currentFile.isBinary = true;
84
+ currentFile.headerLines.push(line);
85
+ continue;
86
+ }
87
+
88
+ if (line.startsWith('@@')) {
89
+ currentHunk = {
90
+ header: line,
91
+ lines: [],
92
+ addedLines: [],
93
+ removedLines: [],
94
+ definitionLines: [],
95
+ };
96
+ currentFile.hunks.push(currentHunk);
97
+ continue;
98
+ }
99
+
100
+ if (currentHunk) {
101
+ currentHunk.lines.push(line);
102
+ if (line.startsWith('+') && !line.startsWith('+++')) {
103
+ currentHunk.addedLines.push(line);
104
+ if (isDefinitionLine(line)) {
105
+ currentHunk.definitionLines.push(line);
106
+ }
107
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
108
+ currentHunk.removedLines.push(line);
109
+ }
110
+ continue;
111
+ }
112
+
113
+ currentFile.headerLines.push(line);
114
+ }
115
+
116
+ finalizeFile();
117
+ return files;
118
+ };
119
+
120
+ const tokensForLines = (lines: string[], encoding: Tiktoken): number => {
121
+ if (lines.length === 0) {
122
+ return 0;
123
+ }
124
+ return countTokens(`${lines.join('\n')}\n`, encoding);
125
+ };
126
+
127
+ const truncateLinesToBudget = (lines: string[], budget: number, encoding: Tiktoken): string[] => {
128
+ const output: string[] = [];
129
+ let used = 0;
130
+ for (const line of lines) {
131
+ const lineTokens = countTokens(`${line}\n`, encoding);
132
+ if (used + lineTokens > budget) {
133
+ break;
134
+ }
135
+ output.push(line);
136
+ used += lineTokens;
137
+ }
138
+ return output;
139
+ };
140
+
141
+ const buildByFileSection = (
142
+ file: DiffFile,
143
+ tokenBudget: number,
144
+ encoding: Tiktoken,
145
+ ): { lines: string[]; truncated: boolean } => {
146
+ const lines: string[] = [];
147
+ let truncated = false;
148
+ let usedTokens = 0;
149
+
150
+ const headerTokens = tokensForLines(file.headerLines, encoding);
151
+ if (headerTokens > tokenBudget) {
152
+ const trimmedHeader = truncateLinesToBudget(file.headerLines, tokenBudget, encoding);
153
+ return { lines: trimmedHeader, truncated: true };
154
+ }
155
+
156
+ lines.push(...file.headerLines);
157
+ usedTokens += headerTokens;
158
+
159
+ for (const hunk of file.hunks) {
160
+ const hunkHeaderTokens = countTokens(`${hunk.header}\n`, encoding);
161
+ if (usedTokens + hunkHeaderTokens > tokenBudget) {
162
+ truncated = true;
163
+ break;
164
+ }
165
+ lines.push(hunk.header);
166
+ usedTokens += hunkHeaderTokens;
167
+
168
+ for (const line of hunk.lines) {
169
+ if (line.startsWith(' ') || line.startsWith('+++') || line.startsWith('---')) {
170
+ continue;
171
+ }
172
+ const lineTokens = countTokens(`${line}\n`, encoding);
173
+ if (usedTokens + lineTokens > tokenBudget) {
174
+ truncated = true;
175
+ break;
176
+ }
177
+ lines.push(line);
178
+ usedTokens += lineTokens;
179
+ }
180
+
181
+ if (truncated) {
182
+ break;
183
+ }
184
+ }
185
+
186
+ if (truncated) {
187
+ lines.push(TRUNCATION_MARKER);
188
+ }
189
+
190
+ return { lines, truncated };
191
+ };
192
+
193
+ const getFileTokenSize = (file: DiffFile, encoding: Tiktoken): number => {
194
+ const allLines: string[] = [...file.headerLines];
195
+ for (const hunk of file.hunks) {
196
+ allLines.push(hunk.header, ...hunk.lines.filter((line) => !line.startsWith(' ')));
197
+ }
198
+ return tokensForLines(allLines, encoding);
199
+ };
200
+
201
+ export const truncateDiffByFile = (
202
+ diffText: string,
203
+ budgetTokens: number,
204
+ encoding: Tiktoken,
205
+ ): TruncateResult => {
206
+ if (budgetTokens <= 0) {
207
+ return { text: '', usedTokens: 0, truncated: true, mode: 'summaryOnly' };
208
+ }
209
+
210
+ const files = parseDiff(diffText);
211
+ const fileSizes = files.map((file) => getFileTokenSize(file, encoding));
212
+ const minQuota = 120;
213
+ const allocations: number[] = Array.from({ length: files.length }, () => 0);
214
+ let remaining = budgetTokens;
215
+
216
+ for (let i = 0; i < files.length; i += 1) {
217
+ const quota = Math.min(fileSizes[i] ?? 0, minQuota);
218
+ const allocation = Math.min(quota, remaining);
219
+ allocations[i] = allocation;
220
+ remaining -= allocation;
221
+ if (remaining <= 0) {
222
+ break;
223
+ }
224
+ }
225
+
226
+ if (remaining > 0) {
227
+ const extras = fileSizes.map((size, index) => Math.max(0, size - allocations[index]));
228
+ const extrasTotal = extras.reduce((sum, size) => sum + size, 0);
229
+ for (let i = 0; i < files.length; i += 1) {
230
+ if (remaining <= 0) {
231
+ break;
232
+ }
233
+ const extraShare = extrasTotal > 0 ? Math.floor((extras[i] / extrasTotal) * remaining) : 0;
234
+ allocations[i] += extraShare;
235
+ }
236
+ }
237
+
238
+ const outputLines: string[] = [];
239
+ let truncated = false;
240
+ for (let i = 0; i < files.length; i += 1) {
241
+ const allocation = allocations[i] ?? 0;
242
+ if (allocation <= 0) {
243
+ truncated = true;
244
+ continue;
245
+ }
246
+ const section = buildByFileSection(files[i], allocation, encoding);
247
+ if (outputLines.length > 0 && section.lines.length > 0) {
248
+ outputLines.push('');
249
+ }
250
+ outputLines.push(...section.lines);
251
+ truncated = truncated || section.truncated;
252
+ }
253
+
254
+ const text = outputLines.join('\n');
255
+ const usedTokens = countTokens(text, encoding);
256
+
257
+ return {
258
+ text,
259
+ usedTokens,
260
+ truncated,
261
+ mode: truncated ? 'byFile' : 'full',
262
+ };
263
+ };
264
+
265
+ const GENERATED_PATH_PATTERNS = [
266
+ /node_modules\//,
267
+ /dist\//,
268
+ /build\//,
269
+ /vendor\//,
270
+ /coverage\//,
271
+ /\.min\./,
272
+ /package-lock\.json$/,
273
+ /pnpm-lock\.yaml$/,
274
+ /yarn\.lock$/,
275
+ /bun\.lock$/,
276
+ ];
277
+
278
+ const SOURCE_EXTENSIONS = new Set([
279
+ 'ts',
280
+ 'tsx',
281
+ 'js',
282
+ 'jsx',
283
+ 'mjs',
284
+ 'cjs',
285
+ 'py',
286
+ 'go',
287
+ 'rs',
288
+ 'java',
289
+ 'kt',
290
+ 'swift',
291
+ 'cpp',
292
+ 'c',
293
+ 'h',
294
+ 'hpp',
295
+ 'cs',
296
+ 'rb',
297
+ 'php',
298
+ 'scala',
299
+ 'clj',
300
+ ]);
301
+
302
+ const DOC_EXTENSIONS = new Set(['md', 'txt', 'rst']);
303
+
304
+ const isGeneratedPath = (filePath: string): boolean =>
305
+ GENERATED_PATH_PATTERNS.some((pattern) => pattern.test(filePath));
306
+
307
+ const getExtension = (filePath: string): string | null => {
308
+ const parts = filePath.split('.');
309
+ if (parts.length <= 1) {
310
+ return null;
311
+ }
312
+ return parts[parts.length - 1]?.toLowerCase() ?? null;
313
+ };
314
+
315
+ const scoreHunk = (filePath: string, hunk: DiffHunk): number => {
316
+ let score = 1;
317
+ const ext = getExtension(filePath);
318
+ if (isGeneratedPath(filePath)) {
319
+ score -= 2;
320
+ }
321
+ if (ext && SOURCE_EXTENSIONS.has(ext)) {
322
+ score += 1;
323
+ }
324
+ if (ext && DOC_EXTENSIONS.has(ext)) {
325
+ score -= 0.5;
326
+ }
327
+ score += Math.min(2, hunk.definitionLines.length * 0.5);
328
+ const changes = hunk.addedLines.length + hunk.removedLines.length;
329
+ score += 1 / (1 + changes / 10);
330
+ return score;
331
+ };
332
+
333
+ interface HunkSelection {
334
+ fileIndex: number;
335
+ hunkIndex: number;
336
+ score: number;
337
+ }
338
+
339
+ const selectHunkLines = (
340
+ hunk: DiffHunk,
341
+ config: DiffConfig,
342
+ ): { lines: string[]; omittedAdded: number; omittedRemoved: number } => {
343
+ const selected: string[] = [];
344
+ let addedCount = 0;
345
+ let removedCount = 0;
346
+ let omittedAdded = 0;
347
+ let omittedRemoved = 0;
348
+
349
+ for (const line of hunk.lines) {
350
+ if (line.startsWith(' ') || line.startsWith('+++') || line.startsWith('---')) {
351
+ continue;
352
+ }
353
+ const isAdd = line.startsWith('+');
354
+ const isRemove = line.startsWith('-');
355
+ const isDefinition = isDefinitionLine(line);
356
+
357
+ if (isAdd) {
358
+ if (isDefinition || addedCount < config.smart.maxAddedLinesPerHunk) {
359
+ selected.push(line);
360
+ if (!isDefinition) {
361
+ addedCount += 1;
362
+ }
363
+ } else {
364
+ omittedAdded += 1;
365
+ }
366
+ } else if (isRemove) {
367
+ if (removedCount < config.smart.maxRemovedLinesPerHunk) {
368
+ selected.push(line);
369
+ removedCount += 1;
370
+ } else {
371
+ omittedRemoved += 1;
372
+ }
373
+ }
374
+ }
375
+
376
+ return { lines: selected, omittedAdded, omittedRemoved };
377
+ };
378
+
379
+ const buildSmartDiff = (
380
+ files: DiffFile[],
381
+ selections: HunkSelection[],
382
+ config: DiffConfig,
383
+ ): string => {
384
+ const selectedByFile = new Map<number, Set<number>>();
385
+ for (const selection of selections) {
386
+ if (!selectedByFile.has(selection.fileIndex)) {
387
+ selectedByFile.set(selection.fileIndex, new Set());
388
+ }
389
+ selectedByFile.get(selection.fileIndex)?.add(selection.hunkIndex);
390
+ }
391
+
392
+ const lines: string[] = [];
393
+ files.forEach((file, fileIndex) => {
394
+ if (file.isBinary) {
395
+ lines.push(`BINARY FILE CHANGED (${file.path})`);
396
+ lines.push('');
397
+ return;
398
+ }
399
+
400
+ const selectedHunks = selectedByFile.get(fileIndex);
401
+ if (!selectedHunks || selectedHunks.size === 0) {
402
+ return;
403
+ }
404
+
405
+ lines.push(...file.headerLines);
406
+ file.hunks.forEach((hunk, hunkIndex) => {
407
+ if (!selectedHunks.has(hunkIndex)) {
408
+ return;
409
+ }
410
+ const selection = selectHunkLines(hunk, config);
411
+ lines.push(hunk.header);
412
+ lines.push(...selection.lines);
413
+ if (selection.omittedAdded + selection.omittedRemoved > 0) {
414
+ lines.push(OMITTED_MARKER(selection.omittedAdded, selection.omittedRemoved));
415
+ }
416
+ });
417
+ lines.push('');
418
+ });
419
+
420
+ return lines.join('\n').trim();
421
+ };
422
+
423
+ const buildHeadersOnlyDiff = (files: DiffFile[], selections: HunkSelection[]): string => {
424
+ const selectedByFile = new Map<number, Set<number>>();
425
+ for (const selection of selections) {
426
+ if (!selectedByFile.has(selection.fileIndex)) {
427
+ selectedByFile.set(selection.fileIndex, new Set());
428
+ }
429
+ selectedByFile.get(selection.fileIndex)?.add(selection.hunkIndex);
430
+ }
431
+
432
+ const lines: string[] = [];
433
+ files.forEach((file, fileIndex) => {
434
+ const selectedHunks = selectedByFile.get(fileIndex);
435
+ if (!selectedHunks || selectedHunks.size === 0) {
436
+ return;
437
+ }
438
+
439
+ if (file.isBinary) {
440
+ lines.push(`BINARY FILE CHANGED (${file.path})`);
441
+ lines.push('');
442
+ return;
443
+ }
444
+
445
+ lines.push(...file.headerLines);
446
+ file.hunks.forEach((hunk, hunkIndex) => {
447
+ if (selectedHunks.has(hunkIndex)) {
448
+ lines.push(hunk.header);
449
+ }
450
+ });
451
+ lines.push('');
452
+ });
453
+
454
+ return lines.join('\n').trim();
455
+ };
456
+
457
+ export const truncateDiffSmart = (
458
+ fileSummary: string,
459
+ diffText: string,
460
+ budgetTokens: number,
461
+ config: DiffConfig,
462
+ encoding: Tiktoken,
463
+ ): TruncateResult => {
464
+ if (budgetTokens <= 0) {
465
+ return {
466
+ text: fileSummary.trim(),
467
+ usedTokens: countTokens(fileSummary, encoding),
468
+ truncated: true,
469
+ mode: 'summaryOnly',
470
+ };
471
+ }
472
+
473
+ const summaryBlock = fileSummary.trim() ? `File summary:\n${fileSummary.trim()}\n` : '';
474
+ const summaryTokens = countTokens(summaryBlock, encoding);
475
+ if (summaryTokens >= budgetTokens) {
476
+ const trimmed = truncateLinesToBudget(summaryBlock.split(/\r?\n/), budgetTokens, encoding).join(
477
+ '\n',
478
+ );
479
+ return {
480
+ text: trimmed,
481
+ usedTokens: countTokens(trimmed, encoding),
482
+ truncated: true,
483
+ mode: 'summaryOnly',
484
+ };
485
+ }
486
+
487
+ const files = parseDiff(diffText);
488
+ const hunks: HunkSelection[] = [];
489
+
490
+ files.forEach((file, fileIndex) => {
491
+ file.hunks.forEach((hunk, hunkIndex) => {
492
+ hunks.push({ fileIndex, hunkIndex, score: scoreHunk(file.path, hunk) });
493
+ });
494
+ });
495
+
496
+ hunks.sort((a, b) => b.score - a.score);
497
+
498
+ const selections: HunkSelection[] = [];
499
+ let usedTokens = summaryTokens;
500
+ const selectedFiles = new Set<number>();
501
+
502
+ for (const hunk of hunks) {
503
+ const file = files[hunk.fileIndex];
504
+ const hunkData = file?.hunks[hunk.hunkIndex];
505
+ if (!file || !hunkData) {
506
+ continue;
507
+ }
508
+
509
+ const headerTokens = selectedFiles.has(hunk.fileIndex)
510
+ ? 0
511
+ : tokensForLines(file.headerLines, encoding);
512
+ const selectionLines = selectHunkLines(hunkData, config);
513
+ const hunkLines = [hunkData.header, ...selectionLines.lines];
514
+ if (selectionLines.omittedAdded + selectionLines.omittedRemoved > 0) {
515
+ hunkLines.push(OMITTED_MARKER(selectionLines.omittedAdded, selectionLines.omittedRemoved));
516
+ }
517
+ const hunkTokens = tokensForLines(hunkLines, encoding);
518
+
519
+ if (usedTokens + headerTokens + hunkTokens > budgetTokens) {
520
+ continue;
521
+ }
522
+
523
+ selections.push(hunk);
524
+ usedTokens += headerTokens + hunkTokens;
525
+ selectedFiles.add(hunk.fileIndex);
526
+ }
527
+
528
+ let diffBody = buildSmartDiff(files, selections, config);
529
+ let diffTokens = countTokens(diffBody, encoding);
530
+ let combined = `${summaryBlock}${diffBody}`.trim();
531
+ let combinedTokens = countTokens(combined, encoding);
532
+
533
+ if (combinedTokens > budgetTokens) {
534
+ diffBody = buildHeadersOnlyDiff(files, selections);
535
+ diffTokens = countTokens(diffBody, encoding);
536
+ combined = `${summaryBlock}${diffBody}`.trim();
537
+ combinedTokens = countTokens(combined, encoding);
538
+ }
539
+
540
+ if (combinedTokens > budgetTokens) {
541
+ combined = summaryBlock.trim();
542
+ combinedTokens = countTokens(combined, encoding);
543
+ return { text: combined, usedTokens: combinedTokens, truncated: true, mode: 'summaryOnly' };
544
+ }
545
+
546
+ return {
547
+ text: combined,
548
+ usedTokens: combinedTokens,
549
+ truncated: diffTokens < countTokens(diffText, encoding),
550
+ mode: 'smart',
551
+ };
552
+ };
@@ -0,0 +1,28 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ensureDir } from '../util/fs.js';
4
+
5
+ export const readCache = async (
6
+ cachePath: string,
7
+ ): Promise<{ data: unknown; mtimeMs: number } | null> => {
8
+ try {
9
+ const stat = await fs.stat(cachePath);
10
+ const content = await fs.readFile(cachePath, 'utf8');
11
+ return { data: JSON.parse(content), mtimeMs: stat.mtimeMs };
12
+ } catch {
13
+ return null;
14
+ }
15
+ };
16
+
17
+ export const writeCache = async (cachePath: string, data: unknown): Promise<void> => {
18
+ await ensureDir(path.dirname(cachePath));
19
+ await fs.writeFile(cachePath, `${JSON.stringify(data)}\n`, 'utf8');
20
+ };
21
+
22
+ export const isCacheFresh = (mtimeMs: number, ttlHours: number): boolean => {
23
+ if (!Number.isFinite(ttlHours)) {
24
+ return false;
25
+ }
26
+ const ttlMs = ttlHours * 60 * 60 * 1000;
27
+ return Date.now() - mtimeMs <= ttlMs;
28
+ };
@@ -0,0 +1,94 @@
1
+ import type { MetadataProvider, ModelMetadata } from './types.js';
2
+ import type { MetadataConfig } from '../config/types.js';
3
+ import { createModelsDevProvider } from './providers/modelsdev.js';
4
+ import { createLocalProvider } from './providers/local.js';
5
+
6
+ export interface MetadataResolver {
7
+ getModel(modelId: string): Promise<ModelMetadata | null>;
8
+ search(query: string, limit?: number): Promise<ModelMetadata[]>;
9
+ list?(): Promise<ModelMetadata[]>;
10
+ }
11
+
12
+ const normalizeLimit = (limit: number | undefined): number => {
13
+ if (!Number.isFinite(limit)) {
14
+ return Number.POSITIVE_INFINITY;
15
+ }
16
+ if (limit !== undefined && limit <= 0) {
17
+ return Number.POSITIVE_INFINITY;
18
+ }
19
+ return Math.floor(limit ?? Number.POSITIVE_INFINITY);
20
+ };
21
+
22
+ type ProviderKey = 'modelsdev' | 'local';
23
+
24
+ const buildProviders = (
25
+ config: MetadataConfig,
26
+ repoRoot: string | null,
27
+ ): Record<ProviderKey, MetadataProvider> => ({
28
+ modelsdev: createModelsDevProvider(config),
29
+ local: createLocalProvider(config, repoRoot),
30
+ });
31
+
32
+ export const createMetadataResolver = (
33
+ config: MetadataConfig,
34
+ repoRoot: string | null,
35
+ ): MetadataResolver => {
36
+ const providers = buildProviders(config, repoRoot);
37
+
38
+ const getProviderOrder = (): ProviderKey[] => {
39
+ if (config.provider === 'modelsdev') {
40
+ return ['modelsdev'];
41
+ }
42
+ if (config.provider === 'local') {
43
+ return ['local'];
44
+ }
45
+ return config.fallbackOrder;
46
+ };
47
+
48
+ return {
49
+ async getModel(modelId: string) {
50
+ const order = getProviderOrder();
51
+ for (const name of order) {
52
+ const provider = providers[name];
53
+ try {
54
+ const model = await provider.getModel(modelId);
55
+ if (model) {
56
+ return model;
57
+ }
58
+ } catch (error) {
59
+ console.warn(`Metadata provider ${name} failed: ${(error as Error).message}`);
60
+ }
61
+ }
62
+ return null;
63
+ },
64
+ async search(query: string, limit = 20) {
65
+ const resolvedLimit = normalizeLimit(limit);
66
+ const order = getProviderOrder();
67
+ const results: ModelMetadata[] = [];
68
+ for (const name of order) {
69
+ const provider = providers[name];
70
+ try {
71
+ const providerResults = await provider.search(query, resolvedLimit);
72
+ for (const model of providerResults) {
73
+ results.push(model);
74
+ if (results.length >= resolvedLimit) {
75
+ return results.slice(0, resolvedLimit);
76
+ }
77
+ }
78
+ } catch (error) {
79
+ console.warn(`Metadata provider ${name} failed: ${(error as Error).message}`);
80
+ }
81
+ }
82
+ return results.slice(0, resolvedLimit);
83
+ },
84
+ async list() {
85
+ const order = getProviderOrder();
86
+ const key = order[0];
87
+ const provider = key ? providers[key] : null;
88
+ if (!provider?.list) {
89
+ return [];
90
+ }
91
+ return await provider.list();
92
+ },
93
+ };
94
+ };