@mearl/plugin-qoder 0.0.0-stage → 3.0.0-beta.10
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/LICENSE +201 -0
- package/README.md +26 -2
- package/README.zh-CN.md +27 -0
- package/dist/THIRD_PARTY_NOTICES.txt +222 -0
- package/dist/agent.d.ts +59 -0
- package/dist/agent.js +937 -0
- package/dist/auth.d.ts +12 -0
- package/dist/auth.js +89 -0
- package/dist/capability/bundle.json +27 -0
- package/dist/capability/preset.json +14 -0
- package/dist/capability/runtime.cjs +27696 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +12 -0
- package/dist/connector.d.ts +108 -0
- package/dist/connector.js +517 -0
- package/dist/endpoint.d.ts +9 -0
- package/dist/endpoint.js +38 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/pairing.d.ts +29 -0
- package/dist/pairing.js +53 -0
- package/dist/runtime.d.ts +14 -0
- package/dist/runtime.js +12 -0
- package/docs/cloud-pairing.md +52 -0
- package/docs/cloud-pairing.zh-CN.md +52 -0
- package/package.json +45 -3
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createQoderPairingInfo, formatQoderPairingOutput } from './pairing.js';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
if (process.argv[2] === '--version' || process.argv[2] === '-v')
|
|
5
|
+
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
6
|
+
else if (process.argv[2] === 'pair')
|
|
7
|
+
console.log(formatQoderPairingOutput(createQoderPairingInfo()));
|
|
8
|
+
else {
|
|
9
|
+
console.log('Usage: mearl-qoder pair\nInstall the Qoder capability preset on the browser machine before starting its connector.');
|
|
10
|
+
if (process.argv[2] && !['--help', '-h'].includes(process.argv[2]))
|
|
11
|
+
process.exitCode = 2;
|
|
12
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { CloudConnectorStateChange } from '@mearl/cloud-connector/contracts';
|
|
2
|
+
import type { ConnectorIdentity } from '@mearl/cloud-connector/contracts';
|
|
3
|
+
import type { QoderConnectorEndpoint } from './endpoint.js';
|
|
4
|
+
export declare const QODER_TOOL_NAME = "mearl";
|
|
5
|
+
export declare const DEFAULT_QODER_API_BASE_URL = "https://api.qoder.com";
|
|
6
|
+
export type QoderContentBlock = {
|
|
7
|
+
type: 'text';
|
|
8
|
+
text: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'image';
|
|
11
|
+
source: {
|
|
12
|
+
type: 'base64';
|
|
13
|
+
media_type: 'image/png' | 'image/jpeg';
|
|
14
|
+
data: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export interface QoderPublicEvent {
|
|
18
|
+
id?: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
input?: unknown;
|
|
22
|
+
processed_at?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
interface QoderSession {
|
|
26
|
+
id: string;
|
|
27
|
+
status?: string;
|
|
28
|
+
updated_at?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface QoderPairingRequest {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
event: QoderPublicEvent & {
|
|
33
|
+
id: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface QoderSseFrame {
|
|
37
|
+
id?: string;
|
|
38
|
+
event?: string;
|
|
39
|
+
data: QoderPublicEvent;
|
|
40
|
+
}
|
|
41
|
+
export declare class QoderApiError extends Error {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
constructor(message: string, status: number);
|
|
44
|
+
}
|
|
45
|
+
export interface FormattedQoderResult {
|
|
46
|
+
content: QoderContentBlock[];
|
|
47
|
+
fallbackContent: QoderContentBlock[];
|
|
48
|
+
}
|
|
49
|
+
/** Format screenshots as multimodal tool results while retaining a text-only API fallback. */
|
|
50
|
+
export declare function formatQoderToolResult(value: unknown): FormattedQoderResult;
|
|
51
|
+
export declare class QoderApiClient {
|
|
52
|
+
private readonly token;
|
|
53
|
+
private readonly fetchImpl;
|
|
54
|
+
private readonly baseUrl;
|
|
55
|
+
constructor(token: string, baseUrl?: string, fetchImpl?: typeof fetch);
|
|
56
|
+
private request;
|
|
57
|
+
listSessions(createdAfter: string, signal?: AbortSignal): Promise<QoderSession[]>;
|
|
58
|
+
listCustomToolEvents(sessionId: string, createdAfter: string, signal?: AbortSignal): Promise<QoderPublicEvent[]>;
|
|
59
|
+
findPairingRequest(pairingCode: string, createdAfter: string, signal?: AbortSignal): Promise<QoderPairingRequest | null>;
|
|
60
|
+
sendCustomToolResult(sessionId: string, customToolUseId: string, content: QoderContentBlock[], isError?: boolean, signal?: AbortSignal): Promise<string | undefined>;
|
|
61
|
+
streamEvents(sessionId: string, lastEventId: string | undefined, signal: AbortSignal, onFrame: (frame: QoderSseFrame) => Promise<void>, onOpen?: () => void): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
export declare function parseQoderSse(body: ReadableStream<Uint8Array>, onFrame: (frame: QoderSseFrame) => Promise<void>): Promise<void>;
|
|
64
|
+
export interface QoderConnectorOptions {
|
|
65
|
+
endpoint: QoderConnectorEndpoint;
|
|
66
|
+
identity: ConnectorIdentity;
|
|
67
|
+
reconnectInterval?: number;
|
|
68
|
+
maxReconnectAttempts?: number;
|
|
69
|
+
failFast?: boolean;
|
|
70
|
+
token?: string;
|
|
71
|
+
apiBaseUrl?: string;
|
|
72
|
+
fetchImpl?: typeof fetch;
|
|
73
|
+
now?: () => number;
|
|
74
|
+
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
75
|
+
invokeAction?: (action: string, payload: Record<string, unknown>, options: {
|
|
76
|
+
browser?: string;
|
|
77
|
+
timeoutSec: number;
|
|
78
|
+
}) => Promise<unknown>;
|
|
79
|
+
onConnected?: () => void;
|
|
80
|
+
onInitialConnectionFailed?: (message: string) => void;
|
|
81
|
+
onStateChange?: (change: CloudConnectorStateChange) => void;
|
|
82
|
+
}
|
|
83
|
+
export declare class QoderConnector {
|
|
84
|
+
private readonly options;
|
|
85
|
+
private readonly api;
|
|
86
|
+
private readonly now;
|
|
87
|
+
private readonly sleep;
|
|
88
|
+
private readonly invokeAction;
|
|
89
|
+
private readonly reconnectInterval;
|
|
90
|
+
private readonly maxReconnectAttempts;
|
|
91
|
+
private abortController;
|
|
92
|
+
private reconnectAttempts;
|
|
93
|
+
private running;
|
|
94
|
+
private connected;
|
|
95
|
+
private sessionId;
|
|
96
|
+
private lastEventId;
|
|
97
|
+
private readonly cachedResults;
|
|
98
|
+
constructor(options: QoderConnectorOptions);
|
|
99
|
+
connect(): void;
|
|
100
|
+
disconnect(): void;
|
|
101
|
+
private run;
|
|
102
|
+
private findPairing;
|
|
103
|
+
private completePairing;
|
|
104
|
+
private handleFrame;
|
|
105
|
+
private executeTool;
|
|
106
|
+
private rememberResult;
|
|
107
|
+
}
|
|
108
|
+
export {};
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { resolveActionTimeoutSec } from '@mearl/client/action-timeouts';
|
|
2
|
+
import { qoderAuthenticationHint, resolveQoderPersonalAccessToken } from './auth.js';
|
|
3
|
+
export const QODER_TOOL_NAME = 'mearl';
|
|
4
|
+
export const DEFAULT_QODER_API_BASE_URL = 'https://api.qoder.com';
|
|
5
|
+
const DEFAULT_DISCOVERY_WINDOW_MS = 60 * 60 * 1000;
|
|
6
|
+
const DEFAULT_RECONNECT_INTERVAL_MS = 5000;
|
|
7
|
+
const MAX_RECENT_SESSIONS = 100;
|
|
8
|
+
const MAX_RECENT_TOOL_EVENTS = 20;
|
|
9
|
+
const MAX_CACHED_RESULTS = 100;
|
|
10
|
+
export class QoderApiError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
constructor(message, status) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.name = 'QoderApiError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
class QoderPairingExpiredError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('Qoder pairing is no longer pending. Generate a new pairing URL with `npx @mearl/plugin-qoder pair`, run the new connector command, and immediately invoke the mearl tool with action "pair". Do not reuse this URL.');
|
|
21
|
+
this.name = 'QoderPairingExpiredError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isPendingActionNotFound(error) {
|
|
25
|
+
return (error instanceof QoderApiError &&
|
|
26
|
+
/^Pending action\b.*\bwas not found\.?$/i.test(error.message.trim()));
|
|
27
|
+
}
|
|
28
|
+
function safeJson(value) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(value ?? null);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return JSON.stringify({ error: 'Mearl returned a value that cannot be serialized' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function asScreenshot(value) {
|
|
40
|
+
if (!isRecord(value) || typeof value.data !== 'string' || !isRecord(value.meta))
|
|
41
|
+
return null;
|
|
42
|
+
const format = value.meta.format;
|
|
43
|
+
if (format !== 'png' && format !== 'jpeg')
|
|
44
|
+
return null;
|
|
45
|
+
return { data: value.data, meta: { ...value.meta, format } };
|
|
46
|
+
}
|
|
47
|
+
function extractBatchScreenshots(value) {
|
|
48
|
+
if (!isRecord(value) || !Array.isArray(value.results))
|
|
49
|
+
return null;
|
|
50
|
+
const screenshots = [];
|
|
51
|
+
const results = value.results.map(result => {
|
|
52
|
+
if (!isRecord(result) || result.action !== 'page_screenshot')
|
|
53
|
+
return result;
|
|
54
|
+
const screenshot = asScreenshot(result.data);
|
|
55
|
+
if (!screenshot)
|
|
56
|
+
return result;
|
|
57
|
+
const data = { ...result.data };
|
|
58
|
+
delete data.data;
|
|
59
|
+
data.meta = screenshot.meta;
|
|
60
|
+
data.imageContentIndex = screenshots.length + 1;
|
|
61
|
+
screenshots.push(screenshot);
|
|
62
|
+
return { ...result, data };
|
|
63
|
+
});
|
|
64
|
+
if (screenshots.length === 0)
|
|
65
|
+
return null;
|
|
66
|
+
return { metadata: { ...value, results }, screenshots };
|
|
67
|
+
}
|
|
68
|
+
function imageContentBlock(screenshot) {
|
|
69
|
+
return {
|
|
70
|
+
type: 'image',
|
|
71
|
+
source: {
|
|
72
|
+
type: 'base64',
|
|
73
|
+
media_type: `image/${screenshot.meta.format}`,
|
|
74
|
+
data: screenshot.data,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Format screenshots as multimodal tool results while retaining a text-only API fallback. */
|
|
79
|
+
export function formatQoderToolResult(value) {
|
|
80
|
+
const directScreenshot = asScreenshot(value);
|
|
81
|
+
const nestedScreenshot = !directScreenshot && isRecord(value) ? asScreenshot(value.screenshot) : null;
|
|
82
|
+
const screenshot = directScreenshot ?? nestedScreenshot;
|
|
83
|
+
if (!screenshot) {
|
|
84
|
+
const batch = extractBatchScreenshots(value);
|
|
85
|
+
if (batch) {
|
|
86
|
+
return {
|
|
87
|
+
content: [
|
|
88
|
+
{ type: 'text', text: safeJson(batch.metadata) },
|
|
89
|
+
...batch.screenshots.map(imageContentBlock),
|
|
90
|
+
],
|
|
91
|
+
fallbackContent: [{ type: 'text', text: safeJson(value) }],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!screenshot) {
|
|
96
|
+
const content = [{ type: 'text', text: safeJson(value) }];
|
|
97
|
+
return { content, fallbackContent: content };
|
|
98
|
+
}
|
|
99
|
+
let metadata;
|
|
100
|
+
if (directScreenshot) {
|
|
101
|
+
metadata = { meta: screenshot.meta };
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
metadata = { ...value, screenshot: { meta: screenshot.meta } };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: 'text', text: safeJson(metadata) }, imageContentBlock(screenshot)],
|
|
108
|
+
fallbackContent: [{ type: 'text', text: safeJson(value) }],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function pairingCodeFromEvent(event) {
|
|
112
|
+
if (event.type !== 'agent.custom_tool_use' || event.name !== QODER_TOOL_NAME)
|
|
113
|
+
return null;
|
|
114
|
+
if (!isRecord(event.input) || event.input.action !== 'pair')
|
|
115
|
+
return null;
|
|
116
|
+
const payload = isRecord(event.input.payload)
|
|
117
|
+
? event.input.payload
|
|
118
|
+
: isRecord(event.input.data)
|
|
119
|
+
? event.input.data
|
|
120
|
+
: null;
|
|
121
|
+
return payload && typeof payload.code === 'string' ? payload.code : null;
|
|
122
|
+
}
|
|
123
|
+
function qoderErrorMessage(status, body) {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(body);
|
|
126
|
+
const message = parsed.error?.message ?? parsed.message;
|
|
127
|
+
if (typeof message === 'string' && message.trim())
|
|
128
|
+
return message.trim();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A non-JSON proxy response is summarized by status below.
|
|
132
|
+
}
|
|
133
|
+
return `Qoder API request failed with HTTP ${status}`;
|
|
134
|
+
}
|
|
135
|
+
export class QoderApiClient {
|
|
136
|
+
token;
|
|
137
|
+
fetchImpl;
|
|
138
|
+
baseUrl;
|
|
139
|
+
constructor(token, baseUrl = DEFAULT_QODER_API_BASE_URL, fetchImpl = fetch) {
|
|
140
|
+
this.token = token;
|
|
141
|
+
this.fetchImpl = fetchImpl;
|
|
142
|
+
const parsed = new URL(baseUrl);
|
|
143
|
+
if (parsed.protocol !== 'https:' &&
|
|
144
|
+
parsed.hostname !== 'localhost' &&
|
|
145
|
+
parsed.hostname !== '127.0.0.1') {
|
|
146
|
+
throw new Error('Qoder API base URL must use https:// (localhost may use http:// for tests)');
|
|
147
|
+
}
|
|
148
|
+
this.baseUrl = parsed.toString().replace(/\/$/, '');
|
|
149
|
+
}
|
|
150
|
+
async request(path, init = {}) {
|
|
151
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
152
|
+
...init,
|
|
153
|
+
headers: {
|
|
154
|
+
Authorization: `Bearer ${this.token}`,
|
|
155
|
+
...init.headers,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
const body = await response.text();
|
|
160
|
+
const message = qoderErrorMessage(response.status, body);
|
|
161
|
+
throw new QoderApiError(response.status === 401 ? `${message}\n${qoderAuthenticationHint()}` : message, response.status);
|
|
162
|
+
}
|
|
163
|
+
return response;
|
|
164
|
+
}
|
|
165
|
+
async listSessions(createdAfter, signal) {
|
|
166
|
+
const query = new URLSearchParams({
|
|
167
|
+
limit: String(MAX_RECENT_SESSIONS),
|
|
168
|
+
order: 'desc',
|
|
169
|
+
'created_at[gte]': createdAfter,
|
|
170
|
+
});
|
|
171
|
+
const response = await this.request(`/api/v1/cloud/sessions?${query}`, { signal });
|
|
172
|
+
const body = (await response.json());
|
|
173
|
+
return Array.isArray(body.data)
|
|
174
|
+
? body.data.filter(session => typeof session.id === 'string')
|
|
175
|
+
: [];
|
|
176
|
+
}
|
|
177
|
+
async listCustomToolEvents(sessionId, createdAfter, signal) {
|
|
178
|
+
const query = new URLSearchParams({
|
|
179
|
+
limit: String(MAX_RECENT_TOOL_EVENTS),
|
|
180
|
+
order: 'desc',
|
|
181
|
+
types: 'agent.custom_tool_use',
|
|
182
|
+
'created_at[gte]': createdAfter,
|
|
183
|
+
});
|
|
184
|
+
const response = await this.request(`/api/v1/cloud/sessions/${encodeURIComponent(sessionId)}/events?${query}`, { signal });
|
|
185
|
+
const body = (await response.json());
|
|
186
|
+
return Array.isArray(body.data) ? body.data : [];
|
|
187
|
+
}
|
|
188
|
+
async findPairingRequest(pairingCode, createdAfter, signal) {
|
|
189
|
+
const sessions = await this.listSessions(createdAfter, signal);
|
|
190
|
+
for (const session of sessions) {
|
|
191
|
+
const events = await this.listCustomToolEvents(session.id, createdAfter, signal);
|
|
192
|
+
const event = events.find(candidate => pairingCodeFromEvent(candidate) === pairingCode);
|
|
193
|
+
if (event?.id)
|
|
194
|
+
return { sessionId: session.id, event: { ...event, id: event.id } };
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
async sendCustomToolResult(sessionId, customToolUseId, content, isError = false, signal) {
|
|
199
|
+
const response = await this.request(`/api/v1/cloud/sessions/${encodeURIComponent(sessionId)}/events`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
signal,
|
|
203
|
+
body: JSON.stringify({
|
|
204
|
+
events: [
|
|
205
|
+
{
|
|
206
|
+
type: 'user.custom_tool_result',
|
|
207
|
+
custom_tool_use_id: customToolUseId,
|
|
208
|
+
content,
|
|
209
|
+
is_error: isError,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
const body = (await response.json());
|
|
215
|
+
return body.data?.[0]?.id;
|
|
216
|
+
}
|
|
217
|
+
async streamEvents(sessionId, lastEventId, signal, onFrame, onOpen) {
|
|
218
|
+
const response = await this.request(`/api/v1/cloud/sessions/${encodeURIComponent(sessionId)}/events/stream`, {
|
|
219
|
+
headers: {
|
|
220
|
+
Accept: 'text/event-stream',
|
|
221
|
+
...(lastEventId ? { 'Last-Event-ID': lastEventId } : {}),
|
|
222
|
+
},
|
|
223
|
+
signal,
|
|
224
|
+
});
|
|
225
|
+
if (!response.body)
|
|
226
|
+
throw new Error('Qoder event stream returned no response body');
|
|
227
|
+
onOpen?.();
|
|
228
|
+
await parseQoderSse(response.body, onFrame);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
export async function parseQoderSse(body, onFrame) {
|
|
232
|
+
const reader = body.getReader();
|
|
233
|
+
const decoder = new TextDecoder();
|
|
234
|
+
let buffer = '';
|
|
235
|
+
let frameLines = [];
|
|
236
|
+
const dispatch = async () => {
|
|
237
|
+
if (frameLines.length === 0)
|
|
238
|
+
return;
|
|
239
|
+
let id;
|
|
240
|
+
let event;
|
|
241
|
+
const data = [];
|
|
242
|
+
for (const line of frameLines) {
|
|
243
|
+
if (!line || line.startsWith(':'))
|
|
244
|
+
continue;
|
|
245
|
+
const separator = line.indexOf(':');
|
|
246
|
+
const field = separator === -1 ? line : line.slice(0, separator);
|
|
247
|
+
const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
|
|
248
|
+
if (field === 'id')
|
|
249
|
+
id = value;
|
|
250
|
+
else if (field === 'event')
|
|
251
|
+
event = value;
|
|
252
|
+
else if (field === 'data')
|
|
253
|
+
data.push(value);
|
|
254
|
+
}
|
|
255
|
+
frameLines = [];
|
|
256
|
+
if (data.length === 0)
|
|
257
|
+
return;
|
|
258
|
+
const parsed = JSON.parse(data.join('\n'));
|
|
259
|
+
await onFrame({ ...(id ? { id } : {}), ...(event ? { event } : {}), data: parsed });
|
|
260
|
+
};
|
|
261
|
+
while (true) {
|
|
262
|
+
const { done, value } = await reader.read();
|
|
263
|
+
buffer += decoder.decode(value, { stream: !done });
|
|
264
|
+
let newlineIndex;
|
|
265
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
266
|
+
const line = buffer.slice(0, newlineIndex).replace(/\r$/, '');
|
|
267
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
268
|
+
if (line === '')
|
|
269
|
+
await dispatch();
|
|
270
|
+
else
|
|
271
|
+
frameLines.push(line);
|
|
272
|
+
}
|
|
273
|
+
if (done)
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
if (buffer)
|
|
277
|
+
frameLines.push(buffer.replace(/\r$/, ''));
|
|
278
|
+
await dispatch();
|
|
279
|
+
}
|
|
280
|
+
function defaultSleep(milliseconds, signal) {
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
if (signal.aborted) {
|
|
283
|
+
reject(signal.reason ?? new Error('Aborted'));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const onAbort = () => {
|
|
287
|
+
clearTimeout(timer);
|
|
288
|
+
reject(signal.reason ?? new Error('Aborted'));
|
|
289
|
+
};
|
|
290
|
+
const timer = setTimeout(() => {
|
|
291
|
+
signal.removeEventListener('abort', onAbort);
|
|
292
|
+
resolve();
|
|
293
|
+
}, milliseconds);
|
|
294
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function toolInput(event) {
|
|
298
|
+
if (!isRecord(event.input) || typeof event.input.action !== 'string') {
|
|
299
|
+
throw new Error('Qoder mearl tool input requires a string action');
|
|
300
|
+
}
|
|
301
|
+
const browser = typeof event.input.browser === 'string' ? event.input.browser : undefined;
|
|
302
|
+
const timeoutSec = typeof event.input.timeoutSec === 'number' && Number.isFinite(event.input.timeoutSec)
|
|
303
|
+
? event.input.timeoutSec
|
|
304
|
+
: undefined;
|
|
305
|
+
const payloadValue = event.input.payload ?? event.input.data ?? {};
|
|
306
|
+
if (!isRecord(payloadValue)) {
|
|
307
|
+
throw new Error('Qoder mearl tool input payload must be an object');
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
action: event.input.action,
|
|
311
|
+
payload: payloadValue,
|
|
312
|
+
...(browser ? { browser } : {}),
|
|
313
|
+
...(timeoutSec === undefined ? {} : { timeoutSec }),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
export class QoderConnector {
|
|
317
|
+
options;
|
|
318
|
+
api;
|
|
319
|
+
now;
|
|
320
|
+
sleep;
|
|
321
|
+
invokeAction;
|
|
322
|
+
reconnectInterval;
|
|
323
|
+
maxReconnectAttempts;
|
|
324
|
+
abortController = null;
|
|
325
|
+
reconnectAttempts = 0;
|
|
326
|
+
running = false;
|
|
327
|
+
connected = false;
|
|
328
|
+
sessionId = null;
|
|
329
|
+
lastEventId;
|
|
330
|
+
cachedResults = new Map();
|
|
331
|
+
constructor(options) {
|
|
332
|
+
this.options = options;
|
|
333
|
+
const token = resolveQoderPersonalAccessToken({ token: options.token });
|
|
334
|
+
if (!token) {
|
|
335
|
+
throw new Error(`Qoder authentication is required.\n${qoderAuthenticationHint()}`);
|
|
336
|
+
}
|
|
337
|
+
this.api = new QoderApiClient(token, options.apiBaseUrl ?? DEFAULT_QODER_API_BASE_URL, options.fetchImpl);
|
|
338
|
+
this.now = options.now ?? Date.now;
|
|
339
|
+
this.sleep = options.sleep ?? defaultSleep;
|
|
340
|
+
this.invokeAction = options.invokeAction ?? (async () => { throw new Error('Qoder connector requires an invokeAction host'); });
|
|
341
|
+
this.reconnectInterval = options.reconnectInterval ?? DEFAULT_RECONNECT_INTERVAL_MS;
|
|
342
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? -1;
|
|
343
|
+
}
|
|
344
|
+
connect() {
|
|
345
|
+
if (this.running)
|
|
346
|
+
return;
|
|
347
|
+
this.running = true;
|
|
348
|
+
this.options.onStateChange?.({ state: 'connecting' });
|
|
349
|
+
this.abortController = new AbortController();
|
|
350
|
+
void this.run(this.abortController.signal);
|
|
351
|
+
}
|
|
352
|
+
disconnect() {
|
|
353
|
+
this.running = false;
|
|
354
|
+
this.abortController?.abort();
|
|
355
|
+
this.abortController = null;
|
|
356
|
+
}
|
|
357
|
+
async run(signal) {
|
|
358
|
+
while (!signal.aborted) {
|
|
359
|
+
try {
|
|
360
|
+
if (!this.sessionId) {
|
|
361
|
+
const pairing = await this.findPairing(signal);
|
|
362
|
+
await this.completePairing(pairing, signal);
|
|
363
|
+
}
|
|
364
|
+
const sessionId = this.sessionId;
|
|
365
|
+
await this.api.streamEvents(sessionId, this.lastEventId, signal, async (frame) => this.handleFrame(sessionId, frame, signal), () => {
|
|
366
|
+
this.reconnectAttempts = 0;
|
|
367
|
+
this.options.onStateChange?.({ state: 'connected' });
|
|
368
|
+
});
|
|
369
|
+
if (!signal.aborted)
|
|
370
|
+
throw new Error('Qoder event stream ended');
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
if (signal.aborted)
|
|
374
|
+
return;
|
|
375
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
376
|
+
if (!this.connected && error instanceof QoderPairingExpiredError) {
|
|
377
|
+
this.running = false;
|
|
378
|
+
this.options.onStateChange?.({ state: 'failed', reason: message });
|
|
379
|
+
if (this.options.onInitialConnectionFailed) {
|
|
380
|
+
this.options.onInitialConnectionFailed(message);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
console.error(`[CloudConnector] Qoder connection error: ${message}`);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (!this.connected && this.options.failFast) {
|
|
388
|
+
this.options.onStateChange?.({ state: 'failed', reason: message });
|
|
389
|
+
this.options.onInitialConnectionFailed?.(message);
|
|
390
|
+
this.running = false;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
this.reconnectAttempts += 1;
|
|
394
|
+
if (this.maxReconnectAttempts !== -1 &&
|
|
395
|
+
this.reconnectAttempts > this.maxReconnectAttempts) {
|
|
396
|
+
console.error(`[CloudConnector] Qoder max reconnect attempts (${this.maxReconnectAttempts}) reached`);
|
|
397
|
+
this.options.onStateChange?.({
|
|
398
|
+
state: 'failed',
|
|
399
|
+
reason: message,
|
|
400
|
+
reconnectAttempt: this.reconnectAttempts,
|
|
401
|
+
});
|
|
402
|
+
this.running = false;
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
console.error(`[CloudConnector] Qoder connection error: ${message}`);
|
|
406
|
+
this.options.onStateChange?.({
|
|
407
|
+
state: 'reconnecting',
|
|
408
|
+
reason: message,
|
|
409
|
+
reconnectAttempt: this.reconnectAttempts,
|
|
410
|
+
retryInMs: this.reconnectInterval,
|
|
411
|
+
});
|
|
412
|
+
try {
|
|
413
|
+
await this.sleep(this.reconnectInterval, signal);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async findPairing(signal) {
|
|
422
|
+
const createdAt = this.options.endpoint.createdAt ?? this.now();
|
|
423
|
+
const createdAfter = new Date(createdAt - DEFAULT_DISCOVERY_WINDOW_MS).toISOString();
|
|
424
|
+
const pairing = await this.api.findPairingRequest(this.options.endpoint.pairingCode, createdAfter, signal);
|
|
425
|
+
if (!pairing) {
|
|
426
|
+
throw new Error('Qoder pairing request was not found. Ask the Agent to call the mearl tool with action "pair" and the code from this URL.');
|
|
427
|
+
}
|
|
428
|
+
return pairing;
|
|
429
|
+
}
|
|
430
|
+
async completePairing(pairing, signal) {
|
|
431
|
+
if (!this.connected) {
|
|
432
|
+
let resultEventId;
|
|
433
|
+
try {
|
|
434
|
+
resultEventId = await this.api.sendCustomToolResult(pairing.sessionId, pairing.event.id, [
|
|
435
|
+
{
|
|
436
|
+
type: 'text',
|
|
437
|
+
text: safeJson({
|
|
438
|
+
connected: true,
|
|
439
|
+
connectorId: this.options.identity.connectorId,
|
|
440
|
+
name: this.options.identity.name,
|
|
441
|
+
}),
|
|
442
|
+
},
|
|
443
|
+
], false, signal);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (isPendingActionNotFound(error))
|
|
447
|
+
throw new QoderPairingExpiredError();
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
this.lastEventId = resultEventId ?? pairing.event.id;
|
|
451
|
+
this.sessionId = pairing.sessionId;
|
|
452
|
+
this.connected = true;
|
|
453
|
+
this.reconnectAttempts = 0;
|
|
454
|
+
this.options.onStateChange?.({ state: 'connected' });
|
|
455
|
+
this.options.onConnected?.();
|
|
456
|
+
console.log(`[CloudConnector] Connected to Qoder session ${pairing.sessionId}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async handleFrame(sessionId, frame, signal) {
|
|
460
|
+
this.reconnectAttempts = 0;
|
|
461
|
+
this.options.onStateChange?.({ state: 'connected', heartbeat: true });
|
|
462
|
+
const event = frame.data;
|
|
463
|
+
const eventId = event.id ?? frame.id;
|
|
464
|
+
if (event.type !== 'agent.custom_tool_use' || event.name !== QODER_TOOL_NAME || !eventId) {
|
|
465
|
+
if (eventId)
|
|
466
|
+
this.lastEventId = eventId;
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const cached = this.cachedResults.get(eventId) ?? (await this.executeTool(event));
|
|
470
|
+
this.rememberResult(eventId, cached);
|
|
471
|
+
try {
|
|
472
|
+
this.lastEventId =
|
|
473
|
+
(await this.api.sendCustomToolResult(sessionId, eventId, cached.content, cached.isError, signal)) ?? eventId;
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (error instanceof QoderApiError &&
|
|
477
|
+
error.status === 400 &&
|
|
478
|
+
cached.content !== cached.fallbackContent) {
|
|
479
|
+
this.lastEventId =
|
|
480
|
+
(await this.api.sendCustomToolResult(sessionId, eventId, cached.fallbackContent, cached.isError, signal)) ?? eventId;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (error instanceof QoderApiError && error.status === 404) {
|
|
484
|
+
this.lastEventId = eventId;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async executeTool(event) {
|
|
491
|
+
try {
|
|
492
|
+
const input = toolInput(event);
|
|
493
|
+
if (input.action === 'pair') {
|
|
494
|
+
throw new Error('This pairing request does not match the active connector');
|
|
495
|
+
}
|
|
496
|
+
const result = await this.invokeAction(input.action, input.payload, {
|
|
497
|
+
browser: input.browser,
|
|
498
|
+
timeoutSec: input.timeoutSec ?? resolveActionTimeoutSec(input.action, input.payload, 15),
|
|
499
|
+
});
|
|
500
|
+
return { ...formatQoderToolResult(result), isError: false };
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
504
|
+
const content = [{ type: 'text', text: safeJson({ error: message }) }];
|
|
505
|
+
return { content, fallbackContent: content, isError: true };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
rememberResult(eventId, result) {
|
|
509
|
+
this.cachedResults.set(eventId, result);
|
|
510
|
+
while (this.cachedResults.size > MAX_CACHED_RESULTS) {
|
|
511
|
+
const oldest = this.cachedResults.keys().next().value;
|
|
512
|
+
if (!oldest)
|
|
513
|
+
break;
|
|
514
|
+
this.cachedResults.delete(oldest);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface QoderConnectorEndpoint {
|
|
2
|
+
/** User-facing URL retained for daemon identity and lifecycle commands. */
|
|
3
|
+
serverUrl: string;
|
|
4
|
+
scheme: 'qoder';
|
|
5
|
+
pairingCode: string;
|
|
6
|
+
createdAt?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseQoderEndpoint(serverUrl: string, parsed: URL): QoderConnectorEndpoint | null;
|
|
9
|
+
export declare function requireQoderEndpoint(serverUrl: string): QoderConnectorEndpoint;
|
package/dist/endpoint.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const PAIRING_CODE_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
|
|
2
|
+
export function parseQoderEndpoint(serverUrl, parsed) {
|
|
3
|
+
if (parsed.hostname !== 'pair' ||
|
|
4
|
+
parsed.port ||
|
|
5
|
+
parsed.username ||
|
|
6
|
+
parsed.password ||
|
|
7
|
+
parsed.hash) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const pathSegments = parsed.pathname.split('/').filter(Boolean);
|
|
11
|
+
if (pathSegments.length !== 1 || !PAIRING_CODE_PATTERN.test(pathSegments[0]))
|
|
12
|
+
return null;
|
|
13
|
+
const allowedParams = new Set(['created']);
|
|
14
|
+
if ([...parsed.searchParams.keys()].some(key => !allowedParams.has(key)))
|
|
15
|
+
return null;
|
|
16
|
+
const createdValues = parsed.searchParams.getAll('created');
|
|
17
|
+
if (createdValues.length > 1)
|
|
18
|
+
return null;
|
|
19
|
+
const createdValue = createdValues[0] ?? null;
|
|
20
|
+
if (createdValue !== null && !/^\d{13}$/.test(createdValue))
|
|
21
|
+
return null;
|
|
22
|
+
const createdAt = createdValue === null ? undefined : Number(createdValue);
|
|
23
|
+
if (createdAt !== undefined && (!Number.isSafeInteger(createdAt) || createdAt <= 0))
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
serverUrl,
|
|
27
|
+
scheme: 'qoder',
|
|
28
|
+
pairingCode: pathSegments[0],
|
|
29
|
+
...(createdAt === undefined ? {} : { createdAt }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function requireQoderEndpoint(serverUrl) {
|
|
33
|
+
const parsed = new URL(serverUrl);
|
|
34
|
+
const endpoint = parsed.protocol === 'qoder:' ? parseQoderEndpoint(serverUrl, parsed) : null;
|
|
35
|
+
if (!endpoint)
|
|
36
|
+
throw new Error('Invalid qoder://pair/<code> endpoint');
|
|
37
|
+
return endpoint;
|
|
38
|
+
}
|