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.
- package/package.json +3 -3
- package/src/agent/agent.test.ts +31 -24
- package/src/agent/index.ts +8 -2
- package/src/agent/loop.ts +303 -346
- package/src/agent/service.ts +252 -233
- package/src/agent/types.ts +2 -2
- package/src/collections/index.ts +2 -1
- package/src/collections/service.ts +352 -345
- package/src/config/config.test.ts +3 -1
- package/src/config/index.ts +615 -727
- package/src/config/remote.ts +214 -369
- package/src/context/index.ts +6 -12
- package/src/context/transaction.ts +23 -30
- package/src/effect/errors.ts +45 -0
- package/src/effect/layers.ts +26 -0
- package/src/effect/runtime.ts +19 -0
- package/src/effect/services.ts +154 -0
- package/src/index.ts +291 -369
- package/src/metrics/index.ts +46 -46
- package/src/pricing/models-dev.ts +104 -106
- package/src/providers/auth.ts +159 -200
- package/src/providers/index.ts +19 -2
- package/src/providers/model.ts +115 -135
- package/src/providers/openai.ts +3 -3
- package/src/resources/impls/git.ts +123 -146
- package/src/resources/impls/npm.test.ts +16 -5
- package/src/resources/impls/npm.ts +66 -75
- package/src/resources/index.ts +6 -1
- package/src/resources/schema.ts +7 -6
- package/src/resources/service.test.ts +13 -12
- package/src/resources/service.ts +153 -112
- package/src/stream/index.ts +1 -1
- package/src/stream/service.test.ts +5 -5
- package/src/stream/service.ts +282 -293
- package/src/tools/glob.ts +126 -141
- package/src/tools/grep.ts +205 -210
- package/src/tools/index.ts +8 -4
- package/src/tools/list.ts +118 -140
- package/src/tools/read.ts +209 -235
- package/src/tools/virtual-sandbox.ts +91 -83
- package/src/validation/index.ts +18 -22
- package/src/vfs/virtual-fs.test.ts +37 -25
- package/src/vfs/virtual-fs.ts +218 -216
package/src/index.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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 {
|
|
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 {
|
|
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 =
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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 (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
path:
|
|
312
|
-
specialNotes:
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
package:
|
|
319
|
-
version:
|
|
320
|
-
specialNotes:
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
444
|
+
setQuietMetrics(true);
|
|
529
445
|
}
|
|
530
446
|
|
|
531
447
|
const requestedPort = options.port ?? PORT;
|
|
532
|
-
|
|
448
|
+
metricsInfo('server.starting', { port: requestedPort });
|
|
533
449
|
|
|
534
|
-
const config = await
|
|
535
|
-
|
|
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((
|
|
455
|
+
resources: config.resources.map((resource) => resource.name),
|
|
540
456
|
resourcesDirectory: config.resourcesDirectory
|
|
541
457
|
});
|
|
542
458
|
|
|
543
|
-
const resources =
|
|
544
|
-
const collections =
|
|
545
|
-
const agent =
|
|
546
|
-
|
|
547
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
+
metricsInfo('server.shutdown', { reason: 'signal' });
|
|
578
500
|
server.stop();
|
|
579
501
|
process.exit(0);
|
|
580
502
|
};
|