opencode-gitlab-duo-agentic 0.1.14 → 0.1.16
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/dist/index.js +828 -36
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,270 @@
|
|
|
1
1
|
// src/constants.ts
|
|
2
2
|
var PROVIDER_ID = "gitlab";
|
|
3
|
-
var
|
|
3
|
+
var DEFAULT_MODEL_ID = "duo-chat-sonnet-4-5";
|
|
4
4
|
var DEFAULT_INSTANCE_URL = "https://gitlab.com";
|
|
5
|
-
var
|
|
5
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
6
|
+
var WORKFLOW_DEFINITION = "chat";
|
|
7
|
+
var WORKFLOW_CLIENT_VERSION = "1.0";
|
|
8
|
+
var WORKFLOW_ENVIRONMENT = "ide";
|
|
9
|
+
var WORKFLOW_CONNECT_TIMEOUT_MS = 15e3;
|
|
10
|
+
var WORKFLOW_HEARTBEAT_INTERVAL_MS = 6e4;
|
|
11
|
+
var WORKFLOW_KEEPALIVE_INTERVAL_MS = 45e3;
|
|
12
|
+
var WORKFLOW_TOKEN_EXPIRY_BUFFER_MS = 3e4;
|
|
13
|
+
var WORKFLOW_TOOL_ERROR_MESSAGE = "Tool execution is not implemented in this client yet";
|
|
14
|
+
|
|
15
|
+
// src/gitlab/models.ts
|
|
16
|
+
import crypto from "crypto";
|
|
17
|
+
import fs2 from "fs/promises";
|
|
18
|
+
import os from "os";
|
|
19
|
+
import path2 from "path";
|
|
20
|
+
|
|
21
|
+
// src/gitlab/client.ts
|
|
22
|
+
var GitLabApiError = class extends Error {
|
|
23
|
+
constructor(status, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.name = "GitLabApiError";
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
async function request(options, path3, init) {
|
|
30
|
+
const url = `${options.instanceUrl}/api/v4/${path3}`;
|
|
31
|
+
const headers = new Headers(init.headers);
|
|
32
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
33
|
+
const response = await fetch(url, {
|
|
34
|
+
...init,
|
|
35
|
+
headers
|
|
36
|
+
});
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
async function get(options, path3) {
|
|
40
|
+
const response = await request(options, path3, { method: "GET" });
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const text2 = await response.text().catch(() => "");
|
|
43
|
+
throw new GitLabApiError(response.status, `GET ${path3} failed (${response.status}): ${text2}`);
|
|
44
|
+
}
|
|
45
|
+
return response.json();
|
|
46
|
+
}
|
|
47
|
+
async function post(options, path3, body) {
|
|
48
|
+
const response = await request(options, path3, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"content-type": "application/json"
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify(body)
|
|
54
|
+
});
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const text2 = await response.text().catch(() => "");
|
|
57
|
+
throw new GitLabApiError(response.status, `POST ${path3} failed (${response.status}): ${text2}`);
|
|
58
|
+
}
|
|
59
|
+
return response.json();
|
|
60
|
+
}
|
|
61
|
+
async function graphql(options, query, variables) {
|
|
62
|
+
const url = `${options.instanceUrl}/api/graphql`;
|
|
63
|
+
const response = await fetch(url, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
"content-type": "application/json",
|
|
67
|
+
authorization: `Bearer ${options.token}`
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({ query, variables })
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const text2 = await response.text().catch(() => "");
|
|
73
|
+
throw new GitLabApiError(response.status, `GraphQL request failed (${response.status}): ${text2}`);
|
|
74
|
+
}
|
|
75
|
+
const result = await response.json();
|
|
76
|
+
if (result.errors?.length) {
|
|
77
|
+
throw new GitLabApiError(0, result.errors.map((e) => e.message).join("; "));
|
|
78
|
+
}
|
|
79
|
+
if (!result.data) {
|
|
80
|
+
throw new GitLabApiError(0, "GraphQL response missing data");
|
|
81
|
+
}
|
|
82
|
+
return result.data;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/gitlab/project.ts
|
|
86
|
+
import fs from "fs/promises";
|
|
87
|
+
import path from "path";
|
|
88
|
+
async function detectProjectPath(cwd, instanceUrl) {
|
|
89
|
+
const instance = new URL(instanceUrl);
|
|
90
|
+
const instanceHost = instance.host;
|
|
91
|
+
const instanceBasePath = instance.pathname.replace(/\/$/, "");
|
|
92
|
+
let current = cwd;
|
|
93
|
+
for (; ; ) {
|
|
94
|
+
try {
|
|
95
|
+
const config = await readGitConfig(current);
|
|
96
|
+
const url = extractOriginUrl(config);
|
|
97
|
+
if (!url) return void 0;
|
|
98
|
+
const remote = parseRemoteUrl(url);
|
|
99
|
+
if (!remote || remote.host !== instanceHost) return void 0;
|
|
100
|
+
return normalizeProjectPath(remote.path, instanceBasePath);
|
|
101
|
+
} catch {
|
|
102
|
+
const parent = path.dirname(current);
|
|
103
|
+
if (parent === current) return void 0;
|
|
104
|
+
current = parent;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function fetchProjectDetails(client, projectPath) {
|
|
109
|
+
const encoded = encodeURIComponent(projectPath);
|
|
110
|
+
const data = await get(client, `projects/${encoded}`);
|
|
111
|
+
if (!data.id || !data.namespace?.id) {
|
|
112
|
+
throw new Error(`Project ${projectPath}: missing id or namespace`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
projectId: String(data.id),
|
|
116
|
+
namespaceId: String(data.namespace.id)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function resolveRootNamespaceId(client, namespaceId) {
|
|
120
|
+
let currentId = namespaceId;
|
|
121
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
122
|
+
let ns;
|
|
123
|
+
try {
|
|
124
|
+
ns = await get(client, `namespaces/${currentId}`);
|
|
125
|
+
} catch {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (!ns.parent_id) {
|
|
129
|
+
currentId = String(ns.id ?? currentId);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
currentId = String(ns.parent_id);
|
|
133
|
+
}
|
|
134
|
+
return `gid://gitlab/Group/${currentId}`;
|
|
135
|
+
}
|
|
136
|
+
async function readGitConfig(cwd) {
|
|
137
|
+
const gitPath = path.join(cwd, ".git");
|
|
138
|
+
const stat = await fs.stat(gitPath);
|
|
139
|
+
if (stat.isDirectory()) {
|
|
140
|
+
return fs.readFile(path.join(gitPath, "config"), "utf8");
|
|
141
|
+
}
|
|
142
|
+
const content = await fs.readFile(gitPath, "utf8");
|
|
143
|
+
const match = /^gitdir:\s*(.+)$/m.exec(content);
|
|
144
|
+
if (!match) throw new Error("Invalid .git file");
|
|
145
|
+
const gitdir = match[1].trim();
|
|
146
|
+
const resolved = path.isAbsolute(gitdir) ? gitdir : path.join(cwd, gitdir);
|
|
147
|
+
return fs.readFile(path.join(resolved, "config"), "utf8");
|
|
148
|
+
}
|
|
149
|
+
function extractOriginUrl(config) {
|
|
150
|
+
const lines = config.split("\n");
|
|
151
|
+
let inOrigin = false;
|
|
152
|
+
let originUrl;
|
|
153
|
+
let firstUrl;
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
const trimmed = line.trim();
|
|
156
|
+
const section = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
|
|
157
|
+
if (section) {
|
|
158
|
+
inOrigin = section[1] === "origin";
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
|
|
162
|
+
if (urlMatch) {
|
|
163
|
+
const value = urlMatch[1].trim();
|
|
164
|
+
if (!firstUrl) firstUrl = value;
|
|
165
|
+
if (inOrigin) originUrl = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return originUrl ?? firstUrl;
|
|
169
|
+
}
|
|
170
|
+
function parseRemoteUrl(url) {
|
|
171
|
+
if (url.startsWith("git@")) {
|
|
172
|
+
const match = /^git@([^:]+):(.+)$/.exec(url);
|
|
173
|
+
return match ? { host: match[1], path: match[2] } : void 0;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const parsed = new URL(url);
|
|
177
|
+
return { host: parsed.host, path: parsed.pathname.replace(/^\//, "") };
|
|
178
|
+
} catch {
|
|
179
|
+
return void 0;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function normalizeProjectPath(remotePath, instanceBasePath) {
|
|
183
|
+
let p = remotePath;
|
|
184
|
+
if (instanceBasePath && instanceBasePath !== "/") {
|
|
185
|
+
const base = instanceBasePath.replace(/^\//, "") + "/";
|
|
186
|
+
if (p.startsWith(base)) p = p.slice(base.length);
|
|
187
|
+
}
|
|
188
|
+
if (p.endsWith(".git")) p = p.slice(0, -4);
|
|
189
|
+
return p.length > 0 ? p : void 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/gitlab/models.ts
|
|
193
|
+
var QUERY = `query lsp_aiChatAvailableModels($rootNamespaceId: GroupID!) {
|
|
194
|
+
aiChatAvailableModels(rootNamespaceId: $rootNamespaceId) {
|
|
195
|
+
defaultModel { name ref }
|
|
196
|
+
selectableModels { name ref }
|
|
197
|
+
pinnedModel { name ref }
|
|
198
|
+
}
|
|
199
|
+
}`;
|
|
200
|
+
async function loadAvailableModels(instanceUrl, token, cwd) {
|
|
201
|
+
const cachePath = getCachePath(instanceUrl, cwd);
|
|
202
|
+
const cached = await readCache(cachePath);
|
|
203
|
+
if (cached && !isStale(cached)) {
|
|
204
|
+
return cached.models;
|
|
205
|
+
}
|
|
206
|
+
if (token) {
|
|
207
|
+
try {
|
|
208
|
+
const models = await fetchModelsFromApi({ instanceUrl, token }, cwd);
|
|
209
|
+
if (models.length > 0) {
|
|
210
|
+
await writeCache(cachePath, { cachedAt: (/* @__PURE__ */ new Date()).toISOString(), instanceUrl, models });
|
|
211
|
+
return models;
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (cached) {
|
|
217
|
+
return cached.models;
|
|
218
|
+
}
|
|
219
|
+
return [{ id: DEFAULT_MODEL_ID, name: DEFAULT_MODEL_ID }];
|
|
220
|
+
}
|
|
221
|
+
async function fetchModelsFromApi(client, cwd) {
|
|
222
|
+
const projectPath = await detectProjectPath(cwd, client.instanceUrl);
|
|
223
|
+
if (!projectPath) return [];
|
|
224
|
+
const project = await fetchProjectDetails(client, projectPath);
|
|
225
|
+
const rootNamespaceId = await resolveRootNamespaceId(client, project.namespaceId);
|
|
226
|
+
const data = await graphql(client, QUERY, { rootNamespaceId });
|
|
227
|
+
const available = data.aiChatAvailableModels;
|
|
228
|
+
if (!available) return [];
|
|
229
|
+
const seen = /* @__PURE__ */ new Set();
|
|
230
|
+
const models = [];
|
|
231
|
+
function add(entry2) {
|
|
232
|
+
if (!entry2?.ref || seen.has(entry2.ref)) return;
|
|
233
|
+
seen.add(entry2.ref);
|
|
234
|
+
models.push({ id: entry2.ref, name: entry2.name || entry2.ref });
|
|
235
|
+
}
|
|
236
|
+
add(available.defaultModel);
|
|
237
|
+
add(available.pinnedModel);
|
|
238
|
+
for (const m of available.selectableModels ?? []) add(m);
|
|
239
|
+
return models;
|
|
240
|
+
}
|
|
241
|
+
function getCachePath(instanceUrl, cwd) {
|
|
242
|
+
const key = `${instanceUrl}::${cwd}`;
|
|
243
|
+
const hash = crypto.createHash("sha256").update(key).digest("hex").slice(0, 12);
|
|
244
|
+
const dir = process.env.XDG_CACHE_HOME?.trim() ? path2.join(process.env.XDG_CACHE_HOME, "opencode") : path2.join(os.homedir(), ".cache", "opencode");
|
|
245
|
+
return path2.join(dir, `gitlab-duo-models-${hash}.json`);
|
|
246
|
+
}
|
|
247
|
+
function isStale(payload) {
|
|
248
|
+
const age = Date.now() - Date.parse(payload.cachedAt);
|
|
249
|
+
return age > CACHE_TTL_MS;
|
|
250
|
+
}
|
|
251
|
+
async function readCache(cachePath) {
|
|
252
|
+
try {
|
|
253
|
+
const raw = await fs2.readFile(cachePath, "utf8");
|
|
254
|
+
const parsed = JSON.parse(raw);
|
|
255
|
+
if (!parsed.models?.length) return null;
|
|
256
|
+
return parsed;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function writeCache(cachePath, payload) {
|
|
262
|
+
try {
|
|
263
|
+
await fs2.mkdir(path2.dirname(cachePath), { recursive: true });
|
|
264
|
+
await fs2.writeFile(cachePath, JSON.stringify(payload, null, 2), "utf8");
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
}
|
|
6
268
|
|
|
7
269
|
// src/utils/url.ts
|
|
8
270
|
function text(value) {
|
|
@@ -25,65 +287,595 @@ function normalizeInstanceUrl(value) {
|
|
|
25
287
|
}
|
|
26
288
|
|
|
27
289
|
// src/plugin/config.ts
|
|
28
|
-
function applyRuntimeConfig(config) {
|
|
290
|
+
async function applyRuntimeConfig(config, directory) {
|
|
29
291
|
config.provider ??= {};
|
|
30
292
|
const current = config.provider[PROVIDER_ID] ?? {};
|
|
31
293
|
const options = current.options ?? {};
|
|
32
|
-
const models = current.models ?? {};
|
|
33
294
|
const instanceUrl = normalizeInstanceUrl(options.instanceUrl ?? envInstanceUrl());
|
|
295
|
+
const token = (typeof options.apiKey === "string" ? options.apiKey : void 0) ?? process.env.GITLAB_TOKEN ?? process.env.GITLAB_OAUTH_TOKEN ?? "";
|
|
296
|
+
const available = await loadAvailableModels(instanceUrl, token, directory);
|
|
297
|
+
const modelIds = available.map((m) => m.id);
|
|
298
|
+
const models = toModelsConfig(available);
|
|
34
299
|
config.provider[PROVIDER_ID] = {
|
|
35
300
|
...current,
|
|
36
|
-
whitelist:
|
|
301
|
+
whitelist: modelIds,
|
|
37
302
|
options: {
|
|
38
303
|
...options,
|
|
39
304
|
instanceUrl
|
|
40
305
|
},
|
|
41
306
|
models: {
|
|
42
|
-
...models,
|
|
43
|
-
|
|
44
|
-
id: MODEL_ID,
|
|
45
|
-
name: "GitLab Duo Agentic"
|
|
46
|
-
}
|
|
307
|
+
...current.models ?? {},
|
|
308
|
+
...models
|
|
47
309
|
}
|
|
48
310
|
};
|
|
49
311
|
}
|
|
312
|
+
function toModelsConfig(available) {
|
|
313
|
+
const out = {};
|
|
314
|
+
for (const m of available) {
|
|
315
|
+
out[m.id] = { id: m.id, name: m.name };
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
50
319
|
|
|
51
320
|
// src/plugin/hooks.ts
|
|
52
|
-
async function createPluginHooks(
|
|
321
|
+
async function createPluginHooks(input) {
|
|
53
322
|
return {
|
|
54
|
-
config: async (config) => applyRuntimeConfig(config)
|
|
323
|
+
config: async (config) => applyRuntimeConfig(config, input.directory),
|
|
324
|
+
"chat.params": async (context, output) => {
|
|
325
|
+
if (!isGitLabProvider(context.model)) return;
|
|
326
|
+
output.options = {
|
|
327
|
+
...output.options,
|
|
328
|
+
workflowSessionID: context.sessionID
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
"chat.headers": async (context, output) => {
|
|
332
|
+
if (!isGitLabProvider(context.model)) return;
|
|
333
|
+
output.headers = {
|
|
334
|
+
...output.headers,
|
|
335
|
+
"x-opencode-session": context.sessionID
|
|
336
|
+
};
|
|
337
|
+
}
|
|
55
338
|
};
|
|
56
339
|
}
|
|
340
|
+
function isGitLabProvider(model) {
|
|
341
|
+
if (model.api?.npm === "opencode-gitlab-duo-agentic") return true;
|
|
342
|
+
if (model.providerID === "gitlab" && model.api?.npm !== "@gitlab/gitlab-ai-provider") return true;
|
|
343
|
+
return model.providerID.toLowerCase().includes("gitlab-duo");
|
|
344
|
+
}
|
|
57
345
|
|
|
58
346
|
// src/provider/index.ts
|
|
59
|
-
import { NoSuchModelError
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
347
|
+
import { NoSuchModelError } from "@ai-sdk/provider";
|
|
348
|
+
|
|
349
|
+
// src/provider/duo-workflow-model.ts
|
|
350
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
351
|
+
|
|
352
|
+
// src/workflow/session.ts
|
|
353
|
+
import { randomUUID } from "crypto";
|
|
354
|
+
|
|
355
|
+
// src/workflow/checkpoint.ts
|
|
356
|
+
function createCheckpointState() {
|
|
67
357
|
return {
|
|
68
|
-
|
|
69
|
-
provider: PROVIDER_ID,
|
|
70
|
-
modelId,
|
|
71
|
-
supportedUrls: {},
|
|
72
|
-
async doGenerate(_options) {
|
|
73
|
-
throw notImplemented();
|
|
74
|
-
},
|
|
75
|
-
async doStream(_options) {
|
|
76
|
-
throw notImplemented();
|
|
77
|
-
}
|
|
358
|
+
uiChatLog: []
|
|
78
359
|
};
|
|
79
360
|
}
|
|
80
|
-
function
|
|
361
|
+
function extractAgentTextDeltas(checkpoint, state) {
|
|
362
|
+
const next = parseCheckpoint(checkpoint);
|
|
363
|
+
const out = [];
|
|
364
|
+
for (let i = 0; i < next.length; i++) {
|
|
365
|
+
const item = next[i];
|
|
366
|
+
if (item.message_type !== "agent") continue;
|
|
367
|
+
const previous = state.uiChatLog[i];
|
|
368
|
+
if (!previous || previous.message_type !== "agent") {
|
|
369
|
+
if (item.content) out.push(item.content);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (item.content === previous.content) continue;
|
|
373
|
+
if (item.content.startsWith(previous.content)) {
|
|
374
|
+
const delta = item.content.slice(previous.content.length);
|
|
375
|
+
if (delta) out.push(delta);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (item.content) out.push(item.content);
|
|
379
|
+
}
|
|
380
|
+
state.uiChatLog = next;
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
function parseCheckpoint(raw) {
|
|
384
|
+
if (!raw) return [];
|
|
385
|
+
try {
|
|
386
|
+
const parsed = JSON.parse(raw);
|
|
387
|
+
const log = parsed.channel_values?.ui_chat_log;
|
|
388
|
+
if (!Array.isArray(log)) return [];
|
|
389
|
+
return log.filter(isUiChatLogEntry);
|
|
390
|
+
} catch {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function isUiChatLogEntry(value) {
|
|
395
|
+
if (!value || typeof value !== "object") return false;
|
|
396
|
+
const item = value;
|
|
397
|
+
if (typeof item.message_type !== "string") return false;
|
|
398
|
+
if (typeof item.content !== "string") return false;
|
|
399
|
+
return item.message_type === "user" || item.message_type === "agent" || item.message_type === "tool" || item.message_type === "request";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/workflow/token-service.ts
|
|
403
|
+
var WorkflowTokenService = class {
|
|
404
|
+
#client;
|
|
405
|
+
#cache = /* @__PURE__ */ new Map();
|
|
406
|
+
constructor(client) {
|
|
407
|
+
this.#client = client;
|
|
408
|
+
}
|
|
409
|
+
clear() {
|
|
410
|
+
this.#cache.clear();
|
|
411
|
+
}
|
|
412
|
+
async get(rootNamespaceId) {
|
|
413
|
+
const key = rootNamespaceId ?? "";
|
|
414
|
+
const cached = this.#cache.get(key);
|
|
415
|
+
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
|
416
|
+
try {
|
|
417
|
+
const value = await post(
|
|
418
|
+
this.#client,
|
|
419
|
+
"ai/duo_workflows/direct_access",
|
|
420
|
+
rootNamespaceId ? {
|
|
421
|
+
workflow_definition: WORKFLOW_DEFINITION,
|
|
422
|
+
root_namespace_id: rootNamespaceId
|
|
423
|
+
} : {
|
|
424
|
+
workflow_definition: WORKFLOW_DEFINITION
|
|
425
|
+
}
|
|
426
|
+
);
|
|
427
|
+
const expiresAt = readExpiry(value);
|
|
428
|
+
this.#cache.set(key, { value, expiresAt });
|
|
429
|
+
return value;
|
|
430
|
+
} catch {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
function readExpiry(value) {
|
|
436
|
+
const workflowExpiry = typeof value.duo_workflow_service?.token_expires_at === "number" ? value.duo_workflow_service.token_expires_at * 1e3 : Number.POSITIVE_INFINITY;
|
|
437
|
+
const railsExpiry = typeof value.gitlab_rails?.token_expires_at === "string" ? Date.parse(value.gitlab_rails.token_expires_at) : Number.POSITIVE_INFINITY;
|
|
438
|
+
const expiry = Math.min(workflowExpiry, railsExpiry);
|
|
439
|
+
if (!Number.isFinite(expiry)) return Date.now() + 5 * 60 * 1e3;
|
|
440
|
+
return Math.max(Date.now() + 1e3, expiry - WORKFLOW_TOKEN_EXPIRY_BUFFER_MS);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/workflow/types.ts
|
|
444
|
+
var WORKFLOW_STATUS = {
|
|
445
|
+
CREATED: "CREATED",
|
|
446
|
+
RUNNING: "RUNNING",
|
|
447
|
+
FINISHED: "FINISHED",
|
|
448
|
+
FAILED: "FAILED",
|
|
449
|
+
STOPPED: "STOPPED",
|
|
450
|
+
INPUT_REQUIRED: "INPUT_REQUIRED",
|
|
451
|
+
PLAN_APPROVAL_REQUIRED: "PLAN_APPROVAL_REQUIRED",
|
|
452
|
+
TOOL_CALL_APPROVAL_REQUIRED: "TOOL_CALL_APPROVAL_REQUIRED"
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// src/workflow/websocket-client.ts
|
|
456
|
+
import WebSocket from "isomorphic-ws";
|
|
457
|
+
var WorkflowWebSocketClient = class {
|
|
458
|
+
#socket = null;
|
|
459
|
+
#heartbeat;
|
|
460
|
+
#keepalive;
|
|
461
|
+
#callbacks;
|
|
462
|
+
constructor(callbacks) {
|
|
463
|
+
this.#callbacks = callbacks;
|
|
464
|
+
}
|
|
465
|
+
async connect(url, headers) {
|
|
466
|
+
const socket = new WebSocket(url, { headers });
|
|
467
|
+
this.#socket = socket;
|
|
468
|
+
await new Promise((resolve, reject) => {
|
|
469
|
+
const timeout = setTimeout(() => {
|
|
470
|
+
cleanup();
|
|
471
|
+
socket.close(1e3);
|
|
472
|
+
reject(new Error(`WebSocket connection timeout after ${WORKFLOW_CONNECT_TIMEOUT_MS}ms`));
|
|
473
|
+
}, WORKFLOW_CONNECT_TIMEOUT_MS);
|
|
474
|
+
const cleanup = () => {
|
|
475
|
+
clearTimeout(timeout);
|
|
476
|
+
socket.off("open", onOpen);
|
|
477
|
+
socket.off("error", onError);
|
|
478
|
+
};
|
|
479
|
+
const onOpen = () => {
|
|
480
|
+
cleanup();
|
|
481
|
+
resolve();
|
|
482
|
+
};
|
|
483
|
+
const onError = (error) => {
|
|
484
|
+
cleanup();
|
|
485
|
+
reject(error);
|
|
486
|
+
};
|
|
487
|
+
socket.once("open", onOpen);
|
|
488
|
+
socket.once("error", onError);
|
|
489
|
+
});
|
|
490
|
+
socket.on("message", (data) => {
|
|
491
|
+
try {
|
|
492
|
+
const payload = decodeSocketMessage(data);
|
|
493
|
+
if (!payload) return;
|
|
494
|
+
const parsed = JSON.parse(payload);
|
|
495
|
+
this.#callbacks.action(parsed);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
const next = error instanceof Error ? error : new Error(String(error));
|
|
498
|
+
this.#callbacks.error(next);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
socket.on("error", (error) => {
|
|
502
|
+
this.#callbacks.error(error instanceof Error ? error : new Error(String(error)));
|
|
503
|
+
});
|
|
504
|
+
socket.on("close", (code, reason) => {
|
|
505
|
+
this.#stopIntervals();
|
|
506
|
+
this.#callbacks.close(code, reason?.toString("utf8") ?? "");
|
|
507
|
+
});
|
|
508
|
+
this.#heartbeat = setInterval(() => {
|
|
509
|
+
this.send({ heartbeat: { timestamp: Date.now() } });
|
|
510
|
+
}, WORKFLOW_HEARTBEAT_INTERVAL_MS);
|
|
511
|
+
this.#keepalive = setInterval(() => {
|
|
512
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) return;
|
|
513
|
+
this.#socket.ping(Buffer.from(String(Date.now())));
|
|
514
|
+
}, WORKFLOW_KEEPALIVE_INTERVAL_MS);
|
|
515
|
+
}
|
|
516
|
+
send(event) {
|
|
517
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) return false;
|
|
518
|
+
this.#socket.send(JSON.stringify(event));
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
close() {
|
|
522
|
+
this.#stopIntervals();
|
|
523
|
+
if (!this.#socket) return;
|
|
524
|
+
this.#socket.close(1e3);
|
|
525
|
+
this.#socket = null;
|
|
526
|
+
}
|
|
527
|
+
#stopIntervals() {
|
|
528
|
+
if (this.#heartbeat) {
|
|
529
|
+
clearInterval(this.#heartbeat);
|
|
530
|
+
this.#heartbeat = void 0;
|
|
531
|
+
}
|
|
532
|
+
if (this.#keepalive) {
|
|
533
|
+
clearInterval(this.#keepalive);
|
|
534
|
+
this.#keepalive = void 0;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
function decodeSocketMessage(data) {
|
|
539
|
+
if (typeof data === "string") return data;
|
|
540
|
+
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
541
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
|
542
|
+
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
|
543
|
+
return void 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// src/workflow/session.ts
|
|
547
|
+
var WorkflowSession = class {
|
|
548
|
+
#client;
|
|
549
|
+
#tokenService;
|
|
550
|
+
#modelId;
|
|
551
|
+
#workflowId;
|
|
552
|
+
#projectPath;
|
|
553
|
+
#rootNamespaceId;
|
|
554
|
+
#checkpoint = createCheckpointState();
|
|
555
|
+
#running = false;
|
|
556
|
+
constructor(client, modelId) {
|
|
557
|
+
this.#client = client;
|
|
558
|
+
this.#tokenService = new WorkflowTokenService(client);
|
|
559
|
+
this.#modelId = modelId;
|
|
560
|
+
}
|
|
561
|
+
get workflowId() {
|
|
562
|
+
return this.#workflowId;
|
|
563
|
+
}
|
|
564
|
+
reset() {
|
|
565
|
+
this.#workflowId = void 0;
|
|
566
|
+
this.#checkpoint = createCheckpointState();
|
|
567
|
+
this.#tokenService.clear();
|
|
568
|
+
}
|
|
569
|
+
async *runTurn(goal, abortSignal) {
|
|
570
|
+
if (this.#running) {
|
|
571
|
+
throw new Error("workflow session is already running");
|
|
572
|
+
}
|
|
573
|
+
this.#running = true;
|
|
574
|
+
const queue = new AsyncQueue();
|
|
575
|
+
const socket = new WorkflowWebSocketClient({
|
|
576
|
+
action: (action) => queue.push({ type: "action", action }),
|
|
577
|
+
error: (error) => queue.push({ type: "error", error }),
|
|
578
|
+
close: (code, reason) => queue.push({ type: "close", code, reason })
|
|
579
|
+
});
|
|
580
|
+
const onAbort = () => {
|
|
581
|
+
socket.send({
|
|
582
|
+
stopWorkflow: {
|
|
583
|
+
reason: "ABORTED"
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
socket.close();
|
|
587
|
+
};
|
|
588
|
+
try {
|
|
589
|
+
if (abortSignal?.aborted) throw new Error("aborted");
|
|
590
|
+
if (!this.#workflowId) this.#workflowId = await this.#createWorkflow(goal);
|
|
591
|
+
const access = await this.#tokenService.get(this.#rootNamespaceId);
|
|
592
|
+
const url = buildWebSocketUrl(this.#client.instanceUrl, this.#modelId);
|
|
593
|
+
await socket.connect(url, {
|
|
594
|
+
authorization: `Bearer ${this.#client.token}`,
|
|
595
|
+
origin: new URL(this.#client.instanceUrl).origin,
|
|
596
|
+
"x-request-id": randomUUID(),
|
|
597
|
+
"x-gitlab-client-type": "node-websocket"
|
|
598
|
+
});
|
|
599
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
600
|
+
const sent = socket.send({
|
|
601
|
+
startRequest: {
|
|
602
|
+
workflowID: this.#workflowId,
|
|
603
|
+
clientVersion: WORKFLOW_CLIENT_VERSION,
|
|
604
|
+
workflowDefinition: WORKFLOW_DEFINITION,
|
|
605
|
+
goal,
|
|
606
|
+
workflowMetadata: JSON.stringify({
|
|
607
|
+
extended_logging: access?.workflow_metadata?.extended_logging ?? false
|
|
608
|
+
}),
|
|
609
|
+
clientCapabilities: [],
|
|
610
|
+
mcpTools: [],
|
|
611
|
+
additional_context: [],
|
|
612
|
+
preapproved_tools: []
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
if (!sent) throw new Error("failed to send workflow startRequest");
|
|
616
|
+
for (; ; ) {
|
|
617
|
+
const event = await queue.shift();
|
|
618
|
+
if (event.type === "error") throw event.error;
|
|
619
|
+
if (event.type === "close") {
|
|
620
|
+
if (event.code === 1e3 || event.code === 1006) return;
|
|
621
|
+
throw new Error(`workflow websocket closed abnormally (${event.code}): ${event.reason}`);
|
|
622
|
+
}
|
|
623
|
+
if (isCheckpointAction(event.action)) {
|
|
624
|
+
const deltas = extractAgentTextDeltas(event.action.newCheckpoint.checkpoint, this.#checkpoint);
|
|
625
|
+
for (const delta of deltas) {
|
|
626
|
+
yield {
|
|
627
|
+
type: "text-delta",
|
|
628
|
+
value: delta
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
if (isTurnComplete(event.action.newCheckpoint.status)) {
|
|
632
|
+
socket.close();
|
|
633
|
+
}
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (!event.action.requestID) continue;
|
|
637
|
+
socket.send({
|
|
638
|
+
actionResponse: {
|
|
639
|
+
requestID: event.action.requestID,
|
|
640
|
+
plainTextResponse: {
|
|
641
|
+
response: "",
|
|
642
|
+
error: WORKFLOW_TOOL_ERROR_MESSAGE
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
} finally {
|
|
648
|
+
this.#running = false;
|
|
649
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
650
|
+
socket.close();
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async #createWorkflow(goal) {
|
|
654
|
+
await this.#loadProjectContext();
|
|
655
|
+
const body = {
|
|
656
|
+
goal,
|
|
657
|
+
workflow_definition: WORKFLOW_DEFINITION,
|
|
658
|
+
environment: WORKFLOW_ENVIRONMENT,
|
|
659
|
+
allow_agent_to_request_user: true,
|
|
660
|
+
...this.#projectPath ? {
|
|
661
|
+
project_id: this.#projectPath
|
|
662
|
+
} : {}
|
|
663
|
+
};
|
|
664
|
+
const created = await post(this.#client, "ai/duo_workflows/workflows", body);
|
|
665
|
+
if (created.id === void 0 || created.id === null) {
|
|
666
|
+
const details = [created.message, created.error].filter(Boolean).join("; ");
|
|
667
|
+
throw new Error(`failed to create workflow${details ? `: ${details}` : ""}`);
|
|
668
|
+
}
|
|
669
|
+
return String(created.id);
|
|
670
|
+
}
|
|
671
|
+
async #loadProjectContext() {
|
|
672
|
+
if (this.#projectPath !== void 0) return;
|
|
673
|
+
const projectPath = await detectProjectPath(process.cwd(), this.#client.instanceUrl);
|
|
674
|
+
this.#projectPath = projectPath;
|
|
675
|
+
if (!projectPath) return;
|
|
676
|
+
try {
|
|
677
|
+
const project = await fetchProjectDetails(this.#client, projectPath);
|
|
678
|
+
this.#rootNamespaceId = await resolveRootNamespaceId(this.#client, project.namespaceId);
|
|
679
|
+
} catch {
|
|
680
|
+
this.#rootNamespaceId = void 0;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
function isCheckpointAction(action) {
|
|
685
|
+
return Boolean(action.newCheckpoint);
|
|
686
|
+
}
|
|
687
|
+
function isTurnComplete(status) {
|
|
688
|
+
return status === WORKFLOW_STATUS.INPUT_REQUIRED || status === WORKFLOW_STATUS.FINISHED || status === WORKFLOW_STATUS.FAILED || status === WORKFLOW_STATUS.STOPPED || status === WORKFLOW_STATUS.PLAN_APPROVAL_REQUIRED || status === WORKFLOW_STATUS.TOOL_CALL_APPROVAL_REQUIRED;
|
|
689
|
+
}
|
|
690
|
+
function buildWebSocketUrl(instanceUrl, modelId) {
|
|
691
|
+
const base = new URL(instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`);
|
|
692
|
+
const url = new URL("api/v4/ai/duo_workflows/ws", base);
|
|
693
|
+
if (base.protocol === "https:") url.protocol = "wss:";
|
|
694
|
+
if (base.protocol === "http:") url.protocol = "ws:";
|
|
695
|
+
if (modelId) url.searchParams.set("user_selected_model_identifier", modelId);
|
|
696
|
+
return url.toString();
|
|
697
|
+
}
|
|
698
|
+
var AsyncQueue = class {
|
|
699
|
+
#values = [];
|
|
700
|
+
#waiters = [];
|
|
701
|
+
push(value) {
|
|
702
|
+
const waiter = this.#waiters.shift();
|
|
703
|
+
if (waiter) {
|
|
704
|
+
waiter(value);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
this.#values.push(value);
|
|
708
|
+
}
|
|
709
|
+
shift() {
|
|
710
|
+
const value = this.#values.shift();
|
|
711
|
+
if (value !== void 0) return Promise.resolve(value);
|
|
712
|
+
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
// src/provider/duo-workflow-model.ts
|
|
717
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
718
|
+
var DuoWorkflowModel = class {
|
|
719
|
+
specificationVersion = "v2";
|
|
720
|
+
provider = PROVIDER_ID;
|
|
721
|
+
modelId;
|
|
722
|
+
supportedUrls = {};
|
|
723
|
+
#client;
|
|
724
|
+
constructor(modelId, client) {
|
|
725
|
+
this.modelId = modelId;
|
|
726
|
+
this.#client = client;
|
|
727
|
+
}
|
|
728
|
+
async doGenerate(options) {
|
|
729
|
+
const goal = extractGoal(options.prompt);
|
|
730
|
+
if (!goal) throw new Error("missing user message content");
|
|
731
|
+
const session = this.#resolveSession(options);
|
|
732
|
+
const chunks = [];
|
|
733
|
+
for await (const item of session.runTurn(goal, options.abortSignal)) {
|
|
734
|
+
if (item.type === "text-delta") chunks.push(item.value);
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
content: [
|
|
738
|
+
{
|
|
739
|
+
type: "text",
|
|
740
|
+
text: chunks.join("")
|
|
741
|
+
}
|
|
742
|
+
],
|
|
743
|
+
finishReason: "stop",
|
|
744
|
+
usage: {
|
|
745
|
+
inputTokens: void 0,
|
|
746
|
+
outputTokens: void 0,
|
|
747
|
+
totalTokens: void 0
|
|
748
|
+
},
|
|
749
|
+
warnings: []
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
async doStream(options) {
|
|
753
|
+
const goal = extractGoal(options.prompt);
|
|
754
|
+
if (!goal) throw new Error("missing user message content");
|
|
755
|
+
const session = this.#resolveSession(options);
|
|
756
|
+
const textId = randomUUID2();
|
|
757
|
+
return {
|
|
758
|
+
stream: new ReadableStream({
|
|
759
|
+
start: async (controller) => {
|
|
760
|
+
controller.enqueue({
|
|
761
|
+
type: "stream-start",
|
|
762
|
+
warnings: []
|
|
763
|
+
});
|
|
764
|
+
let hasText = false;
|
|
765
|
+
try {
|
|
766
|
+
for await (const item of session.runTurn(goal, options.abortSignal)) {
|
|
767
|
+
if (item.type !== "text-delta") continue;
|
|
768
|
+
if (!item.value) continue;
|
|
769
|
+
if (!hasText) {
|
|
770
|
+
hasText = true;
|
|
771
|
+
controller.enqueue({
|
|
772
|
+
type: "text-start",
|
|
773
|
+
id: textId
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
controller.enqueue({
|
|
777
|
+
type: "text-delta",
|
|
778
|
+
id: textId,
|
|
779
|
+
delta: item.value
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
if (hasText) {
|
|
783
|
+
controller.enqueue({
|
|
784
|
+
type: "text-end",
|
|
785
|
+
id: textId
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
controller.enqueue({
|
|
789
|
+
type: "finish",
|
|
790
|
+
finishReason: "stop",
|
|
791
|
+
usage: {
|
|
792
|
+
inputTokens: void 0,
|
|
793
|
+
outputTokens: void 0,
|
|
794
|
+
totalTokens: void 0
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
controller.close();
|
|
798
|
+
} catch (error) {
|
|
799
|
+
controller.enqueue({
|
|
800
|
+
type: "error",
|
|
801
|
+
error
|
|
802
|
+
});
|
|
803
|
+
controller.enqueue({
|
|
804
|
+
type: "finish",
|
|
805
|
+
finishReason: "error",
|
|
806
|
+
usage: {
|
|
807
|
+
inputTokens: void 0,
|
|
808
|
+
outputTokens: void 0,
|
|
809
|
+
totalTokens: void 0
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
controller.close();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}),
|
|
816
|
+
request: {
|
|
817
|
+
body: {
|
|
818
|
+
goal,
|
|
819
|
+
workflowID: session.workflowId
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
#resolveSession(options) {
|
|
825
|
+
const sessionID = readSessionID(options);
|
|
826
|
+
const key = `${this.#client.instanceUrl}::${this.modelId}::${sessionID}`;
|
|
827
|
+
const existing = sessions.get(key);
|
|
828
|
+
if (existing) return existing;
|
|
829
|
+
const created = new WorkflowSession(this.#client, this.modelId);
|
|
830
|
+
sessions.set(key, created);
|
|
831
|
+
return created;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
function extractGoal(prompt) {
|
|
835
|
+
for (let i = prompt.length - 1; i >= 0; i--) {
|
|
836
|
+
const message = prompt[i];
|
|
837
|
+
if (message.role !== "user") continue;
|
|
838
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
839
|
+
const text2 = content.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
|
|
840
|
+
if (text2) return text2;
|
|
841
|
+
}
|
|
842
|
+
return "";
|
|
843
|
+
}
|
|
844
|
+
function readSessionID(options) {
|
|
845
|
+
const providerBlock = readProviderBlock(options);
|
|
846
|
+
if (typeof providerBlock?.workflowSessionID === "string" && providerBlock.workflowSessionID.trim()) {
|
|
847
|
+
return providerBlock.workflowSessionID.trim();
|
|
848
|
+
}
|
|
849
|
+
const headers = options.headers ?? {};
|
|
850
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
851
|
+
if (key.toLowerCase() === "x-opencode-session" && value?.trim()) return value.trim();
|
|
852
|
+
}
|
|
853
|
+
return "default";
|
|
854
|
+
}
|
|
855
|
+
function readProviderBlock(options) {
|
|
856
|
+
const block = options.providerOptions?.[PROVIDER_ID];
|
|
857
|
+
if (block && typeof block === "object" && !Array.isArray(block)) {
|
|
858
|
+
return block;
|
|
859
|
+
}
|
|
860
|
+
if (!options.providerOptions) return void 0;
|
|
861
|
+
for (const value of Object.values(options.providerOptions)) {
|
|
862
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
863
|
+
return value;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return void 0;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/provider/index.ts
|
|
870
|
+
function createFallbackProvider(input = {}) {
|
|
871
|
+
const instanceUrl = normalizeInstanceUrl(input.instanceUrl ?? envInstanceUrl());
|
|
872
|
+
const token = text(input.apiKey) ?? text(input.token) ?? process.env.GITLAB_TOKEN ?? process.env.GITLAB_OAUTH_TOKEN ?? "";
|
|
81
873
|
return {
|
|
82
874
|
languageModel(modelId) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
875
|
+
return new DuoWorkflowModel(modelId, {
|
|
876
|
+
instanceUrl,
|
|
877
|
+
token
|
|
878
|
+
});
|
|
87
879
|
},
|
|
88
880
|
textEmbeddingModel(modelId) {
|
|
89
881
|
throw new NoSuchModelError({ modelId, modelType: "textEmbeddingModel" });
|
|
@@ -104,7 +896,7 @@ var entry = (input) => {
|
|
|
104
896
|
if (isPluginInput(input)) {
|
|
105
897
|
return createPluginHooks(input);
|
|
106
898
|
}
|
|
107
|
-
return createFallbackProvider();
|
|
899
|
+
return createFallbackProvider(input);
|
|
108
900
|
};
|
|
109
901
|
var createGitLabDuoAgentic = entry;
|
|
110
902
|
var GitLabDuoAgenticPlugin = entry;
|