btca-server 1.0.20
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 +195 -0
- package/package.json +56 -0
- package/src/agent/agent.test.ts +111 -0
- package/src/agent/index.ts +2 -0
- package/src/agent/service.ts +328 -0
- package/src/agent/types.ts +16 -0
- package/src/collections/index.ts +2 -0
- package/src/collections/service.ts +100 -0
- package/src/collections/types.ts +18 -0
- package/src/config/config.test.ts +119 -0
- package/src/config/index.ts +563 -0
- package/src/context/index.ts +24 -0
- package/src/context/transaction.ts +28 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +468 -0
- package/src/metrics/index.ts +60 -0
- package/src/resources/helpers.ts +10 -0
- package/src/resources/impls/git.test.ts +119 -0
- package/src/resources/impls/git.ts +156 -0
- package/src/resources/index.ts +10 -0
- package/src/resources/schema.ts +178 -0
- package/src/resources/service.ts +75 -0
- package/src/resources/types.ts +29 -0
- package/src/stream/index.ts +19 -0
- package/src/stream/service.ts +161 -0
- package/src/stream/types.ts +101 -0
- package/src/validation/index.ts +440 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { Config } from '../config/index.ts';
|
|
5
|
+
import { Transaction } from '../context/transaction.ts';
|
|
6
|
+
import { Metrics } from '../metrics/index.ts';
|
|
7
|
+
import { Resources } from '../resources/service.ts';
|
|
8
|
+
import { FS_RESOURCE_SYSTEM_NOTE, type BtcaFsResource } from '../resources/types.ts';
|
|
9
|
+
import { CollectionError, getCollectionKey, type CollectionResult } from './types.ts';
|
|
10
|
+
|
|
11
|
+
export namespace Collections {
|
|
12
|
+
export type Service = {
|
|
13
|
+
load: (args: {
|
|
14
|
+
resourceNames: readonly string[];
|
|
15
|
+
quiet?: boolean;
|
|
16
|
+
}) => Promise<CollectionResult>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const createCollectionInstructionBlock = (resource: BtcaFsResource): string => {
|
|
20
|
+
const lines = [
|
|
21
|
+
`## Resource: ${resource.name}`,
|
|
22
|
+
FS_RESOURCE_SYSTEM_NOTE,
|
|
23
|
+
`Path: ./${resource.name}`,
|
|
24
|
+
resource.repoSubPath ? `Focus: ./${resource.name}/${resource.repoSubPath}` : '',
|
|
25
|
+
resource.specialAgentInstructions ? `Notes: ${resource.specialAgentInstructions}` : ''
|
|
26
|
+
].filter(Boolean);
|
|
27
|
+
|
|
28
|
+
return lines.join('\n');
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const create = (args: {
|
|
32
|
+
config: Config.Service;
|
|
33
|
+
resources: Resources.Service;
|
|
34
|
+
}): Service => {
|
|
35
|
+
return {
|
|
36
|
+
load: ({ resourceNames, quiet = false }) =>
|
|
37
|
+
Transaction.run('collections.load', async () => {
|
|
38
|
+
const uniqueNames = Array.from(new Set(resourceNames));
|
|
39
|
+
if (uniqueNames.length === 0)
|
|
40
|
+
throw new CollectionError({ message: 'Cannot create collection with no resources' });
|
|
41
|
+
|
|
42
|
+
Metrics.info('collections.load', { resources: uniqueNames, quiet });
|
|
43
|
+
|
|
44
|
+
const sortedNames = [...uniqueNames].sort((a, b) => a.localeCompare(b));
|
|
45
|
+
const key = getCollectionKey(sortedNames);
|
|
46
|
+
const collectionPath = path.join(args.config.collectionsDirectory, key);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await fs.mkdir(collectionPath, { recursive: true });
|
|
50
|
+
} catch (cause) {
|
|
51
|
+
throw new CollectionError({ message: 'Failed to create collection directory', cause });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const loadedResources: BtcaFsResource[] = [];
|
|
55
|
+
for (const name of sortedNames) {
|
|
56
|
+
try {
|
|
57
|
+
loadedResources.push(await args.resources.load(name, { quiet }));
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
throw new CollectionError({ message: `Failed to load resource ${name}`, cause });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const resource of loadedResources) {
|
|
64
|
+
let resourcePath: string;
|
|
65
|
+
try {
|
|
66
|
+
resourcePath = await resource.getAbsoluteDirectoryPath();
|
|
67
|
+
} catch (cause) {
|
|
68
|
+
throw new CollectionError({
|
|
69
|
+
message: `Failed to get path for ${resource.name}`,
|
|
70
|
+
cause
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const linkPath = path.join(collectionPath, resource.name);
|
|
75
|
+
try {
|
|
76
|
+
await fs.rm(linkPath, { recursive: true, force: true });
|
|
77
|
+
} catch {
|
|
78
|
+
// ignore
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await fs.symlink(resourcePath, linkPath);
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
throw new CollectionError({
|
|
85
|
+
message: `Failed to create symlink for ${resource.name}`,
|
|
86
|
+
cause
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const instructionBlocks = loadedResources.map(createCollectionInstructionBlock);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
path: collectionPath,
|
|
95
|
+
agentInstructions: instructionBlocks.join('\n\n')
|
|
96
|
+
};
|
|
97
|
+
})
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type CollectionResult = {
|
|
2
|
+
path: string;
|
|
3
|
+
agentInstructions: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export class CollectionError extends Error {
|
|
7
|
+
readonly _tag = 'CollectionError';
|
|
8
|
+
override readonly cause?: unknown;
|
|
9
|
+
|
|
10
|
+
constructor(args: { message: string; cause?: unknown }) {
|
|
11
|
+
super(args.message);
|
|
12
|
+
this.cause = args.cause;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const getCollectionKey = (resourceNames: readonly string[]): string => {
|
|
17
|
+
return [...resourceNames].sort().join('+');
|
|
18
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
import { Config, DEFAULT_MODEL, DEFAULT_PROVIDER, DEFAULT_RESOURCES } from './index.ts';
|
|
7
|
+
|
|
8
|
+
describe('Config', () => {
|
|
9
|
+
let testDir: string;
|
|
10
|
+
let originalCwd: string;
|
|
11
|
+
let originalHome: string | undefined;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-config-test-'));
|
|
15
|
+
originalCwd = process.cwd();
|
|
16
|
+
originalHome = process.env.HOME;
|
|
17
|
+
// Point HOME to test dir so global config goes there
|
|
18
|
+
process.env.HOME = testDir;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
process.chdir(originalCwd);
|
|
23
|
+
process.env.HOME = originalHome;
|
|
24
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('Config.load', () => {
|
|
28
|
+
it('creates default config when no config exists', async () => {
|
|
29
|
+
process.chdir(testDir);
|
|
30
|
+
|
|
31
|
+
const config = await Config.load();
|
|
32
|
+
|
|
33
|
+
expect(config.provider).toBe(DEFAULT_PROVIDER);
|
|
34
|
+
expect(config.model).toBe(DEFAULT_MODEL);
|
|
35
|
+
expect(config.resources.length).toBe(DEFAULT_RESOURCES.length);
|
|
36
|
+
expect(config.getResource('svelte')).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('loads project config when btca.config.jsonc exists in cwd', async () => {
|
|
40
|
+
const projectConfig = {
|
|
41
|
+
$schema: 'https://btca.dev/btca.schema.json',
|
|
42
|
+
provider: 'test-provider',
|
|
43
|
+
model: 'test-model',
|
|
44
|
+
resources: [
|
|
45
|
+
{
|
|
46
|
+
name: 'test-resource',
|
|
47
|
+
type: 'git',
|
|
48
|
+
url: 'https://github.com/test/repo',
|
|
49
|
+
branch: 'main'
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
await fs.writeFile(path.join(testDir, 'btca.config.jsonc'), JSON.stringify(projectConfig));
|
|
55
|
+
process.chdir(testDir);
|
|
56
|
+
|
|
57
|
+
const config = await Config.load();
|
|
58
|
+
|
|
59
|
+
expect(config.provider).toBe('test-provider');
|
|
60
|
+
expect(config.model).toBe('test-model');
|
|
61
|
+
expect(config.resources.length).toBe(1);
|
|
62
|
+
expect(config.resources[0]?.name).toBe('test-resource');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('handles JSONC with comments', async () => {
|
|
66
|
+
const projectConfigWithComments = `{
|
|
67
|
+
// This is a comment
|
|
68
|
+
"$schema": "https://btca.dev/btca.schema.json",
|
|
69
|
+
"provider": "commented-provider",
|
|
70
|
+
"model": "commented-model",
|
|
71
|
+
/* Multi-line
|
|
72
|
+
comment */
|
|
73
|
+
"resources": [
|
|
74
|
+
{
|
|
75
|
+
"name": "commented-resource",
|
|
76
|
+
"type": "git",
|
|
77
|
+
"url": "https://github.com/test/repo",
|
|
78
|
+
"branch": "main",
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
}`;
|
|
82
|
+
|
|
83
|
+
await fs.writeFile(path.join(testDir, 'btca.config.jsonc'), projectConfigWithComments);
|
|
84
|
+
process.chdir(testDir);
|
|
85
|
+
|
|
86
|
+
const config = await Config.load();
|
|
87
|
+
|
|
88
|
+
expect(config.provider).toBe('commented-provider');
|
|
89
|
+
expect(config.model).toBe('commented-model');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('getResource returns undefined for unknown resource', async () => {
|
|
93
|
+
process.chdir(testDir);
|
|
94
|
+
|
|
95
|
+
const config = await Config.load();
|
|
96
|
+
|
|
97
|
+
expect(config.getResource('nonexistent')).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('throws ConfigError for invalid JSON', async () => {
|
|
101
|
+
await fs.writeFile(path.join(testDir, 'btca.config.jsonc'), 'not valid json {{{');
|
|
102
|
+
process.chdir(testDir);
|
|
103
|
+
|
|
104
|
+
expect(Config.load()).rejects.toThrow('Failed to parse config JSONC');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('throws ConfigError for invalid schema', async () => {
|
|
108
|
+
const invalidConfig = {
|
|
109
|
+
provider: 'test'
|
|
110
|
+
// missing required fields
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await fs.writeFile(path.join(testDir, 'btca.config.jsonc'), JSON.stringify(invalidConfig));
|
|
114
|
+
process.chdir(testDir);
|
|
115
|
+
|
|
116
|
+
expect(Config.load()).rejects.toThrow('Invalid config');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|