btca-server 1.0.961 → 2.0.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/package.json +3 -3
  2. package/src/agent/agent.test.ts +31 -24
  3. package/src/agent/index.ts +8 -2
  4. package/src/agent/loop.ts +303 -346
  5. package/src/agent/service.ts +252 -233
  6. package/src/agent/types.ts +2 -2
  7. package/src/collections/index.ts +2 -1
  8. package/src/collections/service.ts +352 -345
  9. package/src/config/config.test.ts +3 -1
  10. package/src/config/index.ts +615 -727
  11. package/src/config/remote.ts +214 -369
  12. package/src/context/index.ts +6 -12
  13. package/src/context/transaction.ts +23 -30
  14. package/src/effect/errors.ts +45 -0
  15. package/src/effect/layers.ts +26 -0
  16. package/src/effect/runtime.ts +19 -0
  17. package/src/effect/services.ts +154 -0
  18. package/src/index.ts +291 -369
  19. package/src/metrics/index.ts +46 -46
  20. package/src/pricing/models-dev.ts +104 -106
  21. package/src/providers/auth.ts +159 -200
  22. package/src/providers/index.ts +19 -2
  23. package/src/providers/model.ts +115 -135
  24. package/src/providers/openai.ts +3 -3
  25. package/src/resources/impls/git.ts +123 -146
  26. package/src/resources/impls/npm.test.ts +16 -5
  27. package/src/resources/impls/npm.ts +66 -75
  28. package/src/resources/index.ts +6 -1
  29. package/src/resources/schema.ts +7 -6
  30. package/src/resources/service.test.ts +13 -12
  31. package/src/resources/service.ts +153 -112
  32. package/src/stream/index.ts +1 -1
  33. package/src/stream/service.test.ts +5 -5
  34. package/src/stream/service.ts +282 -293
  35. package/src/tools/glob.ts +126 -141
  36. package/src/tools/grep.ts +205 -210
  37. package/src/tools/index.ts +8 -4
  38. package/src/tools/list.ts +118 -140
  39. package/src/tools/read.ts +209 -235
  40. package/src/tools/virtual-sandbox.ts +91 -83
  41. package/src/validation/index.ts +18 -22
  42. package/src/vfs/virtual-fs.test.ts +37 -25
  43. package/src/vfs/virtual-fs.ts +218 -216
package/src/index.ts CHANGED
@@ -1,19 +1,19 @@
1
- import { Result } from 'better-result';
2
- import { Hono } from 'hono';
3
- import type { Context as HonoContext, Next } from 'hono';
1
+ import { Effect, Cause } from 'effect';
2
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/unstable/http';
4
3
  import { z } from 'zod';
5
4
 
6
- import { Agent } from './agent/service.ts';
7
- import { Collections } from './collections/service.ts';
8
- import { getCollectionKey } from './collections/types.ts';
9
- import { Config } from './config/index.ts';
10
- import { Context } from './context/index.ts';
11
- import { getErrorMessage, getErrorTag, getErrorHint } from './errors.ts';
12
- import { Metrics } from './metrics/index.ts';
13
- import { ModelsDevPricing } from './pricing/models-dev.ts';
14
- import { Resources } from './resources/service.ts';
5
+ import { createAgentService } from './agent/service.ts';
6
+ import { createCollectionsService } from './collections/service.ts';
7
+ import { load as loadConfig } from './config/index.ts';
8
+ import { runContext } from './context/index.ts';
9
+ import { toHttpErrorPayload } from './effect/errors.ts';
10
+ import { createServerRuntime } from './effect/runtime.ts';
11
+ import * as ServerServices from './effect/services.ts';
12
+ import { metricsError, metricsErrorInfo, metricsInfo, setQuietMetrics } from './metrics/index.ts';
13
+ import { createModelsDevPricing } from './pricing/models-dev.ts';
14
+ import { createResourcesService } from './resources/service.ts';
15
15
  import { GitResourceSchema, LocalResourceSchema, NpmResourceSchema } from './resources/schema.ts';
16
- import { StreamService } from './stream/service.ts';
16
+ import { createSseStream } from './stream/service.ts';
17
17
  import type { BtcaStreamMetaEvent } from './stream/types.ts';
18
18
  import {
19
19
  LIMITS,
@@ -23,46 +23,15 @@ import {
23
23
  validateResourceReference
24
24
  } from './validation/index.ts';
25
25
  import { clearAllVirtualCollectionMetadata } from './collections/virtual-metadata.ts';
26
- import { VirtualFs } from './vfs/virtual-fs.ts';
27
-
28
- /**
29
- * BTCA Server API
30
- *
31
- * Endpoints:
32
- *
33
- * GET / - Health check, returns { ok, service, version }
34
- * GET /config - Returns current configuration (provider, model, directories)
35
- * GET /resources - Lists all configured resources
36
- * POST /question - Ask a question (non-streaming)
37
- * POST /question/stream - Ask a question (streaming SSE response)
38
- */
39
-
40
- // ─────────────────────────────────────────────────────────────────────────────
41
- // Configuration
42
- // ─────────────────────────────────────────────────────────────────────────────
26
+ import { disposeAllVirtualFs } from './vfs/virtual-fs.ts';
43
27
 
44
28
  const DEFAULT_PORT = 8080;
45
29
  const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : DEFAULT_PORT;
30
+ const modelsDevPricing = createModelsDevPricing();
46
31
 
47
- const modelsDevPricing = ModelsDevPricing.create();
48
-
49
- // ─────────────────────────────────────────────────────────────────────────────
50
- // Request Schemas
51
- // ─────────────────────────────────────────────────────────────────────────────
52
-
53
- /**
54
- * Resource name pattern: must start with a letter, alphanumeric and hyphens only.
55
- */
56
32
  const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0-9._-]*)*$/;
57
-
58
- /**
59
- * Safe name pattern for provider/model names.
60
- */
61
33
  const SAFE_NAME_REGEX = /^[a-zA-Z0-9._+\-/:]+$/;
62
34
 
63
- /**
64
- * Validated resource name field for request schemas.
65
- */
66
35
  const ResourceNameField = z
67
36
  .string()
68
37
  .min(1, 'Resource name cannot be empty')
@@ -127,10 +96,6 @@ const UpdateModelRequestSchema = z.object({
127
96
  .optional()
128
97
  });
129
98
 
130
- /**
131
- * Add resource request - uses the full resource schemas for validation.
132
- * This ensures all security checks (URL, branch, path traversal) are applied.
133
- */
134
99
  const AddGitResourceRequestSchema = z.object({
135
100
  type: z.literal('git'),
136
101
  name: GitResourceSchema.shape.name,
@@ -186,10 +151,6 @@ const RemoveResourceRequestSchema = z.object({
186
151
  name: ResourceNameField
187
152
  });
188
153
 
189
- // ─────────────────────────────────────────────────────────────────────────────
190
- // Errors & Helpers
191
- // ─────────────────────────────────────────────────────────────────────────────
192
-
193
154
  class RequestError extends Error {
194
155
  readonly _tag = 'RequestError';
195
156
 
@@ -198,313 +159,274 @@ class RequestError extends Error {
198
159
  }
199
160
  }
200
161
 
201
- const decodeJson = async <T>(req: Request, schema: z.ZodType<T>): Promise<T> => {
202
- const bodyResult = await Result.tryPromise(() => req.json());
203
- if (!Result.isOk(bodyResult)) {
204
- throw new RequestError('Failed to parse request JSON', bodyResult.error);
205
- }
162
+ const decodeJson = <T>(
163
+ request: HttpServerRequest.HttpServerRequest,
164
+ schema: z.ZodType<T>
165
+ ): Effect.Effect<T, RequestError> =>
166
+ Effect.gen(function* () {
167
+ const body = yield* Effect.mapError(request.json, (cause) => {
168
+ return new RequestError('Failed to parse request JSON', cause);
169
+ });
170
+ const parsed = schema.safeParse(body);
171
+ if (!parsed.success) {
172
+ return yield* Effect.fail(new RequestError('Invalid request body', parsed.error));
173
+ }
174
+ return parsed.data;
175
+ });
206
176
 
207
- const parsed = schema.safeParse(bodyResult.value);
208
- if (!parsed.success) {
209
- throw new RequestError('Invalid request body', parsed.error);
210
- }
177
+ const createApp = () => {
178
+ const withHttpErrorHandling = <R>(
179
+ effect: Effect.Effect<HttpServerResponse.HttpServerResponse, unknown, R>
180
+ ): Effect.Effect<HttpServerResponse.HttpServerResponse, never, R> =>
181
+ Effect.catchCause(effect, (cause) => {
182
+ const error = Cause.squash(cause);
183
+ metricsError('http.error', { error: metricsErrorInfo(error) });
184
+ const payload = toHttpErrorPayload(error);
185
+ return Effect.succeed(
186
+ HttpServerResponse.jsonUnsafe(
187
+ { error: payload.error, tag: payload.tag, ...(payload.hint && { hint: payload.hint }) },
188
+ { status: payload.status }
189
+ )
190
+ );
191
+ });
211
192
 
212
- return parsed.data;
213
- };
193
+ return HttpRouter.addAll([
194
+ HttpRouter.route(
195
+ 'GET',
196
+ '/',
197
+ HttpServerResponse.jsonUnsafe({
198
+ ok: true,
199
+ service: 'btca-server',
200
+ version: '0.0.1'
201
+ })
202
+ ),
203
+ HttpRouter.route(
204
+ 'GET',
205
+ '/config',
206
+ withHttpErrorHandling(
207
+ Effect.map(ServerServices.getConfigSnapshot, (snapshot) =>
208
+ HttpServerResponse.jsonUnsafe(snapshot)
209
+ )
210
+ )
211
+ ),
212
+ HttpRouter.route(
213
+ 'GET',
214
+ '/resources',
215
+ withHttpErrorHandling(
216
+ Effect.map(ServerServices.getResourcesSnapshot, (snapshot) =>
217
+ HttpServerResponse.jsonUnsafe(snapshot)
218
+ )
219
+ )
220
+ ),
221
+ HttpRouter.route(
222
+ 'GET',
223
+ '/providers',
224
+ withHttpErrorHandling(
225
+ Effect.gen(function* () {
226
+ const providers = yield* ServerServices.listProviders;
227
+ return HttpServerResponse.jsonUnsafe(providers);
228
+ })
229
+ )
230
+ ),
231
+ HttpRouter.route(
232
+ 'POST',
233
+ '/reload-config',
234
+ withHttpErrorHandling(
235
+ Effect.gen(function* () {
236
+ yield* ServerServices.reloadConfig;
237
+ const resources = yield* ServerServices.getDefaultResourceNames;
238
+ return HttpServerResponse.jsonUnsafe({
239
+ ok: true,
240
+ resources
241
+ });
242
+ })
243
+ )
244
+ ),
245
+ HttpRouter.route('POST', '/question', (request) =>
246
+ withHttpErrorHandling(
247
+ Effect.gen(function* () {
248
+ const decoded = yield* decodeJson(request, QuestionRequestSchema);
249
+ const resourceNames = Array.from(
250
+ decoded.resources && decoded.resources.length > 0
251
+ ? Array.from(new Set(decoded.resources.map(normalizeQuestionResourceReference)))
252
+ : yield* ServerServices.getDefaultResourceNames
253
+ );
254
+
255
+ const collectionKey = ServerServices.loadedResourceCollectionKey(resourceNames);
256
+ metricsInfo('question.received', {
257
+ stream: false,
258
+ quiet: decoded.quiet ?? false,
259
+ questionLength: decoded.question.length,
260
+ resources: resourceNames,
261
+ collectionKey
262
+ });
214
263
 
215
- // ─────────────────────────────────────────────────────────────────────────────
216
- // App Factory
217
- // ─────────────────────────────────────────────────────────────────────────────
218
-
219
- const createApp = (deps: {
220
- config: Config.Service;
221
- resources: Resources.Service;
222
- collections: Collections.Service;
223
- agent: Agent.Service;
224
- }) => {
225
- const { config, collections, agent } = deps;
226
-
227
- const app = new Hono()
228
- // ─────────────────────────────────────────────────────────────────────
229
- // Middleware
230
- // ─────────────────────────────────────────────────────────────────────
231
- .use('*', async (c: HonoContext, next: Next) => {
232
- const requestId = crypto.randomUUID();
233
- return Context.run({ requestId, txDepth: 0 }, async () => {
234
- Metrics.info('http.request', { method: c.req.method, path: c.req.path });
235
- try {
236
- await next();
237
- } finally {
238
- Metrics.info('http.response', {
239
- path: c.req.path,
240
- status: c.res.status
264
+ const collection = yield* ServerServices.loadCollection({
265
+ resourceNames,
266
+ quiet: decoded.quiet
241
267
  });
242
- }
243
- });
244
- })
245
- .onError((err: Error, c: HonoContext) => {
246
- Metrics.error('http.error', { error: Metrics.errorInfo(err) });
247
- const tag = getErrorTag(err);
248
- const message = getErrorMessage(err);
249
- const hint = getErrorHint(err);
250
- const status =
251
- tag === 'RequestError' ||
252
- tag === 'CollectionError' ||
253
- tag === 'ResourceError' ||
254
- tag === 'ConfigError' ||
255
- tag === 'InvalidProviderError' ||
256
- tag === 'InvalidModelError' ||
257
- tag === 'ProviderNotAuthenticatedError' ||
258
- tag === 'ProviderAuthTypeError' ||
259
- tag === 'ProviderNotFoundError' ||
260
- tag === 'ProviderNotConnectedError' ||
261
- tag === 'ProviderOptionsError'
262
- ? 400
263
- : 500;
264
- return c.json({ error: message, tag, ...(hint && { hint }) }, status);
265
- })
268
+ metricsInfo('collection.ready', { collectionKey, path: collection.path });
266
269
 
267
- // ─────────────────────────────────────────────────────────────────────
268
- // Routes
269
- // ─────────────────────────────────────────────────────────────────────
270
+ const result = yield* ServerServices.askQuestion({
271
+ collection,
272
+ question: decoded.question
273
+ });
274
+ metricsInfo('question.done', {
275
+ collectionKey,
276
+ answerLength: result.answer.length,
277
+ model: result.model
278
+ });
270
279
 
271
- // GET / - Health check
272
- .get('/', (c: HonoContext) => {
273
- return c.json({
274
- ok: true,
275
- service: 'btca-server',
276
- version: '0.0.1'
277
- });
278
- })
280
+ return HttpServerResponse.jsonUnsafe({
281
+ answer: result.answer,
282
+ model: result.model,
283
+ resources: resourceNames,
284
+ collection: { key: collectionKey, path: collection.path }
285
+ });
286
+ })
287
+ )
288
+ ),
289
+ HttpRouter.route('POST', '/question/stream', (request) =>
290
+ withHttpErrorHandling(
291
+ Effect.gen(function* () {
292
+ const requestStartMs = performance.now();
293
+ const decoded = yield* decodeJson(request, QuestionRequestSchema);
294
+ const resourceNames = Array.from(
295
+ decoded.resources && decoded.resources.length > 0
296
+ ? Array.from(new Set(decoded.resources.map(normalizeQuestionResourceReference)))
297
+ : yield* ServerServices.getDefaultResourceNames
298
+ );
299
+
300
+ const collectionKey = ServerServices.loadedResourceCollectionKey(resourceNames);
301
+ metricsInfo('question.received', {
302
+ stream: true,
303
+ quiet: decoded.quiet ?? false,
304
+ questionLength: decoded.question.length,
305
+ resources: resourceNames,
306
+ collectionKey
307
+ });
279
308
 
280
- // GET /config
281
- .get('/config', (c: HonoContext) => {
282
- return c.json({
283
- provider: config.provider,
284
- model: config.model,
285
- providerTimeoutMs: config.providerTimeoutMs ?? null,
286
- maxSteps: config.maxSteps,
287
- resourcesDirectory: config.resourcesDirectory,
288
- resourceCount: config.resources.length
289
- });
290
- })
309
+ const collection = yield* ServerServices.loadCollection({
310
+ resourceNames,
311
+ quiet: decoded.quiet
312
+ });
313
+ metricsInfo('collection.ready', { collectionKey, path: collection.path });
291
314
 
292
- // GET /resources
293
- .get('/resources', (c: HonoContext) => {
294
- return c.json({
295
- resources: config.resources.map((r) => {
296
- if (r.type === 'git') {
297
- return {
298
- name: r.name,
299
- type: r.type,
300
- url: r.url,
301
- branch: r.branch,
302
- searchPath: r.searchPath ?? null,
303
- searchPaths: r.searchPaths ?? null,
304
- specialNotes: r.specialNotes ?? null
315
+ const { stream: eventStream, model } = yield* ServerServices.askQuestionStream({
316
+ collection,
317
+ question: decoded.question
318
+ });
319
+
320
+ const meta = {
321
+ type: 'meta',
322
+ model,
323
+ resources: resourceNames,
324
+ collection: {
325
+ key: collectionKey,
326
+ path: collection.path
327
+ }
328
+ } satisfies BtcaStreamMetaEvent;
329
+
330
+ metricsInfo('question.stream.start', { collectionKey });
331
+ modelsDevPricing.prefetch();
332
+ const stream = createSseStream({
333
+ meta,
334
+ eventStream,
335
+ question: decoded.question,
336
+ requestStartMs,
337
+ pricing: modelsDevPricing
338
+ });
339
+
340
+ return HttpServerResponse.raw(
341
+ new Response(stream, {
342
+ headers: {
343
+ 'content-type': 'text/event-stream',
344
+ 'cache-control': 'no-cache',
345
+ connection: 'keep-alive'
346
+ }
347
+ })
348
+ );
349
+ })
350
+ )
351
+ ),
352
+ HttpRouter.route('PUT', '/config/model', (request) =>
353
+ withHttpErrorHandling(
354
+ Effect.gen(function* () {
355
+ const decoded = yield* decodeJson(request, UpdateModelRequestSchema);
356
+ const result = yield* ServerServices.updateModelConfig({
357
+ provider: decoded.provider,
358
+ model: decoded.model,
359
+ providerOptions: decoded.providerOptions
360
+ });
361
+ return HttpServerResponse.jsonUnsafe(result);
362
+ })
363
+ )
364
+ ),
365
+ HttpRouter.route('POST', '/config/resources', (request) =>
366
+ withHttpErrorHandling(
367
+ Effect.gen(function* () {
368
+ const decoded = yield* decodeJson(request, AddResourceRequestSchema);
369
+ if (decoded.type === 'git') {
370
+ const normalizedUrl = normalizeGitHubUrl(decoded.url);
371
+ const resource = {
372
+ type: 'git' as const,
373
+ name: decoded.name,
374
+ url: normalizedUrl,
375
+ branch: decoded.branch ?? 'main',
376
+ ...(decoded.searchPath && { searchPath: decoded.searchPath }),
377
+ ...(decoded.searchPaths && { searchPaths: decoded.searchPaths }),
378
+ ...(decoded.specialNotes && { specialNotes: decoded.specialNotes })
305
379
  };
380
+ const added = yield* ServerServices.addConfigResource(resource);
381
+ return HttpServerResponse.jsonUnsafe(added, { status: 201 });
306
382
  }
307
- if (r.type === 'local') {
308
- return {
309
- name: r.name,
310
- type: r.type,
311
- path: r.path,
312
- specialNotes: r.specialNotes ?? null
383
+ if (decoded.type === 'local') {
384
+ const resource = {
385
+ type: 'local' as const,
386
+ name: decoded.name,
387
+ path: decoded.path,
388
+ ...(decoded.specialNotes && { specialNotes: decoded.specialNotes })
313
389
  };
390
+ const added = yield* ServerServices.addConfigResource(resource);
391
+ return HttpServerResponse.jsonUnsafe(added, { status: 201 });
314
392
  }
315
- return {
316
- name: r.name,
317
- type: r.type,
318
- package: r.package,
319
- version: r.version ?? null,
320
- specialNotes: r.specialNotes ?? null
393
+ const resource = {
394
+ type: 'npm' as const,
395
+ name: decoded.name,
396
+ package: decoded.package,
397
+ ...(decoded.version ? { version: decoded.version } : {}),
398
+ ...(decoded.specialNotes ? { specialNotes: decoded.specialNotes } : {})
321
399
  };
400
+ const added = yield* ServerServices.addConfigResource(resource);
401
+ return HttpServerResponse.jsonUnsafe(added, { status: 201 });
322
402
  })
323
- });
324
- })
325
-
326
- // GET /providers
327
- .get('/providers', async (c: HonoContext) => {
328
- const providers = await agent.listProviders();
329
- return c.json(providers);
330
- })
331
-
332
- // POST /reload-config - Reload config from disk
333
- .post('/reload-config', async (c: HonoContext) => {
334
- await config.reload();
335
- return c.json({
336
- ok: true,
337
- resources: config.resources.map((r) => r.name)
338
- });
339
- })
340
-
341
- // POST /question
342
- .post('/question', async (c: HonoContext) => {
343
- const decoded = await decodeJson(c.req.raw, QuestionRequestSchema);
344
- const resourceNames =
345
- decoded.resources && decoded.resources.length > 0
346
- ? Array.from(new Set(decoded.resources.map(normalizeQuestionResourceReference)))
347
- : config.resources.map((r) => r.name);
348
-
349
- const collectionKey = getCollectionKey(resourceNames);
350
- Metrics.info('question.received', {
351
- stream: false,
352
- quiet: decoded.quiet ?? false,
353
- questionLength: decoded.question.length,
354
- resources: resourceNames,
355
- collectionKey
356
- });
357
-
358
- const collection = await collections.load({ resourceNames, quiet: decoded.quiet });
359
- Metrics.info('collection.ready', { collectionKey, path: collection.path });
360
-
361
- const result = await agent.ask({ collection, question: decoded.question });
362
- Metrics.info('question.done', {
363
- collectionKey,
364
- answerLength: result.answer.length,
365
- model: result.model
366
- });
367
-
368
- return c.json({
369
- answer: result.answer,
370
- model: result.model,
371
- resources: resourceNames,
372
- collection: { key: collectionKey, path: collection.path }
373
- });
374
- })
375
-
376
- // POST /question/stream
377
- .post('/question/stream', async (c: HonoContext) => {
378
- const requestStartMs = performance.now();
379
- const decoded = await decodeJson(c.req.raw, QuestionRequestSchema);
380
- const resourceNames =
381
- decoded.resources && decoded.resources.length > 0
382
- ? Array.from(new Set(decoded.resources.map(normalizeQuestionResourceReference)))
383
- : config.resources.map((r) => r.name);
384
-
385
- const collectionKey = getCollectionKey(resourceNames);
386
- Metrics.info('question.received', {
387
- stream: true,
388
- quiet: decoded.quiet ?? false,
389
- questionLength: decoded.question.length,
390
- resources: resourceNames,
391
- collectionKey
392
- });
393
-
394
- const collection = await collections.load({ resourceNames, quiet: decoded.quiet });
395
- Metrics.info('collection.ready', { collectionKey, path: collection.path });
396
-
397
- const { stream: eventStream, model } = await agent.askStream({
398
- collection,
399
- question: decoded.question
400
- });
401
-
402
- const meta = {
403
- type: 'meta',
404
- model,
405
- resources: resourceNames,
406
- collection: {
407
- key: collectionKey,
408
- path: collection.path
409
- }
410
- } satisfies BtcaStreamMetaEvent;
411
-
412
- Metrics.info('question.stream.start', { collectionKey });
413
- modelsDevPricing.prefetch();
414
- const stream = StreamService.createSseStream({
415
- meta,
416
- eventStream,
417
- question: decoded.question,
418
- requestStartMs,
419
- pricing: modelsDevPricing
420
- });
421
-
422
- return new Response(stream, {
423
- headers: {
424
- 'content-type': 'text/event-stream',
425
- 'cache-control': 'no-cache',
426
- connection: 'keep-alive'
427
- }
428
- });
429
- })
430
-
431
- // PUT /config/model - Update model configuration
432
- .put('/config/model', async (c: HonoContext) => {
433
- const decoded = await decodeJson(c.req.raw, UpdateModelRequestSchema);
434
- const result = await config.updateModel(
435
- decoded.provider,
436
- decoded.model,
437
- decoded.providerOptions
438
- );
439
- return c.json(result);
440
- })
441
-
442
- // POST /config/resources - Add a new resource
443
- // All validation (URL, branch, path traversal, etc.) is handled by the schema
444
- // GitHub URLs are normalized to their base repository format
445
- .post('/config/resources', async (c: HonoContext) => {
446
- const decoded = await decodeJson(c.req.raw, AddResourceRequestSchema);
447
-
448
- if (decoded.type === 'git') {
449
- // Normalize GitHub URLs (e.g., /blob/main/file.txt → base repo URL)
450
- const normalizedUrl = normalizeGitHubUrl(decoded.url);
451
- const resource = {
452
- type: 'git' as const,
453
- name: decoded.name,
454
- url: normalizedUrl,
455
- branch: decoded.branch ?? 'main',
456
- ...(decoded.searchPath && { searchPath: decoded.searchPath }),
457
- ...(decoded.searchPaths && { searchPaths: decoded.searchPaths }),
458
- ...(decoded.specialNotes && { specialNotes: decoded.specialNotes })
459
- };
460
- const added = await config.addResource(resource);
461
- return c.json(added, 201);
462
- }
463
- if (decoded.type === 'local') {
464
- const resource = {
465
- type: 'local' as const,
466
- name: decoded.name,
467
- path: decoded.path,
468
- ...(decoded.specialNotes && { specialNotes: decoded.specialNotes })
469
- };
470
- const added = await config.addResource(resource);
471
- return c.json(added, 201);
472
- }
473
- const resource = {
474
- type: 'npm' as const,
475
- name: decoded.name,
476
- package: decoded.package,
477
- ...(decoded.version ? { version: decoded.version } : {}),
478
- ...(decoded.specialNotes ? { specialNotes: decoded.specialNotes } : {})
479
- };
480
- const added = await config.addResource(resource);
481
- return c.json(added, 201);
482
- })
483
-
484
- // DELETE /config/resources - Remove a resource
485
- .delete('/config/resources', async (c: HonoContext) => {
486
- const decoded = await decodeJson(c.req.raw, RemoveResourceRequestSchema);
487
- await config.removeResource(decoded.name);
488
- return c.json({ success: true, name: decoded.name });
489
- })
490
-
491
- // POST /clear - Clear all locally cloned resources
492
- .post('/clear', async (c: HonoContext) => {
493
- const result = await config.clearResources();
494
- return c.json(result);
495
- });
496
-
497
- return app;
403
+ )
404
+ ),
405
+ HttpRouter.route('DELETE', '/config/resources', (request) =>
406
+ withHttpErrorHandling(
407
+ Effect.gen(function* () {
408
+ const decoded = yield* decodeJson(request, RemoveResourceRequestSchema);
409
+ yield* ServerServices.removeConfigResource(decoded.name);
410
+ return HttpServerResponse.jsonUnsafe({ success: true, name: decoded.name });
411
+ })
412
+ )
413
+ ),
414
+ HttpRouter.route(
415
+ 'POST',
416
+ '/clear',
417
+ withHttpErrorHandling(
418
+ Effect.gen(function* () {
419
+ const result = yield* ServerServices.clearConfigResources;
420
+ return HttpServerResponse.jsonUnsafe(result);
421
+ })
422
+ )
423
+ )
424
+ ]);
498
425
  };
499
426
 
500
- // Export app type for Hono RPC client
501
- // We create a dummy app with null deps just to get the type
502
- type AppType = ReturnType<typeof createApp>;
503
- export type { AppType };
504
-
505
- // ─────────────────────────────────────────────────────────────────────────────
506
- // Server
507
- // ─────────────────────────────────────────────────────────────────────────────
427
+ export type AppType = {
428
+ readonly _tag: 'effect-http-app';
429
+ };
508
430
 
509
431
  export interface ServerInstance {
510
432
  port: number;
@@ -517,64 +439,64 @@ export interface StartServerOptions {
517
439
  quiet?: boolean;
518
440
  }
519
441
 
520
- /**
521
- * Start the btca server programmatically.
522
- * Returns a ServerInstance with the port, url, and stop function.
523
- *
524
- * If port is 0, a random available port will be assigned by the OS.
525
- */
526
442
  export const startServer = async (options: StartServerOptions = {}): Promise<ServerInstance> => {
527
443
  if (options.quiet) {
528
- Metrics.setQuiet(true);
444
+ setQuietMetrics(true);
529
445
  }
530
446
 
531
447
  const requestedPort = options.port ?? PORT;
532
- Metrics.info('server.starting', { port: requestedPort });
448
+ metricsInfo('server.starting', { port: requestedPort });
533
449
 
534
- const config = await Config.load();
535
- Metrics.info('config.ready', {
450
+ const config = await loadConfig();
451
+ metricsInfo('config.ready', {
536
452
  provider: config.provider,
537
453
  model: config.model,
538
454
  maxSteps: config.maxSteps,
539
- resources: config.resources.map((r) => r.name),
455
+ resources: config.resources.map((resource) => resource.name),
540
456
  resourcesDirectory: config.resourcesDirectory
541
457
  });
542
458
 
543
- const resources = Resources.create(config);
544
- const collections = Collections.create({ config, resources });
545
- const agent = Agent.create(config);
546
-
547
- const app = createApp({ config, resources, collections, agent });
459
+ const resources = createResourcesService(config);
460
+ const collections = createCollectionsService({ config, resources });
461
+ const agent = createAgentService(config);
462
+ const runtime = createServerRuntime({ config, collections, agent });
463
+ const appLayer = createApp();
464
+ const { handler, dispose } = HttpRouter.toWebHandler(appLayer, {
465
+ disableLogger: options.quiet === true
466
+ });
467
+ const requestContext = await runtime.services();
548
468
 
549
469
  const server = Bun.serve({
550
470
  port: requestedPort,
551
- fetch: app.fetch,
471
+ fetch: (request) =>
472
+ runContext({ requestId: crypto.randomUUID(), txDepth: 0 }, () =>
473
+ handler(request, requestContext)
474
+ ),
552
475
  idleTimeout: 60
553
476
  });
554
477
 
555
478
  const actualPort = server.port ?? requestedPort;
556
- Metrics.info('server.started', { port: actualPort });
479
+ metricsInfo('server.started', { port: actualPort });
557
480
 
558
481
  return {
559
482
  port: actualPort,
560
483
  url: `http://localhost:${actualPort}`,
561
484
  stop: () => {
562
- VirtualFs.disposeAll();
485
+ disposeAllVirtualFs();
563
486
  clearAllVirtualCollectionMetadata();
564
487
  server.stop();
488
+ void dispose();
489
+ void runtime.dispose();
565
490
  }
566
491
  };
567
492
  };
568
493
 
569
- // Export all public types and interfaces for consumers
570
494
  export type { BtcaStreamEvent, BtcaStreamMetaEvent } from './stream/types.ts';
571
495
 
572
- // Auto-start when run directly (not imported)
573
- const isMainModule = import.meta.main;
574
- if (isMainModule) {
496
+ if (import.meta.main) {
575
497
  const server = await startServer({ port: PORT });
576
498
  const shutdown = () => {
577
- Metrics.info('server.shutdown', { reason: 'signal' });
499
+ metricsInfo('server.shutdown', { reason: 'signal' });
578
500
  server.stop();
579
501
  process.exit(0);
580
502
  };