btca-server 2.0.2 → 2.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "btca-server",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "BTCA server for answering questions about your codebase using OpenCode AI",
5
5
  "author": "Ben Davis",
6
6
  "license": "MIT",
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2
2
  import { promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
+ import { Effect } from 'effect';
5
6
 
6
7
  import { createAgentService } from './service.ts';
7
8
  import { load as loadConfig } from '../config/index.ts';
@@ -71,10 +72,12 @@ describe('Agent', () => {
71
72
  vfsId
72
73
  };
73
74
 
74
- const result = await agent.ask({
75
- collection,
76
- question: 'What number is the answer to life according to the README?'
77
- });
75
+ const result = await Effect.runPromise(
76
+ agent.ask({
77
+ collection,
78
+ question: 'What number is the answer to life according to the README?'
79
+ })
80
+ );
78
81
 
79
82
  expect(result).toBeDefined();
80
83
  expect(result.answer).toBeDefined();
@@ -102,10 +105,12 @@ describe('Agent', () => {
102
105
  vfsId
103
106
  };
104
107
 
105
- const { stream, model } = await agent.askStream({
106
- collection,
107
- question: 'What is the capital of France according to the data file?'
108
- });
108
+ const { stream, model } = await Effect.runPromise(
109
+ agent.askStream({
110
+ collection,
111
+ question: 'What is the capital of France according to the data file?'
112
+ })
113
+ );
109
114
 
110
115
  expect(model).toBeDefined();
111
116
  expect(model.provider).toBeDefined();
@@ -154,10 +159,12 @@ describe('Agent', () => {
154
159
  vfsId
155
160
  };
156
161
 
157
- const result = await agent.ask({
158
- collection,
159
- question: 'What number is the answer according to the README?'
160
- });
162
+ const result = await Effect.runPromise(
163
+ agent.ask({
164
+ collection,
165
+ question: 'What number is the answer according to the README?'
166
+ })
167
+ );
161
168
 
162
169
  expect(result).toBeDefined();
163
170
  expect(hasVirtualFs(vfsId)).toBe(false);
@@ -200,10 +207,12 @@ describe('Agent', () => {
200
207
  vfsId
201
208
  };
202
209
 
203
- const { stream } = await agent.askStream({
204
- collection,
205
- question: 'What is the capital of France according to the README?'
206
- });
210
+ const { stream } = await Effect.runPromise(
211
+ agent.askStream({
212
+ collection,
213
+ question: 'What is the capital of France according to the README?'
214
+ })
215
+ );
207
216
 
208
217
  for await (const _event of stream) {
209
218
  // drain stream to trigger cleanup
package/src/agent/loop.ts CHANGED
@@ -57,35 +57,57 @@ export type AgentLoopResult = {
57
57
  events: AgentEvent[];
58
58
  };
59
59
 
60
+ const BASE_PROMPT = `
61
+ You are btca, an expert research agent. Your job is to answer questions from the user by searching the resources at your disposal.
62
+
63
+ <personality_and_writing_controls>
64
+ - Persona: an expert professional researcher
65
+ - Channel: internal
66
+ - Emotional register: direct, calm, and concise
67
+ - Formatting: bulleted/numbered lists are good + codeblocks
68
+ - Length: be thorough with your response, don't let it get too long though
69
+ - Default follow-through: don't ask permission to do the research, just do it and answer the question. ask for clarifications + suggest good follow up if needed
70
+ </personality_and_writing_controls>
71
+
72
+ <parallel_tool_calling>
73
+ - When multiple retrieval or lookup steps are independent, prefer parallel tool calls to reduce wall-clock time.
74
+ - Do not parallelize steps that have prerequisite dependencies or where one result determines the next action.
75
+ - After parallel retrieval, pause to synthesize the results before making more calls.
76
+ - Prefer selective parallelism: parallelize independent evidence gathering, not speculative or redundant tool use.
77
+ </parallel_tool_calling>
78
+
79
+ <tool_persistence_rules>
80
+ - Use tools whenever they materially improve correctness, completeness, or grounding.
81
+ - Do NOT stop early to save tool calls.
82
+ - Keep calling tools until either:
83
+ 1) the task is complete
84
+ 2) you've hit a doom loop where none of the tools function or something is missing
85
+ - If a tool returns empty/partial results, retry with a different strategy (query, filters, alternate source).
86
+ </tool_persistence_rules>
87
+
88
+ <completeness_contract>
89
+ - Treat the task as incomplete until you have a complete answer to the user's question that's grounded
90
+ - If any item is blocked by missing data, mark it [blocked] and state exactly what is missing.
91
+ </completeness_contract>
92
+
93
+ <dig_deeper_nudge>
94
+ - Don't stop at the first plausible answer.
95
+ - Look for second-order issues, edge cases, and missing constraints.
96
+ </dig_deeper_nudge>
97
+
98
+ <output_contract>
99
+ - Return a thorough answer to the user's question with real code examples
100
+ - Always output in proper markdown format
101
+ - Always include sources for your answer:
102
+ - For git resources, source links must be full github blob urls
103
+ - In "Sources", format git citations as markdown links: - [repo/relative/path.ext](https://github.com/.../blob/.../repo/relative/path.ext)".'
104
+ - For local resources cite local file paths
105
+ - For npm resources cite the path in the npm package
106
+ </output_contract>
107
+ `;
108
+
60
109
  const buildSystemPrompt = (agentInstructions: string): string =>
61
- [
62
- 'You are btca, an expert documentation search agent.',
63
- 'Your job is to answer questions by searching through the collection of resources.',
64
- '',
65
- 'You have access to the following tools:',
66
- '- read: Read file contents with line numbers',
67
- '- grep: Search file contents using regex patterns',
68
- '- glob: Find files matching glob patterns',
69
- '- list: List directory contents',
70
- '',
71
- 'Guidelines:',
72
- '- Ground answers in the loaded resources. Do not rely on unstated prior knowledge.',
73
- '- Search efficiently: start with one focused list/glob pass, then read likely files; only expand search when evidence is insufficient.',
74
- '- Prefer targeted grep/read over broad repeated scans once candidate files are known.',
75
- '- Give clear, unambiguous answers. State assumptions, prerequisites, and important version-sensitive caveats.',
76
- '- For implementation/how-to questions, provide complete step-by-step instructions with commands and code snippets.',
77
- '- Be concise but thorough in your responses.',
78
- '- End every answer with a "Sources" section.',
79
- '- For git resources, source links must be full GitHub blob URLs.',
80
- '- In "Sources", format git citations as markdown links: "- [repo/relative/path.ext](https://github.com/.../blob/.../repo/relative/path.ext)".',
81
- '- Do not use raw URLs as link labels.',
82
- '- Do not repeat a URL in parentheses after a link.',
83
- '- Do not output sources in "url (url)" format.',
84
- '- For local resources, cite local file paths (no GitHub URL required).',
85
- '- If you cannot find the answer, say so clearly',
86
- '',
87
- agentInstructions
88
- ].join('\n');
110
+ [BASE_PROMPT, agentInstructions].join('\n');
89
111
 
90
112
  const createTools = (basePath: string, vfsId?: string) => ({
91
113
  read: tool({
@@ -87,11 +87,7 @@ export class ProviderNotConnectedError extends Error {
87
87
  }
88
88
 
89
89
  export type AgentService = {
90
- askStream: (args: { collection: CollectionResult; question: string }) => Promise<{
91
- stream: AsyncIterable<AgentEvent>;
92
- model: { provider: string; model: string };
93
- }>;
94
- askStreamEffect: (args: { collection: CollectionResult; question: string }) => Effect.Effect<
90
+ askStream: (args: { collection: CollectionResult; question: string }) => Effect.Effect<
95
91
  {
96
92
  stream: AsyncIterable<AgentEvent>;
97
93
  model: { provider: string; model: string };
@@ -99,17 +95,12 @@ export type AgentService = {
99
95
  unknown
100
96
  >;
101
97
 
102
- ask: (args: { collection: CollectionResult; question: string }) => Promise<AgentResult>;
103
- askEffect: (args: {
98
+ ask: (args: {
104
99
  collection: CollectionResult;
105
100
  question: string;
106
101
  }) => Effect.Effect<AgentResult, unknown>;
107
102
 
108
- listProviders: () => Promise<{
109
- all: { id: string; models: Record<string, unknown> }[];
110
- connected: string[];
111
- }>;
112
- listProvidersEffect: () => Effect.Effect<
103
+ listProviders: () => Effect.Effect<
113
104
  {
114
105
  all: { id: string; models: Record<string, unknown> }[];
115
106
  connected: string[];
@@ -121,36 +112,39 @@ export type AgentService = {
121
112
  export type Service = AgentService;
122
113
 
123
114
  export const createAgentService = (config: ConfigServiceShape): AgentService => {
124
- const cleanupCollection = (collection: CollectionResult) =>
125
- Effect.promise(async () => {
126
- if (collection.vfsId) {
127
- disposeVirtualFs(collection.vfsId);
128
- clearVirtualCollectionMetadata(collection.vfsId);
129
- }
130
- try {
131
- await collection.cleanup?.();
132
- } catch {
133
- return;
134
- }
135
- });
115
+ const cleanupCollection = async (collection: CollectionResult) => {
116
+ if (collection.vfsId) {
117
+ disposeVirtualFs(collection.vfsId);
118
+ clearVirtualCollectionMetadata(collection.vfsId);
119
+ }
120
+ try {
121
+ await collection.cleanup?.();
122
+ } catch {
123
+ return;
124
+ }
125
+ };
136
126
 
137
- const ensureProviderConnected = Effect.fn(function* () {
138
- const isAuthed = yield* Effect.tryPromise(() => isAuthenticated(config.provider));
127
+ const ensureProviderConnected = async () => {
128
+ const isAuthed = await isAuthenticated(config.provider);
139
129
  const requiresAuth = config.provider !== 'opencode' && config.provider !== 'openai-compat';
140
130
  if (isAuthed || !requiresAuth) return;
141
- const authenticated = yield* Effect.tryPromise(() => getAuthenticatedProviders());
142
- yield* Effect.fail(
143
- new ProviderNotConnectedError({
144
- providerId: config.provider,
145
- connectedProviders: authenticated
146
- })
147
- );
148
- });
131
+ const authenticated = await getAuthenticatedProviders();
132
+ throw new ProviderNotConnectedError({
133
+ providerId: config.provider,
134
+ connectedProviders: authenticated
135
+ });
136
+ };
149
137
 
150
138
  /**
151
139
  * Ask a question and stream the response using the new AI SDK loop
152
140
  */
153
- const askStream: AgentService['askStream'] = async ({ collection, question }) => {
141
+ const askStreamImpl = async ({
142
+ collection,
143
+ question
144
+ }: {
145
+ collection: CollectionResult;
146
+ question: string;
147
+ }) => {
154
148
  metricsInfo('agent.ask.start', {
155
149
  provider: config.provider,
156
150
  model: config.model,
@@ -158,9 +152,9 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
158
152
  });
159
153
 
160
154
  try {
161
- await Effect.runPromise(ensureProviderConnected());
155
+ await ensureProviderConnected();
162
156
  } catch (error) {
163
- await Effect.runPromise(cleanupCollection(collection));
157
+ await cleanupCollection(collection);
164
158
  throw error;
165
159
  }
166
160
 
@@ -181,7 +175,7 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
181
175
  yield event;
182
176
  }
183
177
  } finally {
184
- await Effect.runPromise(cleanupCollection(collection));
178
+ await cleanupCollection(collection);
185
179
  }
186
180
  })();
187
181
 
@@ -194,103 +188,99 @@ export const createAgentService = (config: ConfigServiceShape): AgentService =>
194
188
  /**
195
189
  * Ask a question and return the complete response
196
190
  */
197
- const ask: AgentService['ask'] = async ({ collection, question }) => {
198
- return Effect.runPromise(
199
- Effect.gen(function* () {
200
- metricsInfo('agent.ask.start', {
201
- provider: config.provider,
202
- model: config.model,
203
- questionLength: question.length
204
- });
191
+ const askImpl = async ({
192
+ collection,
193
+ question
194
+ }: {
195
+ collection: CollectionResult;
196
+ question: string;
197
+ }) => {
198
+ try {
199
+ metricsInfo('agent.ask.start', {
200
+ provider: config.provider,
201
+ model: config.model,
202
+ questionLength: question.length
203
+ });
205
204
 
206
- yield* ensureProviderConnected();
205
+ await ensureProviderConnected();
207
206
 
208
- const result = yield* Effect.tryPromise({
209
- try: () =>
210
- runAgentLoop({
211
- providerId: config.provider,
212
- modelId: config.model,
213
- maxSteps: config.maxSteps,
214
- collectionPath: collection.path,
215
- vfsId: collection.vfsId,
216
- agentInstructions: collection.agentInstructions,
217
- question,
218
- providerOptions: config.getProviderOptions(config.provider)
219
- }),
220
- catch: (cause) =>
221
- new AgentError({
222
- message: getErrorMessage(cause),
223
- hint:
224
- getErrorHint(cause) ??
225
- 'This may be a temporary issue. Try running the command again.',
226
- cause
227
- })
207
+ let result: Awaited<ReturnType<typeof runAgentLoop>>;
208
+ try {
209
+ result = await runAgentLoop({
210
+ providerId: config.provider,
211
+ modelId: config.model,
212
+ maxSteps: config.maxSteps,
213
+ collectionPath: collection.path,
214
+ vfsId: collection.vfsId,
215
+ agentInstructions: collection.agentInstructions,
216
+ question,
217
+ providerOptions: config.getProviderOptions(config.provider)
228
218
  });
229
-
230
- metricsInfo('agent.ask.complete', {
231
- provider: config.provider,
232
- model: config.model,
233
- answerLength: result.answer.length,
234
- eventCount: result.events.length
219
+ } catch (cause) {
220
+ throw new AgentError({
221
+ message: getErrorMessage(cause),
222
+ hint:
223
+ getErrorHint(cause) ?? 'This may be a temporary issue. Try running the command again.',
224
+ cause
235
225
  });
226
+ }
236
227
 
237
- return {
238
- answer: result.answer,
239
- model: result.model,
240
- events: result.events
241
- };
242
- }).pipe(
243
- Effect.tapError((error) =>
244
- Effect.sync(() => metricsError('agent.ask.error', { error: metricsErrorInfo(error) }))
245
- ),
246
- Effect.ensuring(cleanupCollection(collection))
247
- )
248
- );
228
+ metricsInfo('agent.ask.complete', {
229
+ provider: config.provider,
230
+ model: config.model,
231
+ answerLength: result.answer.length,
232
+ eventCount: result.events.length
233
+ });
234
+
235
+ return {
236
+ answer: result.answer,
237
+ model: result.model,
238
+ events: result.events
239
+ };
240
+ } catch (error) {
241
+ metricsError('agent.ask.error', { error: metricsErrorInfo(error) });
242
+ throw error;
243
+ } finally {
244
+ await cleanupCollection(collection);
245
+ }
249
246
  };
250
247
 
251
248
  /**
252
249
  * List available providers using local auth data
253
250
  */
254
- const listProviders: AgentService['listProviders'] = async () => {
255
- return Effect.runPromise(
256
- Effect.gen(function* () {
257
- const supportedProviders = getSupportedProviders();
258
- const authenticatedProviders = yield* Effect.tryPromise(() => getAuthenticatedProviders());
259
- const all = supportedProviders.map((id) => ({
260
- id,
261
- models: {} as Record<string, unknown>
262
- }));
251
+ const listProvidersImpl = async () => {
252
+ const supportedProviders = getSupportedProviders();
253
+ const authenticatedProviders = await getAuthenticatedProviders();
254
+ const all = supportedProviders.map((id) => ({
255
+ id,
256
+ models: {} as Record<string, unknown>
257
+ }));
263
258
 
264
- return {
265
- all,
266
- connected: authenticatedProviders
267
- };
268
- })
269
- );
259
+ return {
260
+ all,
261
+ connected: authenticatedProviders
262
+ };
270
263
  };
271
264
 
272
- const askStreamEffect: AgentService['askStreamEffect'] = (args) =>
265
+ const askStream: AgentService['askStream'] = (args) =>
273
266
  Effect.tryPromise({
274
- try: () => askStream(args),
267
+ try: () => askStreamImpl(args),
275
268
  catch: (cause) => cause
276
269
  });
277
- const askEffect: AgentService['askEffect'] = (args) =>
270
+ const ask: AgentService['ask'] = (args) =>
278
271
  Effect.tryPromise({
279
- try: () => ask(args),
272
+ try: () => askImpl(args),
280
273
  catch: (cause) => cause
281
274
  });
282
- const listProvidersEffect: AgentService['listProvidersEffect'] = () =>
275
+ const listProviders: AgentService['listProviders'] = () =>
283
276
  Effect.tryPromise({
284
- try: () => listProviders(),
277
+ try: () => listProvidersImpl(),
285
278
  catch: (cause) => cause
286
279
  });
287
280
 
288
281
  return {
289
282
  askStream,
290
283
  ask,
291
- listProviders,
292
- askStreamEffect,
293
- askEffect,
294
- listProvidersEffect
284
+ listProviders
295
285
  };
296
286
  };