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 +1 -1
- package/src/agent/agent.test.ts +25 -16
- package/src/agent/loop.ts +50 -28
- package/src/agent/service.ts +100 -110
- package/src/collections/service.test.ts +256 -0
- package/src/collections/service.ts +166 -60
- package/src/config/config.test.ts +21 -16
- package/src/config/index.ts +179 -200
- package/src/effect/services.ts +15 -23
- package/src/resources/service.ts +45 -44
- package/src/vfs/virtual-fs.ts +73 -0
|
@@ -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/<file> and link them to https://unpkg.com/react@19.0.0/<file>. 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: {
|
|
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
|
|
39
|
+
const escapeXml = (value: string) =>
|
|
40
|
+
value
|
|
41
|
+
.replaceAll('&', '&')
|
|
42
|
+
.replaceAll('<', '<')
|
|
43
|
+
.replaceAll('>', '>')
|
|
44
|
+
.replaceAll('"', '"')
|
|
45
|
+
.replaceAll("'", ''');
|
|
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
|
-
)
|
|
47
|
-
const
|
|
48
|
-
(
|
|
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
|
|
52
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
resource.
|
|
64
|
-
|
|
65
|
-
resource.type === '
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
477
|
+
const load: CollectionsService['load'] = ({ resourceNames, quiet }) =>
|
|
372
478
|
Effect.tryPromise({
|
|
373
|
-
try: () =>
|
|
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
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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'));
|