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.
@@ -0,0 +1,156 @@
1
+ import { promises as fs } from 'node:fs';
2
+
3
+ import { Metrics } from '../../metrics/index.ts';
4
+ import { ResourceError } from '../helpers.ts';
5
+ import type { BtcaFsResource, BtcaGitResourceArgs } from '../types.ts';
6
+
7
+ const isValidGitUrl = (url: string) => /^https?:\/\//.test(url) || /^git@/.test(url);
8
+ const isValidBranch = (branch: string) => /^[\w\-./]+$/.test(branch);
9
+ const isValidPath = (path: string) => !path.includes('..') && /^[\w\-./]*$/.test(path);
10
+
11
+ const directoryExists = async (path: string): Promise<boolean> => {
12
+ try {
13
+ const stat = await fs.stat(path);
14
+ return stat.isDirectory();
15
+ } catch {
16
+ return false;
17
+ }
18
+ };
19
+
20
+ const runGit = async (args: string[], options: { cwd?: string; quiet: boolean }) => {
21
+ const stdio = options.quiet ? 'ignore' : 'inherit';
22
+ const proc = Bun.spawn(['git', ...args], {
23
+ cwd: options.cwd,
24
+ stdout: stdio,
25
+ stderr: stdio
26
+ });
27
+ const exitCode = await proc.exited;
28
+ if (exitCode !== 0) {
29
+ throw new ResourceError({
30
+ message: `git ${args[0]} failed`,
31
+ cause: new Error(String(exitCode))
32
+ });
33
+ }
34
+ };
35
+
36
+ const gitClone = async (args: {
37
+ repoUrl: string;
38
+ repoBranch: string;
39
+ repoSubPath: string;
40
+ localAbsolutePath: string;
41
+ quiet: boolean;
42
+ }) => {
43
+ if (!isValidGitUrl(args.repoUrl)) {
44
+ throw new ResourceError({
45
+ message: 'Invalid git URL',
46
+ cause: new Error('URL validation failed')
47
+ });
48
+ }
49
+ if (!isValidBranch(args.repoBranch)) {
50
+ throw new ResourceError({
51
+ message: 'Invalid branch name',
52
+ cause: new Error('Branch validation failed')
53
+ });
54
+ }
55
+ if (args.repoSubPath && !isValidPath(args.repoSubPath)) {
56
+ throw new ResourceError({
57
+ message: 'Invalid path',
58
+ cause: new Error('Path validation failed')
59
+ });
60
+ }
61
+
62
+ const needsSparseCheckout = args.repoSubPath && args.repoSubPath !== '/';
63
+ const cloneArgs = needsSparseCheckout
64
+ ? [
65
+ 'clone',
66
+ '--filter=blob:none',
67
+ '--no-checkout',
68
+ '--sparse',
69
+ '-b',
70
+ args.repoBranch,
71
+ args.repoUrl,
72
+ args.localAbsolutePath
73
+ ]
74
+ : ['clone', '--depth', '1', '-b', args.repoBranch, args.repoUrl, args.localAbsolutePath];
75
+
76
+ await runGit(cloneArgs, { quiet: args.quiet });
77
+
78
+ if (needsSparseCheckout) {
79
+ await runGit(['sparse-checkout', 'set', args.repoSubPath], {
80
+ cwd: args.localAbsolutePath,
81
+ quiet: args.quiet
82
+ });
83
+ await runGit(['checkout'], { cwd: args.localAbsolutePath, quiet: args.quiet });
84
+ }
85
+ };
86
+
87
+ const gitUpdate = async (args: { localAbsolutePath: string; branch: string; quiet: boolean }) => {
88
+ await runGit(['fetch', '--depth', '1', 'origin', args.branch], {
89
+ cwd: args.localAbsolutePath,
90
+ quiet: args.quiet
91
+ });
92
+ await runGit(['reset', '--hard', `origin/${args.branch}`], {
93
+ cwd: args.localAbsolutePath,
94
+ quiet: args.quiet
95
+ });
96
+ };
97
+
98
+ const ensureGitResource = async (config: BtcaGitResourceArgs): Promise<string> => {
99
+ const localPath = `${config.resourcesDirectoryPath}/${config.name}`;
100
+
101
+ return Metrics.span(
102
+ 'resource.git.ensure',
103
+ async () => {
104
+ const exists = await directoryExists(localPath);
105
+
106
+ if (exists) {
107
+ Metrics.info('resource.git.update', {
108
+ name: config.name,
109
+ branch: config.branch,
110
+ repoSubPath: config.repoSubPath
111
+ });
112
+ await gitUpdate({
113
+ localAbsolutePath: localPath,
114
+ branch: config.branch,
115
+ quiet: config.quiet
116
+ });
117
+ return localPath;
118
+ }
119
+
120
+ Metrics.info('resource.git.clone', {
121
+ name: config.name,
122
+ branch: config.branch,
123
+ repoSubPath: config.repoSubPath
124
+ });
125
+
126
+ try {
127
+ await fs.mkdir(config.resourcesDirectoryPath, { recursive: true });
128
+ } catch (cause) {
129
+ throw new ResourceError({ message: 'Failed to create resources directory', cause });
130
+ }
131
+
132
+ await gitClone({
133
+ repoUrl: config.url,
134
+ repoBranch: config.branch,
135
+ repoSubPath: config.repoSubPath,
136
+ localAbsolutePath: localPath,
137
+ quiet: config.quiet
138
+ });
139
+
140
+ return localPath;
141
+ },
142
+ { resource: config.name }
143
+ );
144
+ };
145
+
146
+ export const loadGitResource = async (config: BtcaGitResourceArgs): Promise<BtcaFsResource> => {
147
+ const localPath = await ensureGitResource(config);
148
+ return {
149
+ _tag: 'fs-based',
150
+ name: config.name,
151
+ type: 'git',
152
+ repoSubPath: config.repoSubPath,
153
+ specialAgentInstructions: config.specialAgentInstructions,
154
+ getAbsoluteDirectoryPath: async () => localPath
155
+ };
156
+ };
@@ -0,0 +1,10 @@
1
+ export { ResourceError } from './helpers.ts';
2
+ export { Resources } from './service.ts';
3
+ export {
4
+ GitResourceSchema,
5
+ ResourceDefinitionSchema,
6
+ isGitResource,
7
+ type GitResource,
8
+ type ResourceDefinition
9
+ } from './schema.ts';
10
+ export { FS_RESOURCE_SYSTEM_NOTE, type BtcaFsResource, type BtcaGitResourceArgs } from './types.ts';
@@ -0,0 +1,178 @@
1
+ import { z } from 'zod';
2
+
3
+ import { LIMITS } from '../validation/index.ts';
4
+
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Validation Patterns
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Resource name: must start with a letter, followed by alphanumeric and hyphens only.
11
+ * Prevents path traversal, git option injection, and shell metacharacters.
12
+ */
13
+ const RESOURCE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9-]*$/;
14
+
15
+ /**
16
+ * Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens.
17
+ * Must not start with hyphen to prevent git option injection.
18
+ */
19
+ const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/;
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Field Schemas
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Resource name field with security validation.
27
+ */
28
+ const ResourceNameSchema = z
29
+ .string()
30
+ .min(1, 'Resource name cannot be empty')
31
+ .max(LIMITS.RESOURCE_NAME_MAX, `Resource name too long (max ${LIMITS.RESOURCE_NAME_MAX} chars)`)
32
+ .regex(
33
+ RESOURCE_NAME_REGEX,
34
+ 'Resource name must start with a letter and contain only alphanumeric characters and hyphens'
35
+ );
36
+
37
+ /**
38
+ * Git URL field with security validation.
39
+ * Only allows HTTPS URLs, no credentials, no private IPs.
40
+ */
41
+ const GitUrlSchema = z
42
+ .string()
43
+ .min(1, 'Git URL cannot be empty')
44
+ .refine(
45
+ (url) => {
46
+ try {
47
+ const parsed = new URL(url);
48
+ return parsed.protocol === 'https:';
49
+ } catch {
50
+ return false;
51
+ }
52
+ },
53
+ { message: 'Git URL must be a valid HTTPS URL' }
54
+ )
55
+ .refine(
56
+ (url) => {
57
+ try {
58
+ const parsed = new URL(url);
59
+ return !parsed.username && !parsed.password;
60
+ } catch {
61
+ return true; // Let the URL check above handle invalid URLs
62
+ }
63
+ },
64
+ { message: 'Git URL must not contain embedded credentials' }
65
+ )
66
+ .refine(
67
+ (url) => {
68
+ try {
69
+ const parsed = new URL(url);
70
+ const hostname = parsed.hostname.toLowerCase();
71
+ return !(
72
+ hostname === 'localhost' ||
73
+ hostname.startsWith('127.') ||
74
+ hostname.startsWith('192.168.') ||
75
+ hostname.startsWith('10.') ||
76
+ hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) ||
77
+ hostname === '::1' ||
78
+ hostname === '0.0.0.0'
79
+ );
80
+ } catch {
81
+ return true;
82
+ }
83
+ },
84
+ { message: 'Git URL must not point to localhost or private IP addresses' }
85
+ );
86
+
87
+ /**
88
+ * Branch name field with security validation.
89
+ */
90
+ const BranchNameSchema = z
91
+ .string()
92
+ .min(1, 'Branch name cannot be empty')
93
+ .max(LIMITS.BRANCH_NAME_MAX, `Branch name too long (max ${LIMITS.BRANCH_NAME_MAX} chars)`)
94
+ .regex(
95
+ BRANCH_NAME_REGEX,
96
+ 'Branch name must contain only alphanumeric characters, forward slashes, dots, underscores, and hyphens'
97
+ )
98
+ .refine((branch) => !branch.startsWith('-'), {
99
+ message: "Branch name must not start with '-' to prevent git option injection"
100
+ });
101
+
102
+ /**
103
+ * Search path field with security validation.
104
+ */
105
+ const SearchPathSchema = z
106
+ .string()
107
+ .max(LIMITS.SEARCH_PATH_MAX, `Search path too long (max ${LIMITS.SEARCH_PATH_MAX} chars)`)
108
+ .refine((path) => !path.includes('\n') && !path.includes('\r'), {
109
+ message: 'Search path must not contain newline characters'
110
+ })
111
+ .refine((path) => !path.includes('..'), {
112
+ message: 'Search path must not contain path traversal sequences (..)'
113
+ })
114
+ .refine((path) => !path.startsWith('/') && !path.match(/^[a-zA-Z]:\\/), {
115
+ message: 'Search path must not be an absolute path'
116
+ })
117
+ .optional();
118
+
119
+ /**
120
+ * Local path field with basic validation.
121
+ */
122
+ const LocalPathSchema = z
123
+ .string()
124
+ .min(1, 'Local path cannot be empty')
125
+ .refine((path) => !path.includes('\0'), {
126
+ message: 'Path must not contain null bytes'
127
+ })
128
+ .refine((path) => path.startsWith('/') || path.match(/^[a-zA-Z]:\\/), {
129
+ message: 'Local path must be an absolute path'
130
+ });
131
+
132
+ /**
133
+ * Special notes field with length and content validation.
134
+ */
135
+ const SpecialNotesSchema = z
136
+ .string()
137
+ .max(LIMITS.NOTES_MAX, `Notes too long (max ${LIMITS.NOTES_MAX} chars)`)
138
+ .refine(
139
+ // eslint-disable-next-line no-control-regex
140
+ (notes) => !/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(notes),
141
+ { message: 'Notes contain invalid control characters' }
142
+ )
143
+ .optional();
144
+
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+ // Resource Schemas
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+
149
+ export const GitResourceSchema = z.object({
150
+ type: z.literal('git'),
151
+ name: ResourceNameSchema,
152
+ url: GitUrlSchema,
153
+ branch: BranchNameSchema,
154
+ searchPath: SearchPathSchema,
155
+ specialNotes: SpecialNotesSchema
156
+ });
157
+
158
+ export const LocalResourceSchema = z.object({
159
+ type: z.literal('local'),
160
+ name: ResourceNameSchema,
161
+ path: LocalPathSchema,
162
+ specialNotes: SpecialNotesSchema
163
+ });
164
+
165
+ export const ResourceDefinitionSchema = z.discriminatedUnion('type', [
166
+ GitResourceSchema,
167
+ LocalResourceSchema
168
+ ]);
169
+
170
+ export type GitResource = z.infer<typeof GitResourceSchema>;
171
+ export type LocalResource = z.infer<typeof LocalResourceSchema>;
172
+ export type ResourceDefinition = z.infer<typeof ResourceDefinitionSchema>;
173
+
174
+ export const isGitResource = (value: ResourceDefinition): value is GitResource =>
175
+ value.type === 'git';
176
+
177
+ export const isLocalResource = (value: ResourceDefinition): value is LocalResource =>
178
+ value.type === 'local';
@@ -0,0 +1,75 @@
1
+ import { Config } from '../config/index.ts';
2
+
3
+ import { ResourceError } from './helpers.ts';
4
+ import { loadGitResource } from './impls/git.ts';
5
+ import {
6
+ isGitResource,
7
+ type ResourceDefinition,
8
+ type GitResource,
9
+ type LocalResource
10
+ } from './schema.ts';
11
+ import type { BtcaFsResource, BtcaGitResourceArgs, BtcaLocalResourceArgs } from './types.ts';
12
+
13
+ export namespace Resources {
14
+ export type Service = {
15
+ load: (
16
+ name: string,
17
+ options?: {
18
+ quiet?: boolean;
19
+ }
20
+ ) => Promise<BtcaFsResource>;
21
+ };
22
+
23
+ const definitionToGitArgs = (
24
+ definition: GitResource,
25
+ resourcesDirectory: string,
26
+ quiet: boolean
27
+ ): BtcaGitResourceArgs => ({
28
+ type: 'git',
29
+ name: definition.name,
30
+ url: definition.url,
31
+ branch: definition.branch,
32
+ repoSubPath: definition.searchPath ?? '',
33
+ resourcesDirectoryPath: resourcesDirectory,
34
+ specialAgentInstructions: definition.specialNotes ?? '',
35
+ quiet
36
+ });
37
+
38
+ const definitionToLocalArgs = (definition: LocalResource): BtcaLocalResourceArgs => ({
39
+ type: 'local',
40
+ name: definition.name,
41
+ path: definition.path,
42
+ specialAgentInstructions: definition.specialNotes ?? ''
43
+ });
44
+
45
+ const loadLocalResource = (args: BtcaLocalResourceArgs): BtcaFsResource => ({
46
+ _tag: 'fs-based',
47
+ name: args.name,
48
+ type: 'local',
49
+ repoSubPath: '',
50
+ specialAgentInstructions: args.specialAgentInstructions,
51
+ getAbsoluteDirectoryPath: async () => args.path
52
+ });
53
+
54
+ export const create = (config: Config.Service): Service => {
55
+ const getDefinition = (name: string): ResourceDefinition => {
56
+ const definition = config.getResource(name);
57
+ if (!definition)
58
+ throw new ResourceError({ message: `Resource \"${name}\" not found in config` });
59
+ return definition;
60
+ };
61
+
62
+ return {
63
+ load: async (name, options) => {
64
+ const quiet = options?.quiet ?? false;
65
+ const definition = getDefinition(name);
66
+
67
+ if (isGitResource(definition)) {
68
+ return loadGitResource(definitionToGitArgs(definition, config.resourcesDirectory, quiet));
69
+ } else {
70
+ return loadLocalResource(definitionToLocalArgs(definition));
71
+ }
72
+ }
73
+ };
74
+ };
75
+ }
@@ -0,0 +1,29 @@
1
+ export const FS_RESOURCE_SYSTEM_NOTE =
2
+ 'This is a btca resource - a searchable knowledge source the agent can reference.';
3
+
4
+ export type BtcaFsResource = {
5
+ readonly _tag: 'fs-based';
6
+ readonly name: string;
7
+ readonly type: 'git' | 'local';
8
+ readonly repoSubPath: string;
9
+ readonly specialAgentInstructions: string;
10
+ readonly getAbsoluteDirectoryPath: () => Promise<string>;
11
+ };
12
+
13
+ export type BtcaGitResourceArgs = {
14
+ readonly type: 'git';
15
+ readonly name: string;
16
+ readonly url: string;
17
+ readonly branch: string;
18
+ readonly repoSubPath: string;
19
+ readonly resourcesDirectoryPath: string;
20
+ readonly specialAgentInstructions: string;
21
+ readonly quiet: boolean;
22
+ };
23
+
24
+ export type BtcaLocalResourceArgs = {
25
+ readonly type: 'local';
26
+ readonly name: string;
27
+ readonly path: string;
28
+ readonly specialAgentInstructions: string;
29
+ };
@@ -0,0 +1,19 @@
1
+ export { StreamService } from './service.ts';
2
+ export {
3
+ BtcaStreamEventSchema,
4
+ BtcaStreamMetaEventSchema,
5
+ BtcaStreamTextDeltaEventSchema,
6
+ BtcaStreamReasoningDeltaEventSchema,
7
+ BtcaStreamToolUpdatedEventSchema,
8
+ BtcaStreamDoneEventSchema,
9
+ BtcaStreamErrorEventSchema
10
+ } from './types.ts';
11
+ export type {
12
+ BtcaStreamEvent,
13
+ BtcaStreamMetaEvent,
14
+ BtcaStreamTextDeltaEvent,
15
+ BtcaStreamReasoningDeltaEvent,
16
+ BtcaStreamToolUpdatedEvent,
17
+ BtcaStreamDoneEvent,
18
+ BtcaStreamErrorEvent
19
+ } from './types.ts';
@@ -0,0 +1,161 @@
1
+ import type { OcEvent } from '../agent/types.ts';
2
+ import { getErrorMessage, getErrorTag } from '../errors.ts';
3
+ import { Metrics } from '../metrics/index.ts';
4
+
5
+ import type {
6
+ BtcaStreamDoneEvent,
7
+ BtcaStreamErrorEvent,
8
+ BtcaStreamEvent,
9
+ BtcaStreamMetaEvent,
10
+ BtcaStreamReasoningDeltaEvent,
11
+ BtcaStreamTextDeltaEvent,
12
+ BtcaStreamToolUpdatedEvent
13
+ } from './types.ts';
14
+
15
+ type Accumulator = {
16
+ partIds: string[];
17
+ partText: Map<string, string>;
18
+ combined: string;
19
+ };
20
+
21
+ const makeAccumulator = (): Accumulator => ({ partIds: [], partText: new Map(), combined: '' });
22
+
23
+ const updateAccumulator = (acc: Accumulator, partId: string, nextText: string): string => {
24
+ if (!acc.partIds.includes(partId)) acc.partIds.push(partId);
25
+ acc.partText.set(partId, nextText);
26
+
27
+ const nextCombined = acc.partIds.map((id) => acc.partText.get(id) ?? '').join('');
28
+ const delta = nextCombined.startsWith(acc.combined)
29
+ ? nextCombined.slice(acc.combined.length)
30
+ : nextCombined;
31
+ acc.combined = nextCombined;
32
+ return delta;
33
+ };
34
+
35
+ const toSse = (event: BtcaStreamEvent): string => {
36
+ // Standard SSE: an event name + JSON payload.
37
+ return `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
38
+ };
39
+
40
+ export namespace StreamService {
41
+ export const createSseStream = (args: {
42
+ meta: BtcaStreamMetaEvent;
43
+ eventStream: AsyncIterable<OcEvent>;
44
+ }): ReadableStream<Uint8Array> => {
45
+ const encoder = new TextEncoder();
46
+
47
+ const text = makeAccumulator();
48
+ const reasoning = makeAccumulator();
49
+ const toolsByCallId = new Map<string, Omit<BtcaStreamToolUpdatedEvent, 'type'>>();
50
+
51
+ let toolUpdates = 0;
52
+ let textEvents = 0;
53
+ let reasoningEvents = 0;
54
+
55
+ const emit = (
56
+ controller: ReadableStreamDefaultController<Uint8Array>,
57
+ event: BtcaStreamEvent
58
+ ) => {
59
+ controller.enqueue(encoder.encode(toSse(event)));
60
+ };
61
+
62
+ return new ReadableStream<Uint8Array>({
63
+ start(controller) {
64
+ Metrics.info('stream.start', {
65
+ collectionKey: args.meta.collection.key,
66
+ resources: args.meta.resources,
67
+ model: args.meta.model
68
+ });
69
+
70
+ emit(controller, args.meta);
71
+
72
+ (async () => {
73
+ try {
74
+ for await (const event of args.eventStream) {
75
+ if (event.type === 'message.part.updated') {
76
+ const part: any = (event.properties as any).part;
77
+ if (!part || typeof part !== 'object') continue;
78
+
79
+ if (part.type === 'text') {
80
+ const partId = String(part.id);
81
+ const nextText = String(part.text ?? '');
82
+ const delta = updateAccumulator(text, partId, nextText);
83
+ if (delta.length > 0) {
84
+ textEvents += 1;
85
+ const msg: BtcaStreamTextDeltaEvent = { type: 'text.delta', delta };
86
+ emit(controller, msg);
87
+ }
88
+ continue;
89
+ }
90
+
91
+ if (part.type === 'reasoning') {
92
+ const partId = String(part.id);
93
+ const nextText = String(part.text ?? '');
94
+ const delta = updateAccumulator(reasoning, partId, nextText);
95
+ if (delta.length > 0) {
96
+ reasoningEvents += 1;
97
+ const msg: BtcaStreamReasoningDeltaEvent = { type: 'reasoning.delta', delta };
98
+ emit(controller, msg);
99
+ }
100
+ continue;
101
+ }
102
+
103
+ if (part.type === 'tool') {
104
+ const callID = String(part.callID);
105
+ const tool = String(part.tool);
106
+ const state = part.state as any;
107
+
108
+ const update: BtcaStreamToolUpdatedEvent = {
109
+ type: 'tool.updated',
110
+ callID,
111
+ tool,
112
+ state
113
+ };
114
+ toolUpdates += 1;
115
+ toolsByCallId.set(callID, { callID, tool, state });
116
+ emit(controller, update);
117
+ continue;
118
+ }
119
+ }
120
+
121
+ if (event.type === 'session.idle') {
122
+ const tools = Array.from(toolsByCallId.values());
123
+ Metrics.info('stream.done', {
124
+ collectionKey: args.meta.collection.key,
125
+ textLength: text.combined.length,
126
+ reasoningLength: reasoning.combined.length,
127
+ toolCount: tools.length,
128
+ toolUpdates,
129
+ textEvents,
130
+ reasoningEvents
131
+ });
132
+ const done: BtcaStreamDoneEvent = {
133
+ type: 'done',
134
+ text: text.combined,
135
+ reasoning: reasoning.combined,
136
+ tools
137
+ };
138
+ emit(controller, done);
139
+ continue;
140
+ }
141
+ }
142
+ } catch (cause) {
143
+ Metrics.error('stream.error', {
144
+ collectionKey: args.meta.collection.key,
145
+ error: Metrics.errorInfo(cause)
146
+ });
147
+ const err: BtcaStreamErrorEvent = {
148
+ type: 'error',
149
+ tag: getErrorTag(cause),
150
+ message: getErrorMessage(cause)
151
+ };
152
+ emit(controller, err);
153
+ } finally {
154
+ Metrics.info('stream.closed', { collectionKey: args.meta.collection.key });
155
+ controller.close();
156
+ }
157
+ })();
158
+ }
159
+ });
160
+ };
161
+ }