@trigger.dev/linear 3.3.12 → 4.8.0-webhooks.20261015065339
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/commonjs/index.d.ts +370 -0
- package/dist/commonjs/index.js +961 -0
- package/dist/commonjs/index.js.map +1 -0
- package/dist/commonjs/package.json +3 -0
- package/dist/esm/index.d.ts +370 -0
- package/dist/esm/index.js +942 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/package.json +3 -0
- package/package.json +58 -27
- package/README.md +0 -3
- package/dist/index.d.mts +0 -20027
- package/dist/index.d.ts +0 -20027
- package/dist/index.js +0 -3618
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -3612
- package/dist/index.mjs.map +0 -1
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LINEAR_PLAN_TOOL_NAME = void 0;
|
|
4
|
+
exports.linearPlanTool = linearPlanTool;
|
|
5
|
+
exports.webhookSource = webhookSource;
|
|
6
|
+
exports.linear = linear;
|
|
7
|
+
exports.linearSessionHandle = linearSessionHandle;
|
|
8
|
+
exports.isMention = isMention;
|
|
9
|
+
exports.linearControl = linearControl;
|
|
10
|
+
exports.isStopSignal = isStopSignal;
|
|
11
|
+
exports.defaultLinearInbound = defaultLinearInbound;
|
|
12
|
+
exports.linearFileParts = linearFileParts;
|
|
13
|
+
exports.seedFromPreviousComments = seedFromPreviousComments;
|
|
14
|
+
exports.stoppedMarkdown = stoppedMarkdown;
|
|
15
|
+
exports.toLinearMarkdown = toLinearMarkdown;
|
|
16
|
+
exports.summaryLine = summaryLine;
|
|
17
|
+
exports.linearGraphql = linearGraphql;
|
|
18
|
+
exports.approvalCard = approvalCard;
|
|
19
|
+
exports.inputToMarkdown = inputToMarkdown;
|
|
20
|
+
const node_crypto_1 = require("node:crypto");
|
|
21
|
+
const sdk_1 = require("@trigger.dev/sdk");
|
|
22
|
+
const ai_1 = require("@trigger.dev/sdk/ai");
|
|
23
|
+
const ai_2 = require("ai");
|
|
24
|
+
const webhooks_1 = require("@trigger.dev/core/webhooks");
|
|
25
|
+
/**
|
|
26
|
+
* Linear signs `Linear-Signature: <hex>` as HMAC-SHA256 over the raw body, keyed by the webhook signing
|
|
27
|
+
* secret from the OAuth app's webhook settings. Deliveries are deduplicated on the `Linear-Delivery`
|
|
28
|
+
* header, which stays the same across Linear's retries (1 minute, 1 hour, 6 hours) while each retry
|
|
29
|
+
* refreshes `webhookTimestamp` in the signed body. The header is not signed, so a captured request
|
|
30
|
+
* replayed under a new header value within the replay window is processed again. The body's
|
|
31
|
+
* `webhookTimestamp` (milliseconds) must be within a minute of now, as Linear recommends, which bounds
|
|
32
|
+
* that window. This is the same config
|
|
33
|
+
* `webhooks.linear()` uses, so both Linear surfaces verify identically.
|
|
34
|
+
*/
|
|
35
|
+
const LINEAR_VERIFIER = webhooks_1.webhookProviderConfigs.linear.config();
|
|
36
|
+
/** Only agent session events reach the connector; issue and comment webhooks on the same app do not. */
|
|
37
|
+
const AGENT_SESSION_GUARD = "event.type == 'AgentSessionEvent'";
|
|
38
|
+
/** One session per Linear agent session. */
|
|
39
|
+
const DEFAULT_KEY = "{event.agentSession.id}";
|
|
40
|
+
const LINEAR_SETUP_PROMPT = [
|
|
41
|
+
"These settings live on the Linear OAuth application (Settings > API > OAuth applications). Tell the user each step unless you can change the application yourself.",
|
|
42
|
+
"1. Open the application, turn on Webhooks and set the Webhook URL to the endpoint URL.",
|
|
43
|
+
'2. Under the webhook categories, select "Agent session events". Save. Linear shows the webhook signing secret there.',
|
|
44
|
+
"3. The app must be installed with actor=app and the app:assignable and app:mentionable scopes, so users can mention it and delegate issues to it.",
|
|
45
|
+
].join("\n");
|
|
46
|
+
const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
47
|
+
/** The name of the tool {@link linearPlanTool} defines; the progress mirror skips it. */
|
|
48
|
+
exports.LINEAR_PLAN_TOOL_NAME = "updatePlan";
|
|
49
|
+
/**
|
|
50
|
+
* A tool the model calls to set and update its plan for the issue. Add it to the agent's `tools`
|
|
51
|
+
* and ask for a plan in the system prompt; Linear renders it as a checklist that updates in place.
|
|
52
|
+
* The whole list is replaced on every call. On a turn that is not a Linear session the tool
|
|
53
|
+
* reports that nothing was updated, so the same agent can serve other surfaces unchanged.
|
|
54
|
+
*/
|
|
55
|
+
function linearPlanTool() {
|
|
56
|
+
return (0, ai_2.tool)({
|
|
57
|
+
description: "Set or update your plan for this issue as a short checklist. Replace the whole list each time: " +
|
|
58
|
+
"mark finished steps completed, the current one inProgress, the rest pending. Call it when you " +
|
|
59
|
+
"start and whenever a step changes state.",
|
|
60
|
+
inputSchema: (0, ai_2.jsonSchema)({
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
steps: {
|
|
64
|
+
type: "array",
|
|
65
|
+
minItems: 1,
|
|
66
|
+
maxItems: 10,
|
|
67
|
+
items: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: {
|
|
70
|
+
content: { type: "string", description: "One step, a few words" },
|
|
71
|
+
status: { type: "string", enum: ["pending", "inProgress", "completed", "canceled"] },
|
|
72
|
+
},
|
|
73
|
+
required: ["content", "status"],
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ["steps"],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
}),
|
|
81
|
+
execute: async ({ steps }) => {
|
|
82
|
+
const surface = ai_1.chat.surface();
|
|
83
|
+
if (!surface?.plan) {
|
|
84
|
+
return { updated: false, reason: "not a Linear session" };
|
|
85
|
+
}
|
|
86
|
+
await surface.plan(steps);
|
|
87
|
+
return { updated: true, steps: steps.length };
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The Linear source for `webhooks.endpoint.define({ id, source: webhookSource() })`:
|
|
93
|
+
* `Linear-Signature` HMAC verification, a one minute replay window on the body's
|
|
94
|
+
* `webhookTimestamp`, dedup on the signed request (the same config as `webhooks.linear()`), and a
|
|
95
|
+
* setup prompt. Paste the signing secret from the OAuth app's webhook settings as the endpoint
|
|
96
|
+
* secret. Subscribe `webhook()` tasks to the endpoint, or pass it to `linear({ endpoint })` for an
|
|
97
|
+
* agent frontend.
|
|
98
|
+
*/
|
|
99
|
+
function webhookSource() {
|
|
100
|
+
return {
|
|
101
|
+
provider: "linear",
|
|
102
|
+
verifier: { kind: "config", config: LINEAR_VERIFIER },
|
|
103
|
+
secretProvisioning: "provider",
|
|
104
|
+
setupPrompt: LINEAR_SETUP_PROMPT,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Linear agent sessions as a chat frontend for an agent. List on `chat.agent({ channels: [linear({...})] })`:
|
|
109
|
+
* when a user mentions the app or delegates an issue to it, Linear opens an agent session, the connector
|
|
110
|
+
* routes it to a durable session keyed on the agent session id, runs the prompt as a turn, and posts the
|
|
111
|
+
* reply back as a `response` activity. Install the OAuth app with `actor=app`, request the
|
|
112
|
+
* `app:assignable` and `app:mentionable` scopes, enable the "Agent session events" webhook category, and
|
|
113
|
+
* set the endpoint's signing secret to the webhook signing secret.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* const linearEvents = webhooks.endpoint.define({ id: "linear", source: webhookSource() });
|
|
118
|
+
* export const agent = chat.agent({
|
|
119
|
+
* id: "triage",
|
|
120
|
+
* channels: [linear({ id: "linear", endpoint: linearEvents, token: process.env.LINEAR_TOKEN! })],
|
|
121
|
+
* run: async ({ messages, streamText }) => streamText({ model, messages }),
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
function linear(options) {
|
|
126
|
+
const apiUrl = options.apiUrl ?? LINEAR_GRAPHQL_URL;
|
|
127
|
+
if (!options.endpoint?.id) {
|
|
128
|
+
throw new Error(`linear("${options.id}"): an endpoint is required. Declare one with webhooks.endpoint.define({ id, source: webhookSource() }) and pass it as { endpoint }.`);
|
|
129
|
+
}
|
|
130
|
+
if (options.endpoint.source !== "linear") {
|
|
131
|
+
throw new Error(`linear("${options.id}"): endpoint "${options.endpoint.id}" has source "${options.endpoint.source}". Declare it with webhooks.endpoint.define({ id, source: webhookSource() }) from @trigger.dev/linear.`);
|
|
132
|
+
}
|
|
133
|
+
const filter = options.filter
|
|
134
|
+
? `${AGENT_SESSION_GUARD} && (${options.filter})`
|
|
135
|
+
: AGENT_SESSION_GUARD;
|
|
136
|
+
const context = {
|
|
137
|
+
guidance: options.context?.guidance ?? true,
|
|
138
|
+
promptContext: options.context?.promptContext ?? true,
|
|
139
|
+
previousComments: options.context?.previousComments ?? true,
|
|
140
|
+
};
|
|
141
|
+
const ack = options.ack === null
|
|
142
|
+
? undefined
|
|
143
|
+
: (options.ack ??
|
|
144
|
+
((_event, ctx) => ({
|
|
145
|
+
text: ctx.recovered ? "Picking this back up..." : "Looking into this...",
|
|
146
|
+
activity: { type: "thought", ephemeral: true },
|
|
147
|
+
})));
|
|
148
|
+
const api = makeLinearApi(options.token, apiUrl);
|
|
149
|
+
const uploadsOrigin = apiUrl === LINEAR_GRAPHQL_URL ? undefined : new URL(apiUrl).origin;
|
|
150
|
+
const receivedThought = options.receivedThought === undefined ? DEFAULT_RECEIVED_THOUGHT : options.receivedThought;
|
|
151
|
+
const summarize = options.summarize ?? false;
|
|
152
|
+
const outbound = options.outbound ?? defaultLinearOutbound;
|
|
153
|
+
return ai_1.chat.channels.custom({
|
|
154
|
+
id: options.id,
|
|
155
|
+
endpoint: options.endpoint,
|
|
156
|
+
key: options.key ?? DEFAULT_KEY,
|
|
157
|
+
inbound: options.inbound ??
|
|
158
|
+
((event) => defaultLinearInbound(event, {
|
|
159
|
+
...context,
|
|
160
|
+
uploadsOrigin,
|
|
161
|
+
})),
|
|
162
|
+
bind: linearSessionHandle,
|
|
163
|
+
...(context.previousComments
|
|
164
|
+
? {
|
|
165
|
+
seed: (event) => seedFromPreviousComments(event),
|
|
166
|
+
}
|
|
167
|
+
: {}),
|
|
168
|
+
outbound: summarize ? withSummary(outbound) : outbound,
|
|
169
|
+
ack,
|
|
170
|
+
send: makeLinearSend(api),
|
|
171
|
+
...(options.progress === "off" ? {} : { progress: makeLinearProgress(api) }),
|
|
172
|
+
surface: makeLinearSurface(api),
|
|
173
|
+
renderInteraction: makeLinearRenderInteraction(options.approvals, options.interactions),
|
|
174
|
+
onInteraction: defaultLinearOnInteraction,
|
|
175
|
+
finalizeInteraction: makeLinearFinalizeInteraction(api),
|
|
176
|
+
onControl: linearControl,
|
|
177
|
+
download: makeLinearDownload(api, uploadsOrigin),
|
|
178
|
+
...(receivedThought === null
|
|
179
|
+
? {}
|
|
180
|
+
: {
|
|
181
|
+
received: async (ctx) => {
|
|
182
|
+
const text = typeof receivedThought === "function"
|
|
183
|
+
? receivedThought(ctx.event)
|
|
184
|
+
: receivedThought;
|
|
185
|
+
if (!text)
|
|
186
|
+
return;
|
|
187
|
+
await api.activity(ctx, {
|
|
188
|
+
type: "thought",
|
|
189
|
+
text,
|
|
190
|
+
id: linearActivityId("received", ctx.deliveryId),
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
filter: filter,
|
|
195
|
+
startOn: options.startOn,
|
|
196
|
+
delivery: options.delivery ?? "final",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** The agent session an event belongs to, for `bind` and for posting. */
|
|
200
|
+
function linearSessionHandle(event) {
|
|
201
|
+
const agentSessionId = event.agentSession?.id;
|
|
202
|
+
if (!agentSessionId)
|
|
203
|
+
return null;
|
|
204
|
+
const issueId = event.agentSession.issueId ?? event.agentSession.issue?.id ?? undefined;
|
|
205
|
+
const issueIdentifier = event.agentSession.issue?.identifier;
|
|
206
|
+
return {
|
|
207
|
+
agentSessionId,
|
|
208
|
+
...(event.organizationId ? { organizationId: event.organizationId } : {}),
|
|
209
|
+
...(issueId ? { issueId } : {}),
|
|
210
|
+
...(issueIdentifier ? { issueIdentifier } : {}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* True when the session came from an @mention in a comment. A delegation (the issue assigned to the
|
|
215
|
+
* app) has no `sourceCommentId`; Linear still attaches a placeholder comment ("This thread is for an
|
|
216
|
+
* agent session with ...") to it, so `comment` alone does not tell the two apart.
|
|
217
|
+
*/
|
|
218
|
+
function isMention(event) {
|
|
219
|
+
return !!event.agentSession?.sourceCommentId;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* A Stop pressed in Linear while a turn is running aborts that turn: the partial reply posts as the
|
|
223
|
+
* response and the stop prompt is consumed. Pressed while idle, the same prompt runs as a turn and
|
|
224
|
+
* `defaultLinearInbound` turns it into a wind-down instruction. The stop is dated with the
|
|
225
|
+
* activity's `createdAt`, so one pressed while a turn ran but delivered after it ended is consumed
|
|
226
|
+
* rather than run as a wind-down.
|
|
227
|
+
*/
|
|
228
|
+
function linearControl(event) {
|
|
229
|
+
if (!isStopSignal(event))
|
|
230
|
+
return null;
|
|
231
|
+
const pressedAt = Date.parse(event.agentActivity?.createdAt ?? "");
|
|
232
|
+
return {
|
|
233
|
+
kind: "stop",
|
|
234
|
+
message: "stopped from Linear",
|
|
235
|
+
...(Number.isFinite(pressedAt) ? { at: pressedAt } : {}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/** True when the user asked the agent to halt (`prompted` with the `stop` signal). */
|
|
239
|
+
function isStopSignal(event) {
|
|
240
|
+
return event.action === "prompted" && event.agentActivity?.signal === "stop";
|
|
241
|
+
}
|
|
242
|
+
const STOP_MESSAGE = "The user asked you to stop. Do not take any further actions. Reply with a short summary of what you did and what is left.";
|
|
243
|
+
function userLabel(user, userId) {
|
|
244
|
+
const name = user?.displayName ?? user?.name;
|
|
245
|
+
if (name && name.includes("@") && user?.url) {
|
|
246
|
+
const handle = user.url.split("/").filter(Boolean).pop();
|
|
247
|
+
if (handle)
|
|
248
|
+
return handle;
|
|
249
|
+
}
|
|
250
|
+
return name ?? userId ?? "a user";
|
|
251
|
+
}
|
|
252
|
+
function guidanceText(guidance) {
|
|
253
|
+
if (!guidance)
|
|
254
|
+
return "";
|
|
255
|
+
if (typeof guidance === "string")
|
|
256
|
+
return guidance.trim();
|
|
257
|
+
return guidance
|
|
258
|
+
.map((rule) => (typeof rule?.body === "string" ? rule.body.trim() : ""))
|
|
259
|
+
.filter(Boolean)
|
|
260
|
+
.join("\n\n");
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Default inbound: the user's prompt, framed so the model can tell Linear-supplied context from
|
|
264
|
+
* instructions. `created` carries the issue context and the mention comment (or an assignment notice
|
|
265
|
+
* when the session came from delegation); `prompted` carries just the new message. A `stop` signal
|
|
266
|
+
* becomes an instruction to wind down.
|
|
267
|
+
*/
|
|
268
|
+
function defaultLinearInbound(event, context = {
|
|
269
|
+
guidance: true,
|
|
270
|
+
promptContext: true,
|
|
271
|
+
}) {
|
|
272
|
+
const inbound = linearInboundText(event, context);
|
|
273
|
+
const markdown = event.action === "prompted"
|
|
274
|
+
? isStopSignal(event)
|
|
275
|
+
? ""
|
|
276
|
+
: (event.agentActivity?.content?.body ?? "")
|
|
277
|
+
: [
|
|
278
|
+
isMention(event) ? (event.agentSession?.comment?.body ?? "") : "",
|
|
279
|
+
context.promptContext ? (event.promptContext ?? "") : "",
|
|
280
|
+
].join("\n");
|
|
281
|
+
const files = linearFileParts(markdown, { uploadsOrigin: context.uploadsOrigin });
|
|
282
|
+
if (files.length === 0)
|
|
283
|
+
return inbound;
|
|
284
|
+
return {
|
|
285
|
+
...inbound,
|
|
286
|
+
message: {
|
|
287
|
+
id: "",
|
|
288
|
+
role: "user",
|
|
289
|
+
parts: [{ type: "text", text: inbound.message }, ...files],
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function linearInboundText(event, context) {
|
|
294
|
+
const session = event.agentSession;
|
|
295
|
+
const issue = session?.issue;
|
|
296
|
+
const issueLabel = issue?.identifier
|
|
297
|
+
? `${issue.identifier}${issue.title ? ` "${issue.title}"` : ""}`
|
|
298
|
+
: session?.issueId
|
|
299
|
+
? `issue ${session.issueId}`
|
|
300
|
+
: "this issue";
|
|
301
|
+
if (event.action === "prompted") {
|
|
302
|
+
if (isStopSignal(event)) {
|
|
303
|
+
return {
|
|
304
|
+
message: STOP_MESSAGE,
|
|
305
|
+
externalUserId: event.agentActivity?.userId ?? undefined,
|
|
306
|
+
externalMessageId: event.agentActivity?.id,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const body = event.agentActivity?.content?.body ?? "";
|
|
310
|
+
return {
|
|
311
|
+
message: body,
|
|
312
|
+
externalUserId: event.agentActivity?.userId ?? undefined,
|
|
313
|
+
externalMessageId: event.agentActivity?.id,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const who = userLabel(session?.creator, session?.creatorId);
|
|
317
|
+
const sections = [];
|
|
318
|
+
const comment = isMention(event) ? session?.comment?.body?.trim() : undefined;
|
|
319
|
+
sections.push(comment ? `${who} mentioned you on ${issueLabel}.` : `${who} assigned ${issueLabel} to you.`);
|
|
320
|
+
const guidance = context.guidance ? guidanceText(event.guidance) : "";
|
|
321
|
+
if (guidance)
|
|
322
|
+
sections.push(`Workspace guidance:\n${guidance}`);
|
|
323
|
+
if (context.promptContext && event.promptContext) {
|
|
324
|
+
sections.push(`Issue context:\n${event.promptContext.trim()}`);
|
|
325
|
+
}
|
|
326
|
+
if (comment)
|
|
327
|
+
sections.push(`${who}'s comment:\n${comment}`);
|
|
328
|
+
return {
|
|
329
|
+
message: sections.join("\n\n"),
|
|
330
|
+
externalUserId: session?.creatorId ?? session?.comment?.userId ?? undefined,
|
|
331
|
+
externalMessageId: session?.commentId ?? session?.id,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const LINEAR_UPLOADS_HOST = "uploads.linear.app";
|
|
335
|
+
/** Most files taken from one message, so an issue with dozens of screenshots stays affordable. */
|
|
336
|
+
const MAX_LINEAR_FILES = 10;
|
|
337
|
+
const MAX_LINEAR_FILE_BYTES = 20 * 1024 * 1024;
|
|
338
|
+
const LINEAR_FILE_TIMEOUT_MS = 30_000;
|
|
339
|
+
const DEFAULT_RECEIVED_THOUGHT = "Got it, I'll take that into account.";
|
|
340
|
+
const LINEAR_IMAGE = /<linear-image\b[^>]*>([\s\S]*?)<\/linear-image>/g;
|
|
341
|
+
function isLinearUpload(url, uploadsOrigin) {
|
|
342
|
+
return ((url.protocol === "https:" && url.hostname === LINEAR_UPLOADS_HOST) ||
|
|
343
|
+
url.origin === uploadsOrigin);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Images and PDFs a Linear body links to on Linear's upload host, as file parts for the model: a
|
|
347
|
+
* `<linear-image>` tag (how Linear embeds images in the issue context) or `` as an image,
|
|
348
|
+
* and `[name.pdf](url)` as a PDF. A signed URL's short-lived `signature` is dropped; `linear()`
|
|
349
|
+
* downloads the file with the app's token when the model reads it. The default `inbound` adds them
|
|
350
|
+
* for the prompt, the mention comment and the issue context; use it in a custom `inbound` to keep
|
|
351
|
+
* that. At most ten per message.
|
|
352
|
+
*/
|
|
353
|
+
function linearFileParts(markdown, options = {}) {
|
|
354
|
+
const text = markdown ?? "";
|
|
355
|
+
const found = [];
|
|
356
|
+
for (const match of text.matchAll(LINEAR_IMAGE)) {
|
|
357
|
+
try {
|
|
358
|
+
const node = JSON.parse(match[1]);
|
|
359
|
+
if (node.attrs?.src) {
|
|
360
|
+
found.push({
|
|
361
|
+
index: match.index,
|
|
362
|
+
href: node.attrs.src,
|
|
363
|
+
name: node.attrs.alt ?? "",
|
|
364
|
+
image: true,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const outsideTags = text.replace(LINEAR_IMAGE, (tag) => " ".repeat(tag.length));
|
|
373
|
+
for (const match of outsideTags.matchAll(/(!?)\[([^\]]*)\]\((\S+?)(?:\s+"[^"]*")?\)/g)) {
|
|
374
|
+
const [, bang, label, href] = match;
|
|
375
|
+
const name = label?.trim() ?? "";
|
|
376
|
+
if (bang || /\.pdf$/i.test(name)) {
|
|
377
|
+
found.push({ index: match.index, href: href, name, image: !!bang });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const parts = [];
|
|
381
|
+
const seen = new Set();
|
|
382
|
+
for (const file of found.sort((a, b) => a.index - b.index)) {
|
|
383
|
+
if (parts.length >= MAX_LINEAR_FILES)
|
|
384
|
+
break;
|
|
385
|
+
let url;
|
|
386
|
+
try {
|
|
387
|
+
url = new URL(file.href);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (!isLinearUpload(url, options.uploadsOrigin))
|
|
393
|
+
continue;
|
|
394
|
+
url.searchParams.delete("signature");
|
|
395
|
+
if (seen.has(url.href))
|
|
396
|
+
continue;
|
|
397
|
+
seen.add(url.href);
|
|
398
|
+
parts.push(file.image
|
|
399
|
+
? { type: "file", mediaType: "image/*", url: url.href, filename: file.name || "image" }
|
|
400
|
+
: { type: "file", mediaType: "application/pdf", url: url.href, filename: file.name });
|
|
401
|
+
}
|
|
402
|
+
return parts;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Download a Linear upload with the app's token, for the model. Null for URLs that are not Linear
|
|
406
|
+
* uploads (or, with `apiUrl` overridden, not on that origin).
|
|
407
|
+
*/
|
|
408
|
+
function makeLinearDownload(api, uploadsOrigin) {
|
|
409
|
+
return async (url, ctx) => {
|
|
410
|
+
if (!isLinearUpload(url, uploadsOrigin))
|
|
411
|
+
return null;
|
|
412
|
+
const token = await api.tokenFor({ handle: ctx.handle });
|
|
413
|
+
const res = await fetch(url, {
|
|
414
|
+
headers: { authorization: token.startsWith("Bearer ") ? token : `Bearer ${token}` },
|
|
415
|
+
signal: AbortSignal.timeout(LINEAR_FILE_TIMEOUT_MS),
|
|
416
|
+
});
|
|
417
|
+
if (!res.ok)
|
|
418
|
+
throw new Error(`Linear file download failed: ${res.status}`);
|
|
419
|
+
const mediaType = res.headers.get("content-type")?.split(";")[0]?.trim() || undefined;
|
|
420
|
+
if (mediaType === "text/html") {
|
|
421
|
+
throw new Error("Linear returned a page instead of the file; check the app's token");
|
|
422
|
+
}
|
|
423
|
+
const length = Number(res.headers.get("content-length") ?? 0);
|
|
424
|
+
if (length > MAX_LINEAR_FILE_BYTES)
|
|
425
|
+
throw new Error(`Linear file is too large (${length} bytes)`);
|
|
426
|
+
const data = new Uint8Array(await res.arrayBuffer());
|
|
427
|
+
if (data.byteLength > MAX_LINEAR_FILE_BYTES) {
|
|
428
|
+
throw new Error(`Linear file is too large (${data.byteLength} bytes)`);
|
|
429
|
+
}
|
|
430
|
+
return { data, ...(mediaType ? { mediaType } : {}) };
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/** Prior comments from the payload become transcript messages ahead of the triggering one. */
|
|
434
|
+
function seedFromPreviousComments(event) {
|
|
435
|
+
if (event.action !== "created")
|
|
436
|
+
return [];
|
|
437
|
+
const appUserId = event.appUserId ?? event.agentSession?.appUserId;
|
|
438
|
+
return (event.previousComments ?? [])
|
|
439
|
+
.filter((c) => typeof c?.body === "string" && c.body.trim().length > 0)
|
|
440
|
+
.map((c) => {
|
|
441
|
+
const fromApp = !!appUserId && c.userId === appUserId;
|
|
442
|
+
return {
|
|
443
|
+
role: fromApp ? "assistant" : "user",
|
|
444
|
+
text: fromApp ? c.body.trim() : `${userLabel(c.user, c.userId)}: ${c.body.trim()}`,
|
|
445
|
+
...(c.id ? { externalMessageId: c.id } : {}),
|
|
446
|
+
...(c.userId ? { externalUserId: c.userId } : {}),
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* A reply cut short by Stop: close a code fence the cut left open so the rest of the session renders,
|
|
452
|
+
* and say it was stopped.
|
|
453
|
+
*/
|
|
454
|
+
function stoppedMarkdown(text) {
|
|
455
|
+
const trimmed = toLinearMarkdown(text);
|
|
456
|
+
const fences = (trimmed.match(/^\s*```/gm) ?? []).length;
|
|
457
|
+
const closed = fences % 2 === 1 ? `${trimmed}\n\`\`\`` : trimmed;
|
|
458
|
+
return `${closed}\n\n_Stopped._`;
|
|
459
|
+
}
|
|
460
|
+
/** Linear renders GitHub-flavored markdown and turns bare Linear URLs into mentions. Trims only. */
|
|
461
|
+
function toLinearMarkdown(text) {
|
|
462
|
+
return text.replace(/\s+$/, "");
|
|
463
|
+
}
|
|
464
|
+
function defaultLinearOutbound(reply) {
|
|
465
|
+
if (reply.error) {
|
|
466
|
+
const text = reply.text?.trim() || "Something went wrong while working on this.";
|
|
467
|
+
return { text, activity: { type: "error" } };
|
|
468
|
+
}
|
|
469
|
+
if (reply.stopped) {
|
|
470
|
+
return {
|
|
471
|
+
text: reply.text ? stoppedMarkdown(reply.text) : "_Stopped._",
|
|
472
|
+
activity: { type: "response" },
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (!reply.text)
|
|
476
|
+
return null;
|
|
477
|
+
if (!reply.final) {
|
|
478
|
+
return {
|
|
479
|
+
text: toLinearMarkdown(reply.text),
|
|
480
|
+
activity: { type: "thought", ephemeral: true },
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (reply.pending) {
|
|
484
|
+
return { text: toLinearMarkdown(reply.text), activity: { type: "thought" } };
|
|
485
|
+
}
|
|
486
|
+
return { text: toLinearMarkdown(reply.text), activity: { type: "response" } };
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Resolves where to post and with which token. A channel-started turn posts into the event's agent
|
|
490
|
+
* session; a bound turn (no event) posts into the session the handle names. A token resolver receives
|
|
491
|
+
* the event when there is one and otherwise a minimal event carrying the bound organization, so a
|
|
492
|
+
* resolver keyed on `organizationId` keeps working.
|
|
493
|
+
*/
|
|
494
|
+
function makeLinearApi(token, apiUrl) {
|
|
495
|
+
const handleFor = (ctx) => {
|
|
496
|
+
const fromEvent = ctx.event
|
|
497
|
+
? linearSessionHandle(ctx.event)
|
|
498
|
+
: null;
|
|
499
|
+
const handle = fromEvent ?? ctx.handle;
|
|
500
|
+
if (!handle) {
|
|
501
|
+
throw new Error("linear: no agent session to post into (no event and no session binding)");
|
|
502
|
+
}
|
|
503
|
+
return handle;
|
|
504
|
+
};
|
|
505
|
+
const tokenFor = async (ctx) => {
|
|
506
|
+
if (typeof token !== "function")
|
|
507
|
+
return token;
|
|
508
|
+
const handle = handleFor(ctx);
|
|
509
|
+
const tokenEvent = (ctx.event ??
|
|
510
|
+
{
|
|
511
|
+
type: "AgentSessionEvent",
|
|
512
|
+
action: "bound",
|
|
513
|
+
organizationId: handle.organizationId,
|
|
514
|
+
agentSession: { id: handle.agentSessionId, issueId: handle.issueId },
|
|
515
|
+
});
|
|
516
|
+
return token(tokenEvent);
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
handleFor,
|
|
520
|
+
tokenFor,
|
|
521
|
+
activity: async (ctx, spec) => createAgentActivity(apiUrl, await tokenFor(ctx), handleFor(ctx).agentSessionId, spec),
|
|
522
|
+
updateSession: async (ctx, input) => updateAgentSession(apiUrl, await tokenFor(ctx), handleFor(ctx).agentSessionId, input),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* A stable, UUID-shaped id for one logical activity, derived from what identifies it (the session,
|
|
527
|
+
* the delivery, the activity kind, its key and, for sends, its content), so a turn that retries after
|
|
528
|
+
* Linear accepted the activity recreates the same one instead of a duplicate. Undefined when any part
|
|
529
|
+
* is missing, which makes Linear assign an id and accept a duplicate: the case for a bound turn with
|
|
530
|
+
* no delivery.
|
|
531
|
+
*/
|
|
532
|
+
function linearActivityId(...parts) {
|
|
533
|
+
if (parts.some((part) => part === undefined || part === ""))
|
|
534
|
+
return undefined;
|
|
535
|
+
const hex = (0, node_crypto_1.createHash)("sha256").update(parts.join("\u001f")).digest("hex");
|
|
536
|
+
const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
|
|
537
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
538
|
+
}
|
|
539
|
+
function makeLinearSend(api) {
|
|
540
|
+
return async (message, ctx) => {
|
|
541
|
+
const msg = message;
|
|
542
|
+
const specs = msg.activities && msg.activities.length > 0
|
|
543
|
+
? msg.activities
|
|
544
|
+
: [{ ...(msg.activity ?? { type: "response" }), text: msg.text }];
|
|
545
|
+
const sessionId = api.handleFor(ctx).agentSessionId;
|
|
546
|
+
let lastId;
|
|
547
|
+
for (const [index, spec] of specs.entries()) {
|
|
548
|
+
// Keyed on the content as well as the position: a retry of the same send derives the same id,
|
|
549
|
+
// while the ack, each streamed snapshot, a final response and an error text on one delivery all
|
|
550
|
+
// differ in text or type and so stay distinct activities.
|
|
551
|
+
const id = spec.id ?? linearActivityId("send", sessionId, ctx.deliveryId, index, spec.type, spec.text);
|
|
552
|
+
const result = await api.activity(ctx, id ? { ...spec, id } : spec);
|
|
553
|
+
lastId = result.id ?? lastId;
|
|
554
|
+
}
|
|
555
|
+
if (msg.summary) {
|
|
556
|
+
try {
|
|
557
|
+
await api.updateSession(ctx, { summary: msg.summary });
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
sdk_1.logger.warn("linear: session summary update failed; the reply was posted without it", {
|
|
561
|
+
error: error instanceof Error ? error.message : String(error),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return { ref: lastId };
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/** Wrap an outbound so a final response also sets the session summary from its first line. */
|
|
569
|
+
function withSummary(outbound) {
|
|
570
|
+
return (reply) => {
|
|
571
|
+
const msg = outbound(reply);
|
|
572
|
+
if (!msg ||
|
|
573
|
+
!reply.final ||
|
|
574
|
+
reply.error ||
|
|
575
|
+
msg.summary ||
|
|
576
|
+
(msg.activity?.type ?? "response") !== "response") {
|
|
577
|
+
return msg;
|
|
578
|
+
}
|
|
579
|
+
const summary = summaryLine(msg.text);
|
|
580
|
+
return summary ? { ...msg, summary } : msg;
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The first non-empty line of a reply, stripped of markdown markers and control characters, capped at
|
|
585
|
+
* 255 characters: what Linear accepts as a session summary. Undefined when nothing usable is left.
|
|
586
|
+
*/
|
|
587
|
+
function summaryLine(text) {
|
|
588
|
+
const line = text
|
|
589
|
+
.split("\n")
|
|
590
|
+
.map((l) => l
|
|
591
|
+
.replace(/^[#>*\-\s]+/, "")
|
|
592
|
+
.replace(/[*_`]/g, "")
|
|
593
|
+
.replace(/\p{Cc}/gu, " ")
|
|
594
|
+
.replace(/\s+/g, " ")
|
|
595
|
+
.trim())
|
|
596
|
+
.find((l) => l.length > 0);
|
|
597
|
+
if (!line)
|
|
598
|
+
return undefined;
|
|
599
|
+
return line.length > 255 ? line.slice(0, 252) + "..." : line;
|
|
600
|
+
}
|
|
601
|
+
const MAX_ACTION_PARAMETER_CHARS = 500;
|
|
602
|
+
const MAX_ACTION_RESULT_CHARS = 1500;
|
|
603
|
+
function compact(value, max) {
|
|
604
|
+
const text = typeof value === "string" ? value : safeStringify(value);
|
|
605
|
+
return text.length > max ? text.slice(0, max - 3) + "..." : text;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Mirror tool calls into the session: an ephemeral `action` when the call's input is ready, then the
|
|
609
|
+
* same action with its `result` (or the error) when the tool returns.
|
|
610
|
+
*/
|
|
611
|
+
function makeLinearProgress(api) {
|
|
612
|
+
return async (event, ctx) => {
|
|
613
|
+
if (event.toolName === exports.LINEAR_PLAN_TOOL_NAME)
|
|
614
|
+
return;
|
|
615
|
+
const id = linearActivityId("progress", api.handleFor(ctx).agentSessionId, ctx.deliveryId, event.toolCallId, event.type);
|
|
616
|
+
switch (event.type) {
|
|
617
|
+
case "tool-call":
|
|
618
|
+
await api.activity(ctx, {
|
|
619
|
+
id,
|
|
620
|
+
type: "action",
|
|
621
|
+
text: event.toolName,
|
|
622
|
+
action: event.toolName,
|
|
623
|
+
parameter: compact(event.input ?? "", MAX_ACTION_PARAMETER_CHARS),
|
|
624
|
+
ephemeral: true,
|
|
625
|
+
});
|
|
626
|
+
return;
|
|
627
|
+
case "tool-result":
|
|
628
|
+
await api.activity(ctx, {
|
|
629
|
+
id,
|
|
630
|
+
type: "action",
|
|
631
|
+
text: event.toolName,
|
|
632
|
+
action: event.toolName,
|
|
633
|
+
parameter: "",
|
|
634
|
+
result: compact(event.output ?? "", MAX_ACTION_RESULT_CHARS),
|
|
635
|
+
});
|
|
636
|
+
return;
|
|
637
|
+
case "tool-error":
|
|
638
|
+
await api.activity(ctx, {
|
|
639
|
+
id,
|
|
640
|
+
type: "action",
|
|
641
|
+
text: event.toolName,
|
|
642
|
+
action: event.toolName,
|
|
643
|
+
parameter: "",
|
|
644
|
+
result: `Error: ${compact(event.errorText, MAX_ACTION_RESULT_CHARS)}`,
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The helpers `run({ channel })` gets on a Linear turn. See {@link LinearRunSurface}. Activities
|
|
652
|
+
* posted from here carry no idempotency id: they are the run's own calls with no stable identity
|
|
653
|
+
* the connector can derive, so a retried turn that calls them again posts them again.
|
|
654
|
+
*/
|
|
655
|
+
function makeLinearSurface(api) {
|
|
656
|
+
return (ctx) => {
|
|
657
|
+
const post = (spec) => api.activity(ctx, spec).then(() => undefined);
|
|
658
|
+
return {
|
|
659
|
+
plan: (steps) => api.updateSession(ctx, { plan: steps }),
|
|
660
|
+
thought: (text, options) => post({ type: "thought", text, ephemeral: options?.ephemeral ?? false }),
|
|
661
|
+
action: (action, parameter, result) => post({ type: "action", text: action, action, parameter, result }),
|
|
662
|
+
link: (label, url) => api.updateSession(ctx, { addedExternalUrls: [{ label, url }] }),
|
|
663
|
+
summary: async (text) => {
|
|
664
|
+
const line = summaryLine(text);
|
|
665
|
+
if (line)
|
|
666
|
+
await api.updateSession(ctx, { summary: line });
|
|
667
|
+
},
|
|
668
|
+
session: api.handleFor(ctx),
|
|
669
|
+
};
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function activityContent(spec) {
|
|
673
|
+
switch (spec.type) {
|
|
674
|
+
case "action":
|
|
675
|
+
return {
|
|
676
|
+
type: "action",
|
|
677
|
+
action: spec.action ?? spec.text,
|
|
678
|
+
parameter: spec.parameter ?? "",
|
|
679
|
+
...(spec.result !== undefined ? { result: spec.result } : {}),
|
|
680
|
+
};
|
|
681
|
+
default:
|
|
682
|
+
return { type: spec.type, body: spec.text };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
const AGENT_ACTIVITY_CREATE = `mutation AgentActivityCreate($input: AgentActivityCreateInput!) {
|
|
686
|
+
agentActivityCreate(input: $input) { success agentActivity { id } }
|
|
687
|
+
}`;
|
|
688
|
+
async function createAgentActivity(apiUrl, accessToken, agentSessionId, spec) {
|
|
689
|
+
const input = {
|
|
690
|
+
agentSessionId,
|
|
691
|
+
content: activityContent(spec),
|
|
692
|
+
};
|
|
693
|
+
if (spec.ephemeral && (spec.type === "thought" || spec.type === "action"))
|
|
694
|
+
input.ephemeral = true;
|
|
695
|
+
if (spec.signal)
|
|
696
|
+
input.signal = spec.signal;
|
|
697
|
+
if (spec.signalMetadata)
|
|
698
|
+
input.signalMetadata = spec.signalMetadata;
|
|
699
|
+
if (spec.id)
|
|
700
|
+
input.id = spec.id;
|
|
701
|
+
const data = await linearGraphql(apiUrl, accessToken, AGENT_ACTIVITY_CREATE, { input });
|
|
702
|
+
if (data.agentActivityCreate?.success === false) {
|
|
703
|
+
throw new Error("linear agentActivityCreate failed: success=false");
|
|
704
|
+
}
|
|
705
|
+
return { id: data.agentActivityCreate?.agentActivity?.id ?? undefined };
|
|
706
|
+
}
|
|
707
|
+
const AGENT_SESSION_UPDATE = `mutation AgentSessionUpdate($id: String!, $input: AgentSessionUpdateInput!) {
|
|
708
|
+
agentSessionUpdate(id: $id, input: $input) { success }
|
|
709
|
+
}`;
|
|
710
|
+
async function updateAgentSession(apiUrl, accessToken, agentSessionId, input) {
|
|
711
|
+
const data = await linearGraphql(apiUrl, accessToken, AGENT_SESSION_UPDATE, { id: agentSessionId, input });
|
|
712
|
+
if (data.agentSessionUpdate?.success === false) {
|
|
713
|
+
throw new Error("linear agentSessionUpdate failed: success=false");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/** A minimal GraphQL call against Linear's API with the app's OAuth token. */
|
|
717
|
+
async function linearGraphql(apiUrl, accessToken, query, variables) {
|
|
718
|
+
const res = await fetch(apiUrl, {
|
|
719
|
+
method: "POST",
|
|
720
|
+
headers: {
|
|
721
|
+
"content-type": "application/json",
|
|
722
|
+
authorization: accessToken.startsWith("Bearer ") ? accessToken : `Bearer ${accessToken}`,
|
|
723
|
+
},
|
|
724
|
+
body: JSON.stringify({ query, variables }),
|
|
725
|
+
});
|
|
726
|
+
const body = (await res.json());
|
|
727
|
+
if (!res.ok || (body.errors && body.errors.length > 0)) {
|
|
728
|
+
const detail = body.errors?.map((e) => e.message ?? e.extensions?.code ?? "error").join("; ") ??
|
|
729
|
+
`HTTP ${res.status}`;
|
|
730
|
+
throw new Error(`linear graphql failed: ${detail}`);
|
|
731
|
+
}
|
|
732
|
+
if (!body.data)
|
|
733
|
+
throw new Error("linear graphql failed: empty response");
|
|
734
|
+
return body.data;
|
|
735
|
+
}
|
|
736
|
+
/** Cap for the serialized tool input in an elicitation body. */
|
|
737
|
+
const MAX_INTERACTION_INPUT_CHARS = 2500;
|
|
738
|
+
/** A select card for a tool with its own `interactions` entry; each option names the call it answers. */
|
|
739
|
+
function choiceCard(call, render, deliveryId) {
|
|
740
|
+
let rendered = null;
|
|
741
|
+
try {
|
|
742
|
+
rendered = render(call);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
sdk_1.logger.warn("linear: an interactions renderer threw; posting a note instead", {
|
|
746
|
+
toolName: call.toolName,
|
|
747
|
+
error: error instanceof Error ? error.message : String(error),
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
const id = linearActivityId("elicitation", deliveryId, call.toolCallId);
|
|
751
|
+
if (!rendered || rendered.options.length === 0) {
|
|
752
|
+
return {
|
|
753
|
+
id,
|
|
754
|
+
type: "elicitation",
|
|
755
|
+
text: `\`${call.toolName}\` is waiting for an answer, but its options could not be shown.`,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
id,
|
|
760
|
+
type: "elicitation",
|
|
761
|
+
text: rendered.title ? `**${rendered.title}**\n\n${rendered.body}` : rendered.body,
|
|
762
|
+
signal: "select",
|
|
763
|
+
signalMetadata: {
|
|
764
|
+
options: rendered.options.map((option) => ({
|
|
765
|
+
label: option,
|
|
766
|
+
value: `${option} [choice:${call.toolCallId}]`,
|
|
767
|
+
})),
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function makeLinearRenderInteraction(approvals, interactions) {
|
|
772
|
+
return (pending, ctx) => {
|
|
773
|
+
if (pending.length === 0)
|
|
774
|
+
return null;
|
|
775
|
+
const activities = pending.map((call) => {
|
|
776
|
+
const choice = interactions?.[call.toolName];
|
|
777
|
+
if (choice)
|
|
778
|
+
return choiceCard(call, choice, ctx.deliveryId);
|
|
779
|
+
const target = `${call.toolName} [${call.toolCallId}]`;
|
|
780
|
+
return {
|
|
781
|
+
id: linearActivityId("elicitation", ctx.deliveryId, call.toolCallId),
|
|
782
|
+
type: "elicitation",
|
|
783
|
+
text: approvalCard(call, approvals?.[call.toolName]),
|
|
784
|
+
signal: "select",
|
|
785
|
+
signalMetadata: {
|
|
786
|
+
options: [
|
|
787
|
+
{ label: "Approve", value: `Approve ${target}` },
|
|
788
|
+
{ label: "Deny", value: `Deny ${target}` },
|
|
789
|
+
],
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
return {
|
|
794
|
+
text: pending.length === 1
|
|
795
|
+
? `Approval needed: ${pending[0].toolName}`
|
|
796
|
+
: `Approval needed: ${pending.length} tool calls`,
|
|
797
|
+
activities,
|
|
798
|
+
};
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
/** The markdown body of one approval card: a custom renderer's output, else the input laid out. */
|
|
802
|
+
function approvalCard(call, custom) {
|
|
803
|
+
if (custom) {
|
|
804
|
+
try {
|
|
805
|
+
const rendered = custom(call.input, call);
|
|
806
|
+
if (rendered) {
|
|
807
|
+
return rendered.title ? `**${rendered.title}**\n\n${rendered.body}` : rendered.body;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
sdk_1.logger.warn("linear: approvals renderer threw; falling back to the default card", {
|
|
812
|
+
toolName: call.toolName,
|
|
813
|
+
error: error instanceof Error ? error.message : String(error),
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const heading = `**Approval needed:** \`${call.toolName}\``;
|
|
818
|
+
if (call.input === undefined)
|
|
819
|
+
return heading;
|
|
820
|
+
const body = inputToMarkdown(call.input);
|
|
821
|
+
const shown = body.length > MAX_INTERACTION_INPUT_CHARS
|
|
822
|
+
? body.slice(0, MAX_INTERACTION_INPUT_CHARS) + "\n\n... (truncated)"
|
|
823
|
+
: body;
|
|
824
|
+
return `${heading}\n\n${shown}`;
|
|
825
|
+
}
|
|
826
|
+
function isPlainObject(v) {
|
|
827
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
828
|
+
}
|
|
829
|
+
function labelFor(key) {
|
|
830
|
+
const spaced = key
|
|
831
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
832
|
+
.replace(/[_-]+/g, " ")
|
|
833
|
+
.toLowerCase();
|
|
834
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
835
|
+
}
|
|
836
|
+
const TITLE_KEYS = ["title", "name", "summary", "subject", "label", "id"];
|
|
837
|
+
function scalar(v) {
|
|
838
|
+
if (typeof v === "string")
|
|
839
|
+
return v;
|
|
840
|
+
if (typeof v === "number" || typeof v === "boolean" || v === null)
|
|
841
|
+
return String(v);
|
|
842
|
+
return safeStringify(v);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Lay a tool input out as markdown for a person to read: an array of objects becomes a numbered list
|
|
846
|
+
* with a bold title line and the other fields under it, a flat object becomes key and value bullets,
|
|
847
|
+
* an array of scalars a bullet list. Anything deeper falls back to a JSON block.
|
|
848
|
+
*/
|
|
849
|
+
function inputToMarkdown(input) {
|
|
850
|
+
if (isPlainObject(input)) {
|
|
851
|
+
const keys = Object.keys(input);
|
|
852
|
+
const arrayKeys = keys.filter((k) => Array.isArray(input[k]));
|
|
853
|
+
if (keys.length === 1 && arrayKeys.length === 1) {
|
|
854
|
+
const key = keys[0];
|
|
855
|
+
return `${labelFor(key)}\n\n${inputToMarkdown(input[key])}`;
|
|
856
|
+
}
|
|
857
|
+
const lines = [];
|
|
858
|
+
for (const key of keys) {
|
|
859
|
+
const value = input[key];
|
|
860
|
+
if (Array.isArray(value)) {
|
|
861
|
+
lines.push(`- **${labelFor(key)}**`);
|
|
862
|
+
lines.push(inputToMarkdown(value)
|
|
863
|
+
.split("\n")
|
|
864
|
+
.map((l) => (l ? ` ${l}` : l))
|
|
865
|
+
.join("\n"));
|
|
866
|
+
}
|
|
867
|
+
else if (isPlainObject(value)) {
|
|
868
|
+
lines.push(`- **${labelFor(key)}**`);
|
|
869
|
+
for (const [k2, v2] of Object.entries(value)) {
|
|
870
|
+
lines.push(` - ${labelFor(k2)}: ${scalar(v2)}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
lines.push(`- **${labelFor(key)}:** ${scalar(value)}`);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return lines.join("\n");
|
|
878
|
+
}
|
|
879
|
+
if (Array.isArray(input)) {
|
|
880
|
+
if (input.every((item) => !isPlainObject(item) && !Array.isArray(item))) {
|
|
881
|
+
return input.map((item) => `- ${scalar(item)}`).join("\n");
|
|
882
|
+
}
|
|
883
|
+
if (input.every(isPlainObject)) {
|
|
884
|
+
return input
|
|
885
|
+
.map((item, i) => {
|
|
886
|
+
const obj = item;
|
|
887
|
+
const titleKey = TITLE_KEYS.find((k) => typeof obj[k] === "string");
|
|
888
|
+
const title = titleKey ? String(obj[titleKey]) : `Item ${i + 1}`;
|
|
889
|
+
const rest = Object.entries(obj).filter(([k]) => k !== titleKey);
|
|
890
|
+
const details = rest.map(([k, v]) => isPlainObject(v) || Array.isArray(v)
|
|
891
|
+
? ` - ${labelFor(k)}: \`${safeStringify(v)}\``
|
|
892
|
+
: rest.length === 1 && typeof v === "string"
|
|
893
|
+
? ` ${v}`
|
|
894
|
+
: ` - ${labelFor(k)}: ${scalar(v)}`);
|
|
895
|
+
return [`${i + 1}. **${title}**`, ...details].join("\n");
|
|
896
|
+
})
|
|
897
|
+
.join("\n");
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (input === undefined)
|
|
901
|
+
return "";
|
|
902
|
+
if (!isPlainObject(input) && !Array.isArray(input))
|
|
903
|
+
return scalar(input);
|
|
904
|
+
return "```json\n" + safeStringify(input) + "\n```";
|
|
905
|
+
}
|
|
906
|
+
const DECISION_VALUE = /^\s*(Approve|Deny)\s+([A-Za-z0-9_.-]+)(?:\s+\[([^\]\s]+)\])?\s*$/;
|
|
907
|
+
/** Linear stores prompts as markdown, so `[` in an option value can come back as `\[`. */
|
|
908
|
+
const MARKDOWN_ESCAPE = /\\([\\`*_{}[\]()#+\-.!>|~])/g;
|
|
909
|
+
const CHOICE_VALUE = /^\s*([\s\S]+?)\s+\[choice:([^\]\s]+)\]\s*$/;
|
|
910
|
+
/**
|
|
911
|
+
* Default interaction resolver: a `prompted` activity whose body is one of the option values ("Approve
|
|
912
|
+
* createSubIssues", or "Approve createSubIssues [call_a]" when the card named the call) resolves that
|
|
913
|
+
* tool call to `{ approved }`. Free text (or a `stop` signal) returns null and is handled as a normal
|
|
914
|
+
* inbound message.
|
|
915
|
+
*/
|
|
916
|
+
function defaultLinearOnInteraction(event) {
|
|
917
|
+
const e = event;
|
|
918
|
+
if (e?.action !== "prompted" || isStopSignal(e))
|
|
919
|
+
return null;
|
|
920
|
+
const raw = e.agentActivity?.content?.body;
|
|
921
|
+
if (typeof raw !== "string")
|
|
922
|
+
return null;
|
|
923
|
+
const body = CHOICE_VALUE.test(raw) || DECISION_VALUE.test(raw) ? raw : raw.replace(MARKDOWN_ESCAPE, "$1");
|
|
924
|
+
const choice = CHOICE_VALUE.exec(body);
|
|
925
|
+
if (choice)
|
|
926
|
+
return { toolCallId: choice[2], output: { selected: choice[1] } };
|
|
927
|
+
const m = DECISION_VALUE.exec(body);
|
|
928
|
+
if (!m)
|
|
929
|
+
return null;
|
|
930
|
+
return {
|
|
931
|
+
toolName: m[2],
|
|
932
|
+
...(m[3] ? { toolCallId: m[3] } : {}),
|
|
933
|
+
output: { approved: m[1] === "Approve" },
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* After a decision, post a short non-ephemeral thought recording it. Linear dismisses the select UI
|
|
938
|
+
* as soon as a newer activity arrives, so this both clears the card and leaves an audit line.
|
|
939
|
+
*/
|
|
940
|
+
function makeLinearFinalizeInteraction(api) {
|
|
941
|
+
return async (event, resolution) => {
|
|
942
|
+
const e = event;
|
|
943
|
+
if (!linearSessionHandle(e))
|
|
944
|
+
return;
|
|
945
|
+
const output = resolution.output;
|
|
946
|
+
const who = userLabel(e.agentActivity?.user, e.agentActivity?.userId);
|
|
947
|
+
const text = typeof output?.selected === "string"
|
|
948
|
+
? `${who} chose "${output.selected}".`
|
|
949
|
+
: `${output?.approved === true ? "Approved" : "Denied"} by ${who}.`;
|
|
950
|
+
await api.activity({ event }, { type: "thought", text });
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
function safeStringify(value) {
|
|
954
|
+
try {
|
|
955
|
+
return JSON.stringify(value, null, 2);
|
|
956
|
+
}
|
|
957
|
+
catch {
|
|
958
|
+
return String(value);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
//# sourceMappingURL=index.js.map
|