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 CHANGED
@@ -140,7 +140,6 @@ Example config.toml:
140
140
  provider = "anthropic"
141
141
  model = "claude-3-7-sonnet-20250219"
142
142
  resourcesDirectory = "~/.btca/resources"
143
- collectionsDirectory = "~/.btca/collections"
144
143
 
145
144
  [[resources]]
146
145
  type = "local"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "btca-server",
3
- "version": "1.0.63",
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
  }
@@ -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: collectionPath,
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: collectionPath,
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): Promise<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({
@@ -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 = AgentLoop.stream({
287
- providerId: config.provider,
288
- modelId: config.model,
289
- collectionPath: collection.path,
290
- agentInstructions: collection.agentInstructions,
291
- question
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
- const ocConfig = buildOpenCodeConfig({
357
- agentInstructions: collection.agentInstructions,
358
- providerId: config.provider,
359
- providerTimeoutMs: config.providerTimeoutMs
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 = path.join(args.config.collectionsDirectory, key);
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
- await fs.mkdir(collectionPath, { recursive: true });
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 create collection directory: "${collectionPath}"`,
60
- hint: 'Check that you have write permissions to the btca data directory.',
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 linkPath = path.join(collectionPath, resource.fsName);
154
+ const virtualResourcePath = path.posix.join('/', resource.fsName);
96
155
  try {
97
- await fs.rm(linkPath, { recursive: true, force: true });
156
+ await VirtualFs.rm(virtualResourcePath, { recursive: true, force: true }, vfsId);
98
157
  } catch {
99
158
  // ignore
100
159
  }
101
-
102
160
  try {
103
- await fs.symlink(resourcePath, linkPath, 'junction');
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 create symlink for resource "${resource.name}"`,
107
- hint: 'This may be a filesystem permissions issue or the link already exists.',
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
  };
@@ -4,6 +4,7 @@ import { resourceNameToKey } from '../resources/helpers.ts';
4
4
  export type CollectionResult = {
5
5
  path: string;
6
6
  agentInstructions: string;
7
+ vfsId?: string;
7
8
  };
8
9
 
9
10
  export class CollectionError extends Error {
@@ -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
+ };