loopable-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.output/nitro.json +17 -0
  2. package/.output/public/apple-touch-icon.png +0 -0
  3. package/.output/public/assets/_connectorId-BWjYn5tm.js +1 -0
  4. package/.output/public/assets/_loopId-Ds4RkyEc.js +1 -0
  5. package/.output/public/assets/_taskId-BOzICXDf.js +1 -0
  6. package/.output/public/assets/alert-Bn9-Zm0l.js +1 -0
  7. package/.output/public/assets/arrow-left-BF9juV9T.js +1 -0
  8. package/.output/public/assets/arrow-right-CzqvnOY1.js +1 -0
  9. package/.output/public/assets/button-HCY6DCY-.js +1 -0
  10. package/.output/public/assets/connection-status-CYZB8kLy.js +1 -0
  11. package/.output/public/assets/connector-icon-BZ_MUJV_.js +1 -0
  12. package/.output/public/assets/connectors-BvzttfSu.js +1 -0
  13. package/.output/public/assets/connectors-DdJNwRN_.js +1 -0
  14. package/.output/public/assets/external-link-D46X-bXa.js +1 -0
  15. package/.output/public/assets/gaps-D-I6xiDc.js +1 -0
  16. package/.output/public/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  17. package/.output/public/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  18. package/.output/public/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  19. package/.output/public/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  20. package/.output/public/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  21. package/.output/public/assets/inbox-BUUu9xxE.js +1 -0
  22. package/.output/public/assets/index-ztzex0FN.js +10 -0
  23. package/.output/public/assets/loader-CcBl2u8m.js +1 -0
  24. package/.output/public/assets/loop-form-dB3CLcZb.js +1 -0
  25. package/.output/public/assets/loops-DRunQrxL.js +1 -0
  26. package/.output/public/assets/loops-oSdqMd29.js +1 -0
  27. package/.output/public/assets/manifests-DNMqf7Vi.js +7 -0
  28. package/.output/public/assets/new-uyFxVjuB.js +1 -0
  29. package/.output/public/assets/preload-helper-ORbuV86A.js +3 -0
  30. package/.output/public/assets/runners-DSU3r5Ww.js +1 -0
  31. package/.output/public/assets/setting-fields-ixkpdBb-.js +3 -0
  32. package/.output/public/assets/styles-DZL5V1o7.css +2 -0
  33. package/.output/public/assets/switch-d5ummmHY.js +1 -0
  34. package/.output/public/assets/task-list-BpOStt9v.js +1 -0
  35. package/.output/public/assets/tasks-B8MsKgic.js +1 -0
  36. package/.output/public/assets/trash-2-D6u6NpzO.js +1 -0
  37. package/.output/public/favicon.svg +9 -0
  38. package/.output/server/_chunks/ssr-renderer.mjs +26 -0
  39. package/.output/server/_connectorId-8nb5yOzG.mjs +421 -0
  40. package/.output/server/_libs/@base-ui/react+[...].mjs +12841 -0
  41. package/.output/server/_libs/@tanstack/react-router+[...].mjs +14349 -0
  42. package/.output/server/_libs/class-variance-authority+clsx.mjs +69 -0
  43. package/.output/server/_libs/dijkstrajs.mjs +118 -0
  44. package/.output/server/_libs/drizzle-orm.mjs +4349 -0
  45. package/.output/server/_libs/h3+rou3+srvx.mjs +1361 -0
  46. package/.output/server/_libs/lucide-react.mjs +911 -0
  47. package/.output/server/_libs/next-themes.mjs +53 -0
  48. package/.output/server/_libs/pngjs.mjs +1680 -0
  49. package/.output/server/_libs/qrcode.mjs +2583 -0
  50. package/.output/server/_libs/sonner.mjs +955 -0
  51. package/.output/server/_libs/tailwind-merge.mjs +3380 -0
  52. package/.output/server/_libs/tanstack__history.mjs +341 -0
  53. package/.output/server/_libs/ufo.mjs +64 -0
  54. package/.output/server/_loopId-BsUL1SeL.mjs +272 -0
  55. package/.output/server/_runtime.mjs +35 -0
  56. package/.output/server/_ssr/access-zIl15o8y.mjs +42 -0
  57. package/.output/server/_ssr/agents-Btql102J.mjs +657 -0
  58. package/.output/server/_ssr/agents-DAOZPgO2.mjs +76 -0
  59. package/.output/server/_ssr/alert-NEiOCTOl.mjs +37 -0
  60. package/.output/server/_ssr/callback-CUINvl7M.mjs +62 -0
  61. package/.output/server/_ssr/client-CKkOfh-s.mjs +335 -0
  62. package/.output/server/_ssr/connection-status-Bi5peUAk.mjs +31 -0
  63. package/.output/server/_ssr/connections-qnw2GVlP.mjs +2542 -0
  64. package/.output/server/_ssr/connector-icon-DZXQLLFo.mjs +37 -0
  65. package/.output/server/_ssr/connectors-BHNwuIMj.mjs +106 -0
  66. package/.output/server/_ssr/connectors-C28g0i4c.mjs +83 -0
  67. package/.output/server/_ssr/createServerRpc-DPDrnFdH.mjs +12 -0
  68. package/.output/server/_ssr/empty-plugin-adapters-BJ4m9L8g.mjs +5 -0
  69. package/.output/server/_ssr/gaps-RAFSXLZf.mjs +53 -0
  70. package/.output/server/_ssr/inbox-k5zHergU.mjs +35 -0
  71. package/.output/server/_ssr/loop-form-DlObSewZ.mjs +412 -0
  72. package/.output/server/_ssr/loops-APFcP0y6.mjs +311 -0
  73. package/.output/server/_ssr/loops-xt2Bn7_q.mjs +222 -0
  74. package/.output/server/_ssr/new-DfKyd2Wa.mjs +169 -0
  75. package/.output/server/_ssr/router-3WmpE5b_.mjs +1553 -0
  76. package/.output/server/_ssr/runners-By-mwc3F.mjs +32 -0
  77. package/.output/server/_ssr/runners-DI_NxpHC.mjs +116 -0
  78. package/.output/server/_ssr/runners-DtXs4Q8a.mjs +106 -0
  79. package/.output/server/_ssr/secrets-Hqti-niS.mjs +199 -0
  80. package/.output/server/_ssr/setting-fields-8vKFj4df.mjs +92 -0
  81. package/.output/server/_ssr/ssr.mjs +6605 -0
  82. package/.output/server/_ssr/start-Bdc2GJUI.mjs +51 -0
  83. package/.output/server/_ssr/switch-ReNglgH6.mjs +18 -0
  84. package/.output/server/_ssr/task-list-CUITIqkb.mjs +186 -0
  85. package/.output/server/_ssr/tasks-SAjNovX6.mjs +110 -0
  86. package/.output/server/_ssr/tasks-vcoodp_D.mjs +533 -0
  87. package/.output/server/_tanstack-start-manifest_v-C0cP5F0M.mjs +125 -0
  88. package/.output/server/_taskId-CXS3lod0.mjs +249 -0
  89. package/.output/server/index.mjs +541 -0
  90. package/README.md +81 -0
  91. package/dist/cli.mjs +165 -0
  92. package/dist/dispatcher.mjs +9871 -0
  93. package/dist/runner.mjs +657 -0
  94. package/drizzle/0000_cold_demogoblin.sql +29 -0
  95. package/drizzle/0001_fancy_blonde_phantom.sql +13 -0
  96. package/drizzle/0002_spooky_smiling_tiger.sql +16 -0
  97. package/drizzle/0003_small_golden_guardian.sql +25 -0
  98. package/drizzle/0004_wonderful_thunderbolts.sql +39 -0
  99. package/drizzle/0005_named_workflows.sql +7 -0
  100. package/drizzle/0006_signals.sql +18 -0
  101. package/drizzle/0007_review_comments.sql +1 -0
  102. package/drizzle/0008_held_signals.sql +1 -0
  103. package/drizzle/0009_work_item_ref.sql +58 -0
  104. package/drizzle/0010_signal_payload.sql +2 -0
  105. package/drizzle/0011_rule_poll_cursor.sql +1 -0
  106. package/drizzle/0012_loops.sql +63 -0
  107. package/drizzle/0013_loop_owns_action_and_prompt.sql +154 -0
  108. package/drizzle/0014_loop_poll_every.sql +4 -0
  109. package/drizzle/0015_machines.sql +10 -0
  110. package/drizzle/0016_task_machine.sql +1 -0
  111. package/drizzle/0017_loop_machine.sql +1 -0
  112. package/drizzle/0018_runners.sql +1 -0
  113. package/drizzle/0019_task_runner.sql +1 -0
  114. package/drizzle/0020_loop_runner.sql +1 -0
  115. package/drizzle/0021_drop_runner_kind.sql +1 -0
  116. package/drizzle/0022_drop_loop_runner.sql +1 -0
  117. package/drizzle/0023_shared_host.sql +6 -0
  118. package/drizzle/meta/0000_snapshot.json +206 -0
  119. package/drizzle/meta/0001_snapshot.json +284 -0
  120. package/drizzle/meta/0002_snapshot.json +390 -0
  121. package/drizzle/meta/0003_snapshot.json +566 -0
  122. package/drizzle/meta/0004_snapshot.json +633 -0
  123. package/drizzle/meta/0005_snapshot.json +626 -0
  124. package/drizzle/meta/0006_snapshot.json +752 -0
  125. package/drizzle/meta/0007_snapshot.json +759 -0
  126. package/drizzle/meta/0008_snapshot.json +766 -0
  127. package/drizzle/meta/0009_snapshot.json +752 -0
  128. package/drizzle/meta/0010_snapshot.json +766 -0
  129. package/drizzle/meta/0011_snapshot.json +773 -0
  130. package/drizzle/meta/0012_snapshot.json +773 -0
  131. package/drizzle/meta/0013_snapshot.json +817 -0
  132. package/drizzle/meta/_journal.json +174 -0
  133. package/package.json +88 -0
@@ -0,0 +1,2542 @@
1
+ import { d as and, f as eq, h as lt, l as asc } from "../_libs/drizzle-orm.mjs";
2
+ import { a as connections, i as authAttempts, s as db } from "./client-CKkOfh-s.mjs";
3
+ import { i as readCredential, s as writeCredential, t as deleteCredential } from "./secrets-Hqti-niS.mjs";
4
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ //#region node_modules/.nitro/vite/services/ssr/assets/connections-qnw2GVlP.js
8
+ function defineManifest(manifest) {
9
+ return manifest;
10
+ }
11
+ function defineRuntime(runtime) {
12
+ return runtime;
13
+ }
14
+ var GITHUB_SCOPES = ["repo", "notifications"];
15
+ var repositories = {
16
+ key: "repositories",
17
+ kind: "string_list",
18
+ label: "Repositories",
19
+ help: "owner/name, one per line. Wildcards work: acme/* or */*. Leave empty for every repository you can see.",
20
+ placeholder: "acme/web"
21
+ };
22
+ var ignoreDrafts = {
23
+ key: "ignoreDrafts",
24
+ kind: "boolean",
25
+ label: "Skip draft pull requests",
26
+ default: true
27
+ };
28
+ var ignoreBots = {
29
+ key: "ignoreBots",
30
+ kind: "boolean",
31
+ label: "Skip pull requests opened by bots",
32
+ help: "Dependabot and friends open a great many, and each one costs a full run.",
33
+ default: true
34
+ };
35
+ /**
36
+ * Not a quality setting so much as a cost one: the largest pull requests give
37
+ * the shallowest reviews and take the longest to produce them.
38
+ */
39
+ var sizeLimit = {
40
+ key: "maxChangedFiles",
41
+ kind: "select",
42
+ label: "Skip very large pull requests",
43
+ options: [
44
+ {
45
+ value: "0",
46
+ label: "Never skip"
47
+ },
48
+ {
49
+ value: "25",
50
+ label: "More than 25 files changed"
51
+ },
52
+ {
53
+ value: "50",
54
+ label: "More than 50 files changed"
55
+ },
56
+ {
57
+ value: "100",
58
+ label: "More than 100 files changed"
59
+ }
60
+ ],
61
+ default: "50"
62
+ };
63
+ /**
64
+ * Left empty by every loop that answers what it read, which is most of them.
65
+ * Filled in when the trigger is somewhere else entirely and there is no issue
66
+ * in it to comment on.
67
+ */
68
+ var issueTarget = {
69
+ key: "issue",
70
+ kind: "text",
71
+ label: "Issue or pull request",
72
+ help: "A GitHub URL. Leave empty to write back to whatever triggered the loop.",
73
+ placeholder: "https://github.com/acme/web/issues/12"
74
+ };
75
+ /**
76
+ * Written once, here, rather than left to whoever creates the loop. A review
77
+ * is a well-understood job and the connector should be good at it by default.
78
+ */
79
+ var REVIEW_PROMPT = [
80
+ "Review this pull request the way an experienced engineer on this team would.",
81
+ "",
82
+ "Look for correctness bugs, security problems, races, unhandled errors, and",
83
+ "changes that quietly break existing behaviour. Check whether the change is",
84
+ "tested, and say plainly when it is not.",
85
+ "",
86
+ "Point at specific files and lines rather than describing the change back to",
87
+ "its author. Say when something is done well; a review that lists only faults",
88
+ "reads as hostile. Skip anything a linter or formatter already enforces, and",
89
+ "do not restate what the diff obviously does.",
90
+ "",
91
+ "Where you cannot see enough of the code to judge something, say so instead of",
92
+ "guessing."
93
+ ].join("\n");
94
+ var PLAN_PROMPT = [
95
+ "Write a short implementation plan for this issue.",
96
+ "",
97
+ "Say what should change, in which files where you can tell, and what to watch",
98
+ "out for. Stay inside what the issue actually supports: if it is too vague to",
99
+ "plan, say what is missing rather than inventing the requirements.",
100
+ "",
101
+ "Do not write the implementation."
102
+ ].join("\n");
103
+ var IMPLEMENT_PROMPT = [
104
+ "Implement this issue in the checkout you are in.",
105
+ "",
106
+ "Read enough of the surrounding code first that what you write looks like it",
107
+ "belongs: the same patterns, the same names, the same way of handling errors.",
108
+ "Where the project has tests for work like this, add one.",
109
+ "",
110
+ "Stay inside what the issue asks for. A change that also tidies three other",
111
+ "things is a change nobody can review. If the issue is too vague to implement,",
112
+ "or doing it properly needs a decision that is not yours to make, reply with",
113
+ "exactly NOTHING_TO_DO rather than guessing: an unwanted pull request costs",
114
+ "more attention than a missing one.",
115
+ "",
116
+ "Run the project's tests if you can work out how, and say in your description",
117
+ "whether you did and what happened."
118
+ ].join("\n");
119
+ var githubManifest = defineManifest({
120
+ id: "github",
121
+ name: "GitHub",
122
+ tagline: "Pick up review requests and assigned issues.",
123
+ docsUrl: "https://docs.github.com/rest",
124
+ icon: "Github",
125
+ accent: "bg-neutral-900 text-white",
126
+ auth: {
127
+ kind: "oauth_redirect",
128
+ scopes: GITHUB_SCOPES,
129
+ needsAppRegistration: true
130
+ },
131
+ workflows: [
132
+ {
133
+ id: "github.review_requested",
134
+ name: "Review pull requests I am asked to review",
135
+ summary: "Reads the change and posts a review whenever someone asks for yours.",
136
+ trigger: "your review is requested, either directly or through a team you belong to",
137
+ watches: "is:open is:pr review-requested:@me",
138
+ writes: "a review on the pull request, as a comment rather than an approval",
139
+ settings: [
140
+ repositories,
141
+ ignoreDrafts,
142
+ ignoreBots,
143
+ sizeLimit
144
+ ],
145
+ prompt: REVIEW_PROMPT,
146
+ guidancePlaceholder: "Anything specific to your team. For example: we require a test for every new endpoint.",
147
+ answer: "review",
148
+ runsIn: "checkout",
149
+ actionId: "github.submit_review"
150
+ },
151
+ {
152
+ id: "github.issue_assigned",
153
+ name: "Plan issues assigned to me",
154
+ summary: "Posts a short implementation plan when an issue lands on you.",
155
+ trigger: "an issue is assigned to you",
156
+ watches: "is:open is:issue assignee:@me",
157
+ writes: "a comment on the issue",
158
+ settings: [repositories],
159
+ prompt: PLAN_PROMPT,
160
+ guidancePlaceholder: "Anything specific to this codebase worth knowing before planning.",
161
+ answer: "text",
162
+ runsIn: "checkout",
163
+ actionId: "github.post_issue_comment"
164
+ },
165
+ {
166
+ id: "github.issue_implement",
167
+ name: "Implement issues assigned to me",
168
+ summary: "Writes the change and opens a draft pull request.",
169
+ trigger: "an issue is assigned to you",
170
+ watches: "is:open is:issue assignee:@me",
171
+ writes: "a draft pull request",
172
+ runsIn: "checkout",
173
+ settings: [repositories],
174
+ prompt: IMPLEMENT_PROMPT,
175
+ guidancePlaceholder: "How work is done here. For example: every new endpoint needs a test, and we do not add dependencies without asking.",
176
+ answer: "code",
177
+ actionId: "github.open_pull_request"
178
+ }
179
+ ],
180
+ actions: [
181
+ {
182
+ id: "github.submit_review",
183
+ name: "Submit a review",
184
+ summary: "Post a review on a pull request, as a comment rather than an approval.",
185
+ target: [],
186
+ accepts: ["text", "review"]
187
+ },
188
+ {
189
+ id: "github.post_issue_comment",
190
+ name: "Comment on an issue",
191
+ summary: "Post a comment on an issue or pull request.",
192
+ target: [issueTarget],
193
+ accepts: ["text"]
194
+ },
195
+ {
196
+ id: "github.open_pull_request",
197
+ name: "Open a draft pull request",
198
+ summary: "Open a draft pull request for the branch the agent already pushed.",
199
+ target: [],
200
+ accepts: ["code"]
201
+ }
202
+ ],
203
+ settings: [],
204
+ allowsMultipleAccounts: false,
205
+ byHand: {
206
+ kind: "link",
207
+ placeholder: "https://github.com/acme/web/pull/123"
208
+ }
209
+ });
210
+ /**
211
+ * Reading is the whole of it. Gmail has no send scope here on purpose: an
212
+ * agent that can reply as you, to anyone, is a different and much larger thing
213
+ * to agree to than one that reads your inbox and tells you about it.
214
+ */
215
+ var GMAIL_SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"];
216
+ /**
217
+ * Gmail's own search, appended to what the workflow already asks. This is the
218
+ * setting that makes the difference between a useful loop and a firehose, so
219
+ * it leads.
220
+ */
221
+ var extraQuery = {
222
+ key: "extraQuery",
223
+ kind: "text",
224
+ label: "Narrow it down",
225
+ help: "Gmail search terms, added to the query above. For example: from:bank.example.com, or subject:invoice, or -from:notifications@github.com.",
226
+ placeholder: "from:boss@example.com"
227
+ };
228
+ /**
229
+ * A cap on how many mails one look can turn into runs, which is a cost limit
230
+ * rather than a filter: every mail past it is still noticed on the next poll,
231
+ * because nothing has marked it as dealt with.
232
+ */
233
+ var perPoll = {
234
+ key: "maxPerPoll",
235
+ kind: "select",
236
+ label: "At most, per look",
237
+ help: "Each mail costs a full agent run. A quiet mailbox never reaches this; a busy one should be narrowed above rather than raised here.",
238
+ options: [
239
+ {
240
+ value: "3",
241
+ label: "3 messages"
242
+ },
243
+ {
244
+ value: "10",
245
+ label: "10 messages"
246
+ },
247
+ {
248
+ value: "25",
249
+ label: "25 messages"
250
+ }
251
+ ],
252
+ default: "10"
253
+ };
254
+ /**
255
+ * The setting that decides whether a quiet mailbox is quiet. Without it every
256
+ * receipt arrives as a notification saying a receipt arrived, which costs a
257
+ * run each and trains you to stop looking.
258
+ */
259
+ var needsMeOnly = {
260
+ key: "needsMeOnly",
261
+ kind: "boolean",
262
+ label: "Only tell me when it needs me",
263
+ help: "One cheap look at everything that arrived decides which mails are actually asking something of you, from the sender, subject and first line. The rest wait in the backlog with a reason, so you can still run one if this got it wrong.",
264
+ default: true
265
+ };
266
+ var skipOwn = {
267
+ key: "skipOwn",
268
+ kind: "boolean",
269
+ label: "Skip mail I sent",
270
+ help: "Your own messages show up in threads you are part of, and being told about what you just sent is not useful.",
271
+ default: true
272
+ };
273
+ var TELL_ME_PROMPT = [
274
+ "Read the email in EMAIL.md and tell me what it says.",
275
+ "",
276
+ "You are writing a phone notification, not a summary document. Two or three",
277
+ "sentences. No headings, no bullet lists, no preamble like \"this email is\".",
278
+ "Lead with who it is from and what they want.",
279
+ "",
280
+ "Say plainly if it needs something from me and by when. If there is a",
281
+ "deadline, an amount of money, a date, or a link I have to act on, include it",
282
+ "exactly as written rather than describing it.",
283
+ "",
284
+ "If it is an automated notification, a newsletter, or otherwise needs nothing",
285
+ "from me, say so in one line and stop."
286
+ ].join("\n");
287
+ var gmailManifest = defineManifest({
288
+ id: "gmail",
289
+ name: "Gmail",
290
+ tagline: "Read new mail and have an agent tell you what matters.",
291
+ docsUrl: "https://developers.google.com/gmail/api/guides",
292
+ icon: "Gmail",
293
+ accent: "bg-[#ea4335] text-white",
294
+ auth: {
295
+ kind: "oauth_redirect",
296
+ scopes: GMAIL_SCOPES,
297
+ needsAppRegistration: true
298
+ },
299
+ workflows: [{
300
+ id: "gmail.new_mail",
301
+ name: "Tell me about new mail",
302
+ summary: "Reads mail as it arrives and sends you a short account of it somewhere you look.",
303
+ trigger: "mail arrives in your inbox",
304
+ watches: "in:inbox newer_than:1d",
305
+ writes: "a message telling you what arrived",
306
+ runsIn: "temp",
307
+ settings: [
308
+ extraQuery,
309
+ needsMeOnly,
310
+ perPoll,
311
+ skipOwn
312
+ ],
313
+ prompt: TELL_ME_PROMPT,
314
+ guidancePlaceholder: "What you care about in your mail. For example: I only need to know about anything involving money or a deadline.",
315
+ answer: "text",
316
+ actionConnectorId: "wechat",
317
+ actionId: "wechat.reply",
318
+ actionTarget: { to: "me" },
319
+ pollEveryMs: 18e5
320
+ }],
321
+ actions: [],
322
+ settings: [],
323
+ allowsMultipleAccounts: false,
324
+ byHand: { kind: "none" }
325
+ });
326
+ var WEEKDAY_NAMES = [
327
+ "Sunday",
328
+ "Monday",
329
+ "Tuesday",
330
+ "Wednesday",
331
+ "Thursday",
332
+ "Friday",
333
+ "Saturday"
334
+ ];
335
+ function parseTime(at) {
336
+ const match = /^\s*(\d{1,2})\s*:\s*(\d{2})\s*$/.exec(at);
337
+ if (!match) throw new Error(`"${at}" is not a time. Write it as 09:00, on a 24-hour clock.`);
338
+ const hour = Number(match[1]);
339
+ const minute = Number(match[2]);
340
+ if (hour > 23 || minute > 59) throw new Error(`There is no such time as ${at}.`);
341
+ return {
342
+ hour,
343
+ minute
344
+ };
345
+ }
346
+ /**
347
+ * The schedule a loop's settings describe.
348
+ *
349
+ * Throws rather than falling back to a default, because a loop that was meant
350
+ * to run at seven and quietly runs at midnight is worse than one that says it
351
+ * cannot read its own settings.
352
+ */
353
+ function scheduleFrom(settings) {
354
+ const every = typeof settings.every === "string" ? settings.every : "day";
355
+ const at = typeof settings.at === "string" && settings.at.trim() !== "" ? settings.at : "09:00";
356
+ if (every === "hour") return {
357
+ every: "hour",
358
+ minute: parseTime(at).minute
359
+ };
360
+ if (every === "day") return {
361
+ every: "day",
362
+ ...parseTime(at)
363
+ };
364
+ if (every === "weekday") return {
365
+ every: "weekday",
366
+ ...parseTime(at)
367
+ };
368
+ if (every === "week") {
369
+ const raw = typeof settings.weekday === "string" ? Number(settings.weekday) : 1;
370
+ return {
371
+ every: "week",
372
+ weekday: Number.isInteger(raw) && raw >= 0 && raw <= 6 ? raw : 1,
373
+ ...parseTime(at)
374
+ };
375
+ }
376
+ throw new Error(`Loopable does not know how to run something "${every}".`);
377
+ }
378
+ /** A local wall-clock time on the day `days` before `from`. */
379
+ function on(from, days, hour, minute) {
380
+ return new Date(from.getFullYear(), from.getMonth(), from.getDate() - days, hour, minute, 0, 0);
381
+ }
382
+ /** Whether this schedule accepts a day, which only the weekday one restricts. */
383
+ function accepts(schedule, when) {
384
+ const day = when.getDay();
385
+ if (schedule.every === "weekday") return day >= 1 && day <= 5;
386
+ if (schedule.every === "week") return day === schedule.weekday;
387
+ return true;
388
+ }
389
+ /**
390
+ * The most recent time this schedule came round at or before `now`.
391
+ *
392
+ * Searched backwards a day at a time rather than computed, because "the last
393
+ * weekday" and "the last Tuesday" are the same question asked of a calendar,
394
+ * and a fortnight of candidates is nothing to look through.
395
+ */
396
+ function lastDue(schedule, now) {
397
+ if (schedule.every === "hour") {
398
+ const thisHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), schedule.minute, 0, 0);
399
+ if (thisHour <= now) return thisHour;
400
+ return /* @__PURE__ */ new Date(thisHour.getTime() - 36e5);
401
+ }
402
+ for (let days = 0; days < 15; days += 1) {
403
+ const candidate = on(now, days, schedule.hour, schedule.minute);
404
+ if (candidate <= now && accepts(schedule, candidate)) return candidate;
405
+ }
406
+ throw new Error("Could not work out when this last came round.");
407
+ }
408
+ /**
409
+ * The run owed since `after`, or null when none is.
410
+ *
411
+ * At most one, and the most recent, on purpose. A laptop shut for a week owes
412
+ * a morning summary, not seven of them, and the one worth having is today's.
413
+ * Which of the missed ones is chosen matters because the caller records it: the
414
+ * older ones are passed over rather than queued behind it.
415
+ */
416
+ function dueSince(schedule, after, now) {
417
+ const last = lastDue(schedule, now);
418
+ return last > after ? last : null;
419
+ }
420
+ /**
421
+ * A clock, as a source of work.
422
+ *
423
+ * It is a connector because everything downstream of a trigger already works:
424
+ * a loop watches something, what it notices is deduplicated, becomes a task,
425
+ * runs an agent and is written somewhere by an action. A time coming round is
426
+ * simply another thing to notice. What makes this one unusual is only that
427
+ * there is no account, which is what `auth: none` says.
428
+ */
429
+ var every = {
430
+ key: "every",
431
+ kind: "select",
432
+ label: "How often",
433
+ options: [
434
+ {
435
+ value: "hour",
436
+ label: "Every hour"
437
+ },
438
+ {
439
+ value: "day",
440
+ label: "Every day"
441
+ },
442
+ {
443
+ value: "weekday",
444
+ label: "Every weekday"
445
+ },
446
+ {
447
+ value: "week",
448
+ label: "Once a week"
449
+ }
450
+ ],
451
+ default: "day"
452
+ };
453
+ var at = {
454
+ key: "at",
455
+ kind: "text",
456
+ label: "At",
457
+ help: "On a 24-hour clock, in this host's own time zone. Only the minutes are used by an hourly loop.",
458
+ placeholder: "09:00"
459
+ };
460
+ var weekday = {
461
+ key: "weekday",
462
+ kind: "select",
463
+ label: "On",
464
+ help: "Only used by a loop that runs once a week.",
465
+ options: WEEKDAY_NAMES.map((name, index) => ({
466
+ value: String(index),
467
+ label: name
468
+ })),
469
+ default: "1"
470
+ };
471
+ var folder$1 = {
472
+ key: "folder",
473
+ kind: "text",
474
+ label: "Folder to work in",
475
+ help: "An absolute path. Nothing arrives to be looked at on a schedule, so this is the whole of what the agent is given.",
476
+ placeholder: "/Users/you/code/web"
477
+ };
478
+ /**
479
+ * Deliberately a frame rather than a job. Every other workflow ships a prompt
480
+ * because "review a pull request" is a problem worth solving once and getting
481
+ * right; what to do at nine in the morning is not that kind of problem, and
482
+ * the only person who knows it is the one setting the loop up. So this ships
483
+ * something that works on the day it is turned on, and says plainly that it is
484
+ * meant to be replaced.
485
+ */
486
+ var RECURRING_PROMPT = [
487
+ "Nothing has arrived: this is running because the time came round.",
488
+ "",
489
+ "Look over the folder you are in and report anything that someone working",
490
+ "here would want to know today. Uncommitted work left sitting, a branch",
491
+ "well behind its base, failing checks, dependencies with published security",
492
+ "advisories: whatever is actually true of this checkout right now.",
493
+ "",
494
+ "Be brief and concrete. Name files and branches. Say nothing about what you",
495
+ "looked at and found fine."
496
+ ].join("\n");
497
+ var scheduleManifest = defineManifest({
498
+ id: "schedule",
499
+ name: "Schedule",
500
+ tagline: "Run a loop when the time comes round, rather than when something arrives.",
501
+ icon: "Clock",
502
+ accent: "bg-slate-700 text-white",
503
+ auth: { kind: "none" },
504
+ allowsMultipleAccounts: false,
505
+ byHand: { kind: "now" },
506
+ settings: [],
507
+ workflows: [{
508
+ id: "schedule.recurring",
509
+ name: "Do something on a schedule",
510
+ summary: "Runs an agent in a folder you name, and keeps what it says.",
511
+ trigger: "the time you set comes round",
512
+ writes: "a note in the log",
513
+ runsIn: "folder",
514
+ settings: [
515
+ every,
516
+ at,
517
+ weekday,
518
+ folder$1
519
+ ],
520
+ prompt: RECURRING_PROMPT,
521
+ guidancePlaceholder: "Anything that holds every time this runs. For example: this repository releases on Thursdays, so say if anything is unmerged by Wednesday evening.",
522
+ answer: "text",
523
+ actionId: "schedule.record"
524
+ }],
525
+ actions: [{
526
+ id: "schedule.record",
527
+ name: "Keep it in the log",
528
+ summary: "Leaves the answer here and sends it nowhere. Every run is recorded anyway, so this is what to use until it is worth pushing somewhere.",
529
+ target: [],
530
+ accepts: ["text", "review"]
531
+ }]
532
+ });
533
+ /**
534
+ * Where the agent runs. Asking a phone "what broke the build" is only worth
535
+ * anything if the agent can read the build, so this workflow works in a real
536
+ * checkout rather than an empty directory.
537
+ */
538
+ var folder = {
539
+ key: "folder",
540
+ kind: "text",
541
+ label: "Folder to work in",
542
+ help: "An absolute path to a checkout on this host. The agent runs here, so it can read the code you are asking about.",
543
+ placeholder: "/Users/you/code/your-project"
544
+ };
545
+ /**
546
+ * A bot bound to your account can be messaged by other people, and this
547
+ * workflow runs a coding agent on a runner. Answering only yourself is the
548
+ * default because the alternative hands strangers your terminal.
549
+ */
550
+ var askers = {
551
+ key: "askers",
552
+ kind: "select",
553
+ label: "Who can ask",
554
+ options: [{
555
+ value: "me",
556
+ label: "Only me"
557
+ }, {
558
+ value: "anyone",
559
+ label: "Anyone who messages the bot"
560
+ }],
561
+ default: "me"
562
+ };
563
+ /**
564
+ * Whoever asked is the answer for a loop that a message started, and the only
565
+ * case WeChat handles properly: the reply carries the token off the message it
566
+ * is answering. A loop triggered somewhere else has no message behind it and
567
+ * so no token, which the help says plainly rather than letting someone find
568
+ * out from a notification that never arrives.
569
+ */
570
+ var recipient = {
571
+ key: "to",
572
+ kind: "select",
573
+ label: "Who to send it to",
574
+ help: "WeChat only reliably delivers a reply to a conversation you started. Sending to yourself out of the blue is refused unless you have written to the bot in the last half day or so, which makes this a poor fit for anything that has to arrive overnight.",
575
+ options: [{
576
+ value: "source",
577
+ label: "Whoever asked"
578
+ }, {
579
+ value: "me",
580
+ label: "Me"
581
+ }],
582
+ default: "source"
583
+ };
584
+ var ASK_PROMPT = [
585
+ "Someone has sent you a message. Do what it asks, working in this directory,",
586
+ "and then answer them.",
587
+ "",
588
+ "You are answering into a chat on a phone. Be brief: a couple of short",
589
+ "paragraphs at most, no headings, no bullet lists unless you are genuinely",
590
+ "listing things. Plain sentences.",
591
+ "",
592
+ "If the message asks a question about this code, read enough of it to answer",
593
+ "properly rather than guessing. If it asks for a change, make the change and",
594
+ "say in one line what you did. If the message is too vague to act on, say what",
595
+ "you need to know instead of assuming."
596
+ ].join("\n");
597
+ /**
598
+ * Client-safe registry. Imports are explicit rather than a glob so the bundler
599
+ * can see exactly what ships and so a typo fails the build.
600
+ */
601
+ var CONNECTOR_MANIFESTS = [
602
+ githubManifest,
603
+ gmailManifest,
604
+ {
605
+ id: "wechat",
606
+ name: "WeChat",
607
+ tagline: "Bind a bot to your WeChat by scanning a code.",
608
+ docsUrl: "https://github.com/Tencent/openclaw-weixin/blob/main/docs/protocol.md",
609
+ icon: "Wechat",
610
+ accent: "bg-[#07c160] text-white",
611
+ auth: {
612
+ kind: "qr_scan",
613
+ note: "Scanning binds a bot to your WeChat account. Loopable can then read what people send that bot and answer as it, and nothing else on your account."
614
+ },
615
+ workflows: [{
616
+ id: "wechat.ask",
617
+ name: "Do what I ask the bot",
618
+ summary: "Runs your agent on a runner when you message the bot, and answers in the chat.",
619
+ trigger: "you send the bot a message",
620
+ writes: "a reply in the chat",
621
+ runsIn: "folder",
622
+ settings: [folder, askers],
623
+ prompt: ASK_PROMPT,
624
+ guidancePlaceholder: "Anything the agent should know about this project before it starts. For example: run pnpm test before saying a change works.",
625
+ answer: "text",
626
+ actionId: "wechat.reply"
627
+ }],
628
+ actions: [{
629
+ id: "wechat.reply",
630
+ name: "Reply in the chat",
631
+ summary: "Send a message back to whoever asked.",
632
+ target: [recipient],
633
+ accepts: ["text"]
634
+ }],
635
+ settings: [],
636
+ allowsMultipleAccounts: false,
637
+ byHand: { kind: "none" }
638
+ },
639
+ scheduleManifest
640
+ ];
641
+ function connectorManifest(id) {
642
+ return CONNECTOR_MANIFESTS.find((manifest) => manifest.id === id);
643
+ }
644
+ /**
645
+ * Whether this connector is somewhere you have an account.
646
+ *
647
+ * Asked in three places that would otherwise each assume every connector is:
648
+ * the page listing accounts, the check for what stands between a loop and
649
+ * running, and the fetch of a credential for a run.
650
+ */
651
+ function needsAccount(id) {
652
+ return connectorManifest(id)?.auth.kind !== "none";
653
+ }
654
+ function connectorWorkflow(connectorId, workflowId) {
655
+ return connectorManifest(connectorId)?.workflows.find((entry) => entry.id === workflowId);
656
+ }
657
+ function connectorAction(connectorId, actionId) {
658
+ return connectorManifest(connectorId)?.actions.find((entry) => entry.id === actionId);
659
+ }
660
+ /** Every workflow on offer, for the page that asks which one to turn on. */
661
+ function allWorkflows() {
662
+ return CONNECTOR_MANIFESTS.flatMap((connector) => connector.workflows.map((workflow) => ({
663
+ connector,
664
+ workflow
665
+ })));
666
+ }
667
+ /**
668
+ * App registrations Loopable ships. End users never set these; they only click
669
+ * Connect. A client on the user's machine cannot keep clientSecret confidential,
670
+ * which both GitHub and Google accept for public clients. PKCE is what secures
671
+ * the exchange. Env or config/oauth-app.json override this for GitHub Enterprise
672
+ * or a private registration.
673
+ */
674
+ var SHIPPED = {
675
+ github: {
676
+ clientId: "Ov23lilxTpmZdHyRCH5f",
677
+ clientSecret: "478c0e954e7c573763f128ba80054aca3fe5724e"
678
+ },
679
+ gmail: {
680
+ clientId: "477908327191-4juu8vo8bffs63hoqlul1u2fqdch27i7.apps.googleusercontent.com",
681
+ clientSecret: "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"
682
+ }
683
+ };
684
+ function shippedOAuthApp(connectorId) {
685
+ return SHIPPED[connectorId] ?? null;
686
+ }
687
+ function oauthAppRegistration(connectorId) {
688
+ return fromEnv(connectorId) ?? fromFile(connectorId) ?? shippedOAuthApp(connectorId);
689
+ }
690
+ /** LOOPABLE_GITHUB_CLIENT_ID and friends, for a private or Enterprise app. */
691
+ function fromEnv(connectorId) {
692
+ const prefix = `LOOPABLE_${connectorId.toUpperCase()}_CLIENT_`;
693
+ return pick(process.env[`${prefix}ID`], process.env[`${prefix}SECRET`]);
694
+ }
695
+ /**
696
+ * Optional override in config/oauth-app.json. Gitignored. Either an object per
697
+ * connector or, for GitHub alone, the two keys at the top level.
698
+ */
699
+ function fromFile(connectorId) {
700
+ let file;
701
+ try {
702
+ file = join(process.cwd(), "config", "oauth-app.json");
703
+ if (!existsSync(file)) return null;
704
+ } catch {
705
+ return null;
706
+ }
707
+ let parsed;
708
+ try {
709
+ parsed = JSON.parse(readFileSync(file, "utf8"));
710
+ } catch {
711
+ return null;
712
+ }
713
+ const nested = parsed[connectorId];
714
+ if (nested && typeof nested === "object") {
715
+ const entry = nested;
716
+ return pick(entry.clientId, entry.clientSecret);
717
+ }
718
+ if (connectorId !== "github") return null;
719
+ const flat = parsed;
720
+ return pick(flat.clientId, flat.clientSecret);
721
+ }
722
+ function pick(id, secret) {
723
+ const clientId = typeof id === "string" ? id.trim() : "";
724
+ const clientSecret = typeof secret === "string" ? secret.trim() : "";
725
+ return clientId && clientSecret ? {
726
+ clientId,
727
+ clientSecret
728
+ } : null;
729
+ }
730
+ /**
731
+ * Worth trying again: the service was unreachable, overloaded, or asked us to
732
+ * slow down. Connectors raise this so the dispatcher can retry without knowing
733
+ * anything about a particular service's status codes.
734
+ */
735
+ var TransientError = class extends Error {
736
+ constructor(message) {
737
+ super(message);
738
+ this.name = "TransientError";
739
+ }
740
+ };
741
+ async function request$1(accessToken, path, init = {}) {
742
+ let res;
743
+ try {
744
+ res = await fetch(`https://api.github.com${path}`, {
745
+ ...init,
746
+ headers: {
747
+ Authorization: `Bearer ${accessToken}`,
748
+ Accept: "application/vnd.github+json",
749
+ "User-Agent": "loopable",
750
+ "X-GitHub-Api-Version": "2022-11-28",
751
+ ...init.headers
752
+ }
753
+ });
754
+ } catch (error) {
755
+ throw new TransientError(`GitHub could not be reached: ${error instanceof Error ? error.message : String(error)}`);
756
+ }
757
+ if (!res.ok) {
758
+ const detail = (await res.text()).slice(0, 300);
759
+ const message = `GitHub ${res.status} on ${path}: ${detail}`;
760
+ const rateLimited = res.status === 403 && /rate limit|abuse|try again/i.test(detail);
761
+ if (res.status === 429 || res.status >= 500 || rateLimited) throw new TransientError(message);
762
+ throw new Error(message);
763
+ }
764
+ return await res.json();
765
+ }
766
+ function getPull(accessToken, repo, number) {
767
+ return request$1(accessToken, `/repos/${repo}/pulls/${number}`);
768
+ }
769
+ function getIssue(accessToken, repo, number) {
770
+ return request$1(accessToken, `/repos/${repo}/issues/${number}`);
771
+ }
772
+ function getRepo(accessToken, repo) {
773
+ return request$1(accessToken, `/repos/${repo}`);
774
+ }
775
+ function listPullFiles(accessToken, repo, number) {
776
+ return request$1(accessToken, `/repos/${repo}/pulls/${number}/files?per_page=100`);
777
+ }
778
+ /**
779
+ * Search is capped at one page on purpose. A hundred things waiting on you is
780
+ * already past the point where running an agent on all of them is the right
781
+ * answer, and paginating would only make a bad situation more expensive.
782
+ */
783
+ async function searchIssues(accessToken, query) {
784
+ return (await request$1(accessToken, `/search/issues?q=${encodeURIComponent(query)}&per_page=100&advanced_search=true`)).items;
785
+ }
786
+ /**
787
+ * Reviews are submitted as COMMENT, never APPROVE: a loop may say what it
788
+ * thinks, but whether a pull request is approved stays a human judgement.
789
+ *
790
+ * A comment on a line outside the diff makes GitHub refuse the whole review,
791
+ * so callers check their anchors before getting here.
792
+ */
793
+ function submitReview(accessToken, repo, pull, body, comments = []) {
794
+ return request$1(accessToken, `/repos/${repo}/pulls/${pull}/reviews`, {
795
+ method: "POST",
796
+ body: JSON.stringify({
797
+ event: "COMMENT",
798
+ body,
799
+ ...comments.length > 0 ? { comments } : {}
800
+ })
801
+ });
802
+ }
803
+ function postIssueComment(accessToken, repo, issue, body) {
804
+ return request$1(accessToken, `/repos/${repo}/issues/${issue}/comments`, {
805
+ method: "POST",
806
+ body: JSON.stringify({ body })
807
+ });
808
+ }
809
+ /**
810
+ * Opens a pull request for a branch that has already been pushed.
811
+ *
812
+ * Draft, always. This runs unattended, and the difference between a colleague
813
+ * finding something to look at and a colleague being asked to review something
814
+ * nobody has read is the one flag.
815
+ */
816
+ function createPullRequest(accessToken, repo, input) {
817
+ return request$1(accessToken, `/repos/${repo}/pulls`, {
818
+ method: "POST",
819
+ body: JSON.stringify({
820
+ ...input,
821
+ draft: true
822
+ })
823
+ });
824
+ }
825
+ /**
826
+ * The pull request already open for a branch, if there is one.
827
+ *
828
+ * A retry pushes the same branch again, and asking GitHub to open a second
829
+ * pull request for it fails. Finding the first one instead turns a retry into
830
+ * what a person would expect: the change updated, not an error.
831
+ */
832
+ async function pullForBranch(accessToken, repo, owner, branch) {
833
+ return (await request$1(accessToken, `/repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=open`))[0] ?? null;
834
+ }
835
+ async function getViewer(accessToken) {
836
+ const res = await fetch("https://api.github.com/user", { headers: {
837
+ Authorization: `Bearer ${accessToken}`,
838
+ Accept: "application/vnd.github+json",
839
+ "User-Agent": "loopable",
840
+ "X-GitHub-Api-Version": "2022-11-28"
841
+ } });
842
+ if (!res.ok) throw new Error(`GitHub ${res.status} on /user: ${(await res.text()).slice(0, 200)}`);
843
+ return {
844
+ user: await res.json(),
845
+ scopes: (res.headers.get("x-oauth-scopes") ?? "").split(",").map((scope) => scope.trim()).filter(Boolean)
846
+ };
847
+ }
848
+ /**
849
+ * A unified patch, read twice: once to write line numbers into it, and once to
850
+ * know which of those numbers a comment may be attached to.
851
+ *
852
+ * The numbers are put in the diff on purpose. An agent can work them out from
853
+ * the @@ headers, and gets them wrong often enough that reviews end up pointing
854
+ * a few lines off, which is worse than useless in a review. Telling it the
855
+ * number costs a few characters per line and removes the arithmetic.
856
+ */
857
+ var HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
858
+ /**
859
+ * Only lines that survive into the new file get a number, because those are
860
+ * the only ones a comment on the right-hand side can be attached to. A removed
861
+ * line is shown, and deliberately left unnumbered so it cannot be aimed at.
862
+ */
863
+ function annotate(patch) {
864
+ const out = [];
865
+ const lines = [];
866
+ let next = 0;
867
+ for (const line of patch.split("\n")) {
868
+ const hunk = HUNK.exec(line);
869
+ if (hunk) {
870
+ next = Number(hunk[1]);
871
+ out.push(` | ${line}`);
872
+ continue;
873
+ }
874
+ if (next === 0 || line.startsWith("\\")) {
875
+ out.push(` | ${line}`);
876
+ continue;
877
+ }
878
+ const marker = line[0] ?? " ";
879
+ const content = line.slice(1);
880
+ if (marker === "-") {
881
+ out.push(`- | ${content}`);
882
+ continue;
883
+ }
884
+ out.push(`${marker}${String(next).padStart(5)} | ${content}`);
885
+ lines.push(next);
886
+ next += 1;
887
+ }
888
+ return {
889
+ text: out.join("\n"),
890
+ lines
891
+ };
892
+ }
893
+ /** Said once at the top of the file, rather than above every patch. */
894
+ var ANNOTATION_LEGEND = ["Each line is shown as `<marker><line number> | <content>`, where the number", "is the line in the new version of the file. Removed lines have no number."].join("\n");
895
+ var HOSTS = /* @__PURE__ */ new Set(["github.com", "www.github.com"]);
896
+ /**
897
+ * Accepts what a person actually copies out of the address bar, and checks the
898
+ * host properly: matching "github.com" anywhere in the text would accept
899
+ * https://notgithub.com/acme/web/pull/1 and then go and fetch acme/web from
900
+ * the real GitHub.
901
+ */
902
+ function parseGithubUrl(input) {
903
+ const trimmed = input.trim();
904
+ if (!trimmed) return null;
905
+ let url;
906
+ try {
907
+ url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
908
+ } catch {
909
+ return null;
910
+ }
911
+ if (!HOSTS.has(url.hostname.toLowerCase())) return null;
912
+ const match = url.pathname.match(/^\/([^/]+)\/([^/]+)\/(pull|issues)\/(\d+)/);
913
+ if (!match) return null;
914
+ const [, owner, name, kind, number] = match;
915
+ return {
916
+ repo: `${owner}/${name}`,
917
+ kind: kind === "pull" ? "pull_request" : "issue",
918
+ number: Number(number)
919
+ };
920
+ }
921
+ /** How GitHub names a thing everywhere outside itself: `owner/name#12`. */
922
+ function githubRef(repo, number) {
923
+ return `${repo}#${number}`;
924
+ }
925
+ /**
926
+ * The other direction, for the point where a write has to name a repository
927
+ * and a number again. Throws rather than returns null: a ref that this
928
+ * connector wrote and cannot read back is a bug, not a bad input.
929
+ */
930
+ function parseGithubRef(ref) {
931
+ const match = ref.match(/^([^/\s]+\/[^/\s#]+)#(\d+)$/);
932
+ if (!match) throw new Error(`Not a GitHub ref: ${ref}`);
933
+ return {
934
+ repo: match[1],
935
+ number: Number(match[2])
936
+ };
937
+ }
938
+ /**
939
+ * Generous, because a review written against half a diff is worse than no
940
+ * review: the agent hedges everything it could not see. Nothing is cut while
941
+ * the whole diff fits, and where a cut is unavoidable it is announced, so the
942
+ * agent knows to stay quiet about the rest instead of guessing.
943
+ */
944
+ var TOTAL_LIMIT = 2e5;
945
+ /** No file is cut below this, however many files there are. */
946
+ var MIN_PER_FILE = 8e3;
947
+ /**
948
+ * A file's patch as the agent sees it, and the lines it may comment on. A cut
949
+ * patch keeps only the anchors that are still visible: a comment on a line the
950
+ * agent was never shown would be a guess.
951
+ */
952
+ function patchOf(file, budget) {
953
+ const patch = file.patch;
954
+ if (!patch) return {
955
+ body: file.status === "renamed" ? "_Renamed, no content change._" : "_No diff available._",
956
+ lines: []
957
+ };
958
+ const whole = patch.length <= budget ? patch : patch.slice(0, patch.lastIndexOf("\n", budget));
959
+ const { text, lines } = annotate(whole);
960
+ const block = `\`\`\`diff\n${text}\n\`\`\``;
961
+ if (patch.length <= budget) return {
962
+ body: block,
963
+ lines
964
+ };
965
+ return {
966
+ body: [block, `**The diff for this file was cut here: ${patch.slice(whole.length).split("\n").length} more lines are not shown. Do not draw conclusions about the part you cannot see.**`].join("\n\n"),
967
+ lines
968
+ };
969
+ }
970
+ function diffOf(files) {
971
+ const share = files.reduce((sum, file) => sum + (file.patch?.length ?? 0), 0) <= TOTAL_LIMIT ? Number.POSITIVE_INFINITY : Math.max(MIN_PER_FILE, Math.floor(TOTAL_LIMIT / files.length));
972
+ const sections = [ANNOTATION_LEGEND];
973
+ const commentable = {};
974
+ let budget = TOTAL_LIMIT;
975
+ let shown = 0;
976
+ for (const file of files) {
977
+ if (budget <= 0) break;
978
+ const counts = [file.additions === void 0 ? null : `+${file.additions}`, file.deletions === void 0 ? null : `-${file.deletions}`].filter(Boolean).join(" ");
979
+ const { body, lines } = patchOf(file, Math.min(share, budget));
980
+ sections.push(`### ${file.filename}\n\n${file.status}${counts ? ` ${counts}` : ""}\n\n${body}`);
981
+ if (lines.length > 0) commentable[file.filename] = lines;
982
+ budget -= body.length;
983
+ shown += 1;
984
+ }
985
+ const omitted = files.length - shown;
986
+ if (omitted > 0) sections.push(`**${omitted} more changed files are not shown at all: ${files.slice(shown).map((file) => file.filename).join(", ")}**`);
987
+ return {
988
+ body: sections.join("\n\n"),
989
+ commentable
990
+ };
991
+ }
992
+ function filesOverview(files) {
993
+ if (files.length === 0) return "_No files changed._";
994
+ return files.map((file) => {
995
+ const counts = [file.additions === void 0 ? null : `+${file.additions}`, file.deletions === void 0 ? null : `-${file.deletions}`].filter(Boolean).join(" ");
996
+ return `- ${file.filename} (${file.status}${counts ? ` ${counts}` : ""})`;
997
+ }).join("\n");
998
+ }
999
+ /**
1000
+ * Metadata for the agent. The Connection token stays here: it is used to read
1001
+ * the pull request and to know which lines a comment may land on. Clone URL and
1002
+ * ref are written into the files the agent reads, with no token on them.
1003
+ */
1004
+ async function resolveWorkItem(url, accessToken) {
1005
+ const parsed = parseGithubUrl(url);
1006
+ if (!parsed) throw new Error("That does not look like a GitHub pull request or issue link.");
1007
+ const cloneUrl = `https://github.com/${parsed.repo}.git`;
1008
+ if (parsed.kind === "pull_request") {
1009
+ const pull = await getPull(accessToken, parsed.repo, parsed.number);
1010
+ const files = await listPullFiles(accessToken, parsed.repo, parsed.number);
1011
+ return {
1012
+ kind: "pull_request",
1013
+ commentable: diffOf(files).commentable,
1014
+ checkout: {
1015
+ url: cloneUrl,
1016
+ ref: `pull/${parsed.number}/head`,
1017
+ sha: pull.head.sha,
1018
+ base: pull.base.ref,
1019
+ repo: parsed.repo
1020
+ },
1021
+ ref: githubRef(parsed.repo, pull.number),
1022
+ title: pull.title,
1023
+ url: pull.html_url,
1024
+ context: [{
1025
+ name: "PULL_REQUEST.md",
1026
+ body: [
1027
+ `# ${pull.title}`,
1028
+ ``,
1029
+ `Repository: ${parsed.repo}`,
1030
+ `Pull request: #${pull.number}`,
1031
+ `Opened by: ${pull.user.login}`,
1032
+ `Draft: ${pull.draft ? "yes" : "no"}`,
1033
+ `URL: ${pull.html_url}`,
1034
+ ``,
1035
+ `Clone: ${cloneUrl}`,
1036
+ `Ref: pull/${parsed.number}/head`,
1037
+ ``,
1038
+ `## Description`,
1039
+ ``,
1040
+ pull.body?.trim() || "_No description._",
1041
+ ``,
1042
+ `## Changed files`,
1043
+ ``,
1044
+ filesOverview(files)
1045
+ ].join("\n")
1046
+ }]
1047
+ };
1048
+ }
1049
+ const issue = await getIssue(accessToken, parsed.repo, parsed.number);
1050
+ const repo = await getRepo(accessToken, parsed.repo);
1051
+ return {
1052
+ kind: "issue",
1053
+ checkout: {
1054
+ url: cloneUrl,
1055
+ ref: repo.default_branch,
1056
+ base: repo.default_branch,
1057
+ repo: parsed.repo
1058
+ },
1059
+ ref: githubRef(parsed.repo, issue.number),
1060
+ title: issue.title,
1061
+ url: issue.html_url,
1062
+ context: [{
1063
+ name: "ISSUE.md",
1064
+ body: [
1065
+ `# ${issue.title}`,
1066
+ ``,
1067
+ `Repository: ${parsed.repo}`,
1068
+ `Issue: #${issue.number}`,
1069
+ `Opened by: ${issue.user.login}`,
1070
+ `URL: ${issue.html_url}`,
1071
+ ``,
1072
+ `Clone: ${cloneUrl}`,
1073
+ `Ref: ${repo.default_branch}`,
1074
+ ``,
1075
+ `## Description`,
1076
+ ``,
1077
+ issue.body?.trim() || "_No description._"
1078
+ ].join("\n")
1079
+ }]
1080
+ };
1081
+ }
1082
+ /**
1083
+ * Search tells you which repository a result came from only through its API
1084
+ * URL, so the owner and name are read back out of it.
1085
+ */
1086
+ function repoFromApiUrl(url) {
1087
+ const match = /^https:\/\/api\.github\.com\/repos\/([^/]+)\/([^/]+)$/.exec(url);
1088
+ return match ? `${match[1]}/${match[2]}` : null;
1089
+ }
1090
+ /** An empty list means every repository the account can see. */
1091
+ function repoMatches(repo, patterns) {
1092
+ if (patterns.length === 0) return true;
1093
+ const target = repo.toLowerCase();
1094
+ return patterns.some((pattern) => {
1095
+ const escaped = pattern.trim().toLowerCase().replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
1096
+ return escaped !== "" && new RegExp(`^${escaped}$`).test(target);
1097
+ });
1098
+ }
1099
+ /** Apps get a login ending in [bot]; the type is set for both apps and users. */
1100
+ function isBot(user) {
1101
+ return user.type === "Bot" || user.login.endsWith("[bot]");
1102
+ }
1103
+ function stringList(value) {
1104
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
1105
+ }
1106
+ /**
1107
+ * Missing means "the workflow's default", not "off". A loop saved before a
1108
+ * setting existed must behave as if it had been there all along.
1109
+ */
1110
+ function boolean$1(value, fallback) {
1111
+ return typeof value === "boolean" ? value : fallback;
1112
+ }
1113
+ function count$1(value, fallback) {
1114
+ const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN;
1115
+ return Number.isFinite(parsed) ? parsed : fallback;
1116
+ }
1117
+ function candidates(items, repositories) {
1118
+ return items.flatMap((item) => {
1119
+ const repo = repoFromApiUrl(item.repository_url);
1120
+ return repo && repoMatches(repo, repositories) ? [{
1121
+ repo,
1122
+ item
1123
+ }] : [];
1124
+ });
1125
+ }
1126
+ /**
1127
+ * What the workflow says it watches for, which is what gets asked. Taking the
1128
+ * query from the manifest is the point: it is shown on the loop page, and a
1129
+ * search that quietly differed from the one on screen would be the worst kind
1130
+ * of wrong.
1131
+ */
1132
+ function query(workflowId) {
1133
+ const workflow = githubManifest.workflows.find((entry) => entry.id === workflowId);
1134
+ if (!workflow?.watches) throw new Error(`GitHub cannot watch for ${workflowId}.`);
1135
+ return workflow.watches;
1136
+ }
1137
+ async function pollGithub(input) {
1138
+ const repositories = stringList(input.settings.repositories);
1139
+ if (input.workflowId === "github.review_requested") {
1140
+ const items = await searchIssues(input.accessToken, query(input.workflowId));
1141
+ const ignoreDrafts = boolean$1(input.settings.ignoreDrafts, true);
1142
+ const ignoreBots = boolean$1(input.settings.ignoreBots, true);
1143
+ const maxFiles = count$1(input.settings.maxChangedFiles, 50);
1144
+ const signals = [];
1145
+ for (const { repo, item } of candidates(items, repositories)) {
1146
+ if (ignoreBots && isBot(item.user)) continue;
1147
+ if (ignoreDrafts && item.draft) continue;
1148
+ const pull = await getPull(input.accessToken, repo, item.number);
1149
+ if (ignoreDrafts && pull.draft) continue;
1150
+ const tooBig = maxFiles > 0 && pull.changed_files > maxFiles;
1151
+ signals.push({
1152
+ key: `${githubRef(repo, item.number)}@${pull.head.sha}`,
1153
+ kind: "pull_request",
1154
+ ref: githubRef(repo, item.number),
1155
+ title: item.title,
1156
+ url: item.html_url,
1157
+ hold: tooBig ? `${pull.changed_files} files changed, over this loop's ${maxFiles}` : void 0
1158
+ });
1159
+ }
1160
+ return signals;
1161
+ }
1162
+ if (input.workflowId === "github.issue_assigned") return candidates(await searchIssues(input.accessToken, query(input.workflowId)), repositories).map(({ repo, item }) => ({
1163
+ key: githubRef(repo, item.number),
1164
+ kind: "issue",
1165
+ ref: githubRef(repo, item.number),
1166
+ title: item.title,
1167
+ url: item.html_url
1168
+ }));
1169
+ throw new Error(`GitHub cannot watch for ${input.workflowId}.`);
1170
+ }
1171
+ function createPkce$1() {
1172
+ const verifier = randomBytes(32).toString("base64url");
1173
+ return {
1174
+ verifier,
1175
+ challenge: createHash("sha256").update(verifier).digest("base64url")
1176
+ };
1177
+ }
1178
+ function buildAuthorizeUrl$1(input) {
1179
+ return `https://github.com/login/oauth/authorize?${new URLSearchParams({
1180
+ client_id: input.clientId,
1181
+ redirect_uri: input.redirectUri,
1182
+ scope: GITHUB_SCOPES.join(" "),
1183
+ state: input.state,
1184
+ code_challenge: input.challenge,
1185
+ code_challenge_method: "S256"
1186
+ }).toString()}`;
1187
+ }
1188
+ async function postToken$1(payload, fetchImpl) {
1189
+ const text = await (await fetchImpl("https://github.com/login/oauth/access_token", {
1190
+ method: "POST",
1191
+ headers: {
1192
+ Accept: "application/json",
1193
+ "Content-Type": "application/json"
1194
+ },
1195
+ body: JSON.stringify(payload)
1196
+ })).text();
1197
+ let body;
1198
+ try {
1199
+ body = JSON.parse(text);
1200
+ } catch {
1201
+ throw new Error(`GitHub returned a non-JSON response: ${text.slice(0, 200)}`);
1202
+ }
1203
+ const error = typeof body.error === "string" ? body.error : "";
1204
+ if (error) {
1205
+ const description = typeof body.error_description === "string" ? body.error_description : error;
1206
+ throw new Error(description);
1207
+ }
1208
+ const accessToken = typeof body.access_token === "string" ? body.access_token : "";
1209
+ if (!accessToken) throw new Error("GitHub did not return an access token");
1210
+ const expiresIn = Number(body.expires_in);
1211
+ return {
1212
+ accessToken,
1213
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
1214
+ expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1e3 - 6e4 : void 0
1215
+ };
1216
+ }
1217
+ function exchangeCode$1(input, fetchImpl = fetch) {
1218
+ return postToken$1({
1219
+ client_id: input.clientId,
1220
+ client_secret: input.clientSecret,
1221
+ code: input.code,
1222
+ redirect_uri: input.redirectUri,
1223
+ code_verifier: input.verifier
1224
+ }, fetchImpl);
1225
+ }
1226
+ function refreshCredential$1(input, fetchImpl = fetch) {
1227
+ return postToken$1({
1228
+ client_id: input.clientId,
1229
+ client_secret: input.clientSecret,
1230
+ grant_type: "refresh_token",
1231
+ refresh_token: input.refreshToken
1232
+ }, fetchImpl);
1233
+ }
1234
+ function requireRegistration$1() {
1235
+ const registration = oauthAppRegistration("github");
1236
+ if (!registration) throw new Error("GitHub is not configured in this build: the OAuth app registration is missing.");
1237
+ return registration;
1238
+ }
1239
+ function accountFrom(user) {
1240
+ return {
1241
+ id: String(user.user.id),
1242
+ label: user.user.login,
1243
+ url: user.user.html_url,
1244
+ avatarUrl: user.user.avatar_url,
1245
+ scopes: user.scopes
1246
+ };
1247
+ }
1248
+ /**
1249
+ * Where the loop was triggered, read as a GitHub ref. Only possible when
1250
+ * GitHub is where it came from: another connector's ref is a label this one
1251
+ * has no business taking apart, so a loop that crosses services has to say
1252
+ * where the answer goes instead of leaving it to be guessed.
1253
+ */
1254
+ function sourceRef(source, what) {
1255
+ if (source.connectorId !== "github") throw new Error(`${what} needs a GitHub issue or pull request. Choose one on the loop.`);
1256
+ return parseGithubRef(source.ref);
1257
+ }
1258
+ function namedRef(url) {
1259
+ const parsed = parseGithubUrl(url);
1260
+ if (!parsed) throw new Error(`That is not a GitHub issue or pull request: ${url}`);
1261
+ return {
1262
+ repo: parsed.repo,
1263
+ number: parsed.number
1264
+ };
1265
+ }
1266
+ /** The agent was asked for a subject line first, so this is that line. */
1267
+ function titleOf(body) {
1268
+ const clean = (body.split("\n").find((line) => line.trim() !== "")?.trim() ?? "").replace(/^#+\s*/, "");
1269
+ return clean.length > 120 ? `${clean.slice(0, 117)}…` : clean || "Changes from Loopable";
1270
+ }
1271
+ /**
1272
+ * The description, with the two things the agent cannot say for itself: what
1273
+ * this came from, and how big it is. Both are what a person checks first when
1274
+ * a pull request appears that they did not open.
1275
+ */
1276
+ function pullBody(input) {
1277
+ const closes = input.source.connectorId === "github" && input.source.kind === "issue" ? `Closes #${parseGithubRef(input.source.ref).number}` : `From [${input.source.title}](${input.source.url})`;
1278
+ const size = input.stat ? `\n\n<details><summary>Files changed</summary>\n\n\`\`\`\n${input.stat}\n\`\`\`\n\n</details>` : "";
1279
+ return `${closes}\n\n${input.body}${size}`;
1280
+ }
1281
+ /**
1282
+ * Returns a usable access token, refreshing it when the stored one has expired.
1283
+ * The refreshed credential is handed back so the caller can persist it.
1284
+ */
1285
+ async function usableToken$1(credential) {
1286
+ if (!credential.expiresAt || Date.now() < credential.expiresAt) return { accessToken: credential.accessToken };
1287
+ if (!credential.refreshToken) throw new Error("The GitHub token expired and there is no refresh token. Reconnect the account.");
1288
+ const registration = requireRegistration$1();
1289
+ const refreshed = await refreshCredential$1({
1290
+ clientId: registration.clientId,
1291
+ clientSecret: registration.clientSecret,
1292
+ refreshToken: credential.refreshToken
1293
+ });
1294
+ return {
1295
+ accessToken: refreshed.accessToken,
1296
+ refreshed
1297
+ };
1298
+ }
1299
+ var githubRuntime = defineRuntime({
1300
+ async readiness() {
1301
+ if (oauthAppRegistration("github")) return { ready: true };
1302
+ return {
1303
+ ready: false,
1304
+ reason: "This build has no GitHub app registration.",
1305
+ fixHint: "Set LOOPABLE_GITHUB_CLIENT_ID and LOOPABLE_GITHUB_CLIENT_SECRET to use your own OAuth app."
1306
+ };
1307
+ },
1308
+ identifyLink(url) {
1309
+ const parsed = parseGithubUrl(url);
1310
+ if (!parsed) return null;
1311
+ return {
1312
+ kind: parsed.kind,
1313
+ ref: githubRef(parsed.repo, parsed.number)
1314
+ };
1315
+ },
1316
+ async resolveWorkItem({ url, credential }) {
1317
+ const { accessToken } = await usableToken$1(credential);
1318
+ return resolveWorkItem(url, accessToken);
1319
+ },
1320
+ async poll({ workflowId, settings, credential }) {
1321
+ const { accessToken } = await usableToken$1(credential);
1322
+ return { signals: await pollGithub({
1323
+ workflowId,
1324
+ settings,
1325
+ accessToken
1326
+ }) };
1327
+ },
1328
+ async applyAction({ actionId, target, source, body, comments, changes, credential }) {
1329
+ const { accessToken } = await usableToken$1(credential);
1330
+ if (actionId === "github.submit_review") {
1331
+ const { repo, number } = sourceRef(source, "A review");
1332
+ if (source.kind !== "pull_request") throw new Error("A review can only be submitted on a pull request.");
1333
+ return { url: (await submitReview(accessToken, repo, number, body, (comments ?? []).map((comment) => ({
1334
+ path: comment.path,
1335
+ line: comment.line,
1336
+ side: "RIGHT",
1337
+ ...comment.startLine !== void 0 ? {
1338
+ start_line: comment.startLine,
1339
+ start_side: "RIGHT"
1340
+ } : {},
1341
+ body: comment.body
1342
+ })))).html_url };
1343
+ }
1344
+ if (actionId === "github.post_issue_comment") {
1345
+ const named = typeof target.issue === "string" ? target.issue.trim() : "";
1346
+ const { repo, number } = named ? namedRef(named) : sourceRef(source, "A comment");
1347
+ return { url: (await postIssueComment(accessToken, repo, number, body)).html_url };
1348
+ }
1349
+ if (actionId === "github.open_pull_request") {
1350
+ if (!changes) throw new Error("There is no branch to open a pull request for.");
1351
+ const repo = changes.repo;
1352
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error(`Not a GitHub repository: ${repo}`);
1353
+ const owner = repo.split("/")[0];
1354
+ const existing = await pullForBranch(accessToken, repo, owner, changes.branch);
1355
+ if (existing) return { url: existing.html_url };
1356
+ return { url: (await createPullRequest(accessToken, repo, {
1357
+ title: titleOf(body),
1358
+ body: pullBody({
1359
+ body,
1360
+ source,
1361
+ stat: changes.stat
1362
+ }),
1363
+ head: changes.branch,
1364
+ base: changes.base
1365
+ })).html_url };
1366
+ }
1367
+ throw new Error(`GitHub cannot ${actionId}.`);
1368
+ },
1369
+ auth: {
1370
+ async startAuthorization({ redirectUri, state }) {
1371
+ const registration = requireRegistration$1();
1372
+ const { verifier, challenge } = createPkce$1();
1373
+ return {
1374
+ verifier,
1375
+ redirectUrl: buildAuthorizeUrl$1({
1376
+ clientId: registration.clientId,
1377
+ redirectUri,
1378
+ state,
1379
+ challenge
1380
+ })
1381
+ };
1382
+ },
1383
+ async completeAuthorization({ code, redirectUri, verifier }) {
1384
+ const registration = requireRegistration$1();
1385
+ const credential = await exchangeCode$1({
1386
+ clientId: registration.clientId,
1387
+ clientSecret: registration.clientSecret,
1388
+ code,
1389
+ redirectUri,
1390
+ verifier
1391
+ });
1392
+ return {
1393
+ credential,
1394
+ account: accountFrom(await getViewer(credential.accessToken))
1395
+ };
1396
+ },
1397
+ async identity(credential) {
1398
+ const { accessToken, refreshed } = await usableToken$1(credential);
1399
+ const account = accountFrom(await getViewer(accessToken));
1400
+ return refreshed ? {
1401
+ account,
1402
+ renewedCredential: refreshed
1403
+ } : { account };
1404
+ }
1405
+ }
1406
+ });
1407
+ var BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
1408
+ async function request(accessToken, path) {
1409
+ let res;
1410
+ try {
1411
+ res = await fetch(`${BASE}${path}`, { headers: {
1412
+ Authorization: `Bearer ${accessToken}`,
1413
+ Accept: "application/json"
1414
+ } });
1415
+ } catch (error) {
1416
+ throw new TransientError(`Gmail could not be reached: ${error instanceof Error ? error.message : String(error)}`);
1417
+ }
1418
+ if (!res.ok) {
1419
+ const detail = (await res.text()).slice(0, 300);
1420
+ const message = `Gmail ${res.status} on ${path}: ${detail}`;
1421
+ const backoff = res.status === 403 && /rateLimitExceeded|userRateLimitExceeded/i.test(detail);
1422
+ if (res.status === 429 || res.status >= 500 || backoff) throw new TransientError(message);
1423
+ throw new Error(message);
1424
+ }
1425
+ return await res.json();
1426
+ }
1427
+ function getProfile(accessToken) {
1428
+ return request(accessToken, "/profile");
1429
+ }
1430
+ /**
1431
+ * Message ids matching a Gmail search, newest first.
1432
+ *
1433
+ * One page only, like the GitHub search: past a certain number of unanswered
1434
+ * things, running an agent over all of them is the wrong answer and paging
1435
+ * would only make it a more expensive wrong answer.
1436
+ */
1437
+ async function listMessages(accessToken, query, max) {
1438
+ return ((await request(accessToken, `/messages?q=${encodeURIComponent(query)}&maxResults=${max}`)).messages ?? []).map((entry) => entry.id);
1439
+ }
1440
+ /**
1441
+ * Just enough to recognise a message, without pulling its body down.
1442
+ *
1443
+ * Polling only needs to say what arrived and title it, and bodies are the
1444
+ * expensive part of a mailbox. The whole message is fetched later, once
1445
+ * something is actually going to be done with it.
1446
+ */
1447
+ function getMessageHeaders(accessToken, id) {
1448
+ return request(accessToken, `/messages/${id}?format=metadata${[
1449
+ "From",
1450
+ "To",
1451
+ "Subject",
1452
+ "Date",
1453
+ "List-Id"
1454
+ ].map((name) => `&metadataHeaders=${name}`).join("")}`);
1455
+ }
1456
+ function getMessage(accessToken, id) {
1457
+ return request(accessToken, `/messages/${id}?format=full`);
1458
+ }
1459
+ /** A Gmail message id, as a ref: short, stable, and unique to the mailbox. */
1460
+ function mailRef(id) {
1461
+ return `mail#${id}`;
1462
+ }
1463
+ /**
1464
+ * Where to send someone who wants to see the mail itself. Gmail has no
1465
+ * per-message API link, but the web client will open one by id.
1466
+ */
1467
+ function permalink(id) {
1468
+ return `https://mail.google.com/mail/u/0/#all/${id}`;
1469
+ }
1470
+ function headerOf(message, name) {
1471
+ const wanted = name.toLowerCase();
1472
+ return (message.payload?.headers ?? []).find((header) => header.name.toLowerCase() === wanted)?.value.trim() ?? "";
1473
+ }
1474
+ /** The address out of `Some One <one@example.com>`, or the whole thing if bare. */
1475
+ function addressOf(from) {
1476
+ const angled = /<([^>]+)>/.exec(from);
1477
+ return (angled ? angled[1] : from).trim().toLowerCase();
1478
+ }
1479
+ /** The name out of `Some One <one@example.com>`, falling back to the address. */
1480
+ function senderName(from) {
1481
+ return from.split("<")[0].trim().replace(/^"|"$/g, "") || addressOf(from);
1482
+ }
1483
+ function decode(data) {
1484
+ return Buffer.from(data, "base64url").toString("utf8");
1485
+ }
1486
+ /**
1487
+ * Tags out, text kept, in the order a reader would meet it.
1488
+ *
1489
+ * Only reached for mail that carries no plain text part at all. It is not
1490
+ * trying to be a renderer: an agent reading this needs the words and roughly
1491
+ * where the line breaks were, and everything else is noise it would have to
1492
+ * see past.
1493
+ */
1494
+ function htmlToText(html) {
1495
+ return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|tr|li|h[1-6])>/gi, "\n").replace(/<li\b[^>]*>/gi, "- ").replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, "\"").replace(/&#39;|&apos;/gi, "'").replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
1496
+ }
1497
+ /**
1498
+ * The readable body, preferring what the sender wrote as text.
1499
+ *
1500
+ * A message is a tree, and a normal one from a normal client has both a plain
1501
+ * and an HTML version of the same words. Taking plain text where it exists
1502
+ * avoids ever having to guess at markup; the walk below collects both on one
1503
+ * pass and only falls back when there was no text part.
1504
+ */
1505
+ function bodyText(message) {
1506
+ const plain = [];
1507
+ const html = [];
1508
+ const walk = (part) => {
1509
+ if (!part) return;
1510
+ const type = (part.mimeType ?? "").toLowerCase();
1511
+ if (!(Boolean(part.filename) || Boolean(part.body?.attachmentId)) && part.body?.data) {
1512
+ if (type === "text/plain") plain.push(decode(part.body.data));
1513
+ else if (type === "text/html") html.push(decode(part.body.data));
1514
+ }
1515
+ for (const child of part.parts ?? []) walk(child);
1516
+ };
1517
+ walk(message.payload);
1518
+ const text = plain.join("\n").trim();
1519
+ if (text) return text;
1520
+ const markup = html.join("\n").trim();
1521
+ return markup ? htmlToText(markup) : (message.snippet ?? "").trim();
1522
+ }
1523
+ /** The attachments, named only. Nothing downloads them; the agent should know. */
1524
+ function attachmentNames(message) {
1525
+ const names = [];
1526
+ const walk = (part) => {
1527
+ if (!part) return;
1528
+ if (part.filename) names.push(part.filename);
1529
+ for (const child of part.parts ?? []) walk(child);
1530
+ };
1531
+ walk(message.payload);
1532
+ return names;
1533
+ }
1534
+ /** Enough of a message to pick it out of a list. */
1535
+ function summarise$1(message) {
1536
+ const subject = headerOf(message, "Subject") || "(no subject)";
1537
+ const who = senderName(headerOf(message, "From"));
1538
+ const title = who ? `${who}: ${subject}` : subject;
1539
+ return title.length > 120 ? `${title.slice(0, 119)}…` : title;
1540
+ }
1541
+ /**
1542
+ * A long mail is cut rather than sent whole. What follows this is an agent
1543
+ * with a context window and, at the end of the chain, a phone: a mailing list
1544
+ * digest read to the last line helps nobody and costs the run.
1545
+ */
1546
+ var BODY_LIMIT = 2e4;
1547
+ /** The mail as the agent reads it: one file, headers first, then the words. */
1548
+ function mailContext(message) {
1549
+ const body = bodyText(message);
1550
+ const clipped = body.length > BODY_LIMIT ? `${body.slice(0, BODY_LIMIT)}\n\n[cut here: the message is longer than this]` : body;
1551
+ const attachments = attachmentNames(message);
1552
+ const date = headerOf(message, "Date");
1553
+ return {
1554
+ name: "EMAIL.md",
1555
+ body: [
1556
+ `# ${headerOf(message, "Subject") || "(no subject)"}`,
1557
+ ``,
1558
+ `From: ${headerOf(message, "From") || "(unknown)"}`,
1559
+ ...headerOf(message, "To") ? [`To: ${headerOf(message, "To")}`] : [],
1560
+ ...date ? [`Date: ${date}`] : [],
1561
+ ...attachments.length > 0 ? [`Attachments (not included here): ${attachments.join(", ")}`] : [],
1562
+ ``,
1563
+ `---`,
1564
+ ``,
1565
+ clipped || "(this message has no readable text)",
1566
+ ``
1567
+ ].join("\n")
1568
+ };
1569
+ }
1570
+ /**
1571
+ * What the workflow says it watches for, which is what gets asked. Taken from
1572
+ * the manifest rather than written again here, because it is shown on the loop
1573
+ * page and a query that quietly differed from the one on screen would be the
1574
+ * worst kind of wrong.
1575
+ */
1576
+ function baseQuery(workflowId) {
1577
+ const workflow = gmailManifest.workflows.find((entry) => entry.id === workflowId);
1578
+ if (!workflow?.watches) throw new Error(`Gmail cannot watch for ${workflowId}.`);
1579
+ return workflow.watches;
1580
+ }
1581
+ /**
1582
+ * Missing means "the workflow's default", not "off". A loop saved before a
1583
+ * setting existed must behave as if it had been there all along.
1584
+ */
1585
+ function boolean(value, fallback) {
1586
+ return typeof value === "boolean" ? value : fallback;
1587
+ }
1588
+ function count(value, fallback) {
1589
+ const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN;
1590
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1591
+ }
1592
+ function text(value) {
1593
+ return typeof value === "string" ? value.trim() : "";
1594
+ }
1595
+ /** What Loopable asks Gmail: the workflow's query, then whatever the loop adds. */
1596
+ function queryFor(workflowId, settings) {
1597
+ const extra = text(settings.extraQuery);
1598
+ return extra ? `${baseQuery(workflowId)} ${extra}` : baseQuery(workflowId);
1599
+ }
1600
+ async function pollGmail(input) {
1601
+ if (input.workflowId !== "gmail.new_mail") throw new Error(`Gmail cannot watch for ${input.workflowId}.`);
1602
+ const max = count(input.settings.maxPerPoll, 10);
1603
+ const ids = await listMessages(input.accessToken, queryFor(input.workflowId, input.settings), max);
1604
+ const skipOwn = boolean(input.settings.skipOwn, true);
1605
+ const me = input.emailAddress.toLowerCase();
1606
+ const signals = [];
1607
+ for (const id of ids) {
1608
+ const message = await getMessageHeaders(input.accessToken, id);
1609
+ if (skipOwn && addressOf(headerOf(message, "From")) === me) continue;
1610
+ signals.push({
1611
+ key: mailRef(id),
1612
+ kind: "email",
1613
+ ref: mailRef(id),
1614
+ title: summarise$1(message),
1615
+ url: permalink(id),
1616
+ preview: (message.snippet ?? "").trim() || void 0,
1617
+ payload: { messageId: id }
1618
+ });
1619
+ }
1620
+ return signals;
1621
+ }
1622
+ var AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth";
1623
+ var TOKEN = "https://oauth2.googleapis.com/token";
1624
+ var REVOKE = "https://oauth2.googleapis.com/revoke";
1625
+ function createPkce() {
1626
+ const verifier = randomBytes(32).toString("base64url");
1627
+ return {
1628
+ verifier,
1629
+ challenge: createHash("sha256").update(verifier).digest("base64url")
1630
+ };
1631
+ }
1632
+ function buildAuthorizeUrl(input) {
1633
+ return `${AUTHORIZE}?${new URLSearchParams({
1634
+ client_id: input.clientId,
1635
+ redirect_uri: input.redirectUri,
1636
+ response_type: "code",
1637
+ scope: GMAIL_SCOPES.join(" "),
1638
+ state: input.state,
1639
+ code_challenge: input.challenge,
1640
+ code_challenge_method: "S256",
1641
+ access_type: "offline",
1642
+ prompt: "consent"
1643
+ }).toString()}`;
1644
+ }
1645
+ /** Google's token endpoint takes a form, not JSON, and answers JSON either way. */
1646
+ async function postToken(payload, fetchImpl) {
1647
+ const text = await (await fetchImpl(TOKEN, {
1648
+ method: "POST",
1649
+ headers: {
1650
+ Accept: "application/json",
1651
+ "Content-Type": "application/x-www-form-urlencoded"
1652
+ },
1653
+ body: new URLSearchParams(payload).toString()
1654
+ })).text();
1655
+ let body;
1656
+ try {
1657
+ body = JSON.parse(text);
1658
+ } catch {
1659
+ throw new Error(`Google returned a non-JSON response: ${text.slice(0, 200)}`);
1660
+ }
1661
+ const error = typeof body.error === "string" ? body.error : "";
1662
+ if (error) {
1663
+ const description = typeof body.error_description === "string" ? body.error_description : error;
1664
+ if (error === "invalid_grant") throw new Error(`Google will not renew this connection (${description}). Connect Gmail again.`);
1665
+ throw new Error(description);
1666
+ }
1667
+ const accessToken = typeof body.access_token === "string" ? body.access_token : "";
1668
+ if (!accessToken) throw new Error("Google did not return an access token");
1669
+ const expiresIn = Number(body.expires_in);
1670
+ return {
1671
+ accessToken,
1672
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
1673
+ expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1e3 - 6e4 : void 0
1674
+ };
1675
+ }
1676
+ function exchangeCode(input, fetchImpl = fetch) {
1677
+ return postToken({
1678
+ client_id: input.clientId,
1679
+ client_secret: input.clientSecret,
1680
+ code: input.code,
1681
+ redirect_uri: input.redirectUri,
1682
+ code_verifier: input.verifier,
1683
+ grant_type: "authorization_code"
1684
+ }, fetchImpl);
1685
+ }
1686
+ /**
1687
+ * A renewed access token, keeping the refresh token we already hold.
1688
+ *
1689
+ * Google's refresh response has no refresh_token in it. Taking the response at
1690
+ * face value, as the GitHub flow can, would store a credential with no way to
1691
+ * renew itself and break the account an hour later.
1692
+ */
1693
+ async function refreshCredential(input, fetchImpl = fetch) {
1694
+ const renewed = await postToken({
1695
+ client_id: input.clientId,
1696
+ client_secret: input.clientSecret,
1697
+ refresh_token: input.refreshToken,
1698
+ grant_type: "refresh_token"
1699
+ }, fetchImpl);
1700
+ return {
1701
+ ...renewed,
1702
+ refreshToken: renewed.refreshToken ?? input.refreshToken
1703
+ };
1704
+ }
1705
+ async function revokeCredential(credential, fetchImpl = fetch) {
1706
+ const token = credential.refreshToken ?? credential.accessToken;
1707
+ await fetchImpl(`${REVOKE}?token=${encodeURIComponent(token)}`, {
1708
+ method: "POST",
1709
+ headers: { "Content-Type": "application/x-www-form-urlencoded" }
1710
+ });
1711
+ }
1712
+ function requireRegistration() {
1713
+ const registration = oauthAppRegistration("gmail");
1714
+ if (!registration) throw new Error("Gmail is not configured in this build: the OAuth app registration is missing.");
1715
+ return registration;
1716
+ }
1717
+ /**
1718
+ * A usable access token, renewed when the stored one has expired. The renewed
1719
+ * credential is handed back so the caller stores it instead of asking Google
1720
+ * again on the next call.
1721
+ *
1722
+ * Google's access tokens last an hour, so unlike GitHub this path is the
1723
+ * normal one rather than the exception.
1724
+ */
1725
+ async function usableToken(credential) {
1726
+ if (!credential.expiresAt || Date.now() < credential.expiresAt) return { accessToken: credential.accessToken };
1727
+ if (!credential.refreshToken) throw new Error("The Gmail token expired and there is no refresh token. Connect Gmail again.");
1728
+ const registration = requireRegistration();
1729
+ const refreshed = await refreshCredential({
1730
+ clientId: registration.clientId,
1731
+ clientSecret: registration.clientSecret,
1732
+ refreshToken: credential.refreshToken
1733
+ });
1734
+ return {
1735
+ accessToken: refreshed.accessToken,
1736
+ refreshed
1737
+ };
1738
+ }
1739
+ /** The mailbox, as a person would recognise it. */
1740
+ function accountOf$1(emailAddress) {
1741
+ return {
1742
+ id: emailAddress,
1743
+ label: emailAddress
1744
+ };
1745
+ }
1746
+ /**
1747
+ * Which message this task is about. The poll keeps the id, and a pasted link
1748
+ * carries one too, so either is enough to fetch the mail again.
1749
+ */
1750
+ function messageIdFrom(url, payload) {
1751
+ const kept = payload ?? {};
1752
+ if (typeof kept.messageId === "string" && kept.messageId) return kept.messageId;
1753
+ const fromUrl = /[#/]([A-Za-z0-9_-]{8,})$/.exec(url.trim());
1754
+ if (fromUrl) return fromUrl[1];
1755
+ throw new Error("This task has lost the message it was about.");
1756
+ }
1757
+ var gmailRuntime = defineRuntime({
1758
+ async readiness() {
1759
+ if (oauthAppRegistration("gmail")) return { ready: true };
1760
+ return {
1761
+ ready: false,
1762
+ reason: "This build has no Google app registration.",
1763
+ fixHint: "Set LOOPABLE_GMAIL_CLIENT_ID and LOOPABLE_GMAIL_CLIENT_SECRET to use your own OAuth app."
1764
+ };
1765
+ },
1766
+ identifyLink(url) {
1767
+ const trimmed = url.trim();
1768
+ if (!/^https:\/\/mail\.google\.com\//.test(trimmed)) return null;
1769
+ const id = /[#/]([A-Za-z0-9_-]{8,})$/.exec(trimmed);
1770
+ return id ? {
1771
+ kind: "email",
1772
+ ref: mailRef(id[1])
1773
+ } : null;
1774
+ },
1775
+ async resolveWorkItem({ url, payload, credential }) {
1776
+ const { accessToken } = await usableToken(credential);
1777
+ const message = await getMessage(accessToken, messageIdFrom(url, payload));
1778
+ return {
1779
+ kind: "email",
1780
+ ref: mailRef(message.id),
1781
+ title: summarise$1(message),
1782
+ url: permalink(message.id),
1783
+ context: [mailContext(message)]
1784
+ };
1785
+ },
1786
+ async poll({ workflowId, settings, credential }) {
1787
+ const { accessToken } = await usableToken(credential);
1788
+ return { signals: await pollGmail({
1789
+ workflowId,
1790
+ settings,
1791
+ accessToken,
1792
+ emailAddress: (await getProfile(accessToken)).emailAddress
1793
+ }) };
1794
+ },
1795
+ auth: {
1796
+ async startAuthorization({ redirectUri, state }) {
1797
+ const registration = requireRegistration();
1798
+ const { verifier, challenge } = createPkce();
1799
+ return {
1800
+ verifier,
1801
+ redirectUrl: buildAuthorizeUrl({
1802
+ clientId: registration.clientId,
1803
+ redirectUri,
1804
+ state,
1805
+ challenge
1806
+ })
1807
+ };
1808
+ },
1809
+ async completeAuthorization({ code, redirectUri, verifier }) {
1810
+ const registration = requireRegistration();
1811
+ const credential = await exchangeCode({
1812
+ clientId: registration.clientId,
1813
+ clientSecret: registration.clientSecret,
1814
+ code,
1815
+ redirectUri,
1816
+ verifier
1817
+ });
1818
+ return {
1819
+ credential,
1820
+ account: accountOf$1((await getProfile(credential.accessToken)).emailAddress)
1821
+ };
1822
+ },
1823
+ async identity(credential) {
1824
+ const { accessToken, refreshed } = await usableToken(credential);
1825
+ const account = accountOf$1((await getProfile(accessToken)).emailAddress);
1826
+ return refreshed ? {
1827
+ account,
1828
+ renewedCredential: refreshed
1829
+ } : { account };
1830
+ },
1831
+ async revoke(credential) {
1832
+ await revokeCredential(credential);
1833
+ }
1834
+ }
1835
+ });
1836
+ /** How a time is written where a person will read it back. */
1837
+ function readable(when) {
1838
+ return when.toLocaleString(void 0, {
1839
+ weekday: "short",
1840
+ day: "numeric",
1841
+ month: "short",
1842
+ hour: "2-digit",
1843
+ minute: "2-digit"
1844
+ });
1845
+ }
1846
+ function signalFor(due) {
1847
+ return {
1848
+ kind: "occurrence",
1849
+ key: due.toISOString(),
1850
+ ref: readable(due),
1851
+ title: `Due ${readable(due)}`,
1852
+ url: "",
1853
+ payload: { due: due.toISOString() }
1854
+ };
1855
+ }
1856
+ var scheduleRuntime = {
1857
+ /** Nothing to register and nothing to install: a clock is always ready. */
1858
+ async readiness() {
1859
+ return { ready: true };
1860
+ },
1861
+ /**
1862
+ * There is no account, so none of this is reachable. `identity` is required
1863
+ * of every connector because every other one has a stored credential to
1864
+ * prove still works, and this one has nothing to prove.
1865
+ */
1866
+ auth: { async identity() {
1867
+ throw new Error("A schedule has no account.");
1868
+ } },
1869
+ /**
1870
+ * Whether a time has come round since the last one that did.
1871
+ *
1872
+ * The cursor is the occurrence this loop last fired, and it moves only when
1873
+ * one fires. On a first look there is nothing to compare against, so the
1874
+ * cursor is set to now and nothing is returned: turning a loop on should not
1875
+ * immediately run this morning's, which is what would happen if the last
1876
+ * occurrence counted as owed.
1877
+ */
1878
+ async poll({ settings, cursor }) {
1879
+ const schedule = scheduleFrom(settings);
1880
+ const now = /* @__PURE__ */ new Date();
1881
+ const since = typeof cursor === "string" ? new Date(cursor) : null;
1882
+ if (!since || Number.isNaN(since.getTime())) return {
1883
+ signals: [],
1884
+ cursor: now.toISOString()
1885
+ };
1886
+ const due = dueSince(schedule, since, now);
1887
+ if (!due) return {
1888
+ signals: [],
1889
+ cursor: cursor ?? void 0
1890
+ };
1891
+ return {
1892
+ signals: [signalFor(due)],
1893
+ cursor: due.toISOString()
1894
+ };
1895
+ },
1896
+ /**
1897
+ * A run somebody asked for is about the moment they asked, not about the
1898
+ * last time the clock came round. Waiting until nine tomorrow to find out
1899
+ * whether a loop works is no way to shape one.
1900
+ */
1901
+ itemForNow() {
1902
+ return {
1903
+ kind: "occurrence",
1904
+ ref: readable(/* @__PURE__ */ new Date())
1905
+ };
1906
+ },
1907
+ /**
1908
+ * Writes nothing anywhere.
1909
+ *
1910
+ * Which is worth having as an action rather than as a gap, because a loop
1911
+ * needs somewhere for its answer to go before it will run at all, and until
1912
+ * a reliable destination exists this is the honest one: every run is already
1913
+ * recorded, so the answer is kept where a person will look for it. Returns
1914
+ * no address because the task's own page is the address.
1915
+ */
1916
+ async applyAction({ actionId }) {
1917
+ if (actionId !== "schedule.record") throw new Error(`A schedule cannot ${actionId}.`);
1918
+ return { url: "" };
1919
+ },
1920
+ /**
1921
+ * There is nothing to fetch: the occurrence is the whole of what happened,
1922
+ * and the poll already wrote it down. Anything run from the button rather
1923
+ * than by the clock has no occurrence behind it and is about now.
1924
+ */
1925
+ async resolveWorkItem({ payload }) {
1926
+ return {
1927
+ ...signalFor(payload && typeof payload === "object" && "due" in payload && typeof payload.due === "string" ? new Date(payload.due) : /* @__PURE__ */ new Date()),
1928
+ context: []
1929
+ };
1930
+ }
1931
+ };
1932
+ /**
1933
+ * Tencent's iLink bot API, as documented for clients that are not OpenClaw:
1934
+ * https://github.com/Tencent/openclaw-weixin/blob/main/docs/protocol.md
1935
+ *
1936
+ * The document describes what Tencent's own client does rather than promising
1937
+ * a server contract, so anything not observed here is treated as optional and
1938
+ * a surprise is reported rather than assumed away.
1939
+ */
1940
+ var WECHAT_API = "https://ilinkai.weixin.qq.com";
1941
+ /**
1942
+ * The version of the protocol we speak, not of Loopable. It is the plugin
1943
+ * release whose documented behaviour this client was written against, encoded
1944
+ * as 0x00MMNNPP and sent as decimal.
1945
+ */
1946
+ var PROTOCOL = {
1947
+ major: 2,
1948
+ minor: 4,
1949
+ patch: 8
1950
+ };
1951
+ var CLIENT_VERSION = String(PROTOCOL.major << 16 | PROTOCOL.minor << 8 | PROTOCOL.patch);
1952
+ /**
1953
+ * Who is calling, in the slot Tencent left for clients other than their own.
1954
+ * Sent for their observability, not for auth, and worth being honest in.
1955
+ */
1956
+ var BASE_INFO = {
1957
+ channel_version: `${PROTOCOL.major}.${PROTOCOL.minor}.${PROTOCOL.patch}`,
1958
+ bot_agent: "Loopable/0.1"
1959
+ };
1960
+ var APP_HEADERS = {
1961
+ "iLink-App-Id": "bot",
1962
+ "iLink-App-ClientVersion": CLIENT_VERSION
1963
+ };
1964
+ /** "Base64-encoded decimal representation of a random uint32." */
1965
+ function uin() {
1966
+ return Buffer.from(String(Math.floor(Math.random() * 4294967295))).toString("base64");
1967
+ }
1968
+ function postHeaders(token) {
1969
+ return {
1970
+ "Content-Type": "application/json",
1971
+ AuthorizationType: "ilink_bot_token",
1972
+ "X-WECHAT-UIN": uin(),
1973
+ ...APP_HEADERS,
1974
+ ...token ? { Authorization: `Bearer ${token}` } : {}
1975
+ };
1976
+ }
1977
+ /**
1978
+ * WeChat's own trouble is not ours to fail a loop over. A refused request is
1979
+ * an answer; an unreachable host or a 5xx is a bad minute.
1980
+ */
1981
+ async function call(url, init) {
1982
+ let response;
1983
+ try {
1984
+ response = await fetch(url, init);
1985
+ } catch (error) {
1986
+ throw new TransientError(`WeChat is unreachable: ${error.message}`);
1987
+ }
1988
+ if (response.status >= 500 || response.status === 429) throw new TransientError(`WeChat answered ${response.status}`);
1989
+ if (!response.ok) throw new Error(`WeChat answered ${response.status}`);
1990
+ return await response.json();
1991
+ }
1992
+ async function requestQrCode() {
1993
+ const body = await call(`${WECHAT_API}/ilink/bot/get_bot_qrcode?bot_type=3`, {
1994
+ method: "POST",
1995
+ headers: postHeaders(),
1996
+ body: JSON.stringify({ local_token_list: [] })
1997
+ });
1998
+ if (!body.qrcode || !body.qrcode_img_content) throw new Error("WeChat did not return a login code.");
1999
+ return {
2000
+ qrcode: body.qrcode,
2001
+ url: body.qrcode_img_content
2002
+ };
2003
+ }
2004
+ function absolute(host) {
2005
+ return host.startsWith("http") ? host : `https://${host}`;
2006
+ }
2007
+ /**
2008
+ * One look at a pending scan. Everything except a finished login is a reason
2009
+ * to look again, including a failed request: a login in progress should not be
2010
+ * thrown away over one bad response.
2011
+ */
2012
+ async function qrCodeStatus(qrcode, host) {
2013
+ let body;
2014
+ try {
2015
+ body = await call(`${absolute(host)}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, { headers: APP_HEADERS });
2016
+ } catch {
2017
+ return {
2018
+ state: "waiting",
2019
+ host
2020
+ };
2021
+ }
2022
+ switch (body.status) {
2023
+ case "confirmed":
2024
+ if (!body.bot_token) return {
2025
+ state: "expired",
2026
+ reason: "WeChat confirmed without a token."
2027
+ };
2028
+ return {
2029
+ state: "confirmed",
2030
+ credential: {
2031
+ botToken: body.bot_token,
2032
+ botId: body.ilink_bot_id ?? "",
2033
+ userId: body.ilink_user_id ?? "",
2034
+ baseUrl: body.baseurl ? absolute(body.baseurl) : WECHAT_API
2035
+ }
2036
+ };
2037
+ case "scaned": return {
2038
+ state: "waiting",
2039
+ host,
2040
+ hint: "Scanned. Confirm it on your phone."
2041
+ };
2042
+ case "scaned_but_redirect": return {
2043
+ state: "waiting",
2044
+ host: body.redirect_host ? absolute(body.redirect_host) : host,
2045
+ hint: "Scanned. Confirm it on your phone."
2046
+ };
2047
+ case "need_verifycode": return {
2048
+ state: "waiting",
2049
+ host,
2050
+ hint: "WeChat is asking for a verification code."
2051
+ };
2052
+ case "binded_redirect": return {
2053
+ state: "expired",
2054
+ reason: "This account is already bound somewhere else."
2055
+ };
2056
+ case "verify_code_blocked": return {
2057
+ state: "expired",
2058
+ reason: "Too many verification attempts. Start again."
2059
+ };
2060
+ case "expired": return { state: "expired" };
2061
+ default: return {
2062
+ state: "waiting",
2063
+ host
2064
+ };
2065
+ }
2066
+ }
2067
+ /** A dead token, as opposed to a request WeChat merely disliked. */
2068
+ var SESSION_GONE = -14;
2069
+ /**
2070
+ * Whether WeChat refused, and why.
2071
+ *
2072
+ * It reports trouble in two fields and not always the same one: a call with a
2073
+ * dead token comes back `{errcode:-14}` with no `ret` at all, so a check that
2074
+ * reads only `ret` would call that a success and let a signed-out account look
2075
+ * connected.
2076
+ */
2077
+ function signedOut(body) {
2078
+ return body.ret === SESSION_GONE || body.errcode === SESSION_GONE;
2079
+ }
2080
+ function refusal(body) {
2081
+ const bad = [body.ret, body.errcode].filter((code) => typeof code === "number").find((code) => code !== 0);
2082
+ if (bad === void 0) return null;
2083
+ if (bad === SESSION_GONE) return "WeChat has signed this bot out. Connect again by scanning.";
2084
+ return body.errmsg || `WeChat refused the request (${bad}).`;
2085
+ }
2086
+ /**
2087
+ * Tells WeChat a client is up, and doubles as the proof that a stored token
2088
+ * still works: it needs nothing but the token, and a dead one is refused.
2089
+ *
2090
+ * Reading the account's config would be the obvious check and is the wrong
2091
+ * one. It mints a typing ticket, which needs a conversation, so it fails on
2092
+ * exactly the account that has just been connected and never messaged.
2093
+ */
2094
+ async function notifyStart(credential) {
2095
+ const refused = refusal(await call(`${credential.baseUrl}/ilink/bot/msg/notifystart`, {
2096
+ method: "POST",
2097
+ headers: postHeaders(credential.botToken),
2098
+ body: JSON.stringify({ base_info: BASE_INFO })
2099
+ }));
2100
+ if (refused) throw new Error(refused);
2101
+ }
2102
+ /** `1` is a person typing at us. `2` is the bot, which is to say ourselves. */
2103
+ var FROM_USER = 1;
2104
+ var TEXT_ITEM = 1;
2105
+ /** What someone actually typed, with the parts we cannot read left out. */
2106
+ function textOf(message) {
2107
+ return (message.item_list ?? []).filter((item) => item.type === TEXT_ITEM).map((item) => item.text_item?.text?.trim()).filter((text) => Boolean(text)).join("\n").trim();
2108
+ }
2109
+ function isFromPerson(message) {
2110
+ return message.message_type === FROM_USER;
2111
+ }
2112
+ /**
2113
+ * How long to hold the connection open waiting for someone to type. This is a
2114
+ * long poll and the server decides when to answer, so the limit is ours: the
2115
+ * caller is a loop with other loops to get to, and an empty answer costs it a
2116
+ * turn rather than the whole minute.
2117
+ */
2118
+ var WAIT_MS = 1e4;
2119
+ /**
2120
+ * Messages since the last call, and the cursor to hand back on the next one.
2121
+ *
2122
+ * Unlike everything else Loopable polls, this is a stream: what it returns, it
2123
+ * will not return again. Nothing is lost by giving up on a slow request, since
2124
+ * the cursor only moves when an answer arrives, but an answer that is thrown
2125
+ * away takes its messages with it.
2126
+ */
2127
+ async function getUpdates(credential, cursor) {
2128
+ const abort = new AbortController();
2129
+ const timer = setTimeout(() => abort.abort(), WAIT_MS);
2130
+ let body;
2131
+ try {
2132
+ body = await call(`${credential.baseUrl}/ilink/bot/getupdates`, {
2133
+ method: "POST",
2134
+ headers: postHeaders(credential.botToken),
2135
+ body: JSON.stringify({
2136
+ get_updates_buf: cursor,
2137
+ base_info: BASE_INFO
2138
+ }),
2139
+ signal: abort.signal
2140
+ });
2141
+ } catch (error) {
2142
+ if (abort.signal.aborted) return {
2143
+ messages: [],
2144
+ cursor
2145
+ };
2146
+ throw error;
2147
+ } finally {
2148
+ clearTimeout(timer);
2149
+ }
2150
+ const refused = refusal(body);
2151
+ if (refused) throw new Error(refused);
2152
+ return {
2153
+ messages: body.msgs ?? [],
2154
+ cursor: body.get_updates_buf || cursor
2155
+ };
2156
+ }
2157
+ /**
2158
+ * Replies to whoever sent `contextToken`.
2159
+ *
2160
+ * The token comes off the message being answered and cannot be invented: it is
2161
+ * what tells WeChat which conversation this belongs to, and it goes stale
2162
+ * after a couple of days, which is why a reply is worth queueing but not worth
2163
+ * retrying for a week. `clientId` is what stops a retry saying it twice.
2164
+ */
2165
+ async function sendMessage(credential, input) {
2166
+ const body = await call(`${credential.baseUrl}/ilink/bot/sendmessage`, {
2167
+ method: "POST",
2168
+ headers: postHeaders(credential.botToken),
2169
+ body: JSON.stringify({
2170
+ msg: {
2171
+ from_user_id: "",
2172
+ to_user_id: input.toUserId,
2173
+ client_id: input.clientId,
2174
+ message_type: 2,
2175
+ message_state: 2,
2176
+ ...input.contextToken ? { context_token: input.contextToken } : {},
2177
+ item_list: [{
2178
+ type: TEXT_ITEM,
2179
+ text_item: { text: input.text }
2180
+ }]
2181
+ },
2182
+ base_info: BASE_INFO
2183
+ })
2184
+ });
2185
+ const refused = refusal(body);
2186
+ if (!refused) return;
2187
+ if (!input.contextToken && !signedOut(body)) throw new Error(`WeChat would not take a message nobody asked for (it said: ${refused}). It only delivers those for about half a day after you last write to the bot, so send the bot anything and this will go through.`);
2188
+ throw new Error(refused);
2189
+ }
2190
+ /**
2191
+ * A login code lasted about two minutes when measured against the live API.
2192
+ * Told to the page so it can offer a fresh one rather than leave a dead square
2193
+ * on screen.
2194
+ */
2195
+ var CODE_LIFETIME_MS = 11e4;
2196
+ function asAttempt(value) {
2197
+ const record = value;
2198
+ if (!record || typeof record.qrcode !== "string" || typeof record.host !== "string") throw new Error("This login is no longer valid. Ask for a new code.");
2199
+ return {
2200
+ qrcode: record.qrcode,
2201
+ host: record.host
2202
+ };
2203
+ }
2204
+ function asAsked(value) {
2205
+ const record = value;
2206
+ if (!record || typeof record.fromUserId !== "string" || typeof record.text !== "string") throw new Error("This task has lost the message it was about.");
2207
+ return {
2208
+ messageId: Number(record.messageId ?? 0),
2209
+ fromUserId: record.fromUserId,
2210
+ contextToken: typeof record.contextToken === "string" ? record.contextToken : void 0,
2211
+ text: record.text
2212
+ };
2213
+ }
2214
+ /** Enough of the message to recognise it in a list. */
2215
+ function summarise(text) {
2216
+ const line = text.split("\n")[0].trim();
2217
+ return line.length > 80 ? `${line.slice(0, 79)}…` : line;
2218
+ }
2219
+ /**
2220
+ * WeChat takes a message at a time and long ones are refused, so an answer
2221
+ * that runs on is sent in pieces rather than truncated.
2222
+ */
2223
+ var CHUNK = 1500;
2224
+ function chunked(text) {
2225
+ const parts = [];
2226
+ let rest = text.trim();
2227
+ while (rest.length > CHUNK) {
2228
+ const window = rest.slice(0, CHUNK);
2229
+ const cut = Math.max(window.lastIndexOf("\n\n"), window.lastIndexOf("\n"));
2230
+ const at = cut > CHUNK / 2 ? cut : CHUNK;
2231
+ parts.push(rest.slice(0, at).trim());
2232
+ rest = rest.slice(at).trim();
2233
+ }
2234
+ if (rest) parts.push(rest);
2235
+ return parts;
2236
+ }
2237
+ /**
2238
+ * Who the reply goes to, with the token that says which conversation it
2239
+ * belongs to when there is one.
2240
+ *
2241
+ * Answering whoever asked has that token off the message itself, and is the
2242
+ * case WeChat supports. Telling yourself about something that happened
2243
+ * elsewhere has no message behind it and so no token; that is sent anyway,
2244
+ * because refusing it would mean this cannot be offered at all, and the
2245
+ * setting says as much where it is chosen.
2246
+ */
2247
+ function recipientFor(target, source, account) {
2248
+ if (target.to === "me") return { userId: account.userId };
2249
+ if (source.connectorId !== "wechat") throw new Error("There is no chat behind this task. Choose who to send it to on the loop.");
2250
+ const carry = source.carry ?? {};
2251
+ if (!carry.toUserId) throw new Error("There is nobody to reply to on this task.");
2252
+ return {
2253
+ userId: carry.toUserId,
2254
+ contextToken: carry.contextToken
2255
+ };
2256
+ }
2257
+ /** The account, said the way a person would recognise it. */
2258
+ function accountOf(credential) {
2259
+ return {
2260
+ id: credential.botId || credential.userId,
2261
+ label: credential.botId ? `WeChat bot ${credential.botId}` : "WeChat bot"
2262
+ };
2263
+ }
2264
+ /** Server-only registry. Never import this from a component. */
2265
+ var RUNTIMES = {
2266
+ github: githubRuntime,
2267
+ gmail: gmailRuntime,
2268
+ wechat: {
2269
+ async readiness() {
2270
+ return { ready: true };
2271
+ },
2272
+ /**
2273
+ * A stream, not a search: what this returns has already been taken off the
2274
+ * server and will not be offered again, which is why every message it keeps
2275
+ * carries its whole content rather than a pointer to it.
2276
+ */
2277
+ async poll({ workflowId, settings, credential, cursor }) {
2278
+ if (workflowId !== "wechat.ask") throw new Error(`WeChat cannot watch for ${workflowId}.`);
2279
+ const account = credential;
2280
+ const { messages, cursor: next } = await getUpdates(account, typeof cursor === "string" ? cursor : "");
2281
+ const onlyMe = (settings.askers ?? "me") !== "anyone";
2282
+ const signals = [];
2283
+ for (const message of messages) {
2284
+ if (!isFromPerson(message)) continue;
2285
+ const text = textOf(message);
2286
+ if (!text) continue;
2287
+ const fromUserId = message.from_user_id ?? "";
2288
+ const asked = {
2289
+ messageId: message.message_id ?? 0,
2290
+ fromUserId,
2291
+ contextToken: message.context_token,
2292
+ text
2293
+ };
2294
+ signals.push({
2295
+ key: `msg#${asked.messageId}`,
2296
+ kind: "message",
2297
+ ref: `msg#${asked.messageId}`,
2298
+ title: summarise(text),
2299
+ url: "",
2300
+ hold: onlyMe && fromUserId !== account.userId ? "sent by someone other than you" : void 0,
2301
+ payload: asked
2302
+ });
2303
+ }
2304
+ return {
2305
+ signals,
2306
+ cursor: next
2307
+ };
2308
+ },
2309
+ async resolveWorkItem({ payload }) {
2310
+ const asked = asAsked(payload);
2311
+ return {
2312
+ kind: "message",
2313
+ ref: `msg#${asked.messageId}`,
2314
+ title: summarise(asked.text),
2315
+ url: "",
2316
+ carry: {
2317
+ toUserId: asked.fromUserId,
2318
+ contextToken: asked.contextToken
2319
+ },
2320
+ context: [{
2321
+ name: "MESSAGE.md",
2322
+ body: [
2323
+ `# What you were asked`,
2324
+ ``,
2325
+ asked.text,
2326
+ ``
2327
+ ].join("\n")
2328
+ }]
2329
+ };
2330
+ },
2331
+ async applyAction({ actionId, target, source, body, credential }) {
2332
+ if (actionId !== "wechat.reply") throw new Error(`WeChat cannot ${actionId}.`);
2333
+ const account = credential;
2334
+ const to = recipientFor(target, source, account);
2335
+ for (const part of chunked(body)) await sendMessage(account, {
2336
+ toUserId: to.userId,
2337
+ contextToken: to.contextToken,
2338
+ clientId: randomUUID(),
2339
+ text: part
2340
+ });
2341
+ return { url: "" };
2342
+ },
2343
+ auth: {
2344
+ async startQrLogin() {
2345
+ const code = await requestQrCode();
2346
+ return {
2347
+ attempt: {
2348
+ qrcode: code.qrcode,
2349
+ host: WECHAT_API
2350
+ },
2351
+ encode: code.url,
2352
+ expiresInMs: CODE_LIFETIME_MS
2353
+ };
2354
+ },
2355
+ async pollQrLogin(value) {
2356
+ const attempt = asAttempt(value);
2357
+ const status = await qrCodeStatus(attempt.qrcode, attempt.host);
2358
+ switch (status.state) {
2359
+ case "confirmed": return {
2360
+ state: "confirmed",
2361
+ result: {
2362
+ credential: status.credential,
2363
+ account: accountOf(status.credential)
2364
+ }
2365
+ };
2366
+ case "expired": return {
2367
+ state: "expired",
2368
+ reason: status.reason
2369
+ };
2370
+ case "waiting": return {
2371
+ state: "pending",
2372
+ attempt: {
2373
+ qrcode: attempt.qrcode,
2374
+ host: status.host
2375
+ },
2376
+ hint: status.hint
2377
+ };
2378
+ }
2379
+ },
2380
+ async identity(credential) {
2381
+ const stored = credential;
2382
+ await notifyStart(stored);
2383
+ return { account: accountOf(stored) };
2384
+ }
2385
+ }
2386
+ },
2387
+ schedule: scheduleRuntime
2388
+ };
2389
+ function connectorRuntime(id) {
2390
+ const runtime = RUNTIMES[id];
2391
+ if (!runtime) throw new Error(`Unknown connector: ${id}`);
2392
+ return runtime;
2393
+ }
2394
+ var ATTEMPT_TTL_MS = 6e5;
2395
+ function toView(row) {
2396
+ return {
2397
+ id: row.id,
2398
+ connectorId: row.connectorId,
2399
+ status: row.status,
2400
+ accountLabel: row.accountLabel,
2401
+ accountUrl: row.accountUrl,
2402
+ avatarUrl: row.avatarUrl,
2403
+ scopes: row.scopes ? row.scopes.split(",").filter(Boolean) : [],
2404
+ settings: row.settings,
2405
+ lastSyncedAt: row.lastSyncedAt?.toISOString() ?? null,
2406
+ lastError: row.lastError,
2407
+ createdAt: row.createdAt.toISOString()
2408
+ };
2409
+ }
2410
+ async function listConnections() {
2411
+ return (await db().select().from(connections).orderBy(asc(connections.createdAt)).all()).map(toView);
2412
+ }
2413
+ async function getConnection(id) {
2414
+ return await db().select().from(connections).where(eq(connections.id, id)).get();
2415
+ }
2416
+ async function startAuthAttempt(input) {
2417
+ const now = Date.now();
2418
+ await db().delete(authAttempts).where(lt(authAttempts.expiresAt, new Date(now))).run();
2419
+ await db().insert(authAttempts).values({
2420
+ state: input.state,
2421
+ connectorId: input.connectorId,
2422
+ verifier: input.verifier,
2423
+ redirectUri: input.redirectUri,
2424
+ returnTo: input.returnTo,
2425
+ expiresAt: new Date(now + ATTEMPT_TTL_MS)
2426
+ }).run();
2427
+ }
2428
+ /** Consumes the attempt: a state value is only ever valid once. */
2429
+ async function takeAuthAttempt(state) {
2430
+ const attempt = await db().select().from(authAttempts).where(eq(authAttempts.state, state)).get();
2431
+ if (attempt) await db().delete(authAttempts).where(eq(authAttempts.state, state)).run();
2432
+ if (!attempt) throw new Error("This authorization link is no longer valid. Start again.");
2433
+ if (attempt.expiresAt.getTime() < Date.now()) throw new Error("The authorization took too long. Start again.");
2434
+ return attempt;
2435
+ }
2436
+ function scopesToColumn(account) {
2437
+ return account.scopes?.length ? account.scopes.join(",") : null;
2438
+ }
2439
+ /**
2440
+ * Stores the result of a successful authorization. Re-authorizing the same
2441
+ * account updates the existing connection instead of creating a duplicate.
2442
+ */
2443
+ async function saveAuthorizedConnection(connectorId, result) {
2444
+ const existing = await db().select().from(connections).where(and(eq(connections.connectorId, connectorId), eq(connections.accountId, result.account.id))).get();
2445
+ const id = existing?.id ?? randomUUID();
2446
+ await writeCredential(id, result.credential);
2447
+ const values = {
2448
+ connectorId,
2449
+ status: "connected",
2450
+ accountId: result.account.id,
2451
+ accountLabel: result.account.label,
2452
+ accountUrl: result.account.url ?? null,
2453
+ avatarUrl: result.account.avatarUrl ?? null,
2454
+ scopes: scopesToColumn(result.account),
2455
+ lastError: null,
2456
+ updatedAt: /* @__PURE__ */ new Date()
2457
+ };
2458
+ if (existing) await db().update(connections).set(values).where(eq(connections.id, id)).run();
2459
+ else await db().insert(connections).values({
2460
+ id,
2461
+ ...values
2462
+ }).run();
2463
+ const row = await getConnection(id);
2464
+ if (!row) throw new Error("Failed to persist the connection");
2465
+ return toView(row);
2466
+ }
2467
+ async function removeConnection(id) {
2468
+ const row = await getConnection(id);
2469
+ if (!row) return;
2470
+ const runtime = connectorRuntime(row.connectorId);
2471
+ if (runtime.auth.revoke) {
2472
+ const credential = await readCredential(id);
2473
+ if (credential) try {
2474
+ await runtime.auth.revoke(credential);
2475
+ } catch {}
2476
+ }
2477
+ await deleteCredential(id);
2478
+ await db().delete(connections).where(eq(connections.id, id)).run();
2479
+ }
2480
+ async function updateConnectionSettings(id, settings) {
2481
+ await db().update(connections).set({
2482
+ settings,
2483
+ updatedAt: /* @__PURE__ */ new Date()
2484
+ }).where(eq(connections.id, id)).run();
2485
+ const row = await getConnection(id);
2486
+ if (!row) throw new Error("Connection not found");
2487
+ return toView(row);
2488
+ }
2489
+ /**
2490
+ * A credential a run can use right now. Goes through the connection check so a
2491
+ * rotated credential is stored rather than refreshed again on the next run,
2492
+ * and so a broken connection fails here with a clear message instead of a 401
2493
+ * in the middle of the work.
2494
+ */
2495
+ async function credentialForConnector(connectorId) {
2496
+ if (!needsAccount(connectorId)) return { credential: null };
2497
+ const row = await db().select().from(connections).where(eq(connections.connectorId, connectorId)).orderBy(asc(connections.createdAt)).get();
2498
+ if (!row) throw new Error(`No ${connectorId} account is connected.`);
2499
+ const checked = await checkConnection(row.id);
2500
+ if (checked.status !== "connected") throw new Error(checked.lastError ?? `The ${connectorId} connection needs reconnecting.`);
2501
+ const credential = await readCredential(row.id);
2502
+ if (!credential) throw new Error(`No credential is stored for ${connectorId}.`);
2503
+ return { credential };
2504
+ }
2505
+ /** Proves the stored credential still works and refreshes the shown account. */
2506
+ async function checkConnection(id) {
2507
+ const row = await getConnection(id);
2508
+ if (!row) throw new Error("Connection not found");
2509
+ const runtime = connectorRuntime(row.connectorId);
2510
+ const credential = await readCredential(id);
2511
+ if (!credential) {
2512
+ await db().update(connections).set({
2513
+ status: "needs_reauth",
2514
+ lastError: "No credential is stored for this connection.",
2515
+ updatedAt: /* @__PURE__ */ new Date()
2516
+ }).where(eq(connections.id, id)).run();
2517
+ return toView(await getConnection(id));
2518
+ }
2519
+ try {
2520
+ const { account, renewedCredential } = await runtime.auth.identity(credential);
2521
+ if (renewedCredential) await writeCredential(id, renewedCredential);
2522
+ await db().update(connections).set({
2523
+ status: "connected",
2524
+ accountLabel: account.label,
2525
+ accountUrl: account.url ?? null,
2526
+ avatarUrl: account.avatarUrl ?? null,
2527
+ scopes: scopesToColumn(account),
2528
+ lastError: null,
2529
+ lastSyncedAt: /* @__PURE__ */ new Date(),
2530
+ updatedAt: /* @__PURE__ */ new Date()
2531
+ }).where(eq(connections.id, id)).run();
2532
+ } catch (error) {
2533
+ await db().update(connections).set({
2534
+ status: "needs_reauth",
2535
+ lastError: error instanceof Error ? error.message : String(error),
2536
+ updatedAt: /* @__PURE__ */ new Date()
2537
+ }).where(eq(connections.id, id)).run();
2538
+ }
2539
+ return toView(await getConnection(id));
2540
+ }
2541
+ //#endregion
2542
+ export { connectorManifest as a, credentialForConnector as c, removeConnection as d, saveAuthorizedConnection as f, updateConnectionSettings as h, connectorAction as i, listConnections as l, takeAuthAttempt as m, allWorkflows as n, connectorRuntime as o, startAuthAttempt as p, checkConnection as r, connectorWorkflow as s, CONNECTOR_MANIFESTS as t, needsAccount as u };