opencode-discord-notify 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-JP.md +6 -3
- package/README.md +6 -3
- package/dist/index.d.ts +64 -1
- package/dist/index.js +288 -299
- package/package.json +4 -2
package/README-JP.md
CHANGED
|
@@ -62,11 +62,14 @@ OpenCode を再起動してください。
|
|
|
62
62
|
- `DISCORD_WEBHOOK_COMPLETE_MENTION`: `session.idle` / `session.error` の通知本文に付けるメンション(`@everyone` または `@here` のみ許容。Forum webhook の仕様上、ping は常に発生しない)
|
|
63
63
|
- `DISCORD_WEBHOOK_PERMISSION_MENTION`: `permission.updated` の通知本文に付けるメンション(`DISCORD_WEBHOOK_COMPLETE_MENTION` へのフォールバックなし。`@everyone` または `@here` のみ許容。Forum webhook の仕様上、ping は常に発生しない)
|
|
64
64
|
- `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT`: `1` のとき input context(`<file>` から始まる user `text` part)を通知しない(デフォルト: `1` / `0` で無効化)
|
|
65
|
-
- `
|
|
65
|
+
- `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`: `1` のとき Discord webhook の送信が失敗した場合に OpenCode TUI のトーストを表示します(429 含む)(デフォルト: `1` / `0` で無効化)
|
|
66
|
+
- `DISCORD_SEND_PARAMS`: embed の fields として送るキーをカンマ区切りで指定。指定可能キー: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`。未設定・空文字・空要素のみの場合は全て選択。`session.created` は `DISCORD_SEND_PARAMS` に関わらず `sessionID`, `projectID`, `directory` を必ず含みます。
|
|
66
67
|
|
|
67
68
|
## 仕様メモ
|
|
68
69
|
|
|
69
|
-
- `DISCORD_WEBHOOK_URL` 未設定の場合は no-op
|
|
70
|
+
- `DISCORD_WEBHOOK_URL` 未設定の場合は no-op です。
|
|
71
|
+
- Discord webhook の送信が失敗した場合、OpenCode TUI のトーストを表示することがあります(`DISCORD_WEBHOOK_SHOW_ERROR_ALERT` で制御)。
|
|
72
|
+
- HTTP 429 の場合は `retry_after` があればそれを優先し、なければ 10 秒程度待って 1 回だけリトライし、それでも失敗した場合は warning トーストを表示します。
|
|
70
73
|
- Forum スレッド作成時は `?wait=true` を付け、レスポンスの `channel_id` を thread ID として利用します。
|
|
71
74
|
- スレッド名(`thread_name`)は以下の優先度です(最大100文字)。
|
|
72
75
|
1. 最初の user `text`
|
|
@@ -85,7 +88,7 @@ OpenCode を再起動してください。
|
|
|
85
88
|
- embed タイトルは `User says` / `Agent says` です
|
|
86
89
|
- `tool`: 通知しない
|
|
87
90
|
- `reasoning`: 通知しない(内部思考が含まれる可能性があるため)
|
|
88
|
-
- `
|
|
91
|
+
- `DISCORD_SEND_PARAMS` の制御対象は embed の fields のみです(title/description/content/timestamp などは対象外)。また `share` は fields としては送りません(Session started の embed URL には `shareUrl` を使います)。
|
|
89
92
|
|
|
90
93
|
## 動作確認(手動)
|
|
91
94
|
|
package/README.md
CHANGED
|
@@ -64,11 +64,14 @@ Optional:
|
|
|
64
64
|
- `DISCORD_WEBHOOK_COMPLETE_MENTION`: mention to put in `session.idle` / `session.error` messages (only `@everyone` or `@here` supported; Forum webhooks may not actually ping due to Discord behavior)
|
|
65
65
|
- `DISCORD_WEBHOOK_PERMISSION_MENTION`: mention to put in `permission.updated` messages (no fallback to `DISCORD_WEBHOOK_COMPLETE_MENTION`; only `@everyone` or `@here` supported; Forum webhooks may not actually ping due to Discord behavior)
|
|
66
66
|
- `DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT`: when set to `1`, exclude "input context" (user `text` parts that start with `<file>`) from notifications (default: `1`; set to `0` to disable)
|
|
67
|
-
- `
|
|
67
|
+
- `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`: when set to `1`, show an OpenCode TUI toast when Discord webhook requests fail (includes 429). (default: `1`; set to `0` to disable)
|
|
68
|
+
- `DISCORD_SEND_PARAMS`: comma-separated list of keys to include as embed fields. Allowed keys: `sessionID`, `permissionID`, `type`, `pattern`, `messageID`, `callID`, `partID`, `role`, `directory`, `projectID`. If unset, empty, or containing only empty elements, all keys are selected. `session.created` always includes `sessionID`, `projectID`, `directory` regardless.
|
|
68
69
|
|
|
69
70
|
## Notes / behavior
|
|
70
71
|
|
|
71
|
-
- If `DISCORD_WEBHOOK_URL` is not set, it becomes a no-op
|
|
72
|
+
- If `DISCORD_WEBHOOK_URL` is not set, it becomes a no-op.
|
|
73
|
+
- If a webhook request fails, it may show an OpenCode TUI toast (controlled by `DISCORD_WEBHOOK_SHOW_ERROR_ALERT`).
|
|
74
|
+
- On HTTP 429, it waits `retry_after` seconds if provided (otherwise ~10s) and retries once; if it still fails, it shows a warning toast.
|
|
72
75
|
- For Forum thread creation, it appends `?wait=true` and uses `channel_id` in the response as the thread ID.
|
|
73
76
|
- `thread_name` priority order (max 100 chars):
|
|
74
77
|
1. first user `text`
|
|
@@ -87,7 +90,7 @@ Optional:
|
|
|
87
90
|
- Embed titles are `User says` / `Agent says`
|
|
88
91
|
- `tool`: not posted
|
|
89
92
|
- `reasoning`: not posted (to avoid exposing internal thoughts)
|
|
90
|
-
- `
|
|
93
|
+
- `DISCORD_SEND_PARAMS` controls embed fields only (it does not affect title/description/content/timestamp). `share` is not an embed field (but Session started uses `shareUrl` as the embed URL).
|
|
91
94
|
|
|
92
95
|
## Manual test
|
|
93
96
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
import { Plugin } from '@opencode-ai/plugin';
|
|
2
2
|
|
|
3
|
+
type DiscordWebhookMessageResponse = {
|
|
4
|
+
id: string;
|
|
5
|
+
channel_id: string;
|
|
6
|
+
};
|
|
7
|
+
type DiscordEmbed = {
|
|
8
|
+
title?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
color?: number;
|
|
12
|
+
timestamp?: string;
|
|
13
|
+
fields?: Array<{
|
|
14
|
+
name: string;
|
|
15
|
+
value: string;
|
|
16
|
+
inline?: boolean;
|
|
17
|
+
}>;
|
|
18
|
+
};
|
|
19
|
+
type DiscordAllowedMentions = {
|
|
20
|
+
parse?: Array<'everyone' | 'roles' | 'users'>;
|
|
21
|
+
roles?: string[];
|
|
22
|
+
users?: string[];
|
|
23
|
+
};
|
|
24
|
+
type DiscordExecuteWebhookBody = {
|
|
25
|
+
content?: string;
|
|
26
|
+
username?: string;
|
|
27
|
+
avatar_url?: string;
|
|
28
|
+
thread_name?: string;
|
|
29
|
+
embeds?: DiscordEmbed[];
|
|
30
|
+
allowed_mentions?: DiscordAllowedMentions;
|
|
31
|
+
};
|
|
32
|
+
declare function toIsoTimestamp(ms: unknown): string | undefined;
|
|
33
|
+
declare function buildFields(fields: Array<[string, unknown]>, inline?: boolean): DiscordEmbed['fields'];
|
|
34
|
+
declare function buildMention(mention: string | undefined, nameForLog: string): {
|
|
35
|
+
content?: string;
|
|
36
|
+
allowed_mentions?: DiscordAllowedMentions;
|
|
37
|
+
} | undefined;
|
|
38
|
+
declare function buildTodoChecklist(todos: unknown): string;
|
|
39
|
+
type ToastVariant = 'info' | 'success' | 'warning' | 'error';
|
|
40
|
+
type MaybeAlertError = (input: {
|
|
41
|
+
key: string;
|
|
42
|
+
title?: string;
|
|
43
|
+
message: string;
|
|
44
|
+
variant: ToastVariant;
|
|
45
|
+
}) => Promise<void>;
|
|
46
|
+
type PostDiscordWebhookDeps = {
|
|
47
|
+
showErrorAlert: boolean;
|
|
48
|
+
maybeAlertError: MaybeAlertError;
|
|
49
|
+
waitOnRateLimitMs: number;
|
|
50
|
+
fetchImpl?: typeof fetch;
|
|
51
|
+
sleepImpl?: (ms: number) => Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
declare function postDiscordWebhook(input: {
|
|
54
|
+
webhookUrl: string;
|
|
55
|
+
threadId?: string;
|
|
56
|
+
wait?: boolean;
|
|
57
|
+
body: DiscordExecuteWebhookBody;
|
|
58
|
+
}, deps: PostDiscordWebhookDeps): Promise<DiscordWebhookMessageResponse | undefined>;
|
|
3
59
|
declare const plugin: Plugin;
|
|
60
|
+
declare const __test__: {
|
|
61
|
+
buildMention: typeof buildMention;
|
|
62
|
+
buildTodoChecklist: typeof buildTodoChecklist;
|
|
63
|
+
buildFields: typeof buildFields;
|
|
64
|
+
toIsoTimestamp: typeof toIsoTimestamp;
|
|
65
|
+
postDiscordWebhook: typeof postDiscordWebhook;
|
|
66
|
+
};
|
|
4
67
|
|
|
5
|
-
export { plugin as default };
|
|
68
|
+
export { __test__, plugin as default };
|
package/dist/index.js
CHANGED
|
@@ -88,45 +88,165 @@ function withQuery(url, params) {
|
|
|
88
88
|
}
|
|
89
89
|
return u.toString();
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
function sleep(ms) {
|
|
92
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
|
+
}
|
|
94
|
+
function truncateText(value, maxLength) {
|
|
95
|
+
if (value.length <= maxLength) return value;
|
|
96
|
+
if (maxLength <= 3) return value.slice(0, maxLength);
|
|
97
|
+
return value.slice(0, maxLength - 3) + "...";
|
|
98
|
+
}
|
|
99
|
+
function buildMention(mention, nameForLog) {
|
|
100
|
+
void nameForLog;
|
|
101
|
+
if (!mention) return void 0;
|
|
102
|
+
if (mention === "@everyone" || mention === "@here") {
|
|
103
|
+
return {
|
|
104
|
+
content: mention,
|
|
105
|
+
allowed_mentions: {
|
|
106
|
+
parse: ["everyone"]
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
content: mention,
|
|
112
|
+
allowed_mentions: {
|
|
113
|
+
parse: []
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function normalizeTodoContent(value) {
|
|
118
|
+
return safeString(value).replace(/\s+/g, " ").trim();
|
|
119
|
+
}
|
|
120
|
+
function buildTodoChecklist(todos) {
|
|
121
|
+
const maxDescription = 4096;
|
|
122
|
+
const items = Array.isArray(todos) ? todos : [];
|
|
123
|
+
let matchCount = 0;
|
|
124
|
+
let description = "";
|
|
125
|
+
let truncated = false;
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
const status = item?.status;
|
|
128
|
+
if (status === "cancelled") continue;
|
|
129
|
+
const content = normalizeTodoContent(item?.content);
|
|
130
|
+
if (!content) continue;
|
|
131
|
+
const marker = status === "completed" ? "[\u2713]" : status === "in_progress" ? "[\u25B6]" : "[ ]";
|
|
132
|
+
const line = `> ${marker} ${truncateText(content, 200)}`;
|
|
133
|
+
const nextChunk = (description ? "\n" : "") + line;
|
|
134
|
+
if (description.length + nextChunk.length > maxDescription) {
|
|
135
|
+
truncated = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
description += nextChunk;
|
|
139
|
+
matchCount += 1;
|
|
140
|
+
}
|
|
141
|
+
if (!description) {
|
|
142
|
+
return "> (no todos)";
|
|
143
|
+
}
|
|
144
|
+
if (truncated || matchCount < items.length) {
|
|
145
|
+
const moreLine = `${description ? "\n" : ""}> ...and more`;
|
|
146
|
+
if (description.length + moreLine.length <= maxDescription) {
|
|
147
|
+
description += moreLine;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return description;
|
|
151
|
+
}
|
|
152
|
+
async function postDiscordWebhook(input, deps) {
|
|
92
153
|
const { webhookUrl, threadId, wait, body } = input;
|
|
154
|
+
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
155
|
+
const sleepImpl = deps.sleepImpl ?? sleep;
|
|
93
156
|
const url = withQuery(webhookUrl, {
|
|
94
157
|
thread_id: threadId,
|
|
95
158
|
wait: wait ? "true" : void 0
|
|
96
159
|
});
|
|
97
|
-
const
|
|
160
|
+
const requestInit = {
|
|
98
161
|
method: "POST",
|
|
99
162
|
headers: {
|
|
100
163
|
"content-type": "application/json"
|
|
101
164
|
},
|
|
102
165
|
body: JSON.stringify(body)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
166
|
+
};
|
|
167
|
+
const doRequest = async () => {
|
|
168
|
+
return await fetchImpl(url, requestInit);
|
|
169
|
+
};
|
|
170
|
+
const parseRetryAfterFromText = (text2) => {
|
|
171
|
+
if (!text2) return void 0;
|
|
172
|
+
try {
|
|
173
|
+
const json = JSON.parse(text2);
|
|
174
|
+
const value = json?.retry_after;
|
|
175
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
116
180
|
return void 0;
|
|
117
|
-
return {
|
|
118
|
-
id: messageId,
|
|
119
|
-
channel_id: channelId
|
|
120
181
|
};
|
|
182
|
+
const parseRetryAfterFromHeader = (headers) => {
|
|
183
|
+
const raw = headers.get("Retry-After");
|
|
184
|
+
if (!raw) return void 0;
|
|
185
|
+
const value = Number(raw);
|
|
186
|
+
if (!Number.isFinite(value) || value < 0) return void 0;
|
|
187
|
+
return value;
|
|
188
|
+
};
|
|
189
|
+
const response = await doRequest();
|
|
190
|
+
if (response.ok) {
|
|
191
|
+
if (!wait) return void 0;
|
|
192
|
+
const json = await response.json().catch(() => void 0);
|
|
193
|
+
if (!json || typeof json !== "object") return void 0;
|
|
194
|
+
const channelId = json.channel_id;
|
|
195
|
+
const messageId = json.id;
|
|
196
|
+
if (typeof channelId !== "string" || typeof messageId !== "string")
|
|
197
|
+
return void 0;
|
|
198
|
+
return { id: messageId, channel_id: channelId };
|
|
199
|
+
}
|
|
200
|
+
if (response.status === 429) {
|
|
201
|
+
const text2 = await response.text().catch(() => "");
|
|
202
|
+
const retryAfterSeconds = parseRetryAfterFromText(text2) ?? parseRetryAfterFromHeader(response.headers);
|
|
203
|
+
const waitMs = retryAfterSeconds === void 0 ? deps.waitOnRateLimitMs : Math.ceil(retryAfterSeconds * 1e3);
|
|
204
|
+
await sleepImpl(waitMs);
|
|
205
|
+
const retryResponse = await doRequest();
|
|
206
|
+
if (!retryResponse.ok) {
|
|
207
|
+
if (deps.showErrorAlert) {
|
|
208
|
+
await deps.maybeAlertError({
|
|
209
|
+
key: `discord_webhook_error:${retryResponse.status}`,
|
|
210
|
+
title: "Discord webhook rate-limited",
|
|
211
|
+
message: `Discord webhook returned 429 (rate limited). Waited ${Math.round(
|
|
212
|
+
waitMs / 1e3
|
|
213
|
+
)}s and retried, but it still failed.`,
|
|
214
|
+
variant: "warning"
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const retryText = await retryResponse.text().catch(() => "");
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Discord webhook failed: ${retryResponse.status} ${retryResponse.statusText} ${retryText}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!wait) return void 0;
|
|
223
|
+
const json = await retryResponse.json().catch(() => void 0);
|
|
224
|
+
if (!json || typeof json !== "object") return void 0;
|
|
225
|
+
const channelId = json.channel_id;
|
|
226
|
+
const messageId = json.id;
|
|
227
|
+
if (typeof channelId !== "string" || typeof messageId !== "string")
|
|
228
|
+
return void 0;
|
|
229
|
+
return { id: messageId, channel_id: channelId };
|
|
230
|
+
}
|
|
231
|
+
if (deps.showErrorAlert) {
|
|
232
|
+
await deps.maybeAlertError({
|
|
233
|
+
key: `discord_webhook_error:${response.status}`,
|
|
234
|
+
title: "Discord webhook error",
|
|
235
|
+
message: `Discord webhook failed: ${response.status} ${response.statusText}`,
|
|
236
|
+
variant: "error"
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const text = await response.text().catch(() => "");
|
|
240
|
+
throw new Error(
|
|
241
|
+
`Discord webhook failed: ${response.status} ${response.statusText} ${text}`
|
|
242
|
+
);
|
|
121
243
|
}
|
|
122
244
|
var GLOBAL_GUARD_KEY = "__opencode_discord_notify_registered__";
|
|
123
|
-
var plugin = async () => {
|
|
245
|
+
var plugin = async ({ client }) => {
|
|
124
246
|
const globalWithGuard = globalThis;
|
|
125
247
|
if (globalWithGuard[GLOBAL_GUARD_KEY]) {
|
|
126
|
-
return {
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
};
|
|
248
|
+
return { event: async () => {
|
|
249
|
+
} };
|
|
130
250
|
}
|
|
131
251
|
globalWithGuard[GLOBAL_GUARD_KEY] = true;
|
|
132
252
|
const webhookUrl = getEnv("DISCORD_WEBHOOK_URL");
|
|
@@ -138,16 +258,57 @@ var plugin = async () => {
|
|
|
138
258
|
const permissionMention = permissionMentionRaw || void 0;
|
|
139
259
|
const excludeInputContextRaw = (getEnv("DISCORD_WEBHOOK_EXCLUDE_INPUT_CONTEXT") ?? "1").trim();
|
|
140
260
|
const excludeInputContext = excludeInputContextRaw !== "0";
|
|
141
|
-
const
|
|
261
|
+
const showErrorAlertRaw = (getEnv("DISCORD_WEBHOOK_SHOW_ERROR_ALERT") ?? "1").trim();
|
|
262
|
+
const showErrorAlert = showErrorAlertRaw !== "0";
|
|
263
|
+
const waitOnRateLimitMs = 1e4;
|
|
264
|
+
const toastCooldownMs = 3e4;
|
|
265
|
+
const sendParams = parseSendParams(getEnv("DISCORD_SEND_PARAMS"));
|
|
266
|
+
const lastAlertAtByKey = /* @__PURE__ */ new Map();
|
|
267
|
+
const showToast = async ({ title, message, variant }) => {
|
|
268
|
+
try {
|
|
269
|
+
await client.tui.showToast({
|
|
270
|
+
body: { title, message, variant, duration: 8e3 }
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const maybeAlertError = async ({
|
|
276
|
+
key,
|
|
277
|
+
title,
|
|
278
|
+
message,
|
|
279
|
+
variant
|
|
280
|
+
}) => {
|
|
281
|
+
if (!showErrorAlert) return;
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
const last = lastAlertAtByKey.get(key);
|
|
284
|
+
if (last !== void 0 && now - last < toastCooldownMs) return;
|
|
285
|
+
lastAlertAtByKey.set(key, now);
|
|
286
|
+
await showToast({ title, message, variant });
|
|
287
|
+
};
|
|
288
|
+
const MISSING_URL_KEY = "discord_webhook_missing_url";
|
|
289
|
+
async function showMissingUrlToastOnce() {
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
const last = lastAlertAtByKey.get(MISSING_URL_KEY);
|
|
292
|
+
if (last !== void 0 && now - last < toastCooldownMs) return;
|
|
293
|
+
lastAlertAtByKey.set(MISSING_URL_KEY, now);
|
|
294
|
+
await showToast({
|
|
295
|
+
title: "Discord webhook not configured",
|
|
296
|
+
message: "DISCORD_WEBHOOK_URL is not set. Please configure it to enable Discord notifications.",
|
|
297
|
+
variant: "warning"
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (!webhookUrl) void showMissingUrlToastOnce();
|
|
301
|
+
const postDeps = {
|
|
302
|
+
showErrorAlert,
|
|
303
|
+
maybeAlertError,
|
|
304
|
+
waitOnRateLimitMs
|
|
305
|
+
};
|
|
142
306
|
const sessionToThread = /* @__PURE__ */ new Map();
|
|
143
|
-
const threadCreateInFlight = /* @__PURE__ */ new Map();
|
|
144
307
|
const pendingPostsBySession = /* @__PURE__ */ new Map();
|
|
145
308
|
const firstUserTextBySession = /* @__PURE__ */ new Map();
|
|
146
309
|
const pendingTextPartsByMessageId = /* @__PURE__ */ new Map();
|
|
147
|
-
const sessionSerial = /* @__PURE__ */ new Map();
|
|
148
|
-
const lastSessionInfo = /* @__PURE__ */ new Map();
|
|
149
|
-
const lastPartSnapshotById = /* @__PURE__ */ new Map();
|
|
150
310
|
const messageRoleById = /* @__PURE__ */ new Map();
|
|
311
|
+
const lastSessionInfo = /* @__PURE__ */ new Map();
|
|
151
312
|
function normalizeThreadTitle(value) {
|
|
152
313
|
return safeString(value).replace(/\s+/g, " ").trim();
|
|
153
314
|
}
|
|
@@ -167,182 +328,108 @@ var plugin = async () => {
|
|
|
167
328
|
if (fromSessionId) return fromSessionId.slice(0, 100);
|
|
168
329
|
return "untitled";
|
|
169
330
|
}
|
|
170
|
-
async function
|
|
171
|
-
if (!webhookUrl) return;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
331
|
+
async function ensureThread(sessionID) {
|
|
332
|
+
if (!webhookUrl) return void 0;
|
|
333
|
+
const existing = sessionToThread.get(sessionID);
|
|
334
|
+
if (existing) return existing;
|
|
335
|
+
const queue = pendingPostsBySession.get(sessionID);
|
|
336
|
+
const first = queue?.[0];
|
|
337
|
+
if (!first) return void 0;
|
|
338
|
+
const threadName = buildThreadName(sessionID);
|
|
339
|
+
const res = await postDiscordWebhook(
|
|
340
|
+
{
|
|
341
|
+
webhookUrl,
|
|
342
|
+
wait: true,
|
|
343
|
+
body: {
|
|
344
|
+
...first,
|
|
345
|
+
thread_name: threadName,
|
|
346
|
+
username,
|
|
347
|
+
avatar_url: avatarUrl
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
postDeps
|
|
351
|
+
).catch(async (e) => {
|
|
352
|
+
await postDiscordWebhook(
|
|
353
|
+
{ webhookUrl, body: { ...first, username, avatar_url: avatarUrl } },
|
|
354
|
+
postDeps
|
|
355
|
+
).catch(() => {
|
|
356
|
+
});
|
|
357
|
+
return void 0;
|
|
179
358
|
});
|
|
359
|
+
if (res?.channel_id) {
|
|
360
|
+
sessionToThread.set(sessionID, res.channel_id);
|
|
361
|
+
const nextQueue = pendingPostsBySession.get(sessionID);
|
|
362
|
+
if (nextQueue?.[0] === first) {
|
|
363
|
+
nextQueue.shift();
|
|
364
|
+
if (nextQueue.length) pendingPostsBySession.set(sessionID, nextQueue);
|
|
365
|
+
else pendingPostsBySession.delete(sessionID);
|
|
366
|
+
}
|
|
367
|
+
return res.channel_id;
|
|
368
|
+
}
|
|
369
|
+
return void 0;
|
|
180
370
|
}
|
|
181
371
|
function enqueueToThread(sessionID, body) {
|
|
372
|
+
if (!webhookUrl) {
|
|
373
|
+
void showMissingUrlToastOnce();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
182
376
|
const queue = pendingPostsBySession.get(sessionID) ?? [];
|
|
183
377
|
queue.push(body);
|
|
184
378
|
pendingPostsBySession.set(sessionID, queue);
|
|
185
379
|
}
|
|
186
|
-
function enqueueSerial(sessionID, task) {
|
|
187
|
-
const prev = sessionSerial.get(sessionID) ?? Promise.resolve();
|
|
188
|
-
const next = prev.then(task, task);
|
|
189
|
-
sessionSerial.set(sessionID, next);
|
|
190
|
-
next.finally(() => {
|
|
191
|
-
if (sessionSerial.get(sessionID) === next) sessionSerial.delete(sessionID);
|
|
192
|
-
});
|
|
193
|
-
return next;
|
|
194
|
-
}
|
|
195
|
-
async function ensureThread(sessionID) {
|
|
196
|
-
if (!webhookUrl) return void 0;
|
|
197
|
-
const existingThreadId = sessionToThread.get(sessionID);
|
|
198
|
-
if (existingThreadId) return existingThreadId;
|
|
199
|
-
const inflight = threadCreateInFlight.get(sessionID);
|
|
200
|
-
if (inflight) return await inflight;
|
|
201
|
-
const create = (async () => {
|
|
202
|
-
const queue = pendingPostsBySession.get(sessionID) ?? [];
|
|
203
|
-
const first = queue.shift();
|
|
204
|
-
if (queue.length) pendingPostsBySession.set(sessionID, queue);
|
|
205
|
-
else pendingPostsBySession.delete(sessionID);
|
|
206
|
-
if (!first) return void 0;
|
|
207
|
-
const threadName = buildThreadName(sessionID);
|
|
208
|
-
try {
|
|
209
|
-
const res = await postDiscordWebhook({
|
|
210
|
-
webhookUrl,
|
|
211
|
-
wait: true,
|
|
212
|
-
body: {
|
|
213
|
-
...first,
|
|
214
|
-
thread_name: threadName,
|
|
215
|
-
username,
|
|
216
|
-
avatar_url: avatarUrl
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
if (res?.channel_id) {
|
|
220
|
-
sessionToThread.set(sessionID, res.channel_id);
|
|
221
|
-
return res.channel_id;
|
|
222
|
-
}
|
|
223
|
-
warn(`failed to capture thread_id for session ${sessionID}`);
|
|
224
|
-
return void 0;
|
|
225
|
-
} catch (e) {
|
|
226
|
-
await sendToChannel(first);
|
|
227
|
-
return void 0;
|
|
228
|
-
} finally {
|
|
229
|
-
threadCreateInFlight.delete(sessionID);
|
|
230
|
-
}
|
|
231
|
-
})();
|
|
232
|
-
threadCreateInFlight.set(sessionID, create);
|
|
233
|
-
return await create;
|
|
234
|
-
}
|
|
235
380
|
async function flushPending(sessionID) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
381
|
+
if (!webhookUrl) return;
|
|
382
|
+
const threadId = sessionToThread.get(sessionID) ?? await ensureThread(sessionID);
|
|
383
|
+
const queue = pendingPostsBySession.get(sessionID);
|
|
384
|
+
if (!queue?.length) return;
|
|
385
|
+
let sentCount = 0;
|
|
386
|
+
try {
|
|
387
|
+
if (threadId) {
|
|
388
|
+
for (const body of queue) {
|
|
389
|
+
await postDiscordWebhook(
|
|
390
|
+
{
|
|
245
391
|
webhookUrl,
|
|
246
392
|
threadId,
|
|
247
|
-
body: {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
393
|
+
body: { ...body, username, avatar_url: avatarUrl }
|
|
394
|
+
},
|
|
395
|
+
postDeps
|
|
396
|
+
);
|
|
397
|
+
sentCount += 1;
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
for (const body of queue) {
|
|
401
|
+
await postDiscordWebhook(
|
|
402
|
+
{ webhookUrl, body: { ...body, username, avatar_url: avatarUrl } },
|
|
403
|
+
postDeps
|
|
404
|
+
);
|
|
405
|
+
sentCount += 1;
|
|
258
406
|
}
|
|
259
|
-
} finally {
|
|
260
|
-
pendingPostsBySession.delete(sessionID);
|
|
261
407
|
}
|
|
262
|
-
|
|
408
|
+
pendingPostsBySession.delete(sessionID);
|
|
409
|
+
} catch (e) {
|
|
410
|
+
const current = pendingPostsBySession.get(sessionID);
|
|
411
|
+
if (!current?.length) throw e;
|
|
412
|
+
const rest = current.slice(sentCount);
|
|
413
|
+
if (rest.length) pendingPostsBySession.set(sessionID, rest);
|
|
414
|
+
else pendingPostsBySession.delete(sessionID);
|
|
415
|
+
throw e;
|
|
416
|
+
}
|
|
263
417
|
}
|
|
264
418
|
function shouldFlush(sessionID) {
|
|
265
419
|
return sessionToThread.has(sessionID) || firstUserTextBySession.has(sessionID);
|
|
266
420
|
}
|
|
267
|
-
function warn(message, error) {
|
|
268
|
-
if (error) console.warn(`[opencode-discord-notify] ${message}`, error);
|
|
269
|
-
else console.warn(`[opencode-discord-notify] ${message}`);
|
|
270
|
-
}
|
|
271
|
-
function buildMention(mention, nameForLog) {
|
|
272
|
-
if (!mention) return void 0;
|
|
273
|
-
if (mention === "@everyone" || mention === "@here") {
|
|
274
|
-
return {
|
|
275
|
-
content: mention,
|
|
276
|
-
allowed_mentions: {
|
|
277
|
-
parse: ["everyone"]
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
warn(
|
|
282
|
-
`${nameForLog} is set but unsupported: ${mention}. Only @everyone/@here are supported.`
|
|
283
|
-
);
|
|
284
|
-
return {
|
|
285
|
-
content: mention,
|
|
286
|
-
allowed_mentions: {
|
|
287
|
-
parse: []
|
|
288
|
-
}
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
421
|
function buildCompleteMention() {
|
|
292
422
|
return buildMention(completeMention, "DISCORD_WEBHOOK_COMPLETE_MENTION");
|
|
293
423
|
}
|
|
294
424
|
function buildPermissionMention() {
|
|
295
425
|
return buildMention(permissionMention, "DISCORD_WEBHOOK_PERMISSION_MENTION");
|
|
296
426
|
}
|
|
297
|
-
function normalizeTodoContent(value) {
|
|
298
|
-
return safeString(value).replace(/\s+/g, " ").trim();
|
|
299
|
-
}
|
|
300
|
-
function truncateText(value, maxLength) {
|
|
301
|
-
if (value.length <= maxLength) return value;
|
|
302
|
-
if (maxLength <= 3) return value.slice(0, maxLength);
|
|
303
|
-
return value.slice(0, maxLength - 3) + "...";
|
|
304
|
-
}
|
|
305
427
|
function setIfChanged(map, key, next) {
|
|
306
428
|
const prev = map.get(key);
|
|
307
429
|
if (prev === next) return false;
|
|
308
430
|
map.set(key, next);
|
|
309
431
|
return true;
|
|
310
432
|
}
|
|
311
|
-
function buildTodoChecklist(todos) {
|
|
312
|
-
const maxDescription = 4096;
|
|
313
|
-
const items = Array.isArray(todos) ? todos : [];
|
|
314
|
-
let matchCount = 0;
|
|
315
|
-
let description = "";
|
|
316
|
-
let truncated = false;
|
|
317
|
-
for (const item of items) {
|
|
318
|
-
const status = item?.status;
|
|
319
|
-
if (status === "cancelled") continue;
|
|
320
|
-
const content = normalizeTodoContent(item?.content);
|
|
321
|
-
if (!content) continue;
|
|
322
|
-
const marker = status === "completed" ? "[\u2713]" : status === "in_progress" ? "[\u25B6]" : "[ ]";
|
|
323
|
-
const line = `> ${marker} ${truncateText(content, 200)}`;
|
|
324
|
-
const nextChunk = (description ? "\n" : "") + line;
|
|
325
|
-
if (description.length + nextChunk.length > maxDescription) {
|
|
326
|
-
truncated = true;
|
|
327
|
-
break;
|
|
328
|
-
}
|
|
329
|
-
description += nextChunk;
|
|
330
|
-
matchCount += 1;
|
|
331
|
-
}
|
|
332
|
-
if (!description) {
|
|
333
|
-
return "> (no todos)";
|
|
334
|
-
}
|
|
335
|
-
if (truncated || matchCount < items.length) {
|
|
336
|
-
const moreLine = `${description ? "\n" : ""}> ...and more`;
|
|
337
|
-
if (description.length + moreLine.length <= maxDescription) {
|
|
338
|
-
description += moreLine;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
return description;
|
|
342
|
-
}
|
|
343
|
-
if (!webhookUrl) {
|
|
344
|
-
warn("DISCORD_WEBHOOK_URL is not set; plugin will be a no-op");
|
|
345
|
-
}
|
|
346
433
|
return {
|
|
347
434
|
event: async ({ event }) => {
|
|
348
435
|
try {
|
|
@@ -352,8 +439,6 @@ var plugin = async () => {
|
|
|
352
439
|
const sessionID = info?.id;
|
|
353
440
|
if (!sessionID) return;
|
|
354
441
|
const title = info?.title ?? "(untitled)";
|
|
355
|
-
const directory = info?.directory;
|
|
356
|
-
const projectID = info?.projectID;
|
|
357
442
|
const shareUrl = info?.share?.url;
|
|
358
443
|
const createdAt = toIsoTimestamp(info?.time?.created);
|
|
359
444
|
const embed = {
|
|
@@ -366,8 +451,8 @@ var plugin = async () => {
|
|
|
366
451
|
filterSendFields(
|
|
367
452
|
[
|
|
368
453
|
["sessionID", sessionID],
|
|
369
|
-
["projectID", projectID],
|
|
370
|
-
["directory", directory],
|
|
454
|
+
["projectID", info?.projectID],
|
|
455
|
+
["directory", info?.directory],
|
|
371
456
|
["share", shareUrl]
|
|
372
457
|
],
|
|
373
458
|
withForcedSendParams(sendParams, [
|
|
@@ -375,8 +460,7 @@ var plugin = async () => {
|
|
|
375
460
|
"projectID",
|
|
376
461
|
"directory"
|
|
377
462
|
])
|
|
378
|
-
)
|
|
379
|
-
false
|
|
463
|
+
)
|
|
380
464
|
)
|
|
381
465
|
};
|
|
382
466
|
lastSessionInfo.set(sessionID, { title, shareUrl });
|
|
@@ -384,99 +468,6 @@ var plugin = async () => {
|
|
|
384
468
|
if (shouldFlush(sessionID)) await flushPending(sessionID);
|
|
385
469
|
return;
|
|
386
470
|
}
|
|
387
|
-
case "permission.updated": {
|
|
388
|
-
const p = event.properties;
|
|
389
|
-
const sessionID = p?.sessionID;
|
|
390
|
-
if (!sessionID) return;
|
|
391
|
-
const embed = {
|
|
392
|
-
title: "Permission required",
|
|
393
|
-
description: p?.title,
|
|
394
|
-
color: COLORS.warning,
|
|
395
|
-
timestamp: toIsoTimestamp(p?.time?.created),
|
|
396
|
-
fields: buildFields(
|
|
397
|
-
filterSendFields(
|
|
398
|
-
[
|
|
399
|
-
["sessionID", sessionID],
|
|
400
|
-
["permissionID", p?.id],
|
|
401
|
-
["type", p?.type],
|
|
402
|
-
["pattern", p?.pattern],
|
|
403
|
-
["messageID", p?.messageID],
|
|
404
|
-
["callID", p?.callID]
|
|
405
|
-
],
|
|
406
|
-
sendParams
|
|
407
|
-
),
|
|
408
|
-
false
|
|
409
|
-
)
|
|
410
|
-
};
|
|
411
|
-
const mention = buildPermissionMention();
|
|
412
|
-
enqueueToThread(sessionID, {
|
|
413
|
-
content: mention ? `${mention.content} Permission required` : void 0,
|
|
414
|
-
allowed_mentions: mention?.allowed_mentions,
|
|
415
|
-
embeds: [embed]
|
|
416
|
-
});
|
|
417
|
-
if (shouldFlush(sessionID)) await flushPending(sessionID);
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
case "session.idle": {
|
|
421
|
-
const sessionID = event.properties?.sessionID;
|
|
422
|
-
if (!sessionID) return;
|
|
423
|
-
const embed = {
|
|
424
|
-
title: "Session completed",
|
|
425
|
-
color: COLORS.success,
|
|
426
|
-
fields: buildFields(
|
|
427
|
-
filterSendFields([["sessionID", sessionID]], sendParams),
|
|
428
|
-
false
|
|
429
|
-
)
|
|
430
|
-
};
|
|
431
|
-
const mention = buildCompleteMention();
|
|
432
|
-
enqueueToThread(sessionID, {
|
|
433
|
-
content: mention ? `${mention.content} Session completed` : void 0,
|
|
434
|
-
allowed_mentions: mention?.allowed_mentions,
|
|
435
|
-
embeds: [embed]
|
|
436
|
-
});
|
|
437
|
-
await flushPending(sessionID);
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
case "session.error": {
|
|
441
|
-
const p = event.properties;
|
|
442
|
-
const sessionID = p?.sessionID;
|
|
443
|
-
const errorStr = safeString(p?.error);
|
|
444
|
-
const embed = {
|
|
445
|
-
title: "Session error",
|
|
446
|
-
color: COLORS.error,
|
|
447
|
-
description: errorStr ? errorStr.length > 4096 ? errorStr.slice(0, 4093) + "..." : errorStr : void 0,
|
|
448
|
-
fields: buildFields(
|
|
449
|
-
filterSendFields([["sessionID", sessionID]], sendParams),
|
|
450
|
-
false
|
|
451
|
-
)
|
|
452
|
-
};
|
|
453
|
-
if (!sessionID) return;
|
|
454
|
-
const mention = buildCompleteMention();
|
|
455
|
-
enqueueToThread(sessionID, {
|
|
456
|
-
content: mention ? `${mention.content} Session error` : void 0,
|
|
457
|
-
allowed_mentions: mention?.allowed_mentions,
|
|
458
|
-
embeds: [embed]
|
|
459
|
-
});
|
|
460
|
-
await flushPending(sessionID);
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
case "todo.updated": {
|
|
464
|
-
const p = event.properties;
|
|
465
|
-
const sessionID = p?.sessionID;
|
|
466
|
-
if (!sessionID) return;
|
|
467
|
-
const embed = {
|
|
468
|
-
title: "Todo updated",
|
|
469
|
-
color: COLORS.info,
|
|
470
|
-
fields: buildFields(
|
|
471
|
-
filterSendFields([["sessionID", sessionID]], sendParams),
|
|
472
|
-
false
|
|
473
|
-
),
|
|
474
|
-
description: buildTodoChecklist(p?.todos)
|
|
475
|
-
};
|
|
476
|
-
enqueueToThread(sessionID, { embeds: [embed] });
|
|
477
|
-
if (shouldFlush(sessionID)) await flushPending(sessionID);
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
471
|
case "message.updated": {
|
|
481
472
|
const info = event.properties?.info;
|
|
482
473
|
const messageID = info?.id;
|
|
@@ -494,17 +485,14 @@ var plugin = async () => {
|
|
|
494
485
|
if (!sessionID || !partID || type !== "text") continue;
|
|
495
486
|
const text = safeString(pendingPart?.text);
|
|
496
487
|
if (role === "user" && excludeInputContext && isInputContextText(text)) {
|
|
497
|
-
const
|
|
488
|
+
const snapshot = JSON.stringify({
|
|
498
489
|
type,
|
|
499
490
|
role,
|
|
500
491
|
skipped: "input_context"
|
|
501
492
|
});
|
|
502
|
-
setIfChanged(
|
|
493
|
+
setIfChanged(/* @__PURE__ */ new Map(), partID, snapshot);
|
|
503
494
|
continue;
|
|
504
495
|
}
|
|
505
|
-
const snapshot = JSON.stringify({ type, role, text });
|
|
506
|
-
if (!setIfChanged(lastPartSnapshotById, partID, snapshot))
|
|
507
|
-
continue;
|
|
508
496
|
if (role === "user" && !firstUserTextBySession.has(sessionID)) {
|
|
509
497
|
const normalized = normalizeThreadTitle(text);
|
|
510
498
|
if (normalized)
|
|
@@ -522,8 +510,7 @@ var plugin = async () => {
|
|
|
522
510
|
["role", role]
|
|
523
511
|
],
|
|
524
512
|
sendParams
|
|
525
|
-
)
|
|
526
|
-
false
|
|
513
|
+
)
|
|
527
514
|
),
|
|
528
515
|
description: truncateText(text || "(empty)", 4096)
|
|
529
516
|
};
|
|
@@ -544,27 +531,25 @@ var plugin = async () => {
|
|
|
544
531
|
const type = part?.type;
|
|
545
532
|
if (!sessionID || !messageID || !partID || !type) return;
|
|
546
533
|
if (type === "reasoning") return;
|
|
534
|
+
const role = messageRoleById.get(messageID);
|
|
535
|
+
if (role !== "assistant" && role !== "user") {
|
|
536
|
+
const list = pendingTextPartsByMessageId.get(messageID) ?? [];
|
|
537
|
+
list.push(part);
|
|
538
|
+
pendingTextPartsByMessageId.set(messageID, list);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
547
541
|
if (type === "text") {
|
|
548
|
-
const role = messageRoleById.get(messageID);
|
|
549
|
-
if (role !== "assistant" && role !== "user") {
|
|
550
|
-
const list = pendingTextPartsByMessageId.get(messageID) ?? [];
|
|
551
|
-
list.push(part);
|
|
552
|
-
pendingTextPartsByMessageId.set(messageID, list);
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
542
|
if (role === "assistant" && !part?.time?.end) return;
|
|
556
543
|
const text = safeString(part?.text);
|
|
557
544
|
if (role === "user" && excludeInputContext && isInputContextText(text)) {
|
|
558
|
-
const
|
|
545
|
+
const snapshot = JSON.stringify({
|
|
559
546
|
type,
|
|
560
547
|
role,
|
|
561
548
|
skipped: "input_context"
|
|
562
549
|
});
|
|
563
|
-
setIfChanged(
|
|
550
|
+
setIfChanged(/* @__PURE__ */ new Map(), partID, snapshot);
|
|
564
551
|
return;
|
|
565
552
|
}
|
|
566
|
-
const snapshot = JSON.stringify({ type, role, text });
|
|
567
|
-
if (!setIfChanged(lastPartSnapshotById, partID, snapshot)) return;
|
|
568
553
|
if (role === "user" && !firstUserTextBySession.has(sessionID)) {
|
|
569
554
|
const normalized = normalizeThreadTitle(text);
|
|
570
555
|
if (normalized)
|
|
@@ -582,8 +567,7 @@ var plugin = async () => {
|
|
|
582
567
|
["role", role]
|
|
583
568
|
],
|
|
584
569
|
sendParams
|
|
585
|
-
)
|
|
586
|
-
false
|
|
570
|
+
)
|
|
587
571
|
),
|
|
588
572
|
description: truncateText(text || "(empty)", 4096)
|
|
589
573
|
};
|
|
@@ -593,21 +577,26 @@ var plugin = async () => {
|
|
|
593
577
|
} else if (shouldFlush(sessionID)) {
|
|
594
578
|
await flushPending(sessionID);
|
|
595
579
|
}
|
|
596
|
-
return;
|
|
597
580
|
}
|
|
598
|
-
if (type === "tool") return;
|
|
599
581
|
return;
|
|
600
582
|
}
|
|
601
583
|
default:
|
|
602
584
|
return;
|
|
603
585
|
}
|
|
604
|
-
} catch
|
|
605
|
-
warn(`failed handling event ${event.type}`, e);
|
|
586
|
+
} catch {
|
|
606
587
|
}
|
|
607
588
|
}
|
|
608
589
|
};
|
|
609
590
|
};
|
|
591
|
+
var __test__ = {
|
|
592
|
+
buildMention,
|
|
593
|
+
buildTodoChecklist,
|
|
594
|
+
buildFields,
|
|
595
|
+
toIsoTimestamp,
|
|
596
|
+
postDiscordWebhook
|
|
597
|
+
};
|
|
610
598
|
var index_default = plugin;
|
|
611
599
|
export {
|
|
600
|
+
__test__,
|
|
612
601
|
index_default as default
|
|
613
602
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-discord-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A plugin that posts OpenCode events to a Discord webhook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsup src/index.ts --format esm --dts --out-dir dist --clean",
|
|
29
29
|
"format": "prettier . --write",
|
|
30
|
+
"test": "vitest run",
|
|
30
31
|
"prepublishOnly": "npm run build"
|
|
31
32
|
},
|
|
32
33
|
"peerDependencies": {
|
|
@@ -38,7 +39,8 @@
|
|
|
38
39
|
"prettier": "^3.7.4",
|
|
39
40
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
40
41
|
"tsup": "^8.5.0",
|
|
41
|
-
"typescript": "^5.8.3"
|
|
42
|
+
"typescript": "^5.8.3",
|
|
43
|
+
"vitest": "^4.0.16"
|
|
42
44
|
},
|
|
43
45
|
"keywords": [
|
|
44
46
|
"opencode",
|