btca-server 2.0.3 → 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.3",
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",
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({
@@ -4,31 +4,45 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
6
  import type { ConfigService } from '../config/index.ts';
7
+ import type { ResourceDefinition } from '../resources/schema.ts';
7
8
  import type { ResourcesService } from '../resources/service.ts';
9
+ import type { BtcaFsResource } from '../resources/types.ts';
8
10
  import { createCollectionsService } from './service.ts';
9
11
  import { disposeVirtualFs, existsInVirtualFs } from '../vfs/virtual-fs.ts';
10
12
 
11
- const createLocalResource = (name: string, resourcePath: string) => ({
13
+ const createFsResource = ({
14
+ name,
15
+ resourcePath,
16
+ type = 'local',
17
+ repoSubPaths = [],
18
+ specialAgentInstructions = ''
19
+ }: {
20
+ name: string;
21
+ resourcePath: string;
22
+ type?: BtcaFsResource['type'];
23
+ repoSubPaths?: readonly string[];
24
+ specialAgentInstructions?: string;
25
+ }) => ({
12
26
  _tag: 'fs-based' as const,
13
27
  name,
14
28
  fsName: name,
15
- type: 'local' as const,
16
- repoSubPaths: [],
17
- specialAgentInstructions: '',
29
+ type,
30
+ repoSubPaths,
31
+ specialAgentInstructions,
18
32
  getAbsoluteDirectoryPath: async () => resourcePath
19
33
  });
20
34
 
21
- const createConfigMock = () =>
35
+ const createConfigMock = (definitions: Record<string, ResourceDefinition> = {}) =>
22
36
  ({
23
- getResource: () => undefined
37
+ getResource: (name: string) => definitions[name]
24
38
  }) as unknown as ConfigService;
25
39
 
26
- const createResourcesMock = (resourcePath: string) =>
40
+ const createResourcesMock = (loadPromise: ResourcesService['loadPromise']) =>
27
41
  ({
28
42
  load: () => {
29
43
  throw new Error('Not implemented in test');
30
44
  },
31
- loadPromise: async () => createLocalResource('repo', resourcePath)
45
+ loadPromise
32
46
  }) as unknown as ResourcesService;
33
47
 
34
48
  const runGit = (cwd: string, args: string[]) => {
@@ -55,7 +69,7 @@ describe('createCollectionsService', () => {
55
69
  const resourcePath = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-collections-git-'));
56
70
  const collections = createCollectionsService({
57
71
  config: createConfigMock(),
58
- resources: createResourcesMock(resourcePath)
72
+ resources: createResourcesMock(async () => createFsResource({ name: 'repo', resourcePath }))
59
73
  });
60
74
 
61
75
  try {
@@ -89,7 +103,7 @@ describe('createCollectionsService', () => {
89
103
  const resourcePath = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-collections-local-'));
90
104
  const collections = createCollectionsService({
91
105
  config: createConfigMock(),
92
- resources: createResourcesMock(resourcePath)
106
+ resources: createResourcesMock(async () => createFsResource({ name: 'repo', resourcePath }))
93
107
  });
94
108
 
95
109
  try {
@@ -109,6 +123,129 @@ describe('createCollectionsService', () => {
109
123
  false
110
124
  );
111
125
  expect(await existsInVirtualFs('/repo/dist/bundle.js', collection.vfsId)).toBe(false);
126
+ expect(collection.agentInstructions).not.toContain('<special_notes>');
127
+ } finally {
128
+ await cleanupCollection(collection);
129
+ }
130
+ } finally {
131
+ await fs.rm(resourcePath, { recursive: true, force: true });
132
+ }
133
+ });
134
+
135
+ it('includes git citation metadata in agent instructions', async () => {
136
+ const resourcePath = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-collections-git-meta-'));
137
+ const collections = createCollectionsService({
138
+ config: createConfigMock({
139
+ docs: {
140
+ type: 'git',
141
+ name: 'docs',
142
+ url: 'https://github.com/example/repo.git',
143
+ branch: 'main',
144
+ searchPath: 'guides',
145
+ specialNotes: 'Prefer the guides folder.'
146
+ }
147
+ }),
148
+ resources: createResourcesMock(async () =>
149
+ createFsResource({
150
+ name: 'docs',
151
+ resourcePath,
152
+ type: 'git',
153
+ repoSubPaths: ['guides'],
154
+ specialAgentInstructions: 'Prefer the guides folder.'
155
+ })
156
+ )
157
+ });
158
+
159
+ try {
160
+ await fs.writeFile(path.join(resourcePath, 'README.md'), 'hello\n');
161
+ runGit(resourcePath, ['init', '-q']);
162
+ runGit(resourcePath, ['config', 'user.email', 'test@example.com']);
163
+ runGit(resourcePath, ['config', 'user.name', 'BTCA Test']);
164
+ runGit(resourcePath, ['add', 'README.md']);
165
+ runGit(resourcePath, ['commit', '-m', 'init']);
166
+
167
+ const collection = await collections.loadPromise({ resourceNames: ['docs'] });
168
+
169
+ try {
170
+ expect(collection.agentInstructions).toContain(
171
+ '<repo_url>https://github.com/example/repo</repo_url>'
172
+ );
173
+ expect(collection.agentInstructions).toContain('<repo_branch>main</repo_branch>');
174
+ expect(collection.agentInstructions).toContain(
175
+ '<github_blob_prefix>https://github.com/example/repo/blob/main</github_blob_prefix>'
176
+ );
177
+ expect(collection.agentInstructions).toContain(
178
+ '<citation_rule>Convert virtual paths under ./docs/ to repo-relative paths, then encode each path segment for GitHub URLs.</citation_rule>'
179
+ );
180
+ expect(collection.agentInstructions).toContain('<path>./docs/guides</path>');
181
+ expect(collection.agentInstructions).toContain('<repo_commit>');
182
+ expect(collection.agentInstructions).toContain(
183
+ '<special_notes>Prefer the guides folder.</special_notes>'
184
+ );
185
+ } finally {
186
+ await cleanupCollection(collection);
187
+ }
188
+ } finally {
189
+ await fs.rm(resourcePath, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ it('includes npm citation metadata in agent instructions', async () => {
194
+ const resourcePath = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-collections-npm-meta-'));
195
+ const collections = createCollectionsService({
196
+ config: createConfigMock({
197
+ react: {
198
+ type: 'npm',
199
+ name: 'react',
200
+ package: 'react',
201
+ version: '19.0.0',
202
+ specialNotes: 'Use package docs.'
203
+ }
204
+ }),
205
+ resources: createResourcesMock(async () =>
206
+ createFsResource({
207
+ name: 'react',
208
+ resourcePath,
209
+ type: 'npm',
210
+ specialAgentInstructions: 'Use package docs.'
211
+ })
212
+ )
213
+ });
214
+
215
+ try {
216
+ await fs.writeFile(
217
+ path.join(resourcePath, '.btca-npm-meta.json'),
218
+ JSON.stringify({
219
+ packageName: 'react',
220
+ resolvedVersion: '19.0.0',
221
+ packageUrl: 'https://www.npmjs.com/package/react'
222
+ })
223
+ );
224
+ await fs.writeFile(path.join(resourcePath, 'README.md'), 'react docs\n');
225
+
226
+ const collection = await collections.loadPromise({ resourceNames: ['react'] });
227
+
228
+ try {
229
+ expect(collection.agentInstructions).toContain('<npm_package>react</npm_package>');
230
+ expect(collection.agentInstructions).toContain('<npm_version>19.0.0</npm_version>');
231
+ expect(collection.agentInstructions).toContain(
232
+ '<npm_url>https://www.npmjs.com/package/react</npm_url>'
233
+ );
234
+ expect(collection.agentInstructions).toContain(
235
+ '<npm_citation_alias>npm:react@19.0.0</npm_citation_alias>'
236
+ );
237
+ expect(collection.agentInstructions).toContain(
238
+ '<npm_file_url_prefix>https://unpkg.com/react@19.0.0</npm_file_url_prefix>'
239
+ );
240
+ expect(collection.agentInstructions).toContain(
241
+ '<citation_rule>In Sources, cite npm files using npm:react@19.0.0/&lt;file&gt; and link them to https://unpkg.com/react@19.0.0/&lt;file&gt;. Do not cite encoded virtual folder names.</citation_rule>'
242
+ );
243
+ expect(collection.agentInstructions).toContain(
244
+ '<citation_example>https://unpkg.com/react@19.0.0/package.json</citation_example>'
245
+ );
246
+ expect(collection.agentInstructions).toContain(
247
+ '<special_notes>Use package docs.</special_notes>'
248
+ );
112
249
  } finally {
113
250
  await cleanupCollection(collection);
114
251
  }
@@ -36,56 +36,110 @@ export type CollectionsService = {
36
36
  }) => Promise<CollectionResult>;
37
37
  };
38
38
 
39
- const encodePathSegments = (value: string) => value.split('/').map(encodeURIComponent).join('/');
39
+ const escapeXml = (value: string) =>
40
+ value
41
+ .replaceAll('&', '&amp;')
42
+ .replaceAll('<', '&lt;')
43
+ .replaceAll('>', '&gt;')
44
+ .replaceAll('"', '&quot;')
45
+ .replaceAll("'", '&apos;');
46
+
47
+ const getResourceTypeLabel = (resource: BtcaFsResource) => {
48
+ if (resource.type === 'git') return 'git repo';
49
+ if (resource.type === 'npm') return 'npm package';
50
+ return 'local directory';
51
+ };
40
52
 
41
53
  const trimGitSuffix = (url: string) => url.replace(/\.git$/u, '').replace(/\/+$/u, '');
54
+
55
+ const encodePathSegments = (value: string) => value.split('/').map(encodeURIComponent).join('/');
56
+
57
+ const xmlLine = (tag: string, value?: string) =>
58
+ value ? `\t\t<${tag}>${escapeXml(value)}</${tag}>` : '';
59
+
60
+ const xmlPathBlock = (tag: string, values: readonly string[], prefix = '') =>
61
+ values.length === 0
62
+ ? ''
63
+ : [
64
+ `\t\t<${tag}>`,
65
+ ...values.map((value) => `\t\t\t<path>${escapeXml(`${prefix}${value}`)}</path>`),
66
+ `\t\t</${tag}>`
67
+ ].join('\n');
68
+
42
69
  const getNpmCitationAlias = (metadata?: VirtualResourceMetadata) => {
43
70
  if (!metadata?.package) return undefined;
44
71
  return `npm:${metadata.package}@${metadata.version ?? 'latest'}`;
45
72
  };
46
73
 
74
+ const getNpmFileUrlPrefix = (metadata?: VirtualResourceMetadata) => {
75
+ if (!metadata?.package || !metadata?.version) return undefined;
76
+ return `https://unpkg.com/${metadata.package}@${metadata.version}`;
77
+ };
78
+
47
79
  const createCollectionInstructionBlock = (
48
80
  resource: BtcaFsResource,
49
81
  metadata?: VirtualResourceMetadata
50
- ): string => {
51
- const focusLines = resource.repoSubPaths.map(
52
- (subPath) => `Focus: ./${resource.fsName}/${subPath}`
53
- );
82
+ ) => {
83
+ const repoUrl =
84
+ resource.type === 'git' && metadata?.url ? trimGitSuffix(metadata.url) : undefined;
54
85
  const gitRef = metadata?.branch ?? metadata?.commit;
55
- const githubPrefix =
56
- resource.type === 'git' && metadata?.url && gitRef
57
- ? `${trimGitSuffix(metadata.url)}/blob/${encodeURIComponent(gitRef)}`
58
- : undefined;
86
+ const githubBlobPrefix =
87
+ repoUrl && gitRef ? `${repoUrl}/blob/${encodeURIComponent(gitRef)}` : undefined;
59
88
  const npmCitationAlias = resource.type === 'npm' ? getNpmCitationAlias(metadata) : undefined;
60
- const lines = [
61
- `## Resource: ${resource.name}`,
62
- FS_RESOURCE_SYSTEM_NOTE,
63
- `Path: ./${resource.fsName}`,
64
- resource.type === 'git' && metadata?.url ? `Repo URL: ${trimGitSuffix(metadata.url)}` : '',
65
- resource.type === 'git' && metadata?.branch ? `Repo Branch: ${metadata.branch}` : '',
66
- resource.type === 'git' && metadata?.commit ? `Repo Commit: ${metadata.commit}` : '',
67
- resource.type === 'npm' && metadata?.package ? `NPM Package: ${metadata.package}` : '',
68
- resource.type === 'npm' && metadata?.version ? `NPM Version: ${metadata.version}` : '',
69
- resource.type === 'npm' && metadata?.url ? `NPM URL: ${metadata.url}` : '',
70
- npmCitationAlias ? `NPM Citation Alias: ${npmCitationAlias}` : '',
71
- githubPrefix ? `GitHub Blob Prefix: ${githubPrefix}` : '',
72
- githubPrefix
73
- ? `GitHub Citation Rule: Convert virtual paths under ./${resource.fsName}/ to repo-relative paths, then encode each path segment for GitHub URLs (example segment: "+page.server.js" -> "${encodeURIComponent('+page.server.js')}").`
74
- : '',
75
- githubPrefix
76
- ? `GitHub Citation Example: ${githubPrefix}/${encodePathSegments('src/routes/blog/+page.server.js')}`
77
- : '',
78
- resource.type !== 'git'
79
- ? 'Citation Rule: Cite local file paths only for this resource (no GitHub URL).'
80
- : '',
81
- npmCitationAlias
82
- ? `NPM Citation Rule: In "Sources", cite npm files using "${npmCitationAlias}/<file>" (for example, "${npmCitationAlias}/README.md"). Do not cite encoded virtual folder names.`
83
- : '',
84
- ...focusLines,
85
- resource.specialAgentInstructions ? `Notes: ${resource.specialAgentInstructions}` : ''
86
- ].filter(Boolean);
87
-
88
- return lines.join('\n');
89
+ const npmFileUrlPrefix = resource.type === 'npm' ? getNpmFileUrlPrefix(metadata) : undefined;
90
+
91
+ return [
92
+ '\t<resource>',
93
+ `\t\t<name>${escapeXml(resource.name)}</name>`,
94
+ `\t\t<type>${getResourceTypeLabel(resource)}</type>`,
95
+ `\t\t<system_note>${escapeXml(FS_RESOURCE_SYSTEM_NOTE)}</system_note>`,
96
+ `\t\t<path>${escapeXml(`./${resource.fsName}`)}</path>`,
97
+ xmlLine('repo_url', repoUrl),
98
+ xmlLine('repo_branch', resource.type === 'git' ? metadata?.branch : undefined),
99
+ xmlLine('repo_commit', resource.type === 'git' ? metadata?.commit : undefined),
100
+ xmlLine('npm_package', resource.type === 'npm' ? metadata?.package : undefined),
101
+ xmlLine('npm_version', resource.type === 'npm' ? metadata?.version : undefined),
102
+ xmlLine('npm_url', resource.type === 'npm' ? metadata?.url : undefined),
103
+ xmlLine('npm_citation_alias', npmCitationAlias),
104
+ xmlLine('npm_file_url_prefix', npmFileUrlPrefix),
105
+ xmlLine('github_blob_prefix', githubBlobPrefix),
106
+ xmlLine(
107
+ 'citation_rule',
108
+ githubBlobPrefix
109
+ ? `Convert virtual paths under ./${resource.fsName}/ to repo-relative paths, then encode each path segment for GitHub URLs.`
110
+ : resource.type === 'npm' && npmCitationAlias
111
+ ? `In Sources, cite npm files using ${npmCitationAlias}/<file> and link them to ${npmFileUrlPrefix ?? 'the exact file URL prefix'}/<file>. Do not cite encoded virtual folder names.`
112
+ : 'Cite local file paths only for this resource.'
113
+ ),
114
+ xmlLine(
115
+ 'citation_example',
116
+ githubBlobPrefix
117
+ ? `${githubBlobPrefix}/${encodePathSegments('src/routes/blog/+page.server.js')}`
118
+ : resource.type === 'npm' && npmCitationAlias && npmFileUrlPrefix
119
+ ? `${npmFileUrlPrefix}/package.json`
120
+ : undefined
121
+ ),
122
+ xmlPathBlock('focus_paths', resource.repoSubPaths, `./${resource.fsName}/`),
123
+ xmlLine('special_notes', resource.specialAgentInstructions),
124
+ '\t</resource>'
125
+ ]
126
+ .filter(Boolean)
127
+ .join('\n');
128
+ };
129
+
130
+ const createCollectionInstructions = (
131
+ resources: readonly BtcaFsResource[],
132
+ metadataResources: readonly VirtualResourceMetadata[]
133
+ ) => {
134
+ const metadataByName = new Map(metadataResources.map((resource) => [resource.name, resource]));
135
+
136
+ return [
137
+ '<available_resources>',
138
+ ...resources.map((resource) =>
139
+ createCollectionInstructionBlock(resource, metadataByName.get(resource.name))
140
+ ),
141
+ '</available_resources>'
142
+ ].join('\n');
89
143
  };
90
144
 
91
145
  const ignoreErrors = async (action: () => Promise<unknown>) => {
@@ -400,16 +454,9 @@ export const createCollectionsService = (args: {
400
454
  resources: metadataResources
401
455
  });
402
456
 
403
- const metadataByName = new Map(
404
- metadataResources.map((resource) => [resource.name, resource])
405
- );
406
- const instructionBlocks = loadedResources.map((resource) =>
407
- createCollectionInstructionBlock(resource, metadataByName.get(resource.name))
408
- );
409
-
410
457
  return {
411
458
  path: collectionPath,
412
- agentInstructions: instructionBlocks.join('\n\n'),
459
+ agentInstructions: createCollectionInstructions(loadedResources, metadataResources),
413
460
  vfsId,
414
461
  cleanup: async () => {
415
462
  await cleanupResources(loadedResources);