btca-server 1.0.20

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.
@@ -0,0 +1,563 @@
1
+ import { promises as fs } from 'node:fs';
2
+
3
+ import { z } from 'zod';
4
+ import { Metrics } from '../metrics/index.ts';
5
+ import { ResourceDefinitionSchema, type ResourceDefinition } from '../resources/schema.ts';
6
+
7
+ export const GLOBAL_CONFIG_DIR = '~/.config/btca';
8
+ export const GLOBAL_CONFIG_FILENAME = 'btca.config.jsonc';
9
+ export const LEGACY_CONFIG_FILENAME = 'btca.json';
10
+ export const GLOBAL_DATA_DIR = '~/.local/share/btca';
11
+ export const PROJECT_CONFIG_FILENAME = 'btca.config.jsonc';
12
+ export const PROJECT_DATA_DIR = '.btca';
13
+ export const CONFIG_SCHEMA_URL = 'https://btca.dev/btca.schema.json';
14
+
15
+ export const DEFAULT_MODEL = 'claude-haiku-4-5';
16
+ export const DEFAULT_PROVIDER = 'opencode';
17
+
18
+ export const DEFAULT_RESOURCES: ResourceDefinition[] = [
19
+ {
20
+ name: 'svelte',
21
+ specialNotes:
22
+ 'This is the svelte docs website repo, not the actual svelte repo. Focus on the content directory, it has all the markdown files for the docs.',
23
+ type: 'git',
24
+ url: 'https://github.com/sveltejs/svelte.dev',
25
+ branch: 'main',
26
+ searchPath: 'apps/svelte.dev'
27
+ },
28
+ {
29
+ name: 'tailwindcss',
30
+ specialNotes:
31
+ 'This is the tailwindcss docs website repo, not the actual tailwindcss repo. Use the docs to answer questions about tailwindcss.',
32
+ type: 'git',
33
+ url: 'https://github.com/tailwindlabs/tailwindcss.com',
34
+ searchPath: 'src/docs',
35
+ branch: 'main'
36
+ },
37
+ {
38
+ type: 'git',
39
+ name: 'nextjs',
40
+ url: 'https://github.com/vercel/next.js',
41
+ branch: 'canary',
42
+ searchPath: 'docs',
43
+ specialNotes:
44
+ 'These are the docs for the next.js framework, not the actual next.js repo. Use the docs to answer questions about next.js.'
45
+ }
46
+ ];
47
+
48
+ const StoredConfigSchema = z.object({
49
+ $schema: z.string().optional(),
50
+ resources: z.array(ResourceDefinitionSchema),
51
+ model: z.string(),
52
+ provider: z.string()
53
+ });
54
+
55
+ type StoredConfig = z.infer<typeof StoredConfigSchema>;
56
+
57
+ // Legacy config schemas (btca.json format from old CLI)
58
+ // There are two legacy formats:
59
+ // 1. Very old: has "repos" array with git repos only
60
+ // 2. Intermediate: has "resources" array (already migrated repos->resources but different file name)
61
+
62
+ const LegacyRepoSchema = z.object({
63
+ name: z.string(),
64
+ url: z.string(),
65
+ branch: z.string(),
66
+ specialNotes: z.string().optional(),
67
+ searchPath: z.string().optional()
68
+ });
69
+
70
+ // Very old format with "repos"
71
+ const LegacyReposConfigSchema = z.object({
72
+ $schema: z.string().optional(),
73
+ reposDirectory: z.string().optional(),
74
+ workspacesDirectory: z.string().optional(),
75
+ dataDirectory: z.string().optional(),
76
+ port: z.number().optional(),
77
+ maxInstances: z.number().optional(),
78
+ repos: z.array(LegacyRepoSchema),
79
+ model: z.string(),
80
+ provider: z.string()
81
+ });
82
+
83
+ // Intermediate format with "resources" (same as new format, just different filename)
84
+ const LegacyResourcesConfigSchema = z.object({
85
+ $schema: z.string().optional(),
86
+ dataDirectory: z.string().optional(),
87
+ resources: z.array(ResourceDefinitionSchema),
88
+ model: z.string(),
89
+ provider: z.string()
90
+ });
91
+
92
+ type LegacyReposConfig = z.infer<typeof LegacyReposConfigSchema>;
93
+ type LegacyResourcesConfig = z.infer<typeof LegacyResourcesConfigSchema>;
94
+ type LegacyRepo = z.infer<typeof LegacyRepoSchema>;
95
+
96
+ export namespace Config {
97
+ export class ConfigError extends Error {
98
+ readonly _tag = 'ConfigError';
99
+ override readonly cause?: unknown;
100
+
101
+ constructor(args: { message: string; cause?: unknown }) {
102
+ super(args.message);
103
+ this.cause = args.cause;
104
+ }
105
+ }
106
+
107
+ export type Service = {
108
+ resourcesDirectory: string;
109
+ collectionsDirectory: string;
110
+ resources: readonly ResourceDefinition[];
111
+ model: string;
112
+ provider: string;
113
+ configPath: string;
114
+ getResource: (name: string) => ResourceDefinition | undefined;
115
+ updateModel: (provider: string, model: string) => Promise<{ provider: string; model: string }>;
116
+ addResource: (resource: ResourceDefinition) => Promise<ResourceDefinition>;
117
+ removeResource: (name: string) => Promise<void>;
118
+ clearResources: () => Promise<{ cleared: number }>;
119
+ };
120
+
121
+ const expandHome = (path: string): string => {
122
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
123
+ if (path.startsWith('~/')) return home + path.slice(1);
124
+ return path;
125
+ };
126
+
127
+ const stripJsonc = (content: string): string => {
128
+ // Remove // and /* */ comments without touching strings.
129
+ let out = '';
130
+ let i = 0;
131
+ let inString = false;
132
+ let quote: '"' | "'" | null = null;
133
+ let escaped = false;
134
+
135
+ while (i < content.length) {
136
+ const ch = content[i] ?? '';
137
+ const next = content[i + 1] ?? '';
138
+
139
+ if (inString) {
140
+ out += ch;
141
+ if (escaped) escaped = false;
142
+ else if (ch === '\\') escaped = true;
143
+ else if (quote && ch === quote) {
144
+ inString = false;
145
+ quote = null;
146
+ }
147
+ i += 1;
148
+ continue;
149
+ }
150
+
151
+ if (ch === '/' && next === '/') {
152
+ i += 2;
153
+ while (i < content.length && content[i] !== '\n') i += 1;
154
+ continue;
155
+ }
156
+
157
+ if (ch === '/' && next === '*') {
158
+ i += 2;
159
+ while (i < content.length) {
160
+ if (content[i] === '*' && content[i + 1] === '/') {
161
+ i += 2;
162
+ break;
163
+ }
164
+ i += 1;
165
+ }
166
+ continue;
167
+ }
168
+
169
+ if (ch === '"' || ch === "'") {
170
+ inString = true;
171
+ quote = ch;
172
+ out += ch;
173
+ i += 1;
174
+ continue;
175
+ }
176
+
177
+ out += ch;
178
+ i += 1;
179
+ }
180
+
181
+ // Remove trailing commas (outside strings).
182
+ let normalized = '';
183
+ inString = false;
184
+ quote = null;
185
+ escaped = false;
186
+ i = 0;
187
+
188
+ while (i < out.length) {
189
+ const ch = out[i] ?? '';
190
+
191
+ if (inString) {
192
+ normalized += ch;
193
+ if (escaped) escaped = false;
194
+ else if (ch === '\\') escaped = true;
195
+ else if (quote && ch === quote) {
196
+ inString = false;
197
+ quote = null;
198
+ }
199
+ i += 1;
200
+ continue;
201
+ }
202
+
203
+ if (ch === '"' || ch === "'") {
204
+ inString = true;
205
+ quote = ch;
206
+ normalized += ch;
207
+ i += 1;
208
+ continue;
209
+ }
210
+
211
+ if (ch === ',') {
212
+ let j = i + 1;
213
+ while (j < out.length && /\s/.test(out[j] ?? '')) j += 1;
214
+ const nextNonWs = out[j] ?? '';
215
+ if (nextNonWs === ']' || nextNonWs === '}') {
216
+ i += 1;
217
+ continue;
218
+ }
219
+ }
220
+
221
+ normalized += ch;
222
+ i += 1;
223
+ }
224
+
225
+ return normalized.trim();
226
+ };
227
+
228
+ const parseJsonc = (content: string): unknown => JSON.parse(stripJsonc(content));
229
+
230
+ /**
231
+ * Convert a legacy repo to a git resource
232
+ */
233
+ const legacyRepoToResource = (repo: LegacyRepo): ResourceDefinition => ({
234
+ type: 'git',
235
+ name: repo.name,
236
+ url: repo.url,
237
+ branch: repo.branch,
238
+ ...(repo.specialNotes && { specialNotes: repo.specialNotes }),
239
+ ...(repo.searchPath && { searchPath: repo.searchPath })
240
+ });
241
+
242
+ /**
243
+ * Check for and migrate legacy config (btca.json) to new format
244
+ * Supports two legacy formats:
245
+ * 1. Very old: has "repos" array with git repos only
246
+ * 2. Intermediate: has "resources" array (already migrated repos->resources)
247
+ *
248
+ * Returns migrated config if legacy exists, null otherwise
249
+ */
250
+ const migrateLegacyConfig = async (
251
+ legacyPath: string,
252
+ newConfigPath: string
253
+ ): Promise<StoredConfig | null> => {
254
+ const legacyExists = await Bun.file(legacyPath).exists();
255
+ if (!legacyExists) return null;
256
+
257
+ Metrics.info('config.legacy.found', { path: legacyPath });
258
+
259
+ let content: string;
260
+ try {
261
+ content = await Bun.file(legacyPath).text();
262
+ } catch (cause) {
263
+ Metrics.error('config.legacy.read_failed', { path: legacyPath, error: String(cause) });
264
+ return null;
265
+ }
266
+
267
+ let parsed: unknown;
268
+ try {
269
+ parsed = JSON.parse(content);
270
+ } catch (cause) {
271
+ Metrics.error('config.legacy.parse_failed', { path: legacyPath, error: String(cause) });
272
+ return null;
273
+ }
274
+
275
+ // Try the intermediate format first (has "resources" array)
276
+ const resourcesResult = LegacyResourcesConfigSchema.safeParse(parsed);
277
+ if (resourcesResult.success) {
278
+ const legacy = resourcesResult.data;
279
+ Metrics.info('config.legacy.parsed', {
280
+ format: 'resources',
281
+ resourceCount: legacy.resources.length,
282
+ model: legacy.model,
283
+ provider: legacy.provider
284
+ });
285
+
286
+ // Resources are already in the right format, just copy them over
287
+ const migrated: StoredConfig = {
288
+ $schema: CONFIG_SCHEMA_URL,
289
+ resources: legacy.resources,
290
+ model: legacy.model,
291
+ provider: legacy.provider
292
+ };
293
+
294
+ return finalizeMigration(migrated, legacyPath, newConfigPath, legacy.resources.length);
295
+ }
296
+
297
+ // Try the very old format (has "repos" array)
298
+ const reposResult = LegacyReposConfigSchema.safeParse(parsed);
299
+ if (reposResult.success) {
300
+ const legacy = reposResult.data;
301
+ Metrics.info('config.legacy.parsed', {
302
+ format: 'repos',
303
+ repoCount: legacy.repos.length,
304
+ model: legacy.model,
305
+ provider: legacy.provider
306
+ });
307
+
308
+ // Convert legacy repos to resources
309
+ const migratedResources = legacy.repos.map(legacyRepoToResource);
310
+
311
+ // Merge with default resources (legacy resources take precedence by name)
312
+ const migratedNames = new Set(migratedResources.map((r) => r.name));
313
+ const defaultsToAdd = DEFAULT_RESOURCES.filter((r) => !migratedNames.has(r.name));
314
+ const allResources = [...migratedResources, ...defaultsToAdd];
315
+
316
+ const migrated: StoredConfig = {
317
+ $schema: CONFIG_SCHEMA_URL,
318
+ resources: allResources,
319
+ model: legacy.model,
320
+ provider: legacy.provider
321
+ };
322
+
323
+ return finalizeMigration(migrated, legacyPath, newConfigPath, migratedResources.length);
324
+ }
325
+
326
+ // Neither format matched
327
+ Metrics.error('config.legacy.invalid', {
328
+ path: legacyPath,
329
+ error: 'Config does not match any known legacy format'
330
+ });
331
+ return null;
332
+ };
333
+
334
+ /**
335
+ * Write migrated config and rename legacy file
336
+ */
337
+ const finalizeMigration = async (
338
+ migrated: StoredConfig,
339
+ legacyPath: string,
340
+ newConfigPath: string,
341
+ migratedCount: number
342
+ ): Promise<StoredConfig> => {
343
+ // Save the migrated config
344
+ const configDir = newConfigPath.slice(0, newConfigPath.lastIndexOf('/'));
345
+ try {
346
+ await fs.mkdir(configDir, { recursive: true });
347
+ await Bun.write(newConfigPath, JSON.stringify(migrated, null, 2));
348
+ } catch (cause) {
349
+ throw new ConfigError({ message: 'Failed to write migrated config', cause });
350
+ }
351
+
352
+ Metrics.info('config.legacy.migrated', {
353
+ newPath: newConfigPath,
354
+ resourceCount: migrated.resources.length,
355
+ migratedCount
356
+ });
357
+
358
+ // Rename the legacy file to mark it as migrated
359
+ try {
360
+ await fs.rename(legacyPath, `${legacyPath}.migrated`);
361
+ Metrics.info('config.legacy.renamed', { from: legacyPath, to: `${legacyPath}.migrated` });
362
+ } catch {
363
+ // Not critical if we can't rename
364
+ Metrics.info('config.legacy.rename_skipped', { path: legacyPath });
365
+ }
366
+
367
+ return migrated;
368
+ };
369
+
370
+ const loadConfigFromPath = async (configPath: string): Promise<StoredConfig> => {
371
+ let content: string;
372
+ try {
373
+ content = await Bun.file(configPath).text();
374
+ } catch (cause) {
375
+ throw new ConfigError({ message: 'Failed to read config', cause });
376
+ }
377
+
378
+ let parsed: unknown;
379
+ try {
380
+ parsed = parseJsonc(content);
381
+ } catch (cause) {
382
+ throw new ConfigError({ message: 'Failed to parse config JSONC', cause });
383
+ }
384
+
385
+ const result = StoredConfigSchema.safeParse(parsed);
386
+ if (!result.success) {
387
+ throw new ConfigError({ message: 'Invalid config', cause: result.error });
388
+ }
389
+ return result.data;
390
+ };
391
+
392
+ const createDefaultConfig = async (configPath: string): Promise<StoredConfig> => {
393
+ const configDir = configPath.slice(0, configPath.lastIndexOf('/'));
394
+ try {
395
+ await fs.mkdir(configDir, { recursive: true });
396
+ } catch (cause) {
397
+ throw new ConfigError({ message: 'Failed to create config directory', cause });
398
+ }
399
+
400
+ const defaultStored: StoredConfig = {
401
+ $schema: CONFIG_SCHEMA_URL,
402
+ resources: DEFAULT_RESOURCES,
403
+ model: DEFAULT_MODEL,
404
+ provider: DEFAULT_PROVIDER
405
+ };
406
+
407
+ try {
408
+ await Bun.write(configPath, JSON.stringify(defaultStored, null, 2));
409
+ } catch (cause) {
410
+ throw new ConfigError({ message: 'Failed to write default config', cause });
411
+ }
412
+
413
+ return defaultStored;
414
+ };
415
+
416
+ const saveConfig = async (configPath: string, stored: StoredConfig): Promise<void> => {
417
+ try {
418
+ await Bun.write(configPath, JSON.stringify(stored, null, 2));
419
+ } catch (cause) {
420
+ throw new ConfigError({ message: 'Failed to write config', cause });
421
+ }
422
+ };
423
+
424
+ const makeService = (
425
+ stored: StoredConfig,
426
+ resourcesDirectory: string,
427
+ collectionsDirectory: string,
428
+ configPath: string
429
+ ): Service => {
430
+ // Mutable state that tracks current config
431
+ let currentStored = stored;
432
+
433
+ const service: Service = {
434
+ resourcesDirectory,
435
+ collectionsDirectory,
436
+ configPath,
437
+ get resources() {
438
+ return currentStored.resources;
439
+ },
440
+ get model() {
441
+ return currentStored.model;
442
+ },
443
+ get provider() {
444
+ return currentStored.provider;
445
+ },
446
+ getResource: (name: string) => currentStored.resources.find((r) => r.name === name),
447
+
448
+ updateModel: async (provider: string, model: string) => {
449
+ currentStored = { ...currentStored, provider, model };
450
+ await saveConfig(configPath, currentStored);
451
+ Metrics.info('config.model.updated', { provider, model });
452
+ return { provider, model };
453
+ },
454
+
455
+ addResource: async (resource: ResourceDefinition) => {
456
+ // Check for duplicate name
457
+ if (currentStored.resources.some((r) => r.name === resource.name)) {
458
+ throw new ConfigError({ message: `Resource "${resource.name}" already exists` });
459
+ }
460
+ currentStored = {
461
+ ...currentStored,
462
+ resources: [...currentStored.resources, resource]
463
+ };
464
+ await saveConfig(configPath, currentStored);
465
+ Metrics.info('config.resource.added', { name: resource.name, type: resource.type });
466
+ return resource;
467
+ },
468
+
469
+ removeResource: async (name: string) => {
470
+ const exists = currentStored.resources.some((r) => r.name === name);
471
+ if (!exists) {
472
+ throw new ConfigError({ message: `Resource "${name}" not found` });
473
+ }
474
+ currentStored = {
475
+ ...currentStored,
476
+ resources: currentStored.resources.filter((r) => r.name !== name)
477
+ };
478
+ await saveConfig(configPath, currentStored);
479
+ Metrics.info('config.resource.removed', { name });
480
+ },
481
+
482
+ clearResources: async () => {
483
+ // Clear the resources and collections directories
484
+ let clearedCount = 0;
485
+
486
+ try {
487
+ const resourcesDir = await fs.readdir(resourcesDirectory).catch(() => []);
488
+ for (const item of resourcesDir) {
489
+ await fs.rm(`${resourcesDirectory}/${item}`, { recursive: true, force: true });
490
+ clearedCount++;
491
+ }
492
+ } catch {
493
+ // Directory might not exist
494
+ }
495
+
496
+ try {
497
+ const collectionsDir = await fs.readdir(collectionsDirectory).catch(() => []);
498
+ for (const item of collectionsDir) {
499
+ await fs.rm(`${collectionsDirectory}/${item}`, { recursive: true, force: true });
500
+ }
501
+ } catch {
502
+ // Directory might not exist
503
+ }
504
+
505
+ Metrics.info('config.resources.cleared', { count: clearedCount });
506
+ return { cleared: clearedCount };
507
+ }
508
+ };
509
+
510
+ return service;
511
+ };
512
+
513
+ export const load = async (): Promise<Service> => {
514
+ const cwd = process.cwd();
515
+ Metrics.info('config.load.start', { cwd });
516
+
517
+ const projectConfigPath = `${cwd}/${PROJECT_CONFIG_FILENAME}`;
518
+ if (await Bun.file(projectConfigPath).exists()) {
519
+ Metrics.info('config.load.source', { source: 'project', path: projectConfigPath });
520
+ const stored = await loadConfigFromPath(projectConfigPath);
521
+ return makeService(
522
+ stored,
523
+ `${cwd}/${PROJECT_DATA_DIR}/resources`,
524
+ `${cwd}/${PROJECT_DATA_DIR}/collections`,
525
+ projectConfigPath
526
+ );
527
+ }
528
+
529
+ const globalConfigPath = `${expandHome(GLOBAL_CONFIG_DIR)}/${GLOBAL_CONFIG_FILENAME}`;
530
+ const globalExists = await Bun.file(globalConfigPath).exists();
531
+
532
+ // If new config doesn't exist, check for legacy config to migrate
533
+ if (!globalExists) {
534
+ const legacyConfigPath = `${expandHome(GLOBAL_CONFIG_DIR)}/${LEGACY_CONFIG_FILENAME}`;
535
+ const migrated = await migrateLegacyConfig(legacyConfigPath, globalConfigPath);
536
+ if (migrated) {
537
+ Metrics.info('config.load.source', { source: 'migrated', path: globalConfigPath });
538
+ return makeService(
539
+ migrated,
540
+ `${expandHome(GLOBAL_DATA_DIR)}/resources`,
541
+ `${expandHome(GLOBAL_DATA_DIR)}/collections`,
542
+ globalConfigPath
543
+ );
544
+ }
545
+ }
546
+
547
+ Metrics.info('config.load.source', {
548
+ source: globalExists ? 'global' : 'default',
549
+ path: globalConfigPath
550
+ });
551
+
552
+ const stored = globalExists
553
+ ? await loadConfigFromPath(globalConfigPath)
554
+ : await createDefaultConfig(globalConfigPath);
555
+
556
+ return makeService(
557
+ stored,
558
+ `${expandHome(GLOBAL_DATA_DIR)}/resources`,
559
+ `${expandHome(GLOBAL_DATA_DIR)}/collections`,
560
+ globalConfigPath
561
+ );
562
+ };
563
+ }
@@ -0,0 +1,24 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ export type ContextStore = {
4
+ requestId: string;
5
+ txDepth: number;
6
+ };
7
+
8
+ const storage = new AsyncLocalStorage<ContextStore>();
9
+
10
+ export namespace Context {
11
+ export const run = <T>(store: ContextStore, fn: () => Promise<T> | T): Promise<T> => {
12
+ return Promise.resolve(storage.run(store, fn));
13
+ };
14
+
15
+ export const get = (): ContextStore | undefined => storage.getStore();
16
+
17
+ export const require = (): ContextStore => {
18
+ const store = storage.getStore();
19
+ if (!store) throw new Error('Missing AsyncLocalStorage context');
20
+ return store;
21
+ };
22
+
23
+ export const requestId = (): string => storage.getStore()?.requestId ?? 'unknown';
24
+ }
@@ -0,0 +1,28 @@
1
+ import { Metrics } from '../metrics/index.ts';
2
+ import { Context } from './index.ts';
3
+
4
+ export namespace Transaction {
5
+ export const run = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
6
+ const store = Context.require();
7
+ const depth = store.txDepth;
8
+ store.txDepth = depth + 1;
9
+
10
+ const start = performance.now();
11
+ Metrics.info('tx.start', { name, depth });
12
+ try {
13
+ const result = await fn();
14
+ Metrics.info('tx.commit', { name, depth, ms: Math.round(performance.now() - start) });
15
+ return result;
16
+ } catch (cause) {
17
+ Metrics.error('tx.rollback', {
18
+ name,
19
+ depth,
20
+ ms: Math.round(performance.now() - start),
21
+ error: Metrics.errorInfo(cause)
22
+ });
23
+ throw cause;
24
+ } finally {
25
+ store.txDepth = depth;
26
+ }
27
+ };
28
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,15 @@
1
+ export type TaggedErrorLike = {
2
+ readonly _tag: string;
3
+ readonly message: string;
4
+ };
5
+
6
+ export const getErrorTag = (error: unknown): string => {
7
+ if (error && typeof error === 'object' && '_tag' in error) return String((error as any)._tag);
8
+ return 'UnknownError';
9
+ };
10
+
11
+ export const getErrorMessage = (error: unknown): string => {
12
+ if (error && typeof error === 'object' && 'message' in error)
13
+ return String((error as any).message);
14
+ return String(error);
15
+ };