btca-server 1.0.63 → 1.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/package.json +2 -1
- package/src/agent/agent.test.ts +114 -16
- package/src/agent/loop.ts +14 -11
- package/src/agent/service.ts +40 -31
- package/src/collections/service.ts +100 -12
- package/src/collections/types.ts +1 -0
- package/src/collections/virtual-metadata.ts +32 -0
- package/src/config/index.ts +2 -22
- package/src/index.ts +17 -5
- package/src/tools/context.ts +4 -0
- package/src/tools/glob.ts +54 -31
- package/src/tools/grep.ts +112 -45
- package/src/tools/index.ts +0 -2
- package/src/tools/list.ts +13 -36
- package/src/tools/read.ts +38 -25
- package/src/tools/virtual-sandbox.ts +100 -0
- package/src/vfs/virtual-fs.test.ts +107 -0
- package/src/vfs/virtual-fs.ts +256 -0
- package/src/tools/ripgrep.ts +0 -348
- package/src/tools/sandbox.ts +0 -164
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "btca-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.70",
|
|
4
4
|
"description": "BTCA server for answering questions about your codebase using OpenCode AI",
|
|
5
5
|
"author": "Ben Davis",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"@opencode-ai/sdk": "^1.1.28",
|
|
71
71
|
"ai": "^6.0.49",
|
|
72
72
|
"hono": "^4.7.11",
|
|
73
|
+
"just-bash": "^2.7.0",
|
|
73
74
|
"opencode-ai": "^1.1.36",
|
|
74
75
|
"zod": "^3.25.76"
|
|
75
76
|
}
|
package/src/agent/agent.test.ts
CHANGED
|
@@ -6,6 +6,11 @@ import os from 'node:os';
|
|
|
6
6
|
import { Agent } from './service.ts';
|
|
7
7
|
import { Config } from '../config/index.ts';
|
|
8
8
|
import type { CollectionResult } from '../collections/types.ts';
|
|
9
|
+
import {
|
|
10
|
+
getVirtualCollectionMetadata,
|
|
11
|
+
setVirtualCollectionMetadata
|
|
12
|
+
} from '../collections/virtual-metadata.ts';
|
|
13
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
9
14
|
|
|
10
15
|
describe('Agent', () => {
|
|
11
16
|
let testDir: string;
|
|
@@ -41,21 +46,22 @@ describe('Agent', () => {
|
|
|
41
46
|
// Run with: BTCA_RUN_INTEGRATION_TESTS=1 bun test
|
|
42
47
|
describe.skipIf(!process.env.BTCA_RUN_INTEGRATION_TESTS)('Agent.ask (integration)', () => {
|
|
43
48
|
it('asks a question and receives an answer', async () => {
|
|
44
|
-
// Create a simple collection directory with a test file
|
|
45
|
-
const collectionPath = path.join(testDir, 'test-collection');
|
|
46
|
-
await fs.mkdir(collectionPath, { recursive: true });
|
|
47
|
-
await fs.writeFile(
|
|
48
|
-
path.join(collectionPath, 'README.md'),
|
|
49
|
-
'# Test Documentation\n\nThis is a test file. The answer to life is 42.'
|
|
50
|
-
);
|
|
51
|
-
|
|
52
49
|
process.chdir(testDir);
|
|
53
50
|
const config = await Config.load();
|
|
54
51
|
const agent = Agent.create(config);
|
|
55
52
|
|
|
53
|
+
const vfsId = VirtualFs.create();
|
|
54
|
+
await VirtualFs.mkdir('/', { recursive: true }, vfsId);
|
|
55
|
+
await VirtualFs.writeFile(
|
|
56
|
+
'/README.md',
|
|
57
|
+
'# Test Documentation\n\nThis is a test file. The answer to life is 42.',
|
|
58
|
+
vfsId
|
|
59
|
+
);
|
|
60
|
+
|
|
56
61
|
const collection: CollectionResult = {
|
|
57
|
-
path:
|
|
58
|
-
agentInstructions: 'This is a test collection with a README file.'
|
|
62
|
+
path: '/',
|
|
63
|
+
agentInstructions: 'This is a test collection with a README file.',
|
|
64
|
+
vfsId
|
|
59
65
|
};
|
|
60
66
|
|
|
61
67
|
const result = await agent.ask({
|
|
@@ -75,17 +81,18 @@ describe('Agent', () => {
|
|
|
75
81
|
}, 60000);
|
|
76
82
|
|
|
77
83
|
it('handles askStream and receives events', async () => {
|
|
78
|
-
const collectionPath = path.join(testDir, 'stream-collection');
|
|
79
|
-
await fs.mkdir(collectionPath, { recursive: true });
|
|
80
|
-
await fs.writeFile(path.join(collectionPath, 'data.txt'), 'The capital of France is Paris.');
|
|
81
|
-
|
|
82
84
|
process.chdir(testDir);
|
|
83
85
|
const config = await Config.load();
|
|
84
86
|
const agent = Agent.create(config);
|
|
85
87
|
|
|
88
|
+
const vfsId = VirtualFs.create();
|
|
89
|
+
await VirtualFs.mkdir('/', { recursive: true }, vfsId);
|
|
90
|
+
await VirtualFs.writeFile('/data.txt', 'The capital of France is Paris.', vfsId);
|
|
91
|
+
|
|
86
92
|
const collection: CollectionResult = {
|
|
87
|
-
path:
|
|
88
|
-
agentInstructions: 'Simple test collection.'
|
|
93
|
+
path: '/',
|
|
94
|
+
agentInstructions: 'Simple test collection.',
|
|
95
|
+
vfsId
|
|
89
96
|
};
|
|
90
97
|
|
|
91
98
|
const { stream, model } = await agent.askStream({
|
|
@@ -107,5 +114,96 @@ describe('Agent', () => {
|
|
|
107
114
|
const textEvents = events.filter((e) => e.type === 'text-delta');
|
|
108
115
|
expect(textEvents.length).toBeGreaterThan(0);
|
|
109
116
|
}, 60000);
|
|
117
|
+
|
|
118
|
+
it('cleans up virtual collections after ask', async () => {
|
|
119
|
+
process.chdir(testDir);
|
|
120
|
+
const config = await Config.load();
|
|
121
|
+
const agent = Agent.create(config);
|
|
122
|
+
|
|
123
|
+
const vfsId = VirtualFs.create();
|
|
124
|
+
await VirtualFs.mkdir('/', { recursive: true }, vfsId);
|
|
125
|
+
await VirtualFs.mkdir('/docs', { recursive: true }, vfsId);
|
|
126
|
+
await VirtualFs.writeFile('/docs/README.md', 'Virtual README\nThe answer is 42.', vfsId);
|
|
127
|
+
|
|
128
|
+
setVirtualCollectionMetadata({
|
|
129
|
+
vfsId,
|
|
130
|
+
collectionKey: 'virtual-test',
|
|
131
|
+
createdAt: new Date().toISOString(),
|
|
132
|
+
resources: [
|
|
133
|
+
{
|
|
134
|
+
name: 'docs',
|
|
135
|
+
fsName: 'docs',
|
|
136
|
+
type: 'local',
|
|
137
|
+
path: '/docs',
|
|
138
|
+
repoSubPaths: [],
|
|
139
|
+
loadedAt: new Date().toISOString()
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const collection: CollectionResult = {
|
|
145
|
+
path: '/',
|
|
146
|
+
agentInstructions: 'This is a virtual collection with a README file.',
|
|
147
|
+
vfsId
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const result = await agent.ask({
|
|
151
|
+
collection,
|
|
152
|
+
question: 'What number is the answer according to the README?'
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(result).toBeDefined();
|
|
156
|
+
expect(VirtualFs.has(vfsId)).toBe(false);
|
|
157
|
+
expect(getVirtualCollectionMetadata(vfsId)).toBeUndefined();
|
|
158
|
+
}, 60000);
|
|
159
|
+
|
|
160
|
+
it('cleans up virtual collections after askStream', async () => {
|
|
161
|
+
process.chdir(testDir);
|
|
162
|
+
const config = await Config.load();
|
|
163
|
+
const agent = Agent.create(config);
|
|
164
|
+
|
|
165
|
+
const vfsId = VirtualFs.create();
|
|
166
|
+
await VirtualFs.mkdir('/', { recursive: true }, vfsId);
|
|
167
|
+
await VirtualFs.mkdir('/docs', { recursive: true }, vfsId);
|
|
168
|
+
await VirtualFs.writeFile(
|
|
169
|
+
'/docs/README.md',
|
|
170
|
+
'Virtual README\nThe capital of France is Paris.',
|
|
171
|
+
vfsId
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
setVirtualCollectionMetadata({
|
|
175
|
+
vfsId,
|
|
176
|
+
collectionKey: 'virtual-stream-test',
|
|
177
|
+
createdAt: new Date().toISOString(),
|
|
178
|
+
resources: [
|
|
179
|
+
{
|
|
180
|
+
name: 'docs',
|
|
181
|
+
fsName: 'docs',
|
|
182
|
+
type: 'local',
|
|
183
|
+
path: '/docs',
|
|
184
|
+
repoSubPaths: [],
|
|
185
|
+
loadedAt: new Date().toISOString()
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const collection: CollectionResult = {
|
|
191
|
+
path: '/',
|
|
192
|
+
agentInstructions: 'This is a virtual collection with a README file.',
|
|
193
|
+
vfsId
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const { stream } = await agent.askStream({
|
|
197
|
+
collection,
|
|
198
|
+
question: 'What is the capital of France according to the README?'
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
for await (const _event of stream) {
|
|
202
|
+
// drain stream to trigger cleanup
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
expect(VirtualFs.has(vfsId)).toBe(false);
|
|
206
|
+
expect(getVirtualCollectionMetadata(vfsId)).toBeUndefined();
|
|
207
|
+
}, 60000);
|
|
110
208
|
});
|
|
111
209
|
});
|
package/src/agent/loop.ts
CHANGED
|
@@ -25,6 +25,7 @@ export namespace AgentLoop {
|
|
|
25
25
|
providerId: string;
|
|
26
26
|
modelId: string;
|
|
27
27
|
collectionPath: string;
|
|
28
|
+
vfsId?: string;
|
|
28
29
|
agentInstructions: string;
|
|
29
30
|
question: string;
|
|
30
31
|
maxSteps?: number;
|
|
@@ -65,13 +66,13 @@ export namespace AgentLoop {
|
|
|
65
66
|
/**
|
|
66
67
|
* Create the tools for the agent
|
|
67
68
|
*/
|
|
68
|
-
function createTools(basePath: string) {
|
|
69
|
+
function createTools(basePath: string, vfsId?: string) {
|
|
69
70
|
return {
|
|
70
71
|
read: tool({
|
|
71
72
|
description: 'Read the contents of a file. Returns the file contents with line numbers.',
|
|
72
73
|
inputSchema: ReadTool.Parameters,
|
|
73
74
|
execute: async (params: ReadTool.ParametersType) => {
|
|
74
|
-
const result = await ReadTool.execute(params, { basePath });
|
|
75
|
+
const result = await ReadTool.execute(params, { basePath, vfsId });
|
|
75
76
|
return result.output;
|
|
76
77
|
}
|
|
77
78
|
}),
|
|
@@ -81,7 +82,7 @@ export namespace AgentLoop {
|
|
|
81
82
|
'Search for a regex pattern in file contents. Returns matching lines with file paths and line numbers.',
|
|
82
83
|
inputSchema: GrepTool.Parameters,
|
|
83
84
|
execute: async (params: GrepTool.ParametersType) => {
|
|
84
|
-
const result = await GrepTool.execute(params, { basePath });
|
|
85
|
+
const result = await GrepTool.execute(params, { basePath, vfsId });
|
|
85
86
|
return result.output;
|
|
86
87
|
}
|
|
87
88
|
}),
|
|
@@ -91,7 +92,7 @@ export namespace AgentLoop {
|
|
|
91
92
|
'Find files matching a glob pattern (e.g. "**/*.ts", "src/**/*.js"). Returns a list of matching file paths sorted by modification time.',
|
|
92
93
|
inputSchema: GlobTool.Parameters,
|
|
93
94
|
execute: async (params: GlobTool.ParametersType) => {
|
|
94
|
-
const result = await GlobTool.execute(params, { basePath });
|
|
95
|
+
const result = await GlobTool.execute(params, { basePath, vfsId });
|
|
95
96
|
return result.output;
|
|
96
97
|
}
|
|
97
98
|
}),
|
|
@@ -101,7 +102,7 @@ export namespace AgentLoop {
|
|
|
101
102
|
'List the contents of a directory. Returns files and subdirectories with their types.',
|
|
102
103
|
inputSchema: ListTool.Parameters,
|
|
103
104
|
execute: async (params: ListTool.ParametersType) => {
|
|
104
|
-
const result = await ListTool.execute(params, { basePath });
|
|
105
|
+
const result = await ListTool.execute(params, { basePath, vfsId });
|
|
105
106
|
return result.output;
|
|
106
107
|
}
|
|
107
108
|
})
|
|
@@ -111,8 +112,8 @@ export namespace AgentLoop {
|
|
|
111
112
|
/**
|
|
112
113
|
* Get initial context by listing the collection directory
|
|
113
114
|
*/
|
|
114
|
-
async function getInitialContext(collectionPath: string
|
|
115
|
-
const result = await ListTool.execute({ path: '.' }, { basePath: collectionPath });
|
|
115
|
+
async function getInitialContext(collectionPath: string, vfsId?: string) {
|
|
116
|
+
const result = await ListTool.execute({ path: '.' }, { basePath: collectionPath, vfsId });
|
|
116
117
|
return `Collection contents:\n${result.output}`;
|
|
117
118
|
}
|
|
118
119
|
|
|
@@ -124,6 +125,7 @@ export namespace AgentLoop {
|
|
|
124
125
|
providerId,
|
|
125
126
|
modelId,
|
|
126
127
|
collectionPath,
|
|
128
|
+
vfsId,
|
|
127
129
|
agentInstructions,
|
|
128
130
|
question,
|
|
129
131
|
maxSteps = 40
|
|
@@ -133,7 +135,7 @@ export namespace AgentLoop {
|
|
|
133
135
|
const model = await Model.getModel(providerId, modelId);
|
|
134
136
|
|
|
135
137
|
// Get initial context
|
|
136
|
-
const initialContext = await getInitialContext(collectionPath);
|
|
138
|
+
const initialContext = await getInitialContext(collectionPath, vfsId);
|
|
137
139
|
|
|
138
140
|
// Build messages
|
|
139
141
|
const messages: ModelMessage[] = [
|
|
@@ -144,7 +146,7 @@ export namespace AgentLoop {
|
|
|
144
146
|
];
|
|
145
147
|
|
|
146
148
|
// Create tools
|
|
147
|
-
const tools = createTools(collectionPath);
|
|
149
|
+
const tools = createTools(collectionPath, vfsId);
|
|
148
150
|
|
|
149
151
|
// Collect events
|
|
150
152
|
const events: AgentEvent[] = [];
|
|
@@ -218,6 +220,7 @@ export namespace AgentLoop {
|
|
|
218
220
|
providerId,
|
|
219
221
|
modelId,
|
|
220
222
|
collectionPath,
|
|
223
|
+
vfsId,
|
|
221
224
|
agentInstructions,
|
|
222
225
|
question,
|
|
223
226
|
maxSteps = 40
|
|
@@ -227,7 +230,7 @@ export namespace AgentLoop {
|
|
|
227
230
|
const model = await Model.getModel(providerId, modelId);
|
|
228
231
|
|
|
229
232
|
// Get initial context
|
|
230
|
-
const initialContext = await getInitialContext(collectionPath);
|
|
233
|
+
const initialContext = await getInitialContext(collectionPath, vfsId);
|
|
231
234
|
|
|
232
235
|
// Build messages
|
|
233
236
|
const messages: ModelMessage[] = [
|
|
@@ -238,7 +241,7 @@ export namespace AgentLoop {
|
|
|
238
241
|
];
|
|
239
242
|
|
|
240
243
|
// Create tools
|
|
241
|
-
const tools = createTools(collectionPath);
|
|
244
|
+
const tools = createTools(collectionPath, vfsId);
|
|
242
245
|
|
|
243
246
|
// Run streamText with tool execution
|
|
244
247
|
const result = streamText({
|
package/src/agent/service.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { CommonHints, type TaggedErrorOptions } from '../errors.ts';
|
|
|
14
14
|
import { Metrics } from '../metrics/index.ts';
|
|
15
15
|
import { Auth, getSupportedProviders } from '../providers/index.ts';
|
|
16
16
|
import type { CollectionResult } from '../collections/types.ts';
|
|
17
|
+
import { clearVirtualCollectionMetadata } from '../collections/virtual-metadata.ts';
|
|
18
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
17
19
|
import type { AgentResult, TrackedInstance, InstanceInfo } from './types.ts';
|
|
18
20
|
import { AgentLoop } from './loop.ts';
|
|
19
21
|
|
|
@@ -272,10 +274,17 @@ export namespace Agent {
|
|
|
272
274
|
questionLength: question.length
|
|
273
275
|
});
|
|
274
276
|
|
|
277
|
+
const cleanup = () => {
|
|
278
|
+
if (!collection.vfsId) return;
|
|
279
|
+
VirtualFs.dispose(collection.vfsId);
|
|
280
|
+
clearVirtualCollectionMetadata(collection.vfsId);
|
|
281
|
+
};
|
|
282
|
+
|
|
275
283
|
// Validate provider is authenticated
|
|
276
284
|
const isAuthed = await Auth.isAuthenticated(config.provider);
|
|
277
285
|
if (!isAuthed && config.provider !== 'opencode') {
|
|
278
286
|
const authenticated = await Auth.getAuthenticatedProviders();
|
|
287
|
+
cleanup();
|
|
279
288
|
throw new ProviderNotConnectedError({
|
|
280
289
|
providerId: config.provider,
|
|
281
290
|
connectedProviders: authenticated
|
|
@@ -283,13 +292,23 @@ export namespace Agent {
|
|
|
283
292
|
}
|
|
284
293
|
|
|
285
294
|
// Create a generator that wraps the AgentLoop stream
|
|
286
|
-
const eventGenerator =
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
295
|
+
const eventGenerator = (async function* () {
|
|
296
|
+
try {
|
|
297
|
+
const stream = AgentLoop.stream({
|
|
298
|
+
providerId: config.provider,
|
|
299
|
+
modelId: config.model,
|
|
300
|
+
collectionPath: collection.path,
|
|
301
|
+
vfsId: collection.vfsId,
|
|
302
|
+
agentInstructions: collection.agentInstructions,
|
|
303
|
+
question
|
|
304
|
+
});
|
|
305
|
+
for await (const event of stream) {
|
|
306
|
+
yield event;
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
cleanup();
|
|
310
|
+
}
|
|
311
|
+
})();
|
|
293
312
|
|
|
294
313
|
return {
|
|
295
314
|
stream: eventGenerator,
|
|
@@ -307,10 +326,17 @@ export namespace Agent {
|
|
|
307
326
|
questionLength: question.length
|
|
308
327
|
});
|
|
309
328
|
|
|
329
|
+
const cleanup = () => {
|
|
330
|
+
if (!collection.vfsId) return;
|
|
331
|
+
VirtualFs.dispose(collection.vfsId);
|
|
332
|
+
clearVirtualCollectionMetadata(collection.vfsId);
|
|
333
|
+
};
|
|
334
|
+
|
|
310
335
|
// Validate provider is authenticated
|
|
311
336
|
const isAuthed = await Auth.isAuthenticated(config.provider);
|
|
312
337
|
if (!isAuthed && config.provider !== 'opencode') {
|
|
313
338
|
const authenticated = await Auth.getAuthenticatedProviders();
|
|
339
|
+
cleanup();
|
|
314
340
|
throw new ProviderNotConnectedError({
|
|
315
341
|
providerId: config.provider,
|
|
316
342
|
connectedProviders: authenticated
|
|
@@ -322,6 +348,7 @@ export namespace Agent {
|
|
|
322
348
|
providerId: config.provider,
|
|
323
349
|
modelId: config.model,
|
|
324
350
|
collectionPath: collection.path,
|
|
351
|
+
vfsId: collection.vfsId,
|
|
325
352
|
agentInstructions: collection.agentInstructions,
|
|
326
353
|
question
|
|
327
354
|
});
|
|
@@ -345,6 +372,8 @@ export namespace Agent {
|
|
|
345
372
|
hint: 'This may be a temporary issue. Try running the command again.',
|
|
346
373
|
cause: error
|
|
347
374
|
});
|
|
375
|
+
} finally {
|
|
376
|
+
cleanup();
|
|
348
377
|
}
|
|
349
378
|
};
|
|
350
379
|
|
|
@@ -353,31 +382,11 @@ export namespace Agent {
|
|
|
353
382
|
* This still spawns a full OpenCode instance for clients that need it
|
|
354
383
|
*/
|
|
355
384
|
const getOpencodeInstance: Service['getOpencodeInstance'] = async ({ collection }) => {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
});
|
|
361
|
-
const { server, baseUrl } = await createOpencodeInstance({
|
|
362
|
-
collectionPath: collection.path,
|
|
363
|
-
ocConfig
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
// Register the instance for lifecycle management
|
|
367
|
-
const instanceId = generateInstanceId();
|
|
368
|
-
registerInstance(instanceId, server, collection.path);
|
|
369
|
-
|
|
370
|
-
Metrics.info('agent.oc.instance.ready', {
|
|
371
|
-
baseUrl,
|
|
372
|
-
collectionPath: collection.path,
|
|
373
|
-
instanceId
|
|
385
|
+
throw new AgentError({
|
|
386
|
+
message: 'OpenCode instance not available',
|
|
387
|
+
hint: 'BTCA uses virtual collections only. Use the btca ask/stream APIs instead.',
|
|
388
|
+
cause: new Error('Virtual collections are not compatible with filesystem-based OpenCode')
|
|
374
389
|
});
|
|
375
|
-
|
|
376
|
-
return {
|
|
377
|
-
url: baseUrl,
|
|
378
|
-
model: { provider: config.provider, model: config.model },
|
|
379
|
-
instanceId
|
|
380
|
-
};
|
|
381
390
|
};
|
|
382
391
|
|
|
383
392
|
/**
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
|
|
4
3
|
import { Config } from '../config/index.ts';
|
|
@@ -6,8 +5,15 @@ import { Transaction } from '../context/transaction.ts';
|
|
|
6
5
|
import { CommonHints, getErrorHint, getErrorMessage } from '../errors.ts';
|
|
7
6
|
import { Metrics } from '../metrics/index.ts';
|
|
8
7
|
import { Resources } from '../resources/service.ts';
|
|
8
|
+
import { isGitResource } from '../resources/schema.ts';
|
|
9
9
|
import { FS_RESOURCE_SYSTEM_NOTE, type BtcaFsResource } from '../resources/types.ts';
|
|
10
10
|
import { CollectionError, getCollectionKey, type CollectionResult } from './types.ts';
|
|
11
|
+
import { VirtualFs } from '../vfs/virtual-fs.ts';
|
|
12
|
+
import {
|
|
13
|
+
clearVirtualCollectionMetadata,
|
|
14
|
+
setVirtualCollectionMetadata,
|
|
15
|
+
type VirtualResourceMetadata
|
|
16
|
+
} from './virtual-metadata.ts';
|
|
11
17
|
|
|
12
18
|
export namespace Collections {
|
|
13
19
|
export type Service = {
|
|
@@ -32,6 +38,48 @@ export namespace Collections {
|
|
|
32
38
|
return lines.join('\n');
|
|
33
39
|
};
|
|
34
40
|
|
|
41
|
+
const getGitHeadHash = async (resourcePath: string) => {
|
|
42
|
+
try {
|
|
43
|
+
const proc = Bun.spawn(['git', 'rev-parse', 'HEAD'], {
|
|
44
|
+
cwd: resourcePath,
|
|
45
|
+
stdout: 'pipe',
|
|
46
|
+
stderr: 'pipe'
|
|
47
|
+
});
|
|
48
|
+
const stdout = await new Response(proc.stdout).text();
|
|
49
|
+
const exitCode = await proc.exited;
|
|
50
|
+
if (exitCode !== 0) return undefined;
|
|
51
|
+
const trimmed = stdout.trim();
|
|
52
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const buildVirtualMetadata = async (args: {
|
|
59
|
+
resource: BtcaFsResource;
|
|
60
|
+
resourcePath: string;
|
|
61
|
+
loadedAt: string;
|
|
62
|
+
definition?: ReturnType<Config.Service['getResource']>;
|
|
63
|
+
}) => {
|
|
64
|
+
if (!args.definition) return null;
|
|
65
|
+
const base = {
|
|
66
|
+
name: args.resource.name,
|
|
67
|
+
fsName: args.resource.fsName,
|
|
68
|
+
type: args.resource.type,
|
|
69
|
+
path: args.resourcePath,
|
|
70
|
+
repoSubPaths: args.resource.repoSubPaths,
|
|
71
|
+
loadedAt: args.loadedAt
|
|
72
|
+
};
|
|
73
|
+
if (!isGitResource(args.definition)) return base;
|
|
74
|
+
const commit = await getGitHeadHash(args.resourcePath);
|
|
75
|
+
return {
|
|
76
|
+
...base,
|
|
77
|
+
url: args.definition.url,
|
|
78
|
+
branch: args.definition.branch,
|
|
79
|
+
commit
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
35
83
|
export const create = (args: {
|
|
36
84
|
config: Config.Service;
|
|
37
85
|
resources: Resources.Service;
|
|
@@ -50,19 +98,28 @@ export namespace Collections {
|
|
|
50
98
|
|
|
51
99
|
const sortedNames = [...uniqueNames].sort((a, b) => a.localeCompare(b));
|
|
52
100
|
const key = getCollectionKey(sortedNames);
|
|
53
|
-
const collectionPath =
|
|
101
|
+
const collectionPath = '/';
|
|
102
|
+
const vfsId = VirtualFs.create();
|
|
103
|
+
const cleanupVirtual = () => {
|
|
104
|
+
VirtualFs.dispose(vfsId);
|
|
105
|
+
clearVirtualCollectionMetadata(vfsId);
|
|
106
|
+
};
|
|
54
107
|
|
|
55
108
|
try {
|
|
56
|
-
|
|
109
|
+
// Virtual collections use the VFS root as the collection root.
|
|
110
|
+
await VirtualFs.mkdir(collectionPath, { recursive: true }, vfsId);
|
|
57
111
|
} catch (cause) {
|
|
112
|
+
cleanupVirtual();
|
|
58
113
|
throw new CollectionError({
|
|
59
|
-
message: `Failed to
|
|
60
|
-
hint: 'Check that
|
|
114
|
+
message: `Failed to initialize virtual collection root: "${collectionPath}"`,
|
|
115
|
+
hint: 'Check that the virtual filesystem is available.',
|
|
61
116
|
cause
|
|
62
117
|
});
|
|
63
118
|
}
|
|
64
119
|
|
|
65
120
|
const loadedResources: BtcaFsResource[] = [];
|
|
121
|
+
const metadataResources: VirtualResourceMetadata[] = [];
|
|
122
|
+
const loadedAt = new Date().toISOString();
|
|
66
123
|
for (const name of sortedNames) {
|
|
67
124
|
try {
|
|
68
125
|
loadedResources.push(await args.resources.load(name, { quiet }));
|
|
@@ -70,6 +127,7 @@ export namespace Collections {
|
|
|
70
127
|
// Preserve the hint from the underlying error if available
|
|
71
128
|
const underlyingHint = getErrorHint(cause);
|
|
72
129
|
const underlyingMessage = getErrorMessage(cause);
|
|
130
|
+
cleanupVirtual();
|
|
73
131
|
throw new CollectionError({
|
|
74
132
|
message: `Failed to load resource "${name}": ${underlyingMessage}`,
|
|
75
133
|
hint:
|
|
@@ -85,6 +143,7 @@ export namespace Collections {
|
|
|
85
143
|
try {
|
|
86
144
|
resourcePath = await resource.getAbsoluteDirectoryPath();
|
|
87
145
|
} catch (cause) {
|
|
146
|
+
cleanupVirtual();
|
|
88
147
|
throw new CollectionError({
|
|
89
148
|
message: `Failed to get path for resource "${resource.name}"`,
|
|
90
149
|
hint: CommonHints.CLEAR_CACHE,
|
|
@@ -92,29 +151,58 @@ export namespace Collections {
|
|
|
92
151
|
});
|
|
93
152
|
}
|
|
94
153
|
|
|
95
|
-
const
|
|
154
|
+
const virtualResourcePath = path.posix.join('/', resource.fsName);
|
|
96
155
|
try {
|
|
97
|
-
await
|
|
156
|
+
await VirtualFs.rm(virtualResourcePath, { recursive: true, force: true }, vfsId);
|
|
98
157
|
} catch {
|
|
99
158
|
// ignore
|
|
100
159
|
}
|
|
101
|
-
|
|
102
160
|
try {
|
|
103
|
-
await
|
|
161
|
+
await VirtualFs.importDirectoryFromDisk({
|
|
162
|
+
sourcePath: resourcePath,
|
|
163
|
+
destinationPath: virtualResourcePath,
|
|
164
|
+
vfsId,
|
|
165
|
+
ignore: (relativePath) => {
|
|
166
|
+
const normalized = relativePath.split(path.sep).join('/');
|
|
167
|
+
return (
|
|
168
|
+
normalized === '.git' ||
|
|
169
|
+
normalized.startsWith('.git/') ||
|
|
170
|
+
normalized.includes('/.git/')
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
104
174
|
} catch (cause) {
|
|
175
|
+
cleanupVirtual();
|
|
105
176
|
throw new CollectionError({
|
|
106
|
-
message: `Failed to
|
|
107
|
-
hint:
|
|
177
|
+
message: `Failed to virtualize resource "${resource.name}"`,
|
|
178
|
+
hint: CommonHints.CLEAR_CACHE,
|
|
108
179
|
cause
|
|
109
180
|
});
|
|
110
181
|
}
|
|
182
|
+
|
|
183
|
+
const definition = args.config.getResource(resource.name);
|
|
184
|
+
const metadata = await buildVirtualMetadata({
|
|
185
|
+
resource,
|
|
186
|
+
resourcePath,
|
|
187
|
+
loadedAt,
|
|
188
|
+
definition
|
|
189
|
+
});
|
|
190
|
+
if (metadata) metadataResources.push(metadata);
|
|
111
191
|
}
|
|
112
192
|
|
|
193
|
+
setVirtualCollectionMetadata({
|
|
194
|
+
vfsId,
|
|
195
|
+
collectionKey: key,
|
|
196
|
+
createdAt: loadedAt,
|
|
197
|
+
resources: metadataResources
|
|
198
|
+
});
|
|
199
|
+
|
|
113
200
|
const instructionBlocks = loadedResources.map(createCollectionInstructionBlock);
|
|
114
201
|
|
|
115
202
|
return {
|
|
116
203
|
path: collectionPath,
|
|
117
|
-
agentInstructions: instructionBlocks.join('\n\n')
|
|
204
|
+
agentInstructions: instructionBlocks.join('\n\n'),
|
|
205
|
+
vfsId
|
|
118
206
|
};
|
|
119
207
|
})
|
|
120
208
|
};
|
package/src/collections/types.ts
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type VirtualResourceMetadata = {
|
|
2
|
+
name: string;
|
|
3
|
+
fsName: string;
|
|
4
|
+
type: 'git' | 'local';
|
|
5
|
+
path: string;
|
|
6
|
+
repoSubPaths: readonly string[];
|
|
7
|
+
url?: string;
|
|
8
|
+
branch?: string;
|
|
9
|
+
commit?: string;
|
|
10
|
+
loadedAt: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type VirtualCollectionMetadata = {
|
|
14
|
+
vfsId: string;
|
|
15
|
+
collectionKey: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
resources: VirtualResourceMetadata[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const metadataByVfsId = new Map<string, VirtualCollectionMetadata>();
|
|
21
|
+
|
|
22
|
+
export const setVirtualCollectionMetadata = (metadata: VirtualCollectionMetadata) => {
|
|
23
|
+
metadataByVfsId.set(metadata.vfsId, metadata);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const getVirtualCollectionMetadata = (vfsId: string) => metadataByVfsId.get(vfsId);
|
|
27
|
+
|
|
28
|
+
export const clearVirtualCollectionMetadata = (vfsId: string) => metadataByVfsId.delete(vfsId);
|
|
29
|
+
|
|
30
|
+
export const clearAllVirtualCollectionMetadata = () => {
|
|
31
|
+
metadataByVfsId.clear();
|
|
32
|
+
};
|