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.
@@ -0,0 +1,256 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import type { ConfigService } from '../config/index.ts';
7
+ import type { ResourceDefinition } from '../resources/schema.ts';
8
+ import type { ResourcesService } from '../resources/service.ts';
9
+ import type { BtcaFsResource } from '../resources/types.ts';
10
+ import { createCollectionsService } from './service.ts';
11
+ import { disposeVirtualFs, existsInVirtualFs } from '../vfs/virtual-fs.ts';
12
+
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
+ }) => ({
26
+ _tag: 'fs-based' as const,
27
+ name,
28
+ fsName: name,
29
+ type,
30
+ repoSubPaths,
31
+ specialAgentInstructions,
32
+ getAbsoluteDirectoryPath: async () => resourcePath
33
+ });
34
+
35
+ const createConfigMock = (definitions: Record<string, ResourceDefinition> = {}) =>
36
+ ({
37
+ getResource: (name: string) => definitions[name]
38
+ }) as unknown as ConfigService;
39
+
40
+ const createResourcesMock = (loadPromise: ResourcesService['loadPromise']) =>
41
+ ({
42
+ load: () => {
43
+ throw new Error('Not implemented in test');
44
+ },
45
+ loadPromise
46
+ }) as unknown as ResourcesService;
47
+
48
+ const runGit = (cwd: string, args: string[]) => {
49
+ const result = Bun.spawnSync({
50
+ cmd: ['git', ...args],
51
+ cwd,
52
+ stdout: 'pipe',
53
+ stderr: 'pipe'
54
+ });
55
+ if (result.exitCode !== 0) {
56
+ throw new Error(
57
+ `git ${args.join(' ')} failed: ${new TextDecoder().decode(result.stderr).trim()}`
58
+ );
59
+ }
60
+ };
61
+
62
+ const cleanupCollection = async (collection: { vfsId?: string; cleanup?: () => Promise<void> }) => {
63
+ await collection.cleanup?.();
64
+ if (collection.vfsId) disposeVirtualFs(collection.vfsId);
65
+ };
66
+
67
+ describe('createCollectionsService', () => {
68
+ it('imports git-backed local resources from tracked and unignored files only', async () => {
69
+ const resourcePath = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-collections-git-'));
70
+ const collections = createCollectionsService({
71
+ config: createConfigMock(),
72
+ resources: createResourcesMock(async () => createFsResource({ name: 'repo', resourcePath }))
73
+ });
74
+
75
+ try {
76
+ await fs.mkdir(path.join(resourcePath, 'node_modules', 'pkg'), { recursive: true });
77
+ await fs.writeFile(path.join(resourcePath, '.gitignore'), 'node_modules\n');
78
+ await fs.writeFile(path.join(resourcePath, 'package.json'), '{"name":"repo"}\n');
79
+ await fs.writeFile(path.join(resourcePath, 'README.md'), 'local notes\n');
80
+ await fs.writeFile(path.join(resourcePath, 'node_modules', 'pkg', 'index.js'), 'ignored\n');
81
+
82
+ runGit(resourcePath, ['init', '-q']);
83
+ runGit(resourcePath, ['add', '.gitignore', 'package.json']);
84
+
85
+ const collection = await collections.loadPromise({ resourceNames: ['repo'] });
86
+
87
+ try {
88
+ expect(await existsInVirtualFs('/repo/package.json', collection.vfsId)).toBe(true);
89
+ expect(await existsInVirtualFs('/repo/README.md', collection.vfsId)).toBe(true);
90
+ expect(await existsInVirtualFs('/repo/node_modules/pkg/index.js', collection.vfsId)).toBe(
91
+ false
92
+ );
93
+ expect(await existsInVirtualFs('/repo/.git/config', collection.vfsId)).toBe(false);
94
+ } finally {
95
+ await cleanupCollection(collection);
96
+ }
97
+ } finally {
98
+ await fs.rm(resourcePath, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ it('falls back to directory import and still skips heavy local build directories', async () => {
103
+ const resourcePath = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-collections-local-'));
104
+ const collections = createCollectionsService({
105
+ config: createConfigMock(),
106
+ resources: createResourcesMock(async () => createFsResource({ name: 'repo', resourcePath }))
107
+ });
108
+
109
+ try {
110
+ await fs.mkdir(path.join(resourcePath, 'node_modules', 'pkg'), { recursive: true });
111
+ await fs.mkdir(path.join(resourcePath, 'dist'), { recursive: true });
112
+ await fs.writeFile(path.join(resourcePath, 'package.json'), '{"name":"repo"}\n');
113
+ await fs.writeFile(path.join(resourcePath, 'README.md'), 'hello\n');
114
+ await fs.writeFile(path.join(resourcePath, 'node_modules', 'pkg', 'index.js'), 'ignored\n');
115
+ await fs.writeFile(path.join(resourcePath, 'dist', 'bundle.js'), 'ignored\n');
116
+
117
+ const collection = await collections.loadPromise({ resourceNames: ['repo'] });
118
+
119
+ try {
120
+ expect(await existsInVirtualFs('/repo/package.json', collection.vfsId)).toBe(true);
121
+ expect(await existsInVirtualFs('/repo/README.md', collection.vfsId)).toBe(true);
122
+ expect(await existsInVirtualFs('/repo/node_modules/pkg/index.js', collection.vfsId)).toBe(
123
+ false
124
+ );
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
+ );
249
+ } finally {
250
+ await cleanupCollection(collection);
251
+ }
252
+ } finally {
253
+ await fs.rm(resourcePath, { recursive: true, force: true });
254
+ }
255
+ });
256
+ });
@@ -15,6 +15,7 @@ import {
15
15
  createVirtualFs,
16
16
  disposeVirtualFs,
17
17
  importDirectoryIntoVirtualFs,
18
+ importPathsIntoVirtualFs,
18
19
  mkdirVirtualFs,
19
20
  rmVirtualFs
20
21
  } from '../vfs/virtual-fs.ts';
@@ -25,63 +26,120 @@ import {
25
26
  } from './virtual-metadata.ts';
26
27
 
27
28
  export type CollectionsService = {
28
- load: (args: { resourceNames: readonly string[]; quiet?: boolean }) => Promise<CollectionResult>;
29
- loadEffect: (args: {
29
+ load: (args: {
30
30
  resourceNames: readonly string[];
31
31
  quiet?: boolean;
32
- }) => Effect.Effect<CollectionResult, CollectionError>;
32
+ }) => Effect.Effect<CollectionResult, CollectionError, never>;
33
+ loadPromise: (args: {
34
+ resourceNames: readonly string[];
35
+ quiet?: boolean;
36
+ }) => Promise<CollectionResult>;
33
37
  };
34
38
 
35
- 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
+ };
36
52
 
37
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
+
38
69
  const getNpmCitationAlias = (metadata?: VirtualResourceMetadata) => {
39
70
  if (!metadata?.package) return undefined;
40
71
  return `npm:${metadata.package}@${metadata.version ?? 'latest'}`;
41
72
  };
42
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
+
43
79
  const createCollectionInstructionBlock = (
44
80
  resource: BtcaFsResource,
45
81
  metadata?: VirtualResourceMetadata
46
- ): string => {
47
- const focusLines = resource.repoSubPaths.map(
48
- (subPath) => `Focus: ./${resource.fsName}/${subPath}`
49
- );
82
+ ) => {
83
+ const repoUrl =
84
+ resource.type === 'git' && metadata?.url ? trimGitSuffix(metadata.url) : undefined;
50
85
  const gitRef = metadata?.branch ?? metadata?.commit;
51
- const githubPrefix =
52
- resource.type === 'git' && metadata?.url && gitRef
53
- ? `${trimGitSuffix(metadata.url)}/blob/${encodeURIComponent(gitRef)}`
54
- : undefined;
86
+ const githubBlobPrefix =
87
+ repoUrl && gitRef ? `${repoUrl}/blob/${encodeURIComponent(gitRef)}` : undefined;
55
88
  const npmCitationAlias = resource.type === 'npm' ? getNpmCitationAlias(metadata) : undefined;
56
- const lines = [
57
- `## Resource: ${resource.name}`,
58
- FS_RESOURCE_SYSTEM_NOTE,
59
- `Path: ./${resource.fsName}`,
60
- resource.type === 'git' && metadata?.url ? `Repo URL: ${trimGitSuffix(metadata.url)}` : '',
61
- resource.type === 'git' && metadata?.branch ? `Repo Branch: ${metadata.branch}` : '',
62
- resource.type === 'git' && metadata?.commit ? `Repo Commit: ${metadata.commit}` : '',
63
- resource.type === 'npm' && metadata?.package ? `NPM Package: ${metadata.package}` : '',
64
- resource.type === 'npm' && metadata?.version ? `NPM Version: ${metadata.version}` : '',
65
- resource.type === 'npm' && metadata?.url ? `NPM URL: ${metadata.url}` : '',
66
- npmCitationAlias ? `NPM Citation Alias: ${npmCitationAlias}` : '',
67
- githubPrefix ? `GitHub Blob Prefix: ${githubPrefix}` : '',
68
- githubPrefix
69
- ? `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')}").`
70
- : '',
71
- githubPrefix
72
- ? `GitHub Citation Example: ${githubPrefix}/${encodePathSegments('src/routes/blog/+page.server.js')}`
73
- : '',
74
- resource.type !== 'git'
75
- ? 'Citation Rule: Cite local file paths only for this resource (no GitHub URL).'
76
- : '',
77
- npmCitationAlias
78
- ? `NPM Citation Rule: In "Sources", cite npm files using "${npmCitationAlias}/<file>" (for example, "${npmCitationAlias}/README.md"). Do not cite encoded virtual folder names.`
79
- : '',
80
- ...focusLines,
81
- resource.specialAgentInstructions ? `Notes: ${resource.specialAgentInstructions}` : ''
82
- ].filter(Boolean);
83
-
84
- 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');
85
143
  };
86
144
 
87
145
  const ignoreErrors = async (action: () => Promise<unknown>) => {
@@ -92,6 +150,53 @@ const ignoreErrors = async (action: () => Promise<unknown>) => {
92
150
  }
93
151
  };
94
152
 
153
+ const LOCAL_RESOURCE_IGNORED_DIRECTORIES = new Set([
154
+ '.git',
155
+ '.turbo',
156
+ '.next',
157
+ '.svelte-kit',
158
+ '.vercel',
159
+ '.cache',
160
+ 'coverage',
161
+ 'dist',
162
+ 'build',
163
+ 'out',
164
+ 'node_modules'
165
+ ]);
166
+
167
+ const normalizeRelativePath = (value: string) => value.split(path.sep).join('/');
168
+
169
+ const shouldIgnoreImportedPath = (resource: BtcaFsResource, relativePath: string) => {
170
+ const normalized = normalizeRelativePath(relativePath);
171
+ if (!normalized || normalized === '.') return false;
172
+ const segments = normalized.split('/');
173
+ if (segments.includes('.git')) return true;
174
+ if (resource.type !== 'local') return false;
175
+ return segments.some((segment) => LOCAL_RESOURCE_IGNORED_DIRECTORIES.has(segment));
176
+ };
177
+
178
+ const listGitVisiblePaths = async (resourcePath: string) => {
179
+ try {
180
+ const proc = Bun.spawn(
181
+ ['git', 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
182
+ {
183
+ cwd: resourcePath,
184
+ stdout: 'pipe',
185
+ stderr: 'ignore'
186
+ }
187
+ );
188
+ const stdout = await new Response(proc.stdout).text();
189
+ const exitCode = await proc.exited;
190
+ if (exitCode !== 0) return null;
191
+ return stdout
192
+ .split('\0')
193
+ .map((entry) => entry.trim())
194
+ .filter((entry) => entry.length > 0);
195
+ } catch {
196
+ return null;
197
+ }
198
+ };
199
+
95
200
  const initVirtualRoot = async (collectionPath: string, vfsId: string) => {
96
201
  try {
97
202
  await mkdirVirtualFs(collectionPath, { recursive: true }, vfsId);
@@ -106,7 +211,7 @@ const initVirtualRoot = async (collectionPath: string, vfsId: string) => {
106
211
 
107
212
  const loadResource = async (resources: ResourcesService, name: string, quiet: boolean) => {
108
213
  try {
109
- return await resources.load(name, { quiet });
214
+ return await resources.loadPromise(name, { quiet });
110
215
  } catch (cause) {
111
216
  const underlyingHint = getErrorHint(cause);
112
217
  const underlyingMessage = getErrorMessage(cause);
@@ -139,16 +244,24 @@ const virtualizeResource = async (args: {
139
244
  vfsId: string;
140
245
  }) => {
141
246
  try {
247
+ if (args.resource.type === 'local') {
248
+ const gitVisiblePaths = await listGitVisiblePaths(args.resourcePath);
249
+ if (gitVisiblePaths) {
250
+ await importPathsIntoVirtualFs({
251
+ sourcePath: args.resourcePath,
252
+ destinationPath: args.virtualResourcePath,
253
+ relativePaths: gitVisiblePaths,
254
+ vfsId: args.vfsId
255
+ });
256
+ return;
257
+ }
258
+ }
259
+
142
260
  await importDirectoryIntoVirtualFs({
143
261
  sourcePath: args.resourcePath,
144
262
  destinationPath: args.virtualResourcePath,
145
263
  vfsId: args.vfsId,
146
- ignore: (relativePath) => {
147
- const normalized = relativePath.split(path.sep).join('/');
148
- return (
149
- normalized === '.git' || normalized.startsWith('.git/') || normalized.includes('/.git/')
150
- );
151
- }
264
+ ignore: (relativePath) => shouldIgnoreImportedPath(args.resource, relativePath)
152
265
  });
153
266
  } catch (cause) {
154
267
  throw new CollectionError({
@@ -270,7 +383,7 @@ export const createCollectionsService = (args: {
270
383
  config: ConfigServiceShape;
271
384
  resources: ResourcesService;
272
385
  }): CollectionsService => {
273
- const load: CollectionsService['load'] = ({ resourceNames, quiet = false }) =>
386
+ const loadPromise: CollectionsService['loadPromise'] = ({ resourceNames, quiet = false }) =>
274
387
  runTransaction('collections.load', async () => {
275
388
  const uniqueNames = Array.from(new Set(resourceNames));
276
389
  if (uniqueNames.length === 0)
@@ -341,16 +454,9 @@ export const createCollectionsService = (args: {
341
454
  resources: metadataResources
342
455
  });
343
456
 
344
- const metadataByName = new Map(
345
- metadataResources.map((resource) => [resource.name, resource])
346
- );
347
- const instructionBlocks = loadedResources.map((resource) =>
348
- createCollectionInstructionBlock(resource, metadataByName.get(resource.name))
349
- );
350
-
351
457
  return {
352
458
  path: collectionPath,
353
- agentInstructions: instructionBlocks.join('\n\n'),
459
+ agentInstructions: createCollectionInstructions(loadedResources, metadataResources),
354
460
  vfsId,
355
461
  cleanup: async () => {
356
462
  await cleanupResources(loadedResources);
@@ -368,9 +474,9 @@ export const createCollectionsService = (args: {
368
474
  }
369
475
  });
370
476
 
371
- const loadEffect: CollectionsService['loadEffect'] = ({ resourceNames, quiet }) =>
477
+ const load: CollectionsService['load'] = ({ resourceNames, quiet }) =>
372
478
  Effect.tryPromise({
373
- try: () => load({ resourceNames, quiet }),
479
+ try: () => loadPromise({ resourceNames, quiet }),
374
480
  catch: (cause) =>
375
481
  cause instanceof CollectionError
376
482
  ? cause
@@ -383,6 +489,6 @@ export const createCollectionsService = (args: {
383
489
 
384
490
  return {
385
491
  load,
386
- loadEffect
492
+ loadPromise
387
493
  };
388
494
  };
@@ -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 {
7
8
  load as loadConfig,
@@ -380,12 +381,14 @@ describe('Config', () => {
380
381
  expect(config.resources.length).toBe(3);
381
382
 
382
383
  // Add a new resource
383
- await config.addResource({
384
- name: 'new-resource',
385
- type: 'git',
386
- url: 'https://github.com/test/new-resource',
387
- branch: 'main'
388
- });
384
+ await Effect.runPromise(
385
+ config.addResource({
386
+ name: 'new-resource',
387
+ type: 'git',
388
+ url: 'https://github.com/test/new-resource',
389
+ branch: 'main'
390
+ })
391
+ );
389
392
 
390
393
  // Verify merged state shows 4 resources
391
394
  expect(config.resources.length).toBe(4);
@@ -459,7 +462,7 @@ describe('Config', () => {
459
462
  expect(config.resources.length).toBe(3);
460
463
 
461
464
  // Remove the project resource
462
- await config.removeResource('myproject');
465
+ await Effect.runPromise(config.removeResource('myproject'));
463
466
 
464
467
  // Verify merged state shows 2 resources (only global)
465
468
  expect(config.resources.length).toBe(2);
@@ -477,7 +480,7 @@ describe('Config', () => {
477
480
  ).toBeUndefined();
478
481
 
479
482
  // Trying to remove a global resource should throw an error
480
- expect(config.removeResource('svelte')).rejects.toThrow(
483
+ expect(Effect.runPromise(config.removeResource('svelte'))).rejects.toThrow(
481
484
  'Resource "svelte" is defined in the global config'
482
485
  );
483
486
 
@@ -528,7 +531,7 @@ describe('Config', () => {
528
531
  // Update the model
529
532
  const nextProvider = 'openrouter';
530
533
  const nextModel = 'openai/gpt-4o-mini';
531
- await config.updateModel(nextProvider, nextModel);
534
+ await Effect.runPromise(config.updateModel(nextProvider, nextModel));
532
535
 
533
536
  expect(config.provider).toBe(nextProvider);
534
537
  expect(config.model).toBe(nextModel);
@@ -574,12 +577,14 @@ describe('Config', () => {
574
577
  const config = await Config.load();
575
578
 
576
579
  // Add a resource (should go to global)
577
- await config.addResource({
578
- name: 'new-resource',
579
- type: 'git',
580
- url: 'https://github.com/test/new-resource',
581
- branch: 'main'
582
- });
580
+ await Effect.runPromise(
581
+ config.addResource({
582
+ name: 'new-resource',
583
+ type: 'git',
584
+ url: 'https://github.com/test/new-resource',
585
+ branch: 'main'
586
+ })
587
+ );
583
588
 
584
589
  expect(config.resources.length).toBe(2);
585
590
 
@@ -592,7 +597,7 @@ describe('Config', () => {
592
597
  ]);
593
598
 
594
599
  // Remove a resource (should work since we're in global-only mode)
595
- await config.removeResource('svelte');
600
+ await Effect.runPromise(config.removeResource('svelte'));
596
601
  expect(config.resources.length).toBe(1);
597
602
 
598
603
  const savedGlobalConfig2 = JSON.parse(await fs.readFile(globalConfigPath, 'utf-8'));