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
package/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# @btca/server
|
|
2
|
+
|
|
3
|
+
BTCA (Better Context AI) server for answering questions about your codebase using OpenCode AI.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @btca/server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Starting the Server
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { startServer } from '@btca/server';
|
|
17
|
+
|
|
18
|
+
// Start with default options (port 8080 or process.env.PORT)
|
|
19
|
+
const server = await startServer();
|
|
20
|
+
console.log(`Server running at ${server.url}`);
|
|
21
|
+
|
|
22
|
+
// Start with custom port
|
|
23
|
+
const server = await startServer({ port: 3000 });
|
|
24
|
+
|
|
25
|
+
// Start with quiet mode (no logging)
|
|
26
|
+
const server = await startServer({ port: 3000, quiet: true });
|
|
27
|
+
|
|
28
|
+
// Stop the server when needed
|
|
29
|
+
server.stop();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Server Instance
|
|
33
|
+
|
|
34
|
+
The `startServer` function returns a `ServerInstance` object:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
interface ServerInstance {
|
|
38
|
+
port: number; // Actual port the server is running on
|
|
39
|
+
url: string; // Full URL (e.g., "http://localhost:8080")
|
|
40
|
+
stop: () => void; // Function to stop the server
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Random Port Assignment
|
|
45
|
+
|
|
46
|
+
You can pass `port: 0` to let the OS assign a random available port:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
const server = await startServer({ port: 0 });
|
|
50
|
+
console.log(`Server running on port ${server.port}`);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## API Endpoints
|
|
54
|
+
|
|
55
|
+
Once the server is running, it exposes the following REST API endpoints:
|
|
56
|
+
|
|
57
|
+
### Health Check
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
GET /
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Returns service status and version info.
|
|
64
|
+
|
|
65
|
+
### Configuration
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
GET /config
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Returns current configuration (provider, model, resources).
|
|
72
|
+
|
|
73
|
+
### Resources
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
GET /resources
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Lists all configured resources (local directories or git repositories).
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
POST /config/resources
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Add a new resource (git or local).
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
DELETE /config/resources
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Remove a resource by name.
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
POST /clear
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Clear all locally cloned resources.
|
|
98
|
+
|
|
99
|
+
### Questions
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
POST /question
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Ask a question (non-streaming response).
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
POST /question/stream
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Ask a question with streaming SSE response.
|
|
112
|
+
|
|
113
|
+
### OpenCode Instance
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
POST /opencode
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Get an OpenCode instance URL for a collection of resources.
|
|
120
|
+
|
|
121
|
+
### Model Configuration
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
PUT /config/model
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Update the AI provider and model configuration.
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
The server reads configuration from `~/.btca/config.toml` or your local project's `.btca/config.toml`. You'll need to configure:
|
|
132
|
+
|
|
133
|
+
- **AI Provider**: OpenCode AI provider (e.g., "anthropic")
|
|
134
|
+
- **Model**: AI model to use (e.g., "claude-3-7-sonnet-20250219")
|
|
135
|
+
- **Resources**: Local directories or git repositories to query
|
|
136
|
+
|
|
137
|
+
Example config.toml:
|
|
138
|
+
|
|
139
|
+
```toml
|
|
140
|
+
provider = "anthropic"
|
|
141
|
+
model = "claude-3-7-sonnet-20250219"
|
|
142
|
+
resourcesDirectory = "~/.btca/resources"
|
|
143
|
+
collectionsDirectory = "~/.btca/collections"
|
|
144
|
+
|
|
145
|
+
[[resources]]
|
|
146
|
+
type = "local"
|
|
147
|
+
name = "my-project"
|
|
148
|
+
path = "/path/to/my/project"
|
|
149
|
+
|
|
150
|
+
[[resources]]
|
|
151
|
+
type = "git"
|
|
152
|
+
name = "some-repo"
|
|
153
|
+
url = "https://github.com/user/repo"
|
|
154
|
+
branch = "main"
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Environment Variables
|
|
158
|
+
|
|
159
|
+
- `PORT`: Server port (default: 8080)
|
|
160
|
+
- `OPENCODE_API_KEY`: OpenCode AI API key (required)
|
|
161
|
+
|
|
162
|
+
## TypeScript Types
|
|
163
|
+
|
|
164
|
+
The package exports TypeScript types for use with Hono RPC client:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import type { AppType } from '@btca/server';
|
|
168
|
+
import { hc } from 'hono/client';
|
|
169
|
+
|
|
170
|
+
const client = hc<AppType>('http://localhost:8080');
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Stream Types
|
|
174
|
+
|
|
175
|
+
For working with SSE streaming responses:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import type {
|
|
179
|
+
BtcaStreamEvent,
|
|
180
|
+
BtcaStreamMetaEvent
|
|
181
|
+
} from '@btca/server/stream/types';
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Requirements
|
|
185
|
+
|
|
186
|
+
- **Bun**: >= 1.1.0 (this package is designed specifically for Bun runtime)
|
|
187
|
+
- **OpenCode AI API Key**: Required for AI functionality
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
|
192
|
+
|
|
193
|
+
## Repository
|
|
194
|
+
|
|
195
|
+
[https://github.com/bmdavis419/better-context](https://github.com/bmdavis419/better-context)
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "btca-server",
|
|
3
|
+
"version": "1.0.20",
|
|
4
|
+
"description": "BTCA server for answering questions about your codebase using OpenCode AI",
|
|
5
|
+
"author": "Ben Davis",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/bmdavis419/better-context",
|
|
10
|
+
"directory": "apps/server"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"btca",
|
|
14
|
+
"opencode",
|
|
15
|
+
"ai",
|
|
16
|
+
"codebase",
|
|
17
|
+
"documentation",
|
|
18
|
+
"server",
|
|
19
|
+
"api",
|
|
20
|
+
"bun"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"module": "src/index.ts",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": "./src/index.ts",
|
|
32
|
+
"./stream": "./src/stream/index.ts",
|
|
33
|
+
"./stream/types": "./src/stream/types.ts"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"check": "tsgo --noEmit",
|
|
41
|
+
"dev": "bun --watch src/index.ts",
|
|
42
|
+
"format": "prettier --write .",
|
|
43
|
+
"test": "bun test",
|
|
44
|
+
"test:watch": "bun test --watch"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/bun": "latest",
|
|
48
|
+
"@typescript/native-preview": "^7.0.0-dev.20260109.1",
|
|
49
|
+
"prettier": "^3.7.4"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@opencode-ai/sdk": "^1.0.208",
|
|
53
|
+
"hono": "^4.7.11",
|
|
54
|
+
"zod": "^3.25.76"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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 { Agent } from './service.ts';
|
|
7
|
+
import { Config } from '../config/index.ts';
|
|
8
|
+
import type { CollectionResult } from '../collections/types.ts';
|
|
9
|
+
|
|
10
|
+
describe('Agent', () => {
|
|
11
|
+
let testDir: string;
|
|
12
|
+
let originalCwd: string;
|
|
13
|
+
let originalHome: string | undefined;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'btca-agent-test-'));
|
|
17
|
+
originalCwd = process.cwd();
|
|
18
|
+
originalHome = process.env.HOME;
|
|
19
|
+
process.env.HOME = testDir;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
process.chdir(originalCwd);
|
|
24
|
+
process.env.HOME = originalHome;
|
|
25
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('Agent.create', () => {
|
|
29
|
+
it('creates an agent service with ask and askStream methods', async () => {
|
|
30
|
+
process.chdir(testDir);
|
|
31
|
+
const config = await Config.load();
|
|
32
|
+
const agent = Agent.create(config);
|
|
33
|
+
|
|
34
|
+
expect(agent).toBeDefined();
|
|
35
|
+
expect(typeof agent.ask).toBe('function');
|
|
36
|
+
expect(typeof agent.askStream).toBe('function');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Integration tests - require valid OpenCode credentials and provider
|
|
41
|
+
// Run with: BTCA_RUN_INTEGRATION_TESTS=1 bun test
|
|
42
|
+
describe.skipIf(!process.env.BTCA_RUN_INTEGRATION_TESTS)('Agent.ask (integration)', () => {
|
|
43
|
+
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
|
+
process.chdir(testDir);
|
|
53
|
+
const config = await Config.load();
|
|
54
|
+
const agent = Agent.create(config);
|
|
55
|
+
|
|
56
|
+
const collection: CollectionResult = {
|
|
57
|
+
path: collectionPath,
|
|
58
|
+
agentInstructions: 'This is a test collection with a README file.'
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = await agent.ask({
|
|
62
|
+
collection,
|
|
63
|
+
question: 'What number is the answer to life according to the README?'
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result).toBeDefined();
|
|
67
|
+
expect(result.answer).toBeDefined();
|
|
68
|
+
expect(typeof result.answer).toBe('string');
|
|
69
|
+
expect(result.answer.length).toBeGreaterThan(0);
|
|
70
|
+
expect(result.model).toBeDefined();
|
|
71
|
+
expect(result.model.provider).toBeDefined();
|
|
72
|
+
expect(result.model.model).toBeDefined();
|
|
73
|
+
expect(result.events).toBeDefined();
|
|
74
|
+
expect(Array.isArray(result.events)).toBe(true);
|
|
75
|
+
}, 60000);
|
|
76
|
+
|
|
77
|
+
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
|
+
process.chdir(testDir);
|
|
83
|
+
const config = await Config.load();
|
|
84
|
+
const agent = Agent.create(config);
|
|
85
|
+
|
|
86
|
+
const collection: CollectionResult = {
|
|
87
|
+
path: collectionPath,
|
|
88
|
+
agentInstructions: 'Simple test collection.'
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const { stream, model } = await agent.askStream({
|
|
92
|
+
collection,
|
|
93
|
+
question: 'What is the capital of France according to the data file?'
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(model).toBeDefined();
|
|
97
|
+
expect(model.provider).toBeDefined();
|
|
98
|
+
expect(model.model).toBeDefined();
|
|
99
|
+
|
|
100
|
+
const events = [];
|
|
101
|
+
for await (const event of stream) {
|
|
102
|
+
events.push(event);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
expect(events.length).toBeGreaterThan(0);
|
|
106
|
+
// Should have received some message.part.updated events
|
|
107
|
+
const textEvents = events.filter((e) => e.type === 'message.part.updated');
|
|
108
|
+
expect(textEvents.length).toBeGreaterThan(0);
|
|
109
|
+
}, 60000);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createOpencode,
|
|
3
|
+
createOpencodeClient,
|
|
4
|
+
type Config as OpenCodeConfig,
|
|
5
|
+
type OpencodeClient,
|
|
6
|
+
type Event as OcEvent
|
|
7
|
+
} from '@opencode-ai/sdk';
|
|
8
|
+
|
|
9
|
+
import { Config } from '../config/index.ts';
|
|
10
|
+
import { Metrics } from '../metrics/index.ts';
|
|
11
|
+
import type { CollectionResult } from '../collections/types.ts';
|
|
12
|
+
import type { AgentResult } from './types.ts';
|
|
13
|
+
|
|
14
|
+
export namespace Agent {
|
|
15
|
+
export class AgentError extends Error {
|
|
16
|
+
readonly _tag = 'AgentError';
|
|
17
|
+
override readonly cause?: unknown;
|
|
18
|
+
|
|
19
|
+
constructor(args: { message: string; cause?: unknown }) {
|
|
20
|
+
super(args.message);
|
|
21
|
+
this.cause = args.cause;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class InvalidProviderError extends Error {
|
|
26
|
+
readonly _tag = 'InvalidProviderError';
|
|
27
|
+
readonly providerId: string;
|
|
28
|
+
readonly availableProviders: string[];
|
|
29
|
+
|
|
30
|
+
constructor(args: { providerId: string; availableProviders: string[] }) {
|
|
31
|
+
super(`Invalid provider: ${args.providerId}`);
|
|
32
|
+
this.providerId = args.providerId;
|
|
33
|
+
this.availableProviders = args.availableProviders;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class InvalidModelError extends Error {
|
|
38
|
+
readonly _tag = 'InvalidModelError';
|
|
39
|
+
readonly providerId: string;
|
|
40
|
+
readonly modelId: string;
|
|
41
|
+
readonly availableModels: string[];
|
|
42
|
+
|
|
43
|
+
constructor(args: { providerId: string; modelId: string; availableModels: string[] }) {
|
|
44
|
+
super(`Invalid model: ${args.modelId}`);
|
|
45
|
+
this.providerId = args.providerId;
|
|
46
|
+
this.modelId = args.modelId;
|
|
47
|
+
this.availableModels = args.availableModels;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ProviderNotConnectedError extends Error {
|
|
52
|
+
readonly _tag = 'ProviderNotConnectedError';
|
|
53
|
+
readonly providerId: string;
|
|
54
|
+
readonly connectedProviders: string[];
|
|
55
|
+
|
|
56
|
+
constructor(args: { providerId: string; connectedProviders: string[] }) {
|
|
57
|
+
super(`Provider not connected: ${args.providerId}`);
|
|
58
|
+
this.providerId = args.providerId;
|
|
59
|
+
this.connectedProviders = args.connectedProviders;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type Service = {
|
|
64
|
+
askStream: (args: {
|
|
65
|
+
collection: CollectionResult;
|
|
66
|
+
question: string;
|
|
67
|
+
}) => Promise<{ stream: AsyncIterable<OcEvent>; model: { provider: string; model: string } }>;
|
|
68
|
+
|
|
69
|
+
ask: (args: { collection: CollectionResult; question: string }) => Promise<AgentResult>;
|
|
70
|
+
|
|
71
|
+
getOpencodeInstance: (args: { collection: CollectionResult }) => Promise<{
|
|
72
|
+
url: string;
|
|
73
|
+
model: { provider: string; model: string };
|
|
74
|
+
}>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const buildOpenCodeConfig = (args: { agentInstructions: string }): OpenCodeConfig => {
|
|
78
|
+
const prompt = [
|
|
79
|
+
'You are an expert internal agent who`s job is to answer questions about the collection.',
|
|
80
|
+
'You operate inside a collection directory.',
|
|
81
|
+
'Use the resources in this collection to answer the user`s question.',
|
|
82
|
+
args.agentInstructions
|
|
83
|
+
].join('\n');
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
agent: {
|
|
87
|
+
build: { disable: true },
|
|
88
|
+
explore: { disable: true },
|
|
89
|
+
general: { disable: true },
|
|
90
|
+
plan: { disable: true },
|
|
91
|
+
docs: {
|
|
92
|
+
prompt,
|
|
93
|
+
description: 'Answer questions by searching the collection',
|
|
94
|
+
permission: {
|
|
95
|
+
webfetch: 'deny',
|
|
96
|
+
edit: 'deny',
|
|
97
|
+
bash: 'deny',
|
|
98
|
+
external_directory: 'deny',
|
|
99
|
+
doom_loop: 'deny'
|
|
100
|
+
},
|
|
101
|
+
mode: 'primary',
|
|
102
|
+
tools: {
|
|
103
|
+
write: false,
|
|
104
|
+
bash: false,
|
|
105
|
+
delete: false,
|
|
106
|
+
read: true,
|
|
107
|
+
grep: true,
|
|
108
|
+
glob: true,
|
|
109
|
+
list: true,
|
|
110
|
+
path: false,
|
|
111
|
+
todowrite: false,
|
|
112
|
+
todoread: false,
|
|
113
|
+
websearch: false,
|
|
114
|
+
webfetch: false,
|
|
115
|
+
skill: false,
|
|
116
|
+
task: false,
|
|
117
|
+
mcp: false,
|
|
118
|
+
edit: false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const validateProviderAndModel = async (
|
|
126
|
+
client: OpencodeClient,
|
|
127
|
+
providerId: string,
|
|
128
|
+
modelId: string
|
|
129
|
+
) => {
|
|
130
|
+
const response = await client.provider.list().catch(() => null);
|
|
131
|
+
if (!response?.data) return;
|
|
132
|
+
|
|
133
|
+
type ProviderInfo = { id: string; models: Record<string, unknown> };
|
|
134
|
+
const data = response.data as { all: ProviderInfo[]; connected: string[] };
|
|
135
|
+
|
|
136
|
+
const { all, connected } = data;
|
|
137
|
+
const provider = all.find((p) => p.id === providerId);
|
|
138
|
+
if (!provider)
|
|
139
|
+
throw new InvalidProviderError({ providerId, availableProviders: all.map((p) => p.id) });
|
|
140
|
+
if (!connected.includes(providerId)) {
|
|
141
|
+
throw new ProviderNotConnectedError({ providerId, connectedProviders: connected });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const modelIds = Object.keys(provider.models);
|
|
145
|
+
if (!modelIds.includes(modelId)) {
|
|
146
|
+
throw new InvalidModelError({ providerId, modelId, availableModels: modelIds });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const getOpencodeInstance = async (args: {
|
|
151
|
+
collectionPath: string;
|
|
152
|
+
ocConfig: OpenCodeConfig;
|
|
153
|
+
}): Promise<{
|
|
154
|
+
client: OpencodeClient;
|
|
155
|
+
server: { close(): void; url: string };
|
|
156
|
+
baseUrl: string;
|
|
157
|
+
}> => {
|
|
158
|
+
const maxAttempts = 10;
|
|
159
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
160
|
+
const port = Math.floor(Math.random() * 3000) + 3000;
|
|
161
|
+
const created = await createOpencode({ port, config: args.ocConfig }).catch((err: any) => {
|
|
162
|
+
if (err?.cause instanceof Error && err.cause.stack?.includes('port')) return null;
|
|
163
|
+
throw new AgentError({ message: 'Failed to create OpenCode instance', cause: err });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (created) {
|
|
167
|
+
const baseUrl = `http://localhost:${port}`;
|
|
168
|
+
return {
|
|
169
|
+
client: createOpencodeClient({ baseUrl, directory: args.collectionPath }),
|
|
170
|
+
server: created.server,
|
|
171
|
+
baseUrl
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw new AgentError({
|
|
177
|
+
message: 'Failed to create OpenCode instance - all port attempts exhausted'
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const sessionEvents = async (args: {
|
|
182
|
+
sessionID: string;
|
|
183
|
+
client: OpencodeClient;
|
|
184
|
+
}): Promise<AsyncIterable<OcEvent>> => {
|
|
185
|
+
const events = await args.client.event.subscribe().catch((cause: unknown) => {
|
|
186
|
+
throw new AgentError({ message: 'Failed to subscribe to events', cause });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
async function* gen() {
|
|
190
|
+
for await (const event of events.stream) {
|
|
191
|
+
const props = event.properties as any;
|
|
192
|
+
if (props && 'sessionID' in props && props.sessionID !== args.sessionID) continue;
|
|
193
|
+
yield event;
|
|
194
|
+
if (
|
|
195
|
+
event.type === 'session.idle' &&
|
|
196
|
+
(event.properties as any)?.sessionID === args.sessionID
|
|
197
|
+
)
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return gen();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const extractAnswerFromEvents = (events: readonly OcEvent[]): string => {
|
|
206
|
+
const partIds: string[] = [];
|
|
207
|
+
const partText = new Map<string, string>();
|
|
208
|
+
|
|
209
|
+
for (const event of events) {
|
|
210
|
+
if (event.type !== 'message.part.updated') continue;
|
|
211
|
+
const part: any = (event.properties as any).part;
|
|
212
|
+
if (!part || part.type !== 'text') continue;
|
|
213
|
+
if (!partIds.includes(part.id)) partIds.push(part.id);
|
|
214
|
+
partText.set(part.id, String(part.text ?? ''));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return partIds
|
|
218
|
+
.map((id) => partText.get(id) ?? '')
|
|
219
|
+
.join('')
|
|
220
|
+
.trim();
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const create = (config: Config.Service): Service => {
|
|
224
|
+
const askStream: Service['askStream'] = async ({ collection, question }) => {
|
|
225
|
+
const ocConfig = buildOpenCodeConfig({ agentInstructions: collection.agentInstructions });
|
|
226
|
+
const { client, server, baseUrl } = await getOpencodeInstance({
|
|
227
|
+
collectionPath: collection.path,
|
|
228
|
+
ocConfig
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
Metrics.info('agent.oc.ready', { baseUrl, collectionPath: collection.path });
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
try {
|
|
235
|
+
await validateProviderAndModel(client, config.provider, config.model);
|
|
236
|
+
Metrics.info('agent.validate.ok', { provider: config.provider, model: config.model });
|
|
237
|
+
} catch (cause) {
|
|
238
|
+
throw new AgentError({ message: 'Provider/model validation failed', cause });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const session = await client.session.create().catch((cause: unknown) => {
|
|
242
|
+
throw new AgentError({ message: 'Failed to create session', cause });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (session.error)
|
|
246
|
+
throw new AgentError({ message: 'Failed to create session', cause: session.error });
|
|
247
|
+
|
|
248
|
+
const sessionID = session.data?.id;
|
|
249
|
+
if (!sessionID) {
|
|
250
|
+
throw new AgentError({
|
|
251
|
+
message: 'Failed to create session',
|
|
252
|
+
cause: new Error('Missing session id')
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
Metrics.info('agent.session.created', { sessionID });
|
|
256
|
+
|
|
257
|
+
const eventStream = await sessionEvents({ sessionID, client });
|
|
258
|
+
|
|
259
|
+
Metrics.info('agent.prompt.sent', { sessionID, questionLength: question.length });
|
|
260
|
+
void client.session
|
|
261
|
+
.prompt({
|
|
262
|
+
path: { id: sessionID },
|
|
263
|
+
body: {
|
|
264
|
+
agent: 'docs',
|
|
265
|
+
model: { providerID: config.provider, modelID: config.model },
|
|
266
|
+
parts: [{ type: 'text', text: question }]
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
.catch((cause: unknown) => {
|
|
270
|
+
Metrics.error('agent.prompt.err', { error: Metrics.errorInfo(cause) });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
async function* filtered() {
|
|
274
|
+
try {
|
|
275
|
+
for await (const event of eventStream) {
|
|
276
|
+
if (event.type === 'session.error') {
|
|
277
|
+
const props: any = event.properties;
|
|
278
|
+
throw new AgentError({
|
|
279
|
+
message: props?.error?.name ?? 'Unknown session error',
|
|
280
|
+
cause: props?.error
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
yield event;
|
|
284
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
Metrics.info('agent.session.closed', { sessionID });
|
|
287
|
+
server.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
stream: filtered(),
|
|
293
|
+
model: { provider: config.provider, model: config.model }
|
|
294
|
+
};
|
|
295
|
+
} catch (cause) {
|
|
296
|
+
server.close();
|
|
297
|
+
throw cause;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const ask: Service['ask'] = async ({ collection, question }) => {
|
|
302
|
+
const { stream, model } = await askStream({ collection, question });
|
|
303
|
+
const events: OcEvent[] = [];
|
|
304
|
+
for await (const event of stream) events.push(event);
|
|
305
|
+
return { answer: extractAnswerFromEvents(events), model, events };
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const getOpencodeInstanceMethod: Service['getOpencodeInstance'] = async ({ collection }) => {
|
|
309
|
+
const ocConfig = buildOpenCodeConfig({ agentInstructions: collection.agentInstructions });
|
|
310
|
+
const { baseUrl } = await getOpencodeInstance({
|
|
311
|
+
collectionPath: collection.path,
|
|
312
|
+
ocConfig
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
Metrics.info('agent.oc.instance.ready', { baseUrl, collectionPath: collection.path });
|
|
316
|
+
|
|
317
|
+
// Note: The server stays alive - it's the caller's responsibility to manage the lifecycle
|
|
318
|
+
// For CLI usage, the opencode CLI will connect to this instance and manage it
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
url: baseUrl,
|
|
322
|
+
model: { provider: config.provider, model: config.model }
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return { askStream, ask, getOpencodeInstance: getOpencodeInstanceMethod };
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Event as OcEvent, OpencodeClient } from '@opencode-ai/sdk';
|
|
2
|
+
|
|
3
|
+
export type AgentResult = {
|
|
4
|
+
answer: string;
|
|
5
|
+
model: { provider: string; model: string };
|
|
6
|
+
events: OcEvent[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SessionState = {
|
|
10
|
+
client: OpencodeClient;
|
|
11
|
+
server: { close: () => void; url: string };
|
|
12
|
+
sessionID: string;
|
|
13
|
+
collectionPath: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export { type OcEvent };
|