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.
package/README.md ADDED
@@ -0,0 +1,195 @@
1
+ # @btca/server
2
+
3
+ BTCA (Better Context AI) server for answering questions about your codebase using OpenCode AI.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @btca/server
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Starting the Server
14
+
15
+ ```typescript
16
+ import { startServer } from '@btca/server';
17
+
18
+ // Start with default options (port 8080 or process.env.PORT)
19
+ const server = await startServer();
20
+ console.log(`Server running at ${server.url}`);
21
+
22
+ // Start with custom port
23
+ const server = await startServer({ port: 3000 });
24
+
25
+ // Start with quiet mode (no logging)
26
+ const server = await startServer({ port: 3000, quiet: true });
27
+
28
+ // Stop the server when needed
29
+ server.stop();
30
+ ```
31
+
32
+ ### Server Instance
33
+
34
+ The `startServer` function returns a `ServerInstance` object:
35
+
36
+ ```typescript
37
+ interface ServerInstance {
38
+ port: number; // Actual port the server is running on
39
+ url: string; // Full URL (e.g., "http://localhost:8080")
40
+ stop: () => void; // Function to stop the server
41
+ }
42
+ ```
43
+
44
+ ### Random Port Assignment
45
+
46
+ You can pass `port: 0` to let the OS assign a random available port:
47
+
48
+ ```typescript
49
+ const server = await startServer({ port: 0 });
50
+ console.log(`Server running on port ${server.port}`);
51
+ ```
52
+
53
+ ## API Endpoints
54
+
55
+ Once the server is running, it exposes the following REST API endpoints:
56
+
57
+ ### Health Check
58
+
59
+ ```
60
+ GET /
61
+ ```
62
+
63
+ Returns service status and version info.
64
+
65
+ ### Configuration
66
+
67
+ ```
68
+ GET /config
69
+ ```
70
+
71
+ Returns current configuration (provider, model, resources).
72
+
73
+ ### Resources
74
+
75
+ ```
76
+ GET /resources
77
+ ```
78
+
79
+ Lists all configured resources (local directories or git repositories).
80
+
81
+ ```
82
+ POST /config/resources
83
+ ```
84
+
85
+ Add a new resource (git or local).
86
+
87
+ ```
88
+ DELETE /config/resources
89
+ ```
90
+
91
+ Remove a resource by name.
92
+
93
+ ```
94
+ POST /clear
95
+ ```
96
+
97
+ Clear all locally cloned resources.
98
+
99
+ ### Questions
100
+
101
+ ```
102
+ POST /question
103
+ ```
104
+
105
+ Ask a question (non-streaming response).
106
+
107
+ ```
108
+ POST /question/stream
109
+ ```
110
+
111
+ Ask a question with streaming SSE response.
112
+
113
+ ### OpenCode Instance
114
+
115
+ ```
116
+ POST /opencode
117
+ ```
118
+
119
+ Get an OpenCode instance URL for a collection of resources.
120
+
121
+ ### Model Configuration
122
+
123
+ ```
124
+ PUT /config/model
125
+ ```
126
+
127
+ Update the AI provider and model configuration.
128
+
129
+ ## Configuration
130
+
131
+ The server reads configuration from `~/.btca/config.toml` or your local project's `.btca/config.toml`. You'll need to configure:
132
+
133
+ - **AI Provider**: OpenCode AI provider (e.g., "anthropic")
134
+ - **Model**: AI model to use (e.g., "claude-3-7-sonnet-20250219")
135
+ - **Resources**: Local directories or git repositories to query
136
+
137
+ Example config.toml:
138
+
139
+ ```toml
140
+ provider = "anthropic"
141
+ model = "claude-3-7-sonnet-20250219"
142
+ resourcesDirectory = "~/.btca/resources"
143
+ collectionsDirectory = "~/.btca/collections"
144
+
145
+ [[resources]]
146
+ type = "local"
147
+ name = "my-project"
148
+ path = "/path/to/my/project"
149
+
150
+ [[resources]]
151
+ type = "git"
152
+ name = "some-repo"
153
+ url = "https://github.com/user/repo"
154
+ branch = "main"
155
+ ```
156
+
157
+ ## Environment Variables
158
+
159
+ - `PORT`: Server port (default: 8080)
160
+ - `OPENCODE_API_KEY`: OpenCode AI API key (required)
161
+
162
+ ## TypeScript Types
163
+
164
+ The package exports TypeScript types for use with Hono RPC client:
165
+
166
+ ```typescript
167
+ import type { AppType } from '@btca/server';
168
+ import { hc } from 'hono/client';
169
+
170
+ const client = hc<AppType>('http://localhost:8080');
171
+ ```
172
+
173
+ ## Stream Types
174
+
175
+ For working with SSE streaming responses:
176
+
177
+ ```typescript
178
+ import type {
179
+ BtcaStreamEvent,
180
+ BtcaStreamMetaEvent
181
+ } from '@btca/server/stream/types';
182
+ ```
183
+
184
+ ## Requirements
185
+
186
+ - **Bun**: >= 1.1.0 (this package is designed specifically for Bun runtime)
187
+ - **OpenCode AI API Key**: Required for AI functionality
188
+
189
+ ## License
190
+
191
+ MIT
192
+
193
+ ## Repository
194
+
195
+ [https://github.com/bmdavis419/better-context](https://github.com/bmdavis419/better-context)
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "btca-server",
3
+ "version": "1.0.20",
4
+ "description": "BTCA server for answering questions about your codebase using OpenCode AI",
5
+ "author": "Ben Davis",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/bmdavis419/better-context",
10
+ "directory": "apps/server"
11
+ },
12
+ "keywords": [
13
+ "btca",
14
+ "opencode",
15
+ "ai",
16
+ "codebase",
17
+ "documentation",
18
+ "server",
19
+ "api",
20
+ "bun"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "bun": ">=1.1.0"
27
+ },
28
+ "module": "src/index.ts",
29
+ "type": "module",
30
+ "exports": {
31
+ ".": "./src/index.ts",
32
+ "./stream": "./src/stream/index.ts",
33
+ "./stream/types": "./src/stream/types.ts"
34
+ },
35
+ "files": [
36
+ "src",
37
+ "README.md"
38
+ ],
39
+ "scripts": {
40
+ "check": "tsgo --noEmit",
41
+ "dev": "bun --watch src/index.ts",
42
+ "format": "prettier --write .",
43
+ "test": "bun test",
44
+ "test:watch": "bun test --watch"
45
+ },
46
+ "devDependencies": {
47
+ "@types/bun": "latest",
48
+ "@typescript/native-preview": "^7.0.0-dev.20260109.1",
49
+ "prettier": "^3.7.4"
50
+ },
51
+ "dependencies": {
52
+ "@opencode-ai/sdk": "^1.0.208",
53
+ "hono": "^4.7.11",
54
+ "zod": "^3.25.76"
55
+ }
56
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ import { Agent } from './service.ts';
7
+ import { Config } from '../config/index.ts';
8
+ import type { CollectionResult } from '../collections/types.ts';
9
+
10
+ describe('Agent', () => {
11
+ let testDir: string;
12
+ let originalCwd: string;
13
+ let originalHome: string | undefined;
14
+
15
+ beforeEach(async () => {
16
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-agent-test-'));
17
+ originalCwd = process.cwd();
18
+ originalHome = process.env.HOME;
19
+ process.env.HOME = testDir;
20
+ });
21
+
22
+ afterEach(async () => {
23
+ process.chdir(originalCwd);
24
+ process.env.HOME = originalHome;
25
+ await fs.rm(testDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('Agent.create', () => {
29
+ it('creates an agent service with ask and askStream methods', async () => {
30
+ process.chdir(testDir);
31
+ const config = await Config.load();
32
+ const agent = Agent.create(config);
33
+
34
+ expect(agent).toBeDefined();
35
+ expect(typeof agent.ask).toBe('function');
36
+ expect(typeof agent.askStream).toBe('function');
37
+ });
38
+ });
39
+
40
+ // Integration tests - require valid OpenCode credentials and provider
41
+ // Run with: BTCA_RUN_INTEGRATION_TESTS=1 bun test
42
+ describe.skipIf(!process.env.BTCA_RUN_INTEGRATION_TESTS)('Agent.ask (integration)', () => {
43
+ it('asks a question and receives an answer', async () => {
44
+ // Create a simple collection directory with a test file
45
+ const collectionPath = path.join(testDir, 'test-collection');
46
+ await fs.mkdir(collectionPath, { recursive: true });
47
+ await fs.writeFile(
48
+ path.join(collectionPath, 'README.md'),
49
+ '# Test Documentation\n\nThis is a test file. The answer to life is 42.'
50
+ );
51
+
52
+ process.chdir(testDir);
53
+ const config = await Config.load();
54
+ const agent = Agent.create(config);
55
+
56
+ const collection: CollectionResult = {
57
+ path: collectionPath,
58
+ agentInstructions: 'This is a test collection with a README file.'
59
+ };
60
+
61
+ const result = await agent.ask({
62
+ collection,
63
+ question: 'What number is the answer to life according to the README?'
64
+ });
65
+
66
+ expect(result).toBeDefined();
67
+ expect(result.answer).toBeDefined();
68
+ expect(typeof result.answer).toBe('string');
69
+ expect(result.answer.length).toBeGreaterThan(0);
70
+ expect(result.model).toBeDefined();
71
+ expect(result.model.provider).toBeDefined();
72
+ expect(result.model.model).toBeDefined();
73
+ expect(result.events).toBeDefined();
74
+ expect(Array.isArray(result.events)).toBe(true);
75
+ }, 60000);
76
+
77
+ it('handles askStream and receives events', async () => {
78
+ const collectionPath = path.join(testDir, 'stream-collection');
79
+ await fs.mkdir(collectionPath, { recursive: true });
80
+ await fs.writeFile(path.join(collectionPath, 'data.txt'), 'The capital of France is Paris.');
81
+
82
+ process.chdir(testDir);
83
+ const config = await Config.load();
84
+ const agent = Agent.create(config);
85
+
86
+ const collection: CollectionResult = {
87
+ path: collectionPath,
88
+ agentInstructions: 'Simple test collection.'
89
+ };
90
+
91
+ const { stream, model } = await agent.askStream({
92
+ collection,
93
+ question: 'What is the capital of France according to the data file?'
94
+ });
95
+
96
+ expect(model).toBeDefined();
97
+ expect(model.provider).toBeDefined();
98
+ expect(model.model).toBeDefined();
99
+
100
+ const events = [];
101
+ for await (const event of stream) {
102
+ events.push(event);
103
+ }
104
+
105
+ expect(events.length).toBeGreaterThan(0);
106
+ // Should have received some message.part.updated events
107
+ const textEvents = events.filter((e) => e.type === 'message.part.updated');
108
+ expect(textEvents.length).toBeGreaterThan(0);
109
+ }, 60000);
110
+ });
111
+ });
@@ -0,0 +1,2 @@
1
+ export { Agent } from './service.ts';
2
+ export type { AgentResult, OcEvent, SessionState } from './types.ts';
@@ -0,0 +1,328 @@
1
+ import {
2
+ createOpencode,
3
+ createOpencodeClient,
4
+ type Config as OpenCodeConfig,
5
+ type OpencodeClient,
6
+ type Event as OcEvent
7
+ } from '@opencode-ai/sdk';
8
+
9
+ import { Config } from '../config/index.ts';
10
+ import { Metrics } from '../metrics/index.ts';
11
+ import type { CollectionResult } from '../collections/types.ts';
12
+ import type { AgentResult } from './types.ts';
13
+
14
+ export namespace Agent {
15
+ export class AgentError extends Error {
16
+ readonly _tag = 'AgentError';
17
+ override readonly cause?: unknown;
18
+
19
+ constructor(args: { message: string; cause?: unknown }) {
20
+ super(args.message);
21
+ this.cause = args.cause;
22
+ }
23
+ }
24
+
25
+ export class InvalidProviderError extends Error {
26
+ readonly _tag = 'InvalidProviderError';
27
+ readonly providerId: string;
28
+ readonly availableProviders: string[];
29
+
30
+ constructor(args: { providerId: string; availableProviders: string[] }) {
31
+ super(`Invalid provider: ${args.providerId}`);
32
+ this.providerId = args.providerId;
33
+ this.availableProviders = args.availableProviders;
34
+ }
35
+ }
36
+
37
+ export class InvalidModelError extends Error {
38
+ readonly _tag = 'InvalidModelError';
39
+ readonly providerId: string;
40
+ readonly modelId: string;
41
+ readonly availableModels: string[];
42
+
43
+ constructor(args: { providerId: string; modelId: string; availableModels: string[] }) {
44
+ super(`Invalid model: ${args.modelId}`);
45
+ this.providerId = args.providerId;
46
+ this.modelId = args.modelId;
47
+ this.availableModels = args.availableModels;
48
+ }
49
+ }
50
+
51
+ export class ProviderNotConnectedError extends Error {
52
+ readonly _tag = 'ProviderNotConnectedError';
53
+ readonly providerId: string;
54
+ readonly connectedProviders: string[];
55
+
56
+ constructor(args: { providerId: string; connectedProviders: string[] }) {
57
+ super(`Provider not connected: ${args.providerId}`);
58
+ this.providerId = args.providerId;
59
+ this.connectedProviders = args.connectedProviders;
60
+ }
61
+ }
62
+
63
+ export type Service = {
64
+ askStream: (args: {
65
+ collection: CollectionResult;
66
+ question: string;
67
+ }) => Promise<{ stream: AsyncIterable<OcEvent>; model: { provider: string; model: string } }>;
68
+
69
+ ask: (args: { collection: CollectionResult; question: string }) => Promise<AgentResult>;
70
+
71
+ getOpencodeInstance: (args: { collection: CollectionResult }) => Promise<{
72
+ url: string;
73
+ model: { provider: string; model: string };
74
+ }>;
75
+ };
76
+
77
+ const buildOpenCodeConfig = (args: { agentInstructions: string }): OpenCodeConfig => {
78
+ const prompt = [
79
+ 'You are an expert internal agent who`s job is to answer questions about the collection.',
80
+ 'You operate inside a collection directory.',
81
+ 'Use the resources in this collection to answer the user`s question.',
82
+ args.agentInstructions
83
+ ].join('\n');
84
+
85
+ return {
86
+ agent: {
87
+ build: { disable: true },
88
+ explore: { disable: true },
89
+ general: { disable: true },
90
+ plan: { disable: true },
91
+ docs: {
92
+ prompt,
93
+ description: 'Answer questions by searching the collection',
94
+ permission: {
95
+ webfetch: 'deny',
96
+ edit: 'deny',
97
+ bash: 'deny',
98
+ external_directory: 'deny',
99
+ doom_loop: 'deny'
100
+ },
101
+ mode: 'primary',
102
+ tools: {
103
+ write: false,
104
+ bash: false,
105
+ delete: false,
106
+ read: true,
107
+ grep: true,
108
+ glob: true,
109
+ list: true,
110
+ path: false,
111
+ todowrite: false,
112
+ todoread: false,
113
+ websearch: false,
114
+ webfetch: false,
115
+ skill: false,
116
+ task: false,
117
+ mcp: false,
118
+ edit: false
119
+ }
120
+ }
121
+ }
122
+ };
123
+ };
124
+
125
+ const validateProviderAndModel = async (
126
+ client: OpencodeClient,
127
+ providerId: string,
128
+ modelId: string
129
+ ) => {
130
+ const response = await client.provider.list().catch(() => null);
131
+ if (!response?.data) return;
132
+
133
+ type ProviderInfo = { id: string; models: Record<string, unknown> };
134
+ const data = response.data as { all: ProviderInfo[]; connected: string[] };
135
+
136
+ const { all, connected } = data;
137
+ const provider = all.find((p) => p.id === providerId);
138
+ if (!provider)
139
+ throw new InvalidProviderError({ providerId, availableProviders: all.map((p) => p.id) });
140
+ if (!connected.includes(providerId)) {
141
+ throw new ProviderNotConnectedError({ providerId, connectedProviders: connected });
142
+ }
143
+
144
+ const modelIds = Object.keys(provider.models);
145
+ if (!modelIds.includes(modelId)) {
146
+ throw new InvalidModelError({ providerId, modelId, availableModels: modelIds });
147
+ }
148
+ };
149
+
150
+ const getOpencodeInstance = async (args: {
151
+ collectionPath: string;
152
+ ocConfig: OpenCodeConfig;
153
+ }): Promise<{
154
+ client: OpencodeClient;
155
+ server: { close(): void; url: string };
156
+ baseUrl: string;
157
+ }> => {
158
+ const maxAttempts = 10;
159
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
160
+ const port = Math.floor(Math.random() * 3000) + 3000;
161
+ const created = await createOpencode({ port, config: args.ocConfig }).catch((err: any) => {
162
+ if (err?.cause instanceof Error && err.cause.stack?.includes('port')) return null;
163
+ throw new AgentError({ message: 'Failed to create OpenCode instance', cause: err });
164
+ });
165
+
166
+ if (created) {
167
+ const baseUrl = `http://localhost:${port}`;
168
+ return {
169
+ client: createOpencodeClient({ baseUrl, directory: args.collectionPath }),
170
+ server: created.server,
171
+ baseUrl
172
+ };
173
+ }
174
+ }
175
+
176
+ throw new AgentError({
177
+ message: 'Failed to create OpenCode instance - all port attempts exhausted'
178
+ });
179
+ };
180
+
181
+ const sessionEvents = async (args: {
182
+ sessionID: string;
183
+ client: OpencodeClient;
184
+ }): Promise<AsyncIterable<OcEvent>> => {
185
+ const events = await args.client.event.subscribe().catch((cause: unknown) => {
186
+ throw new AgentError({ message: 'Failed to subscribe to events', cause });
187
+ });
188
+
189
+ async function* gen() {
190
+ for await (const event of events.stream) {
191
+ const props = event.properties as any;
192
+ if (props && 'sessionID' in props && props.sessionID !== args.sessionID) continue;
193
+ yield event;
194
+ if (
195
+ event.type === 'session.idle' &&
196
+ (event.properties as any)?.sessionID === args.sessionID
197
+ )
198
+ return;
199
+ }
200
+ }
201
+
202
+ return gen();
203
+ };
204
+
205
+ const extractAnswerFromEvents = (events: readonly OcEvent[]): string => {
206
+ const partIds: string[] = [];
207
+ const partText = new Map<string, string>();
208
+
209
+ for (const event of events) {
210
+ if (event.type !== 'message.part.updated') continue;
211
+ const part: any = (event.properties as any).part;
212
+ if (!part || part.type !== 'text') continue;
213
+ if (!partIds.includes(part.id)) partIds.push(part.id);
214
+ partText.set(part.id, String(part.text ?? ''));
215
+ }
216
+
217
+ return partIds
218
+ .map((id) => partText.get(id) ?? '')
219
+ .join('')
220
+ .trim();
221
+ };
222
+
223
+ export const create = (config: Config.Service): Service => {
224
+ const askStream: Service['askStream'] = async ({ collection, question }) => {
225
+ const ocConfig = buildOpenCodeConfig({ agentInstructions: collection.agentInstructions });
226
+ const { client, server, baseUrl } = await getOpencodeInstance({
227
+ collectionPath: collection.path,
228
+ ocConfig
229
+ });
230
+
231
+ Metrics.info('agent.oc.ready', { baseUrl, collectionPath: collection.path });
232
+
233
+ try {
234
+ try {
235
+ await validateProviderAndModel(client, config.provider, config.model);
236
+ Metrics.info('agent.validate.ok', { provider: config.provider, model: config.model });
237
+ } catch (cause) {
238
+ throw new AgentError({ message: 'Provider/model validation failed', cause });
239
+ }
240
+
241
+ const session = await client.session.create().catch((cause: unknown) => {
242
+ throw new AgentError({ message: 'Failed to create session', cause });
243
+ });
244
+
245
+ if (session.error)
246
+ throw new AgentError({ message: 'Failed to create session', cause: session.error });
247
+
248
+ const sessionID = session.data?.id;
249
+ if (!sessionID) {
250
+ throw new AgentError({
251
+ message: 'Failed to create session',
252
+ cause: new Error('Missing session id')
253
+ });
254
+ }
255
+ Metrics.info('agent.session.created', { sessionID });
256
+
257
+ const eventStream = await sessionEvents({ sessionID, client });
258
+
259
+ Metrics.info('agent.prompt.sent', { sessionID, questionLength: question.length });
260
+ void client.session
261
+ .prompt({
262
+ path: { id: sessionID },
263
+ body: {
264
+ agent: 'docs',
265
+ model: { providerID: config.provider, modelID: config.model },
266
+ parts: [{ type: 'text', text: question }]
267
+ }
268
+ })
269
+ .catch((cause: unknown) => {
270
+ Metrics.error('agent.prompt.err', { error: Metrics.errorInfo(cause) });
271
+ });
272
+
273
+ async function* filtered() {
274
+ try {
275
+ for await (const event of eventStream) {
276
+ if (event.type === 'session.error') {
277
+ const props: any = event.properties;
278
+ throw new AgentError({
279
+ message: props?.error?.name ?? 'Unknown session error',
280
+ cause: props?.error
281
+ });
282
+ }
283
+ yield event;
284
+ }
285
+ } finally {
286
+ Metrics.info('agent.session.closed', { sessionID });
287
+ server.close();
288
+ }
289
+ }
290
+
291
+ return {
292
+ stream: filtered(),
293
+ model: { provider: config.provider, model: config.model }
294
+ };
295
+ } catch (cause) {
296
+ server.close();
297
+ throw cause;
298
+ }
299
+ };
300
+
301
+ const ask: Service['ask'] = async ({ collection, question }) => {
302
+ const { stream, model } = await askStream({ collection, question });
303
+ const events: OcEvent[] = [];
304
+ for await (const event of stream) events.push(event);
305
+ return { answer: extractAnswerFromEvents(events), model, events };
306
+ };
307
+
308
+ const getOpencodeInstanceMethod: Service['getOpencodeInstance'] = async ({ collection }) => {
309
+ const ocConfig = buildOpenCodeConfig({ agentInstructions: collection.agentInstructions });
310
+ const { baseUrl } = await getOpencodeInstance({
311
+ collectionPath: collection.path,
312
+ ocConfig
313
+ });
314
+
315
+ Metrics.info('agent.oc.instance.ready', { baseUrl, collectionPath: collection.path });
316
+
317
+ // Note: The server stays alive - it's the caller's responsibility to manage the lifecycle
318
+ // For CLI usage, the opencode CLI will connect to this instance and manage it
319
+
320
+ return {
321
+ url: baseUrl,
322
+ model: { provider: config.provider, model: config.model }
323
+ };
324
+ };
325
+
326
+ return { askStream, ask, getOpencodeInstance: getOpencodeInstanceMethod };
327
+ };
328
+ }
@@ -0,0 +1,16 @@
1
+ import type { Event as OcEvent, OpencodeClient } from '@opencode-ai/sdk';
2
+
3
+ export type AgentResult = {
4
+ answer: string;
5
+ model: { provider: string; model: string };
6
+ events: OcEvent[];
7
+ };
8
+
9
+ export type SessionState = {
10
+ client: OpencodeClient;
11
+ server: { close: () => void; url: string };
12
+ sessionID: string;
13
+ collectionPath: string;
14
+ };
15
+
16
+ export { type OcEvent };
@@ -0,0 +1,2 @@
1
+ export { Collections } from './service.ts';
2
+ export { CollectionError, getCollectionKey, type CollectionResult } from './types.ts';