@hubblecommerce/overmind-core 0.1.0
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 +40 -0
- package/dist/src/config/app.config.js +52 -0
- package/dist/src/embeddings/embeddings.interface.js +1 -0
- package/dist/src/embeddings/transformers.provider.js +19 -0
- package/dist/src/integrations/confluence/confluence.client.js +230 -0
- package/dist/src/integrations/confluence/confluence.types.js +1 -0
- package/dist/src/integrations/gitlab/gitlab.client.js +15 -0
- package/dist/src/integrations/gitlab/gitlab.types.js +1 -0
- package/dist/src/integrations/jira/jira.client.js +150 -0
- package/dist/src/llm/anthropic-retry-wrapper.js +110 -0
- package/dist/src/llm/anthropic.provider.js +11 -0
- package/dist/src/processors/confluence-document.processor.js +162 -0
- package/dist/src/processors/confluence-html-parser.js +220 -0
- package/dist/src/tools/confluence-search.tool.js +152 -0
- package/dist/src/tools/get-current-date.tool.js +18 -0
- package/dist/src/vectorstore/postgres.provider.js +95 -0
- package/dist/src/vectorstore/vectorstore.interface.js +1 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @hubblecommerce/overmind-core
|
|
2
|
+
|
|
3
|
+
Shared infrastructure package for the **Overmind** AI agent system. Contains all foundational code used across the agent server and webhook worker — API clients, vectorstore, embeddings, LLM providers, document processors, and reusable LangChain tools.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
| Module | Import path | Description |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Config | `@hubblecommerce/overmind-core/config` | Centralized app configuration from env vars |
|
|
10
|
+
| Confluence client | `@hubblecommerce/overmind-core/integrations/confluence` | Confluence REST API v2 client |
|
|
11
|
+
| Jira client | `@hubblecommerce/overmind-core/integrations/jira` | Jira REST API v3 client |
|
|
12
|
+
| GitLab client | `@hubblecommerce/overmind-core/integrations/gitlab` | GitLab API client |
|
|
13
|
+
| Vectorstore | `@hubblecommerce/overmind-core/vectorstore` | pgvector provider + interface |
|
|
14
|
+
| Embeddings | `@hubblecommerce/overmind-core/embeddings` | Hugging Face Transformers provider + interface |
|
|
15
|
+
| LLM | `@hubblecommerce/overmind-core/llm` | Anthropic/Claude provider |
|
|
16
|
+
| LLM retry | `@hubblecommerce/overmind-core/llm/retry` | 429 rate limit retry wrapper |
|
|
17
|
+
| Processors | `@hubblecommerce/overmind-core/processors` | Confluence HTML parser + document processor |
|
|
18
|
+
| Confluence search tool | `@hubblecommerce/overmind-core/tools/confluence-search` | LangChain tool for semantic Confluence search |
|
|
19
|
+
| Date tool | `@hubblecommerce/overmind-core/tools/get-current-date` | LangChain tool returning current date |
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { config } from '@hubblecommerce/overmind-core/config';
|
|
25
|
+
import { ConfluenceClient } from '@hubblecommerce/overmind-core/integrations/confluence';
|
|
26
|
+
import { createPostgresVectorStore } from '@hubblecommerce/overmind-core/vectorstore';
|
|
27
|
+
import { createConfluenceSearchTool } from '@hubblecommerce/overmind-core/tools/confluence-search';
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Node.js 20+
|
|
33
|
+
- PostgreSQL 15+ with `pgvector` and `uuid-ossp` extensions enabled
|
|
34
|
+
- `ANTHROPIC_API_KEY` env var set
|
|
35
|
+
|
|
36
|
+
## Publishing
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm publish --access restricted
|
|
40
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const config = {
|
|
2
|
+
llm: {
|
|
3
|
+
provider: 'anthropic',
|
|
4
|
+
modelName: 'claude-sonnet-4-6',
|
|
5
|
+
temperature: 0,
|
|
6
|
+
timeoutMs: parseInt(process.env.CLAUDE_TIMEOUT_MS || '60000'),
|
|
7
|
+
maxRetries: 2, // LangChain built-in retry for network errors
|
|
8
|
+
},
|
|
9
|
+
queue: {
|
|
10
|
+
pollIntervalMs: parseInt(process.env.QUEUE_POLL_INTERVAL_MS || '5000'),
|
|
11
|
+
maxRetries: parseInt(process.env.QUEUE_MAX_RETRIES || '3'),
|
|
12
|
+
},
|
|
13
|
+
embedding: {
|
|
14
|
+
provider: 'transformers',
|
|
15
|
+
modelName: 'Xenova/jina-embeddings-v2-base-de',
|
|
16
|
+
pooling: 'mean',
|
|
17
|
+
normalize: true,
|
|
18
|
+
},
|
|
19
|
+
vectorStore: {
|
|
20
|
+
postgres: {
|
|
21
|
+
host: process.env.POSTGRES_HOST || 'localhost',
|
|
22
|
+
port: parseInt(process.env.POSTGRES_PORT || '5432'),
|
|
23
|
+
user: process.env.POSTGRES_USER || 'postgres',
|
|
24
|
+
password: process.env.POSTGRES_PASSWORD || 'postgres',
|
|
25
|
+
database: process.env.POSTGRES_DB || 'rag_db',
|
|
26
|
+
tableName: 'confluence',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
gitlab: {
|
|
30
|
+
baseUrl: process.env.GITLAB_BASE_URL || 'https://gitlab.com',
|
|
31
|
+
token: process.env.GITLAB_ACCESS_TOKEN || '',
|
|
32
|
+
rateLimit: 10, // requests per second
|
|
33
|
+
chunking: {
|
|
34
|
+
chunkSize: 1000,
|
|
35
|
+
chunkOverlap: 200,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
confluence: {
|
|
39
|
+
baseUrl: process.env.CONFLUENCE_URL || '',
|
|
40
|
+
email: process.env.CONFLUENCE_EMAIL || '',
|
|
41
|
+
apiToken: process.env.CONFLUENCE_API_TOKEN || '',
|
|
42
|
+
spaces: process.env.CONFLUENCE_SPACES?.split(',').map(s => s.trim()).filter(Boolean) || [],
|
|
43
|
+
rateLimit: 10, // requests per second
|
|
44
|
+
},
|
|
45
|
+
jira: {
|
|
46
|
+
baseUrl: process.env.JIRA_BASE_URL || '',
|
|
47
|
+
email: process.env.JIRA_EMAIL || '',
|
|
48
|
+
apiToken: process.env.JIRA_API_TOKEN || '',
|
|
49
|
+
suggestionsSpaceId: process.env.CONFLUENCE_SUGGESTIONS_SPACE_ID || '',
|
|
50
|
+
suggestionsParentId: process.env.CONFLUENCE_SUGGESTIONS_PARENT_ID || '',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { pipeline } from '@xenova/transformers';
|
|
2
|
+
export async function createTransformersEmbeddings(modelName, pooling, normalize) {
|
|
3
|
+
console.log('Loading local embedding model (first time may take a moment to download)...');
|
|
4
|
+
const embedder = await pipeline('feature-extraction', modelName);
|
|
5
|
+
return {
|
|
6
|
+
async embedDocuments(texts) {
|
|
7
|
+
const embeddings = [];
|
|
8
|
+
for (const text of texts) {
|
|
9
|
+
const output = await embedder(text, { pooling, normalize });
|
|
10
|
+
embeddings.push(Array.from(output.data));
|
|
11
|
+
}
|
|
12
|
+
return embeddings;
|
|
13
|
+
},
|
|
14
|
+
async embedQuery(text) {
|
|
15
|
+
const output = await embedder(text, { pooling, normalize });
|
|
16
|
+
return Array.from(output.data);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
export class ConfluenceClient {
|
|
2
|
+
baseUrl;
|
|
3
|
+
authHeader;
|
|
4
|
+
rateLimit;
|
|
5
|
+
lastRequestTime = 0;
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
8
|
+
this.rateLimit = config.rateLimit || 10; // Default: 10 req/sec
|
|
9
|
+
// Create Basic Auth header
|
|
10
|
+
const credentials = Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
|
|
11
|
+
this.authHeader = `Basic ${credentials}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Rate limiting: Ensure we don't exceed configured requests per second
|
|
15
|
+
*/
|
|
16
|
+
async enforceRateLimit() {
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
19
|
+
const minInterval = 1000 / this.rateLimit;
|
|
20
|
+
if (timeSinceLastRequest < minInterval) {
|
|
21
|
+
const delay = minInterval - timeSinceLastRequest;
|
|
22
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
23
|
+
}
|
|
24
|
+
this.lastRequestTime = Date.now();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Make HTTP request to Confluence API with retries
|
|
28
|
+
*/
|
|
29
|
+
async request(endpoint, options = {}) {
|
|
30
|
+
await this.enforceRateLimit();
|
|
31
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
32
|
+
const maxRetries = 3;
|
|
33
|
+
let lastError = null;
|
|
34
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(url, {
|
|
37
|
+
...options,
|
|
38
|
+
headers: {
|
|
39
|
+
'Authorization': this.authHeader,
|
|
40
|
+
'Accept': 'application/json',
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
...options.headers,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const errorText = await response.text();
|
|
47
|
+
const error = {
|
|
48
|
+
statusCode: response.status,
|
|
49
|
+
message: `Confluence API error: ${response.statusText}`,
|
|
50
|
+
reason: errorText,
|
|
51
|
+
};
|
|
52
|
+
throw new Error(JSON.stringify(error));
|
|
53
|
+
}
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
lastError = error;
|
|
59
|
+
// Don't retry on 4xx errors (client errors)
|
|
60
|
+
if (error instanceof Error && error.message.includes('"statusCode":4')) {
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
// Retry on network errors or 5xx errors
|
|
64
|
+
if (attempt < maxRetries) {
|
|
65
|
+
const backoffDelay = Math.pow(2, attempt) * 1000; // Exponential backoff
|
|
66
|
+
console.warn(`Request failed (attempt ${attempt}/${maxRetries}), retrying in ${backoffDelay}ms...`);
|
|
67
|
+
await new Promise(resolve => setTimeout(resolve, backoffDelay));
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
throw lastError;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
throw lastError || new Error('Request failed after max retries');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get all spaces (with pagination)
|
|
78
|
+
*/
|
|
79
|
+
async getSpaces(limit = 25) {
|
|
80
|
+
const spaces = [];
|
|
81
|
+
let nextUrl = `/wiki/api/v2/spaces?limit=${limit}`;
|
|
82
|
+
while (nextUrl) {
|
|
83
|
+
const response = await this.request(nextUrl);
|
|
84
|
+
const results = response.results;
|
|
85
|
+
spaces.push(...results);
|
|
86
|
+
// Extract cursor from next link if exists
|
|
87
|
+
const links = response._links;
|
|
88
|
+
const nextLink = links.next;
|
|
89
|
+
nextUrl = nextLink ? nextLink.replace(this.baseUrl, '') : undefined;
|
|
90
|
+
}
|
|
91
|
+
return spaces;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Get all pages in a space (with pagination)
|
|
95
|
+
* Note: Accepts space key and automatically resolves to space ID for API v2
|
|
96
|
+
*/
|
|
97
|
+
async getSpacePages(spaceKey, limit = 25) {
|
|
98
|
+
// First, resolve space key to space ID (required by API v2)
|
|
99
|
+
const space = await this.getSpaceByKey(spaceKey);
|
|
100
|
+
const spaceId = space.id;
|
|
101
|
+
const pages = [];
|
|
102
|
+
let nextUrl = `/wiki/api/v2/spaces/${spaceId}/pages?limit=${limit}&status=current`;
|
|
103
|
+
while (nextUrl) {
|
|
104
|
+
const response = await this.request(nextUrl);
|
|
105
|
+
const results = response.results;
|
|
106
|
+
pages.push(...results);
|
|
107
|
+
// Extract cursor from next link if exists
|
|
108
|
+
const links = response._links;
|
|
109
|
+
nextUrl = links.next || undefined;
|
|
110
|
+
if (nextUrl) {
|
|
111
|
+
// Convert full URL to relative path
|
|
112
|
+
nextUrl = nextUrl.replace(this.baseUrl, '');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return pages;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get a single page by ID with content
|
|
119
|
+
*/
|
|
120
|
+
async getPageById(pageId, bodyFormat = 'storage') {
|
|
121
|
+
const endpoint = `/wiki/api/v2/pages/${pageId}?body-format=${bodyFormat}`;
|
|
122
|
+
return await this.request(endpoint);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get page content only (HTML in storage format)
|
|
126
|
+
*/
|
|
127
|
+
async getPageContent(pageId) {
|
|
128
|
+
const page = await this.getPageById(pageId, 'storage');
|
|
129
|
+
if (!page.body?.storage?.value) {
|
|
130
|
+
throw new Error(`Page ${pageId} has no content`);
|
|
131
|
+
}
|
|
132
|
+
return page.body.storage.value;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get space by key
|
|
136
|
+
*/
|
|
137
|
+
async getSpaceByKey(spaceKey) {
|
|
138
|
+
const endpoint = `/wiki/api/v2/spaces?keys=${spaceKey}&limit=1`;
|
|
139
|
+
const response = await this.request(endpoint);
|
|
140
|
+
if (response.results.length === 0) {
|
|
141
|
+
throw new Error(`Space with key "${spaceKey}" not found`);
|
|
142
|
+
}
|
|
143
|
+
return response.results[0];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Get recent pages across all spaces or specific spaces
|
|
147
|
+
*/
|
|
148
|
+
async getRecentPages(spaceKeys, limit = 25) {
|
|
149
|
+
let endpoint = `/wiki/api/v2/pages?limit=${limit}&sort=-modified-date&status=current`;
|
|
150
|
+
if (spaceKeys && spaceKeys.length > 0) {
|
|
151
|
+
const spaceFilter = spaceKeys.map(key => `space-key=${key}`).join('&');
|
|
152
|
+
endpoint += `&${spaceFilter}`;
|
|
153
|
+
}
|
|
154
|
+
const response = await this.request(endpoint);
|
|
155
|
+
return response.results;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Get labels for a page (using v1 API as v2 doesn't support this yet)
|
|
159
|
+
* Returns only label names as strings
|
|
160
|
+
*/
|
|
161
|
+
async getPageLabels(pageId) {
|
|
162
|
+
const labels = [];
|
|
163
|
+
let start = 0;
|
|
164
|
+
const limit = 200;
|
|
165
|
+
// Use v1 API endpoint for labels
|
|
166
|
+
let hasMore = true;
|
|
167
|
+
while (hasMore) {
|
|
168
|
+
const endpoint = `/wiki/rest/api/content/${pageId}/label?start=${start}&limit=${limit}`;
|
|
169
|
+
const response = await this.request(endpoint);
|
|
170
|
+
labels.push(...response.results);
|
|
171
|
+
// Check if there are more results
|
|
172
|
+
if (response.results.length < limit) {
|
|
173
|
+
hasMore = false;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
start += limit;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Return only the label names (without prefix)
|
|
180
|
+
return labels.map(label => label.name);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Get numeric space ID from space key
|
|
184
|
+
* Helper method to convert space key to numeric ID required by v2 API
|
|
185
|
+
* @param spaceKey - The space key (e.g., "TEST")
|
|
186
|
+
* @returns The numeric space ID
|
|
187
|
+
*/
|
|
188
|
+
async getSpaceIdByKey(spaceKey) {
|
|
189
|
+
const space = await this.getSpaceByKey(spaceKey);
|
|
190
|
+
return space.id;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Create a new page in Confluence using REST API v2 with ADF (Atlassian Document Format)
|
|
194
|
+
* This is the modern format for the new Confluence editor
|
|
195
|
+
* @param spaceId - The numeric space ID (not space key) - use getSpaceIdByKey() if you have a space key
|
|
196
|
+
* @param title - The page title
|
|
197
|
+
* @param adfBody - The page content in ADF format (from @atlaskit/editor-markdown-transformer)
|
|
198
|
+
* @param parentId - Optional parent page ID to create as child page
|
|
199
|
+
* @returns The created page
|
|
200
|
+
*/
|
|
201
|
+
async createPageWithADF(params) {
|
|
202
|
+
const payload = {
|
|
203
|
+
spaceId: params.spaceId,
|
|
204
|
+
status: 'current',
|
|
205
|
+
title: params.title,
|
|
206
|
+
body: {
|
|
207
|
+
representation: 'atlas_doc_format',
|
|
208
|
+
value: JSON.stringify(params.adfBody), // ADF must be JSON-encoded string
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
// If parentId provided, add to payload
|
|
212
|
+
if (params.parentId) {
|
|
213
|
+
payload.parentId = params.parentId;
|
|
214
|
+
}
|
|
215
|
+
const response = await this.request('/wiki/api/v2/pages', {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
body: JSON.stringify(payload),
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
id: response.id,
|
|
221
|
+
status: response.status,
|
|
222
|
+
title: response.title,
|
|
223
|
+
spaceId: response.spaceId,
|
|
224
|
+
authorId: response.authorId,
|
|
225
|
+
createdAt: response.createdAt,
|
|
226
|
+
version: response.version,
|
|
227
|
+
_links: response._links,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Gitlab } from '@gitbeaker/rest';
|
|
2
|
+
import { config } from '../../config/app.config.js';
|
|
3
|
+
let gitlabClient = null;
|
|
4
|
+
export function getGitlabClient() {
|
|
5
|
+
if (!gitlabClient) {
|
|
6
|
+
if (!config.gitlab.token) {
|
|
7
|
+
throw new Error('GitLab access token not configured. Set GITLAB_ACCESS_TOKEN in .env');
|
|
8
|
+
}
|
|
9
|
+
gitlabClient = new Gitlab({
|
|
10
|
+
host: config.gitlab.baseUrl,
|
|
11
|
+
token: config.gitlab.token,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return gitlabClient;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
export class JiraClient {
|
|
2
|
+
baseUrl;
|
|
3
|
+
authHeader;
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.baseUrl = config.baseUrl;
|
|
6
|
+
this.authHeader = 'Basic ' + Buffer.from(`${config.email}:${config.apiToken}`).toString('base64');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Parse Atlassian Document Format (ADF) to plain text
|
|
10
|
+
*/
|
|
11
|
+
parseADFToText(node) {
|
|
12
|
+
if (node.type === 'text') {
|
|
13
|
+
return node.text || '';
|
|
14
|
+
}
|
|
15
|
+
if (node.type === 'mention' && node.attrs?.text) {
|
|
16
|
+
return node.attrs.text;
|
|
17
|
+
}
|
|
18
|
+
if (node.content) {
|
|
19
|
+
const childText = node.content.map(child => this.parseADFToText(child)).join('');
|
|
20
|
+
// Add spacing/formatting based on node type
|
|
21
|
+
switch (node.type) {
|
|
22
|
+
case 'paragraph':
|
|
23
|
+
return childText + '\n';
|
|
24
|
+
case 'blockquote':
|
|
25
|
+
return '> ' + childText + '\n';
|
|
26
|
+
case 'heading':
|
|
27
|
+
return '## ' + childText + '\n';
|
|
28
|
+
case 'listItem':
|
|
29
|
+
return '- ' + childText + '\n';
|
|
30
|
+
case 'codeBlock':
|
|
31
|
+
return '```\n' + childText + '```\n';
|
|
32
|
+
default:
|
|
33
|
+
return childText;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Convert ADF comment body to readable text
|
|
40
|
+
*/
|
|
41
|
+
parseCommentBody(body) {
|
|
42
|
+
if (typeof body === 'string') {
|
|
43
|
+
return body;
|
|
44
|
+
}
|
|
45
|
+
if (typeof body === 'object' && body !== null) {
|
|
46
|
+
const adfDoc = body;
|
|
47
|
+
if (adfDoc.type === 'doc' && adfDoc.content) {
|
|
48
|
+
return adfDoc.content.map(node => this.parseADFToText(node)).join('').trim();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Fetch a Jira issue by key or ID, including comments
|
|
55
|
+
*/
|
|
56
|
+
async getIssue(issueKeyOrId) {
|
|
57
|
+
try {
|
|
58
|
+
// Fetch issue details
|
|
59
|
+
const issueResponse = await fetch(`${this.baseUrl}/rest/api/3/issue/${issueKeyOrId}`, {
|
|
60
|
+
headers: {
|
|
61
|
+
'Authorization': this.authHeader,
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
'Accept': 'application/json',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
if (!issueResponse.ok) {
|
|
67
|
+
throw new Error(`Failed to fetch issue: ${issueResponse.status} ${issueResponse.statusText}`);
|
|
68
|
+
}
|
|
69
|
+
const issue = await issueResponse.json();
|
|
70
|
+
// Fetch comments
|
|
71
|
+
const commentsResponse = await fetch(`${this.baseUrl}/rest/api/3/issue/${issueKeyOrId}/comment`, {
|
|
72
|
+
headers: {
|
|
73
|
+
'Authorization': this.authHeader,
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'Accept': 'application/json',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
if (!commentsResponse.ok) {
|
|
79
|
+
throw new Error(`Failed to fetch comments: ${commentsResponse.status} ${commentsResponse.statusText}`);
|
|
80
|
+
}
|
|
81
|
+
const commentsData = await commentsResponse.json();
|
|
82
|
+
// Parse and filter comments (only 'doc' type, latest version only)
|
|
83
|
+
const commentsMap = new Map();
|
|
84
|
+
commentsData.comments?.forEach(comment => {
|
|
85
|
+
// Only include ADF doc type comments
|
|
86
|
+
if (typeof comment.body === 'object' && comment.body !== null) {
|
|
87
|
+
const adfDoc = comment.body;
|
|
88
|
+
if (adfDoc.type !== 'doc') {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const version = adfDoc.version || 1;
|
|
92
|
+
const existing = commentsMap.get(comment.id);
|
|
93
|
+
// Only keep the latest version of each comment
|
|
94
|
+
if (!existing || version > existing.version) {
|
|
95
|
+
commentsMap.set(comment.id, {
|
|
96
|
+
author: comment.author.displayName,
|
|
97
|
+
body: this.parseCommentBody(comment.body),
|
|
98
|
+
updated: comment.updated,
|
|
99
|
+
version,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
// Convert map to array (without version field)
|
|
105
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
106
|
+
const parsedComments = Array.from(commentsMap.values()).map(({ version, ...comment }) => comment);
|
|
107
|
+
// Transform to our interface
|
|
108
|
+
return {
|
|
109
|
+
id: issue.id,
|
|
110
|
+
key: issue.key,
|
|
111
|
+
self: issue.self,
|
|
112
|
+
fields: {
|
|
113
|
+
summary: issue.fields.summary,
|
|
114
|
+
description: issue.fields.description,
|
|
115
|
+
status: {
|
|
116
|
+
name: issue.fields.status.name,
|
|
117
|
+
id: issue.fields.status.id,
|
|
118
|
+
},
|
|
119
|
+
issuetype: {
|
|
120
|
+
name: issue.fields.issuetype.name,
|
|
121
|
+
id: issue.fields.issuetype.id,
|
|
122
|
+
},
|
|
123
|
+
project: {
|
|
124
|
+
key: issue.fields.project.key,
|
|
125
|
+
id: issue.fields.project.id,
|
|
126
|
+
name: issue.fields.project.name,
|
|
127
|
+
},
|
|
128
|
+
assignee: issue.fields.assignee ? {
|
|
129
|
+
accountId: issue.fields.assignee.accountId,
|
|
130
|
+
displayName: issue.fields.assignee.displayName,
|
|
131
|
+
} : null,
|
|
132
|
+
reporter: issue.fields.reporter ? {
|
|
133
|
+
accountId: issue.fields.reporter.accountId,
|
|
134
|
+
displayName: issue.fields.reporter.displayName,
|
|
135
|
+
} : null,
|
|
136
|
+
created: issue.fields.created,
|
|
137
|
+
updated: issue.fields.updated,
|
|
138
|
+
resolutiondate: issue.fields.resolutiondate,
|
|
139
|
+
labels: issue.fields.labels || [],
|
|
140
|
+
components: issue.fields.components || [],
|
|
141
|
+
},
|
|
142
|
+
comments: parsedComments,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.error(`[JiraClient] Error fetching issue ${issueKeyOrId}:`, error);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry wrapper for Anthropic API calls with 429 rate limit handling
|
|
3
|
+
* Respects retry-after headers and implements exponential backoff
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Custom error for rate limit responses
|
|
7
|
+
* Includes retry-after information from API headers
|
|
8
|
+
*/
|
|
9
|
+
export class RateLimitError extends Error {
|
|
10
|
+
retryAfterSeconds;
|
|
11
|
+
constructor(message, retryAfterSeconds) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
14
|
+
this.name = 'RateLimitError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Invoke an agent with automatic retry on 429 errors
|
|
19
|
+
* @param agent LangChain agent instance
|
|
20
|
+
* @param input Agent input (messages, etc.)
|
|
21
|
+
* @param maxRetries Maximum number of retry attempts (default: 3)
|
|
22
|
+
* @returns Agent response
|
|
23
|
+
* @throws RateLimitError if all retries exhausted due to 429
|
|
24
|
+
* @throws Original error if non-429 error occurs
|
|
25
|
+
*/
|
|
26
|
+
export async function invokeWithRetry(agent, input, maxRetries = 3) {
|
|
27
|
+
let lastError = new Error('Unknown error');
|
|
28
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
const result = await agent.invoke(input);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!(error instanceof Error)) {
|
|
35
|
+
throw new Error('Unknown error occurred');
|
|
36
|
+
}
|
|
37
|
+
lastError = error;
|
|
38
|
+
const apiError = error;
|
|
39
|
+
// Check if it's a 429 rate limit error
|
|
40
|
+
const is429 = apiError.status === 429 ||
|
|
41
|
+
apiError.message.includes('429') ||
|
|
42
|
+
apiError.message.includes('rate limit') ||
|
|
43
|
+
apiError.message.toLowerCase().includes('too many requests');
|
|
44
|
+
if (is429) {
|
|
45
|
+
// Extract retry-after header (in seconds)
|
|
46
|
+
const retryAfter = extractRetryAfter(apiError);
|
|
47
|
+
const waitMs = retryAfter
|
|
48
|
+
? retryAfter * 1000
|
|
49
|
+
: Math.pow(2, attempt) * 1000; // Exponential fallback: 2s, 4s, 8s
|
|
50
|
+
console.log(`[AnthropicRetry] Attempt ${attempt}/${maxRetries} failed with 429`, {
|
|
51
|
+
retryAfterHeader: retryAfter,
|
|
52
|
+
waitMs,
|
|
53
|
+
willRetry: attempt < maxRetries,
|
|
54
|
+
});
|
|
55
|
+
if (attempt < maxRetries) {
|
|
56
|
+
// Wait and retry
|
|
57
|
+
await sleep(waitMs);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Last attempt failed - throw with retry info for queue worker
|
|
62
|
+
throw new RateLimitError(apiError.message || 'Rate limit exceeded', retryAfter);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Non-429 errors - throw immediately (don't retry)
|
|
66
|
+
console.error('[AnthropicRetry] Non-retryable error:', apiError.message);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Should never reach here, but TypeScript requires it
|
|
71
|
+
throw lastError;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Extract retry-after header from error response
|
|
75
|
+
* Tries multiple possible error structures from different API clients
|
|
76
|
+
* @param error Error object from API call
|
|
77
|
+
* @returns Retry-after value in seconds, or null if not found
|
|
78
|
+
*/
|
|
79
|
+
function extractRetryAfter(error) {
|
|
80
|
+
// Try multiple paths to find retry-after header
|
|
81
|
+
let retryAfter;
|
|
82
|
+
if (error.response?.headers) {
|
|
83
|
+
const headers = error.response.headers;
|
|
84
|
+
if (typeof headers === 'object' && 'get' in headers && typeof headers.get === 'function') {
|
|
85
|
+
retryAfter = headers.get('retry-after');
|
|
86
|
+
}
|
|
87
|
+
else if (typeof headers === 'object') {
|
|
88
|
+
retryAfter = headers['retry-after'];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!retryAfter && error.headers) {
|
|
92
|
+
retryAfter = error.headers['retry-after'];
|
|
93
|
+
}
|
|
94
|
+
if (!retryAfter && error.error?.headers) {
|
|
95
|
+
retryAfter = error.error.headers['retry-after'];
|
|
96
|
+
}
|
|
97
|
+
if (!retryAfter) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
// Parse as integer (seconds)
|
|
101
|
+
const parsed = parseInt(retryAfter, 10);
|
|
102
|
+
return isNaN(parsed) ? null : parsed;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Sleep utility for async delays
|
|
106
|
+
* @param ms Milliseconds to sleep
|
|
107
|
+
*/
|
|
108
|
+
function sleep(ms) {
|
|
109
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
110
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ChatAnthropic } from '@langchain/anthropic';
|
|
2
|
+
export function createAnthropicProvider(config) {
|
|
3
|
+
return new ChatAnthropic({
|
|
4
|
+
modelName: config.modelName,
|
|
5
|
+
temperature: config.temperature,
|
|
6
|
+
maxRetries: config.maxRetries || 2, // LangChain built-in retry for network errors
|
|
7
|
+
clientOptions: {
|
|
8
|
+
timeout: config.timeoutMs || 60000, // 60 second timeout default
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
}
|