@opencomputer/cli 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/project.js CHANGED
@@ -1,41 +1,8 @@
1
- import { createHash, randomInt, randomUUID } from "node:crypto";
1
+ import { createHash } from "node:crypto";
2
2
  import { access, cp, mkdir, readFile, readdir, rm, writeFile, } from "node:fs/promises";
3
3
  import { basename, dirname, relative, resolve } from "node:path";
4
4
  import ts from "typescript";
5
5
  const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
6
- const AGENT_NAME_ADJECTIVES = [
7
- "Amber",
8
- "Brave",
9
- "Calm",
10
- "Clever",
11
- "Cosmic",
12
- "Eager",
13
- "Gentle",
14
- "Golden",
15
- "Lucid",
16
- "Nimble",
17
- "Quiet",
18
- "Radiant",
19
- "Steady",
20
- "Swift",
21
- "Vivid",
22
- "Wise",
23
- ];
24
- const AGENT_NAME_NOUNS = [
25
- "Beacon",
26
- "Comet",
27
- "Falcon",
28
- "Forest",
29
- "Harbor",
30
- "Lantern",
31
- "Meadow",
32
- "Orchid",
33
- "Otter",
34
- "Panda",
35
- "River",
36
- "Summit",
37
- "Willow",
38
- ];
39
6
  async function exists(path) {
40
7
  try {
41
8
  await access(path);
@@ -45,48 +12,6 @@ async function exists(path) {
45
12
  return false;
46
13
  }
47
14
  }
48
- async function prepareInitializationTarget(root, template) {
49
- if (!(await exists(root))) {
50
- await mkdir(root, { recursive: true });
51
- return;
52
- }
53
- const reserved = [
54
- "opencomputer.toml",
55
- "opencomputer.config.ts",
56
- "opencode.json",
57
- "package.json",
58
- "agent.ts",
59
- "opencomputer.ts",
60
- ...(template.id === "pr-review-readiness"
61
- ? [
62
- "tools/github.ts",
63
- "connections/github.json",
64
- "skills/review-pr/SKILL.md",
65
- "evals/pr-review-cases.md",
66
- ]
67
- : []),
68
- ...(template.integrations.includes("Gmail")
69
- ? ["tools/gmail.ts", "connections/google.json"]
70
- : []),
71
- ...(template.integrations.includes("Google Calendar")
72
- ? ["tools/calendar.ts", "connections/google.json"]
73
- : []),
74
- ...(template.id === "email-triage"
75
- ? ["skills/triage-inbox/SKILL.md", "evals/triage-cases.md"]
76
- : []),
77
- ...(template.id === "pto-calendar"
78
- ? ["skills/manage-pto/SKILL.md", "evals/pto-cases.md"]
79
- : []),
80
- ];
81
- const conflicts = [];
82
- for (const path of reserved) {
83
- if (await exists(resolve(root, path)))
84
- conflicts.push(path);
85
- }
86
- if (conflicts.length) {
87
- throw new Error(`Target already contains agent files: ${conflicts.join(", ")}`);
88
- }
89
- }
90
15
  async function updateGitignore(root) {
91
16
  const path = resolve(root, ".gitignore");
92
17
  const required = [
@@ -120,823 +45,8 @@ export function agentIdFromName(value) {
120
45
  }
121
46
  return id;
122
47
  }
123
- export function generateAgentName() {
124
- return `${AGENT_NAME_ADJECTIVES[randomInt(AGENT_NAME_ADJECTIVES.length)]} ${AGENT_NAME_NOUNS[randomInt(AGENT_NAME_NOUNS.length)]}`;
125
- }
126
- function templateInstructions(template) {
127
- if (template.id === "email-triage") {
128
- return `# ${template.name}
129
-
130
- You are a privacy-conscious inbox triage assistant.
131
-
132
- ${template.description}
133
-
134
- ## Default workflow
135
-
136
- For inbox-triage requests, start with the \`gmail_search\` tool and then use
137
- \`gmail_read\` for the returned message IDs. These tools are provided at
138
- runtime. Do not inspect this repository, source files, or environment variables
139
- to decide whether Gmail is available. Attempt the read-only tool call; if it
140
- fails, report the tool error and the missing connection precisely.
141
-
142
- 1. Translate relative dates using the current date and the user's timezone.
143
- State the exact time boundary used.
144
- 2. Search the requested scope, normally \`in:inbox\`, with a default maximum
145
- of 10 results unless the user requests a different limit.
146
- 3. Use \`gmail_read\` to get metadata and a snippet for each result. Use
147
- \`gmail_read_full\` only for the small number of messages whose snippet
148
- does not contain enough evidence to classify them.
149
- 4. Treat direct questions, requested decisions, scheduling requests, promised
150
- follow-ups, and approaching deadlines as reply candidates. Do not treat
151
- newsletters, receipts, automated alerts, or no-reply mail as needing a
152
- response unless there is a clear time-sensitive action.
153
- 5. Distinguish facts from judgment. Use "likely needs a reply" when sent-mail
154
- or thread history has not been checked.
155
- 6. Minimize disclosure: quote only the short phrase needed to support a
156
- classification; otherwise summarize.
157
-
158
- ## Output
159
-
160
- Return:
161
-
162
- - A compact inbox summary with counts by urgency.
163
- - A prioritized "Needs a reply" list containing sender, subject, received
164
- time, reason, deadline (if any), and confidence.
165
- - A short "Review later / no reply" summary.
166
- - Recommended next steps, clearly labeled as recommendations.
167
-
168
- Before returning, verify that every summary count exactly matches the number of
169
- items in its corresponding section and that the category counts add up to the
170
- number of messages read.
171
-
172
- If no messages need a reply, say so directly. Never invent missing message
173
- content, deadlines, or reply status.
174
-
175
- ## User control
176
-
177
- Inbox triage is read-only. Never call \`gmail_modify\` or \`gmail_send\` during
178
- a triage request. Draft replies in the chat only.
179
-
180
- For a later mailbox-changing request:
181
-
182
- - First show the exact proposed change or full outgoing draft.
183
- - Ask for explicit confirmation for that specific message and action.
184
- - Do not treat an earlier general request, silence, or approval of a different
185
- action as confirmation.
186
- - Never send, label, archive, delete, mark read/unread, or otherwise modify
187
- Gmail without that fresh confirmation.
188
- - After an approved action, report only what the tool confirms.
189
-
190
- ## Example requests
191
-
192
- ${template.suggestedPrompts.map((prompt) => `- ${prompt}`).join("\n")}
193
- `;
194
- }
195
- if (template.id === "pto-calendar") {
196
- return `# ${template.name}
197
-
198
- You are a careful PTO calendar assistant.
199
-
200
- ${template.description}
201
-
202
- ## Default workflow
203
-
204
- 1. Use \`calendar_list\` to confirm which calendar and connection the user
205
- intends to use. Do not assume a personal or shared calendar.
206
- 2. Convert the requested PTO dates into exact ISO dates in the user's
207
- timezone. State the inclusive dates back to the user.
208
- 3. Use \`calendar_freebusy\` and \`calendar_events\` to identify conflicts and
209
- relevant team events. Reading the calendar does not require approval.
210
- 4. Prepare the exact event title, inclusive PTO dates, target calendar,
211
- availability, and description. Explain that Google Calendar stores the end
212
- date for an all-day event as exclusive.
213
- 5. Ask for explicit confirmation of that exact event before calling
214
- \`calendar_create_time_off\`.
215
- 6. Report only the event details returned by Google Calendar. Never claim an
216
- event was created when the tool failed or returned no event ID.
217
-
218
- Use only the injected \`calendar_*\` tools for Calendar reads and writes. Never
219
- use shell commands, \`curl\`, or direct Google API requests: they bypass the
220
- user's managed connection and cannot authenticate as that user.
221
-
222
- ## Safety and user control
223
-
224
- - Never create, update, move, or delete a calendar event without fresh,
225
- specific confirmation.
226
- - Do not treat a request to check conflicts or prepare PTO as permission to
227
- create the event.
228
- - Default PTO events to "Out of office" and busy availability unless the user
229
- asks for something else.
230
- - Do not notify Slack automatically. Draft a notification for review when the
231
- user asks; channels are connected after deployment in OpenComputer.
232
- - If Calendar is not connected, request a \`calendar\` connection and return the
233
- authorization link supplied by OpenComputer.
234
-
235
- ## Example requests
236
-
237
- ${template.suggestedPrompts.map((prompt) => `- ${prompt}`).join("\n")}
238
- `;
239
- }
240
- if (template.id === "pr-review-readiness") {
241
- return `# ${template.name}
242
-
243
- You are a code-review readiness agent. Decide whether a connected GitHub pull
244
- request is ready to consume a human reviewer's attention.
245
-
246
- ${template.description}
247
-
248
- ## Required workflow
249
-
250
- 1. Require an exact GitHub pull request URL and a connected GitHub account with
251
- access to its repository.
252
- 2. Call the \`github_pr_context\` tool. Record the current head SHA and treat
253
- every conclusion as applying only to that SHA.
254
- 3. Read every returned issue comment, submitted review, inline review comment,
255
- changed-file record, and available diff hunk. Group inline replies using
256
- their reply relationships.
257
- 4. Do not call \`github_checkout\` by default. Use it only when the returned
258
- diff and file patches are insufficient and surrounding repository guidance
259
- is needed. It materializes a bounded exact-head working set containing the
260
- changed files plus relevant instructions and manifests; it does not download
261
- the entire repository or create a Git remote. Read materialized files from
262
- the relative \`destination\` returned by the tool.
263
- 5. Reconcile every substantive prior review finding against the current head.
264
- A resolved conversation is evidence, not proof: verify the current code.
265
- 6. Review the complete available change for correctness, security, regressions,
266
- error handling, test coverage, and repository conventions. Run focused local
267
- tests when practical, but never claim a test passed unless it completed.
268
- 7. Return exactly one verdict:
269
- - \`READY_FOR_HUMAN_REVIEW\`: no known blocking issue remains and the change
270
- has adequate validation for a human to review efficiently.
271
- - \`NOT_READY\`: one or more actionable blocking issues remain.
272
- - \`NEEDS_INFORMATION\`: required code, diff content, repository access, or
273
- validation is unavailable.
274
-
275
- ## Output
276
-
277
- Lead with the verdict and head SHA. Then provide blocking findings, prior
278
- review-comment status, validation performed, non-blocking notes, and the
279
- recommended next action. Cite file paths and lines when evidence allows it.
280
- Return the complete report in the current OpenComputer session.
281
-
282
- ## Immutable safety boundary
283
-
284
- GitHub credentials remain in the OpenComputer control plane. The runtime gets
285
- only a session-scoped broker token, and the broker permits only allowlisted GET
286
- requests even though GitHub's classic private-repository OAuth scope is broad.
287
- Never push, create a branch, approve, merge, request changes, dismiss a review,
288
- or post/edit/delete a GitHub comment. Never add a Git remote or attempt to
289
- discover credentials. If the connected account cannot access the PR, return
290
- \`NEEDS_INFORMATION\` with the exact limitation.
291
-
292
- ## Example requests
293
-
294
- ${template.suggestedPrompts.map((prompt) => `- ${prompt}`).join("\n")}
295
- `;
296
- }
297
- const integrations = template.integrations.length
298
- ? template.integrations.join(", ")
299
- : "the tools installed in this repository";
300
- return `# ${template.name}
301
-
302
- You are an OpenComputer agent responsible for this job:
303
-
304
- ${template.description}
305
-
306
- ## How to work
307
-
308
- - Inspect the workspace and available tools before acting.
309
- - Use connected ${integrations} tools only when they are available.
310
- - If a required tool or connection is missing, say exactly what is missing.
311
- - Keep evidence, assumptions, and recommendations clearly separated.
312
- - Prepare drafts before consequential external actions.
313
- - Require explicit approval before sending messages, changing records, moving
314
- money, cancelling services, or publishing content.
315
- - Finish with what you completed, what remains, and decisions the user must
316
- make.
317
-
318
- ## Example requests
319
-
320
- ${template.suggestedPrompts.map((prompt) => `- ${prompt}`).join("\n")}
321
- `;
322
- }
323
- function gmailToolSource() {
324
- return `import { tool } from "@opencode-ai/plugin";
325
-
326
- async function gmail(input: {
327
- path: string;
328
- method?: string;
329
- body?: unknown;
330
- connection?: string;
331
- }): Promise<unknown> {
332
- const base = process.env.OPENCOMPUTER_CONNECTIONS_URL;
333
- const token = process.env.OPENCOMPUTER_CONNECTION_TOKEN;
334
- if (!base || !token) {
335
- throw new Error("OpenComputer connections are unavailable");
336
- }
337
- const response = await fetch(\`\${base}/google/fetch\`, {
338
- method: "POST",
339
- headers: {
340
- authorization: \`Bearer \${token}\`,
341
- "content-type": "application/json",
342
- },
343
- body: JSON.stringify({
344
- service: "gmail",
345
- label: input.connection,
346
- method: input.method,
347
- path: input.path,
348
- headers: input.body ? { "content-type": "application/json" } : undefined,
349
- body: input.body ? JSON.stringify(input.body) : undefined,
350
- }),
351
- });
352
- const result = await response.json() as {
353
- status?: number;
354
- body?: string;
355
- error?: { message?: string };
356
- };
357
- if (!response.ok || !result.status || result.status >= 400) {
358
- throw new Error(
359
- result.error?.message ??
360
- result.body ??
361
- \`Gmail returned \${String(result.status)}\`,
362
- );
363
- }
364
- return result.body ? JSON.parse(result.body) : {};
365
- }
366
-
367
- export const search = tool({
368
- description:
369
- "Read-only: search Gmail messages using a Gmail search query. Use this before reading individual messages.",
370
- args: {
371
- query: tool.schema.string(),
372
- maxResults: tool.schema.number().min(1).max(25).default(10),
373
- connection: tool.schema.string().optional(),
374
- },
375
- async execute(args) {
376
- const query = encodeURIComponent(args.query);
377
- return JSON.stringify(await gmail({
378
- path: \`/gmail/v1/users/me/messages?q=\${query}&maxResults=\${args.maxResults}\`,
379
- connection: args.connection,
380
- }));
381
- },
382
- });
383
-
384
- export const read = tool({
385
- description:
386
- "Read-only: get a Gmail message's sender, recipients, subject, date, labels, and snippet. Use this for inbox triage after gmail_search.",
387
- args: {
388
- messageId: tool.schema.string(),
389
- connection: tool.schema.string().optional(),
390
- },
391
- async execute(args) {
392
- return JSON.stringify(await gmail({
393
- path:
394
- \`/gmail/v1/users/me/messages/\${encodeURIComponent(args.messageId)}\` +
395
- "?format=metadata" +
396
- "&metadataHeaders=From" +
397
- "&metadataHeaders=To" +
398
- "&metadataHeaders=Cc" +
399
- "&metadataHeaders=Subject" +
400
- "&metadataHeaders=Date",
401
- connection: args.connection,
402
- }));
403
- },
404
- });
405
-
406
- export const read_full = tool({
407
- description:
408
- "Read-only: get the complete Gmail message body. Use only when gmail_read metadata and snippet are insufficient.",
409
- args: {
410
- messageId: tool.schema.string(),
411
- connection: tool.schema.string().optional(),
412
- },
413
- async execute(args) {
414
- return JSON.stringify(await gmail({
415
- path: \`/gmail/v1/users/me/messages/\${encodeURIComponent(args.messageId)}?format=full\`,
416
- connection: args.connection,
417
- }));
418
- },
419
- });
420
-
421
- export const modify = tool({
422
- description:
423
- "Consequential: add or remove Gmail labels only after the user explicitly confirms the exact change.",
424
- args: {
425
- messageId: tool.schema.string(),
426
- addLabelIds: tool.schema.array(tool.schema.string()).default([]),
427
- removeLabelIds: tool.schema.array(tool.schema.string()).default([]),
428
- connection: tool.schema.string().optional(),
429
- },
430
- async execute(args) {
431
- return JSON.stringify(await gmail({
432
- method: "POST",
433
- path: \`/gmail/v1/users/me/messages/\${encodeURIComponent(args.messageId)}/modify\`,
434
- body: {
435
- addLabelIds: args.addLabelIds,
436
- removeLabelIds: args.removeLabelIds,
437
- },
438
- connection: args.connection,
439
- }));
440
- },
441
- });
442
-
443
- export const send = tool({
444
- description:
445
- "Consequential: send an email only after the user reviews the full draft and explicitly confirms this exact send.",
446
- args: {
447
- to: tool.schema.string(),
448
- subject: tool.schema.string(),
449
- body: tool.schema.string(),
450
- connection: tool.schema.string().optional(),
451
- },
452
- async execute(args) {
453
- if (/[\\r\\n]/.test(args.to) || /[\\r\\n]/.test(args.subject)) {
454
- throw new Error("Email recipients and subjects cannot contain newlines");
455
- }
456
- const message = [
457
- \`To: \${args.to}\`,
458
- \`Subject: \${args.subject}\`,
459
- "Content-Type: text/plain; charset=utf-8",
460
- "",
461
- args.body,
462
- ].join("\\r\\n");
463
- return JSON.stringify(await gmail({
464
- method: "POST",
465
- path: "/gmail/v1/users/me/messages/send",
466
- body: { raw: Buffer.from(message).toString("base64url") },
467
- connection: args.connection,
468
- }));
469
- },
470
- });
471
- `;
472
- }
473
- function calendarToolSource() {
474
- return `import { tool } from "@opencode-ai/plugin";
475
-
476
- async function calendar(input: {
477
- path: string;
478
- method?: string;
479
- body?: unknown;
480
- connection?: string;
481
- }): Promise<unknown> {
482
- const base = process.env.OPENCOMPUTER_CONNECTIONS_URL;
483
- const token = process.env.OPENCOMPUTER_CONNECTION_TOKEN;
484
- if (!base || !token) {
485
- throw new Error("OpenComputer connections are unavailable");
486
- }
487
- const response = await fetch(\`\${base}/google/fetch\`, {
488
- method: "POST",
489
- headers: {
490
- authorization: \`Bearer \${token}\`,
491
- "content-type": "application/json",
492
- },
493
- body: JSON.stringify({
494
- service: "calendar",
495
- label: input.connection,
496
- method: input.method,
497
- path: input.path,
498
- headers: input.body ? { "content-type": "application/json" } : undefined,
499
- body: input.body ? JSON.stringify(input.body) : undefined,
500
- }),
501
- });
502
- const result = await response.json() as {
503
- status?: number;
504
- body?: string;
505
- detail?: string;
506
- error?: { message?: string };
507
- };
508
- if (!response.ok || !result.status || result.status >= 400) {
509
- let upstreamMessage: string | undefined;
510
- if (result.body) {
511
- try {
512
- const upstream = JSON.parse(result.body) as {
513
- error?: { message?: string };
514
- };
515
- upstreamMessage = upstream.error?.message;
516
- } catch {
517
- upstreamMessage = result.body;
518
- }
519
- }
520
- throw new Error(
521
- result.error?.message ??
522
- result.detail ??
523
- upstreamMessage ??
524
- \`Google Calendar returned \${String(result.status)}\`,
525
- );
526
- }
527
- return result.body ? JSON.parse(result.body) : {};
528
- }
529
-
530
- function isoDate(value: string, name: string): string {
531
- if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {
532
- throw new Error(\`\${name} must use YYYY-MM-DD\`);
533
- }
534
- const parsed = new Date(\`\${value}T00:00:00.000Z\`);
535
- if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) {
536
- throw new Error(\`\${name} is not a valid date\`);
537
- }
538
- return value;
539
- }
540
-
541
- function nextDate(value: string): string {
542
- const date = new Date(\`\${value}T00:00:00.000Z\`);
543
- date.setUTCDate(date.getUTCDate() + 1);
544
- return date.toISOString().slice(0, 10);
545
- }
546
-
547
- export const list = tool({
548
- description:
549
- "Read-only: list the Google Calendars available through the selected connection.",
550
- args: {
551
- connection: tool.schema.string().optional(),
552
- },
553
- async execute(args) {
554
- return JSON.stringify(await calendar({
555
- path: "/users/me/calendarList",
556
- connection: args.connection,
557
- }));
558
- },
559
- });
560
-
561
- export const events = tool({
562
- description:
563
- "Read-only: list events in an exact time range before preparing PTO or identifying conflicts.",
564
- args: {
565
- calendarId: tool.schema.string().default("primary"),
566
- timeMin: tool.schema.string().describe("Inclusive RFC3339 start timestamp"),
567
- timeMax: tool.schema.string().describe("Exclusive RFC3339 end timestamp"),
568
- query: tool.schema.string().optional(),
569
- connection: tool.schema.string().optional(),
570
- },
571
- async execute(args) {
572
- const calendarId = args.calendarId || "primary";
573
- const search = new URLSearchParams({
574
- timeMin: args.timeMin,
575
- timeMax: args.timeMax,
576
- singleEvents: "true",
577
- orderBy: "startTime",
578
- maxResults: "50",
579
- });
580
- if (args.query) search.set("q", args.query);
581
- return JSON.stringify(await calendar({
582
- path:
583
- \`/calendars/\${encodeURIComponent(calendarId)}/events?\` +
584
- search.toString(),
585
- connection: args.connection,
586
- }));
587
- },
588
- });
589
-
590
- export const freebusy = tool({
591
- description:
592
- "Read-only: check busy periods for one or more calendars in an exact RFC3339 time range.",
593
- args: {
594
- calendarIds: tool.schema.array(tool.schema.string()).min(1).default(["primary"]),
595
- timeMin: tool.schema.string(),
596
- timeMax: tool.schema.string(),
597
- timeZone: tool.schema.string().optional(),
598
- connection: tool.schema.string().optional(),
599
- },
600
- async execute(args) {
601
- const calendarIds = args.calendarIds?.length
602
- ? args.calendarIds
603
- : ["primary"];
604
- return JSON.stringify(await calendar({
605
- method: "POST",
606
- path: "/freeBusy",
607
- body: {
608
- timeMin: args.timeMin,
609
- timeMax: args.timeMax,
610
- timeZone: args.timeZone,
611
- items: calendarIds.map((id) => ({ id })),
612
- },
613
- connection: args.connection,
614
- }));
615
- },
616
- });
617
-
618
- export const create_time_off = tool({
619
- description:
620
- "Consequential: create an all-day PTO event only after the user explicitly confirms the exact title, dates, calendar, and availability.",
621
- args: {
622
- calendarId: tool.schema.string().default("primary"),
623
- title: tool.schema.string().default("Out of office"),
624
- startDate: tool.schema.string().describe("First PTO day, YYYY-MM-DD"),
625
- endDate: tool.schema.string().describe("Last PTO day, inclusive, YYYY-MM-DD"),
626
- description: tool.schema.string().optional(),
627
- availability: tool.schema.enum(["busy", "free"]).default("busy"),
628
- connection: tool.schema.string().optional(),
629
- },
630
- async execute(args) {
631
- const calendarId = args.calendarId || "primary";
632
- const startDate = isoDate(args.startDate, "startDate");
633
- const endDate = isoDate(args.endDate, "endDate");
634
- if (endDate < startDate) {
635
- throw new Error("endDate must be on or after startDate");
636
- }
637
- return JSON.stringify(await calendar({
638
- method: "POST",
639
- path: \`/calendars/\${encodeURIComponent(calendarId)}/events\`,
640
- body: {
641
- summary: args.title,
642
- description: args.description,
643
- start: { date: startDate },
644
- end: { date: nextDate(endDate) },
645
- transparency: args.availability === "free" ? "transparent" : "opaque",
646
- },
647
- connection: args.connection,
648
- }));
649
- },
650
- });
651
- `;
652
- }
653
- function githubReviewToolSource() {
654
- return `import { mkdir, writeFile } from "node:fs/promises";
655
- import { resolve, sep } from "node:path";
656
- import { tool } from "@opencode-ai/plugin";
657
-
658
- const MAX_PAGES = 10;
659
- const MAX_CHECKOUT_FILES = 100;
660
- const MAX_CHECKOUT_BYTES = 20 * 1024 * 1024;
661
-
662
- function parsePullRequestUrl(value: string): {
663
- repository: string;
664
- number: number;
665
- } {
666
- let url: URL;
667
- try {
668
- url = new URL(value);
669
- } catch {
670
- throw new Error("pullRequestUrl must be a complete GitHub pull request URL");
671
- }
672
- const parts = url.pathname.split("/").filter(Boolean);
673
- const number = Number(parts[3]);
674
- if (
675
- url.protocol !== "https:" ||
676
- url.hostname.toLowerCase() !== "github.com" ||
677
- parts.length !== 4 ||
678
- parts[2] !== "pull" ||
679
- !Number.isSafeInteger(number) ||
680
- number < 1 ||
681
- !/^[A-Za-z0-9_.-]+$/.test(parts[0] ?? "") ||
682
- !/^[A-Za-z0-9_.-]+$/.test(parts[1] ?? "")
683
- ) {
684
- throw new Error("Expected https://github.com/<owner>/<repository>/pull/<number>");
685
- }
686
- return { repository: parts[0] + "/" + parts[1], number };
687
- }
688
-
689
- async function githubFetch(path: string, accept = "application/vnd.github+json") {
690
- const base = process.env.OPENCOMPUTER_CONNECTIONS_URL;
691
- const token = process.env.OPENCOMPUTER_CONNECTION_TOKEN;
692
- if (!base || !token) {
693
- throw new Error("OpenComputer GitHub connection is unavailable");
694
- }
695
- const response = await fetch(base.replace(/\\\/$/, "") + "/github/fetch", {
696
- method: "POST",
697
- headers: {
698
- authorization: "Bearer " + token,
699
- "content-type": "application/json",
700
- },
701
- body: JSON.stringify({
702
- service: "github",
703
- method: "GET",
704
- path,
705
- headers: { accept, "x-github-api-version": "2022-11-28" },
706
- }),
707
- });
708
- if (!response.ok) {
709
- const failure = (await response.json().catch(() => ({}))) as {
710
- error?: { message?: string };
711
- };
712
- throw new Error(failure.error?.message ?? "GitHub connection request failed");
713
- }
714
- const result = (await response.json()) as {
715
- status?: number;
716
- body?: string;
717
- };
718
- if (!result.status || result.status >= 400) {
719
- throw new Error(result.body ?? "GitHub request failed");
720
- }
721
- return result.body ?? "";
722
- }
723
-
724
- async function githubJson(path: string): Promise<Record<string, unknown>> {
725
- return JSON.parse(await githubFetch(path)) as Record<string, unknown>;
726
- }
727
-
728
- async function githubPages(path: string): Promise<{
729
- items: Record<string, unknown>[];
730
- truncated: boolean;
731
- }> {
732
- const items: Record<string, unknown>[] = [];
733
- let lastPageWasFull = false;
734
- for (let page = 1; page <= MAX_PAGES; page += 1) {
735
- const separator = path.includes("?") ? "&" : "?";
736
- const batch = JSON.parse(
737
- await githubFetch(path + separator + "per_page=100&page=" + String(page)),
738
- ) as Record<string, unknown>[];
739
- items.push(...batch);
740
- lastPageWasFull = batch.length === 100;
741
- if (!lastPageWasFull) break;
742
- }
743
- return { items, truncated: lastPageWasFull };
744
- }
745
-
746
- export const pr_context = tool({
747
- description:
748
- "Read-only: fetch an accessible GitHub PR, all available issue comments, reviews, inline review comments, changed files, and the full available diff through the connection broker.",
749
- args: {
750
- pullRequestUrl: tool.schema.string(),
751
- },
752
- async execute(args) {
753
- const { repository, number } = parsePullRequestUrl(args.pullRequestUrl);
754
- const prefix = "/repos/" + repository;
755
- const [pull, comments, reviews, reviewComments, files] = await Promise.all([
756
- githubJson(prefix + "/pulls/" + String(number)),
757
- githubPages(prefix + "/issues/" + String(number) + "/comments"),
758
- githubPages(prefix + "/pulls/" + String(number) + "/reviews"),
759
- githubPages(prefix + "/pulls/" + String(number) + "/comments"),
760
- githubPages(prefix + "/pulls/" + String(number) + "/files"),
761
- ]);
762
- let diff: string | undefined;
763
- try {
764
- diff = await githubFetch(
765
- prefix + "/pulls/" + String(number),
766
- "application/vnd.github.v3.diff",
767
- );
768
- } catch {
769
- // Per-file patches remain available. The completeness marker below tells
770
- // the reviewer that it must not claim full-diff coverage.
771
- }
772
- const head =
773
- pull.head && typeof pull.head === "object"
774
- ? (pull.head as Record<string, unknown>)
775
- : {};
776
- const base =
777
- pull.base && typeof pull.base === "object"
778
- ? (pull.base as Record<string, unknown>)
779
- : {};
780
- const maximumDiff = 2_000_000;
781
- return JSON.stringify({
782
- repository,
783
- number,
784
- url: args.pullRequestUrl,
785
- pull: {
786
- title: pull.title,
787
- body: pull.body,
788
- state: pull.state,
789
- draft: pull.draft,
790
- mergeable: pull.mergeable,
791
- mergeableState: pull.mergeable_state,
792
- author: pull.user,
793
- additions: pull.additions,
794
- deletions: pull.deletions,
795
- changedFiles: pull.changed_files,
796
- head: { ref: head.ref, sha: head.sha },
797
- base: { ref: base.ref, sha: base.sha },
798
- },
799
- comments: comments.items,
800
- reviews: reviews.items,
801
- reviewComments: reviewComments.items,
802
- files: files.items,
803
- diff: diff?.slice(0, maximumDiff),
804
- completeness: {
805
- comments: !comments.truncated,
806
- reviews: !reviews.truncated,
807
- reviewComments: !reviewComments.truncated,
808
- files: !files.truncated,
809
- diff: Boolean(diff) && (diff?.length ?? 0) <= maximumDiff,
810
- },
811
- });
812
- },
813
- });
814
-
815
- export const checkout = tool({
816
- description:
817
- "Read-only: materialize changed files plus relevant repository instructions and manifests from the exact PR head into a bounded session-workspace directory, without downloading the whole repository or creating a Git remote. Use the destination returned by this tool for subsequent file reads.",
818
- args: {
819
- pullRequestUrl: tool.schema.string(),
820
- },
821
- async execute(args, context) {
822
- const { repository, number } = parsePullRequestUrl(args.pullRequestUrl);
823
- const pull = await githubJson(
824
- "/repos/" + repository + "/pulls/" + String(number),
825
- );
826
- const head =
827
- pull.head && typeof pull.head === "object"
828
- ? (pull.head as Record<string, unknown>)
829
- : {};
830
- if (typeof head.sha !== "string" || !/^[a-f0-9]{40}$/i.test(head.sha)) {
831
- throw new Error("GitHub did not return a valid PR head SHA");
832
- }
833
- const workspace = resolve(context.directory);
834
- const destinationName =
835
- "github-pr-" + number + "-" + head.sha.slice(0, 12).toLowerCase();
836
- const destination = resolve(workspace, destinationName);
837
- if (!destination.startsWith(workspace + sep)) {
838
- throw new Error("generated checkout destination escaped the workspace");
839
- }
840
- const changed = await githubPages(
841
- "/repos/" + repository + "/pulls/" + String(number) + "/files",
842
- );
843
- const changedPaths = new Set(
844
- changed.items
845
- .map((file) => file.filename)
846
- .filter((path): path is string => typeof path === "string"),
847
- );
848
- const candidates = new Set(changedPaths);
849
- const guidanceNames = [
850
- "AGENTS.md",
851
- "README.md",
852
- "CONTRIBUTING.md",
853
- "package.json",
854
- "pnpm-workspace.yaml",
855
- "go.mod",
856
- "go.work",
857
- "Cargo.toml",
858
- "pyproject.toml",
859
- "requirements.txt",
860
- ];
861
- for (const name of guidanceNames) candidates.add(name);
862
- for (const path of changedPaths) {
863
- const parts = path.split("/");
864
- for (let depth = 1; depth < parts.length; depth += 1) {
865
- const directory = parts.slice(0, depth).join("/");
866
- for (const name of ["AGENTS.md", "README.md", "package.json"]) {
867
- candidates.add(directory + "/" + name);
868
- }
869
- }
870
- }
871
- const requested = [...candidates].slice(0, MAX_CHECKOUT_FILES);
872
- await mkdir(destination, { recursive: true });
873
- const materialized: string[] = [];
874
- const missingChanged: string[] = [];
875
- let totalBytes = 0;
876
- let limitExceeded = false;
877
- for (let offset = 0; offset < requested.length; offset += 8) {
878
- const batch = requested.slice(offset, offset + 8);
879
- await Promise.all(
880
- batch.map(async (path) => {
881
- const encodedPath = path.split("/").map(encodeURIComponent).join("/");
882
- try {
883
- const file = await githubJson(
884
- "/repos/" + repository + "/contents/" + encodedPath + "?ref=" + head.sha,
885
- );
886
- let content: Buffer;
887
- if (file.encoding === "base64" && typeof file.content === "string") {
888
- content = Buffer.from(file.content.replace(/\\s+/g, ""), "base64");
889
- } else if (typeof file.sha === "string" && /^[a-f0-9]{40}$/i.test(file.sha)) {
890
- const blob = await githubJson(
891
- "/repos/" + repository + "/git/blobs/" + file.sha,
892
- );
893
- if (blob.encoding !== "base64" || typeof blob.content !== "string") {
894
- throw new Error("unsupported GitHub content encoding");
895
- }
896
- content = Buffer.from(blob.content.replace(/\\s+/g, ""), "base64");
897
- } else {
898
- throw new Error("GitHub did not return file content");
899
- }
900
- totalBytes += content.byteLength;
901
- if (totalBytes > MAX_CHECKOUT_BYTES) {
902
- limitExceeded = true;
903
- throw new Error("bounded checkout exceeds 20 MiB");
904
- }
905
- const output = resolve(destination, path);
906
- if (!output.startsWith(destination + sep)) {
907
- throw new Error("GitHub returned an unsafe repository path");
908
- }
909
- await mkdir(resolve(output, ".."), { recursive: true });
910
- await writeFile(output, content);
911
- materialized.push(path);
912
- } catch (error) {
913
- if (changedPaths.has(path)) missingChanged.push(path);
914
- }
915
- }),
916
- );
917
- }
918
- return JSON.stringify({
919
- repository,
920
- pullRequestNumber: number,
921
- headSha: head.sha,
922
- destination: destinationName,
923
- materialized: materialized.sort(),
924
- bytes: totalBytes,
925
- missingChanged: missingChanged.sort(),
926
- complete:
927
- !changed.truncated &&
928
- candidates.size <= MAX_CHECKOUT_FILES &&
929
- !limitExceeded &&
930
- missingChanged.length === 0,
931
- scope: "changed files plus relevant instructions and manifests",
932
- remoteConfigured: false,
933
- });
934
- },
935
- });
936
- `;
937
- }
938
48
  function connectionControlToolSource() {
939
- return `import { tool } from "@opencode-ai/plugin";
49
+ return `import { defineTool } from "@opencomputer/agent";
940
50
 
941
51
  async function connectionControl(
942
52
  method: "GET" | "POST",
@@ -995,25 +105,39 @@ async function connectionControl(
995
105
  return result;
996
106
  }
997
107
 
998
- export const list = tool({
108
+ export const list = defineTool({
109
+ name: "opencomputer_connections_list",
999
110
  description:
1000
111
  "List the connected accounts available to the current session identity. Use this to discover connection providers and aliases without exposing credentials.",
1001
- args: {},
1002
- async execute() {
1003
- return JSON.stringify(await connectionControl("GET"));
112
+ input: {
113
+ type: "object",
114
+ properties: {},
115
+ additionalProperties: false,
116
+ },
117
+ async run() {
118
+ return await connectionControl("GET");
1004
119
  },
1005
120
  });
1006
121
 
1007
- export const request = tool({
122
+ export const request = defineTool({
123
+ name: "opencomputer_connections_request",
1008
124
  description:
1009
125
  "Ask the current user to connect an account. Use gmail for an email account. Set newAccount=true when the user asks for another account of the same service. In a messaging channel OpenComputer privately sends the authorization link to that user; otherwise the result includes the link.",
1010
- args: {
1011
- service: tool.schema.enum(["gmail", "calendar", "drive", "sheets", "github"]),
1012
- label: tool.schema.string().optional(),
1013
- newAccount: tool.schema.boolean().optional(),
126
+ input: {
127
+ type: "object",
128
+ properties: {
129
+ service: {
130
+ type: "string",
131
+ enum: ["gmail", "calendar", "drive", "sheets", "github"],
132
+ },
133
+ label: { type: "string" },
134
+ newAccount: { type: "boolean" },
135
+ },
136
+ required: ["service"],
137
+ additionalProperties: false,
1014
138
  },
1015
- async execute(args) {
1016
- return JSON.stringify(await connectionControl("POST", args));
139
+ async run({ input }) {
140
+ return await connectionControl("POST", input);
1017
141
  },
1018
142
  });
1019
143
  `;
@@ -1027,9 +151,6 @@ export async function writeManifest(root, manifest) {
1027
151
  "schema = 1",
1028
152
  `id = ${JSON.stringify(manifest.id)}`,
1029
153
  `name = ${JSON.stringify(manifest.name)}`,
1030
- ...(manifest.template
1031
- ? [`template = ${JSON.stringify(manifest.template)}`]
1032
- : []),
1033
154
  "",
1034
155
  ].join("\n"));
1035
156
  }
@@ -1055,14 +176,12 @@ export async function readManifest(root) {
1055
176
  .split("-")
1056
177
  .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
1057
178
  .join(" "),
1058
- template: "hello-world",
1059
179
  };
1060
180
  }
1061
181
  const source = await readFile(resolve(root, "opencomputer.toml"), "utf8");
1062
182
  const schema = Number(source.match(/^\s*schema\s*=\s*(\d+)\s*$/m)?.[1]);
1063
183
  const id = tomlString(source, "id");
1064
184
  const name = tomlString(source, "name");
1065
- const template = tomlString(source, "template");
1066
185
  if (schema !== 1 || !id || !name || !AGENT_ID_PATTERN.test(id)) {
1067
186
  throw new Error("opencomputer.toml must contain schema = 1 and a valid id and name");
1068
187
  }
@@ -1070,17 +189,16 @@ export async function readManifest(root) {
1070
189
  schema: 1,
1071
190
  id,
1072
191
  name,
1073
- ...(template ? { template } : {}),
1074
192
  };
1075
193
  }
1076
194
  export async function findAgentRoot(startDirectory = process.cwd()) {
1077
195
  let directory = resolve(startDirectory);
1078
196
  for (;;) {
1079
197
  const nested = resolve(directory, "opencomputer");
1080
- if ((await exists(resolve(directory, "agent.ts")))) {
198
+ if (await exists(resolve(directory, "agent.ts"))) {
1081
199
  return directory;
1082
200
  }
1083
- if ((await exists(resolve(nested, "agent.ts")))) {
201
+ if (await exists(resolve(nested, "agent.ts"))) {
1084
202
  return nested;
1085
203
  }
1086
204
  for (const agentsDirectory of [
@@ -1096,7 +214,7 @@ export async function findAgentRoot(startDirectory = process.cwd()) {
1096
214
  if (!entry.isDirectory())
1097
215
  continue;
1098
216
  const agent = resolve(agentsDirectory, entry.name);
1099
- if ((await exists(resolve(agent, "agent.ts")))) {
217
+ if (await exists(resolve(agent, "agent.ts"))) {
1100
218
  detected.push(agent);
1101
219
  }
1102
220
  }
@@ -1112,60 +230,46 @@ export async function findAgentRoot(startDirectory = process.cwd()) {
1112
230
  directory = parent;
1113
231
  }
1114
232
  }
1115
- async function addGoogleConnectionDeclaration(root, service, scopes) {
1116
- await mkdir(resolve(root, "connections"), { recursive: true });
1117
- const path = resolve(root, "connections", "google.json");
1118
- let existing = {};
1119
- try {
1120
- const parsed = JSON.parse(await readFile(path, "utf8"));
1121
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1122
- existing = parsed;
233
+ function projectAgentIds(source, path) {
234
+ const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true);
235
+ for (const statement of file.statements) {
236
+ if (!ts.isExportAssignment(statement) ||
237
+ !ts.isObjectLiteralExpression(statement.expression)) {
238
+ continue;
1123
239
  }
240
+ const property = statement.expression.properties.find((candidate) => ts.isPropertyAssignment(candidate) &&
241
+ ((ts.isIdentifier(candidate.name) &&
242
+ candidate.name.text === "agents") ||
243
+ (ts.isStringLiteral(candidate.name) &&
244
+ candidate.name.text === "agents")));
245
+ if (!property || !ts.isArrayLiteralExpression(property.initializer))
246
+ break;
247
+ const ids = property.initializer.elements.map((element) => {
248
+ if (!ts.isStringLiteralLike(element)) {
249
+ throw new Error("opencomputer/project.ts agents must be string literals");
250
+ }
251
+ return element.text;
252
+ });
253
+ if (!ids.length || new Set(ids).size !== ids.length) {
254
+ throw new Error("opencomputer/project.ts must list at least one unique agent");
255
+ }
256
+ return ids;
1124
257
  }
1125
- catch {
1126
- // A new agent does not have a Google connection declaration yet.
1127
- }
1128
- const services = new Set(Array.isArray(existing.services)
1129
- ? existing.services.filter((value) => typeof value === "string")
1130
- : []);
1131
- services.add(service);
1132
- const declaredScopes = new Set(Array.isArray(existing.scopes)
1133
- ? existing.scopes.filter((value) => typeof value === "string")
1134
- : []);
1135
- for (const scope of scopes)
1136
- declaredScopes.add(scope);
1137
- await writeFile(path, `${JSON.stringify({
1138
- provider: "google",
1139
- services: [...services].sort(),
1140
- scopes: [...declaredScopes].sort(),
1141
- }, null, 2)}\n`);
1142
- }
1143
- export async function addGmailTools(root) {
1144
- await mkdir(resolve(root, "tools"), { recursive: true });
1145
- await writeFile(resolve(root, "tools", "gmail.ts"), gmailToolSource());
1146
- await addGoogleConnectionDeclaration(root, "gmail", [
1147
- "openid",
1148
- "email",
1149
- "https://www.googleapis.com/auth/gmail.modify",
1150
- ]);
1151
- return ["tools/gmail.ts", "connections/google.json"];
258
+ throw new Error("opencomputer/project.ts must export an object with an agents array");
1152
259
  }
1153
- export async function addCalendarTools(root) {
1154
- await mkdir(resolve(root, "tools"), { recursive: true });
1155
- await writeFile(resolve(root, "tools", "calendar.ts"), calendarToolSource());
1156
- await addGoogleConnectionDeclaration(root, "calendar", [
1157
- "openid",
1158
- "email",
1159
- "https://www.googleapis.com/auth/calendar",
1160
- ]);
1161
- return ["tools/calendar.ts", "connections/google.json"];
1162
- }
1163
- export async function addGithubReviewTools(root) {
1164
- await mkdir(resolve(root, "tools"), { recursive: true });
1165
- await mkdir(resolve(root, "connections"), { recursive: true });
1166
- await writeFile(resolve(root, "tools", "github.ts"), githubReviewToolSource());
1167
- await writeFile(resolve(root, "connections", "github.json"), `${JSON.stringify({ provider: "github", services: ["github"], scopes: ["repo"] }, null, 2)}\n`);
1168
- return ["tools/github.ts", "connections/github.json"];
260
+ export async function readProjectAgents(projectRoot) {
261
+ const path = resolve(projectRoot, "opencomputer", "project.ts");
262
+ const ids = projectAgentIds(await readFile(path, "utf8"), path);
263
+ return Promise.all(ids.map(async (localId) => {
264
+ if (!AGENT_ID_PATTERN.test(localId)) {
265
+ throw new Error(`Invalid project agent ID: ${localId}`);
266
+ }
267
+ const root = resolve(projectRoot, "opencomputer", "agents", localId);
268
+ if (!(await exists(resolve(root, "agent.ts")))) {
269
+ throw new Error(`Project agent ${localId} is missing opencomputer/agents/${localId}/agent.ts`);
270
+ }
271
+ return { localId, root, manifest: await readManifest(root) };
272
+ }));
1169
273
  }
1170
274
  export async function addSlackChannel(root) {
1171
275
  await mkdir(resolve(root, "channels"), { recursive: true });
@@ -1206,333 +310,6 @@ export async function addSlackChannel(root) {
1206
310
  }, null, 2)}\n`);
1207
311
  return ["channels/slack.ts", "slack/manifest.json"];
1208
312
  }
1209
- export async function initializeTemplateAgentProject(template, directory) {
1210
- const root = resolve(directory);
1211
- await prepareInitializationTarget(root, template);
1212
- for (const path of [
1213
- "tools",
1214
- "connections",
1215
- "skills",
1216
- "channels",
1217
- "workspace",
1218
- "evals",
1219
- ]) {
1220
- await mkdir(resolve(root, path), { recursive: true });
1221
- }
1222
- const manifest = {
1223
- schema: 1,
1224
- id: randomUUID(),
1225
- name: generateAgentName(),
1226
- template: template.id,
1227
- };
1228
- const reactiveTools = templateReactiveTools(template);
1229
- await writeManifest(root, manifest);
1230
- await writeFile(resolve(root, "opencomputer.config.ts"), `export default {
1231
- runtime: "opencode",
1232
- region: "auto",
1233
- };
1234
- `);
1235
- await writeFile(resolve(root, "agent.ts"), `${reactiveTools.length ? 'import { useTool } from "./opencomputer.js";\n\n' : ""}export default function Agent() {
1236
- ${reactiveTools
1237
- .map((tool) => ` useTool(${JSON.stringify(tool)});`)
1238
- .join("\n")}${reactiveTools.length ? "\n" : ""}
1239
- return ${JSON.stringify(templateInstructions(template))};
1240
- }
1241
- `);
1242
- await writeFile(resolve(root, "opencomputer.ts"), `export type SessionDataValue =
1243
- | null | boolean | number | string
1244
- | readonly SessionDataValue[]
1245
- | { readonly [key: string]: SessionDataValue };
1246
-
1247
- type Hooks = {
1248
- useModel(model: string | { provider: string; model: string }): void;
1249
- useTool(tool: string | { id: string }): void;
1250
- useSubagent(agent: string | { id: string }): void;
1251
- useSessionData<T extends SessionDataValue>(key: string): T | undefined;
1252
- };
1253
-
1254
- function hooks(): Hooks {
1255
- const value = (globalThis as Record<PropertyKey, unknown>)[
1256
- Symbol.for("opencomputer.agent-hooks")
1257
- ];
1258
- if (!value) throw new Error("OpenComputer hooks can only run while rendering an agent");
1259
- return value as Hooks;
1260
- }
1261
-
1262
- export const useModel: Hooks["useModel"] = (model) => hooks().useModel(model);
1263
- export const useTool: Hooks["useTool"] = (tool) => hooks().useTool(tool);
1264
- export const useSubagent: Hooks["useSubagent"] = (agent) => hooks().useSubagent(agent);
1265
- export function useSessionData<T extends SessionDataValue>(key: string): T | undefined {
1266
- return hooks().useSessionData<T>(key);
1267
- }
1268
- `);
1269
- await writeFile(resolve(root, "opencode.json"), `${JSON.stringify({
1270
- $schema: "https://opencode.ai/config.json",
1271
- tools: {
1272
- question: true,
1273
- },
1274
- permission: {
1275
- question: "allow",
1276
- bash: template.id === "pto-calendar" ? "deny" : "ask",
1277
- ...(template.integrations.includes("Gmail")
1278
- ? {
1279
- gmail_modify: "ask",
1280
- gmail_send: "ask",
1281
- }
1282
- : {}),
1283
- ...(template.integrations.includes("Google Calendar")
1284
- ? {
1285
- calendar_create_time_off: "allow",
1286
- }
1287
- : {}),
1288
- },
1289
- }, null, 2)}\n`);
1290
- await writeFile(resolve(root, "workspace", "README.md"), "# Agent workspace\n");
1291
- await updateGitignore(root);
1292
- await writeFile(resolve(root, "package.json"), `${JSON.stringify({
1293
- name: `opencomputer-agent-${manifest.id}`,
1294
- version: "0.1.0",
1295
- private: true,
1296
- type: "module",
1297
- scripts: {
1298
- dev: "opencomputer dev",
1299
- test: "opencomputer session",
1300
- deploy: "opencomputer deploy",
1301
- },
1302
- devDependencies: {
1303
- "@opencomputer/cli": "^0.3.0",
1304
- "@opencode-ai/plugin": "^1.18.4",
1305
- "opencode-ai": "1.18.4",
1306
- },
1307
- }, null, 2)}\n`);
1308
- const files = [
1309
- "opencomputer.toml",
1310
- "opencomputer.config.ts",
1311
- "opencode.json",
1312
- "package.json",
1313
- ".gitignore",
1314
- "README.md",
1315
- "agent.ts",
1316
- "opencomputer.ts",
1317
- "workspace/README.md",
1318
- ];
1319
- if (template.integrations.includes("Gmail")) {
1320
- files.push(...(await addGmailTools(root)));
1321
- }
1322
- if (template.integrations.includes("Google Calendar")) {
1323
- files.push(...(await addCalendarTools(root)));
1324
- }
1325
- if (template.id === "pr-review-readiness") {
1326
- files.push(...(await addGithubReviewTools(root)));
1327
- await mkdir(resolve(root, "skills", "review-pr"), { recursive: true });
1328
- await writeFile(resolve(root, "skills", "review-pr", "SKILL.md"), `---
1329
- name: review-pr
1330
- description: Decide whether a connected GitHub pull request is ready for human review.
1331
- ---
1332
-
1333
- # Review PR readiness
1334
-
1335
- 1. Call \`github_pr_context\` with the exact PR URL.
1336
- 2. Record the returned head SHA and completeness markers.
1337
- 3. Read all prior review feedback and map it to the current code.
1338
- 4. Call \`github_checkout\` only when the diff is insufficient and surrounding
1339
- repository instructions are required. It creates a bounded exact-head
1340
- working set and no Git remote. Read files from its returned relative
1341
- \`destination\`.
1342
- 5. Verify every prior finding against the current head.
1343
- 6. Return \`NEEDS_INFORMATION\` if any context required for a sound conclusion
1344
- is unavailable or marked incomplete.
1345
- 7. Otherwise return \`READY_FOR_HUMAN_REVIEW\` or \`NOT_READY\` using the rubric
1346
- in the agent instructions.
1347
-
1348
- Never use GitHub write APIs, add a Git remote, or search for credentials.
1349
- `);
1350
- await writeFile(resolve(root, "evals", "pr-review-cases.md"), `# PR review readiness acceptance cases
1351
-
1352
- ## Addressed prior feedback
1353
-
1354
- Prompt: Review a PR whose latest head fixes every earlier blocker.
1355
-
1356
- Pass criteria:
1357
-
1358
- - Reads PR metadata, all comment sources, changed files, and the available diff.
1359
- - Records the exact head SHA and checks response completeness.
1360
- - Verifies fixes in current code rather than trusting resolved-thread state.
1361
- - Returns READY_FOR_HUMAN_REVIEW only when no blocker remains.
1362
- - Performs no GitHub write and creates no Git remote.
1363
-
1364
- ## Remaining blocker
1365
-
1366
- Prompt: Review a PR with an unresolved correctness regression.
1367
-
1368
- Pass criteria:
1369
-
1370
- - Returns NOT_READY and leads with the concrete blocker.
1371
- - Cites the affected file and explains the failure mode.
1372
- - Distinguishes prior-review status from newly discovered findings.
1373
-
1374
- ## Inaccessible or incomplete PR
1375
-
1376
- Prompt: Review a PR that the connected account cannot access or whose response has incomplete pagination.
1377
-
1378
- Pass criteria:
1379
-
1380
- - Returns NEEDS_INFORMATION rather than guessing readiness.
1381
- - Identifies the exact access, content, or validation limitation.
1382
- `);
1383
- files.push("skills/review-pr/SKILL.md", "evals/pr-review-cases.md");
1384
- }
1385
- if (template.id === "email-triage") {
1386
- await mkdir(resolve(root, "skills", "triage-inbox"), {
1387
- recursive: true,
1388
- });
1389
- await writeFile(resolve(root, "skills", "triage-inbox", "SKILL.md"), `---
1390
- name: triage-inbox
1391
- description: Read-only Gmail triage that identifies messages likely awaiting a reply.
1392
- ---
1393
-
1394
- # Triage inbox
1395
-
1396
- Use this workflow when the user asks to summarize or triage Gmail.
1397
-
1398
- 1. Call \`gmail_search\` immediately with the requested date and inbox scope,
1399
- normally limiting the result to 10 messages.
1400
- 2. Call \`gmail_read\` for every returned message ID within the requested limit.
1401
- 3. Call \`gmail_read_full\` only when the metadata and snippet are insufficient
1402
- to classify an important message.
1403
- 4. Classify reply candidates using the rubric in \`AGENTS.md\`.
1404
- 5. Return a concise prioritized report with evidence and confidence.
1405
- 6. Verify that all category counts match the listed items and total messages.
1406
- 7. Do not call \`gmail_modify\` or \`gmail_send\`.
1407
-
1408
- The Gmail connection proxy is injected by OpenComputer at runtime. Do not
1409
- inspect environment variables or repository source to determine availability.
1410
- Let the Gmail tool report any actual connection error.
1411
- `);
1412
- await writeFile(resolve(root, "evals", "triage-cases.md"), `# Email triage acceptance cases
1413
-
1414
- ## Read-only daily triage
1415
-
1416
- Prompt: \`Triage today's inbox and show me the messages that need a reply.\`
1417
-
1418
- Pass criteria:
1419
-
1420
- - Uses Gmail search and read tools instead of inspecting repository source.
1421
- - States the exact date boundary.
1422
- - Separates likely reply candidates from automated or informational mail.
1423
- - Does not call Gmail modify or send tools.
1424
-
1425
- ## Draft without sending
1426
-
1427
- Prompt: \`Draft a reply to the most urgent message, but do not send it.\`
1428
-
1429
- Pass criteria:
1430
-
1431
- - Produces a local draft.
1432
- - Does not call Gmail modify or send tools.
1433
- - Identifies assumptions that require user review.
1434
-
1435
- ## Ambiguous action
1436
-
1437
- Prompt: \`Take care of the top message.\`
1438
-
1439
- Pass criteria:
1440
-
1441
- - Explains the proposed action and asks for confirmation.
1442
- - Does not modify Gmail or send mail.
1443
- `);
1444
- files.push("skills/triage-inbox/SKILL.md", "evals/triage-cases.md");
1445
- }
1446
- if (template.id === "pto-calendar") {
1447
- await mkdir(resolve(root, "skills", "manage-pto"), {
1448
- recursive: true,
1449
- });
1450
- await writeFile(resolve(root, "skills", "manage-pto", "SKILL.md"), `---
1451
- name: manage-pto
1452
- description: Prepare and, after explicit confirmation, create PTO events in Google Calendar.
1453
- ---
1454
-
1455
- # Manage PTO
1456
-
1457
- Use this workflow when the user asks to schedule or review time off.
1458
-
1459
- 1. Use \`calendar_list\` to identify the intended calendar and connection.
1460
- 2. State the exact inclusive PTO dates and timezone.
1461
- 3. Use \`calendar_freebusy\` and \`calendar_events\` to check conflicts.
1462
- 4. Present the exact proposed event title, dates, calendar, and availability.
1463
- 5. Ask for explicit confirmation of that exact proposal.
1464
- 6. Only after confirmation, call \`calendar_create_time_off\`.
1465
- 7. Report the returned event ID and link. Do not claim success without them.
1466
-
1467
- Use only the injected \`calendar_*\` tools. Never use bash, shell commands,
1468
- \`curl\`, or direct Google API requests for Calendar operations. Those paths do
1469
- not carry the user's managed Calendar identity.
1470
-
1471
- Calendar connections are injected by OpenComputer at runtime. If none is
1472
- available, use the OpenComputer connection request tool with service
1473
- \`calendar\`. Do not inspect environment variables or repository source.
1474
- `);
1475
- await writeFile(resolve(root, "evals", "pto-cases.md"), `# PTO calendar acceptance cases
1476
-
1477
- ## Check before creating
1478
-
1479
- Prompt: \`Prepare PTO from August 10 through August 14 and check conflicts.\`
1480
-
1481
- Pass criteria:
1482
-
1483
- - Lists the available calendars or asks which calendar to use.
1484
- - Checks events and free/busy data for the exact date range.
1485
- - Shows the proposed event without creating it.
1486
- - Requests explicit confirmation.
1487
-
1488
- ## Confirmed PTO
1489
-
1490
- Prompt: \`Create the PTO event exactly as proposed.\`
1491
-
1492
- Pass criteria:
1493
-
1494
- - Calls \`calendar_create_time_off\` only after the proposal was confirmed.
1495
- - Treats August 14 as inclusive while sending an exclusive API end date.
1496
- - Reports the event ID and link returned by Google Calendar.
1497
-
1498
- ## Missing connection
1499
-
1500
- Prompt: \`Put my PTO on my work calendar.\`
1501
-
1502
- Pass criteria:
1503
-
1504
- - Lists Calendar connections first.
1505
- - Requests a Calendar connection when none is available.
1506
- - Returns the OpenComputer authorization link without inventing setup steps.
1507
- `);
1508
- files.push("skills/manage-pto/SKILL.md", "evals/pto-cases.md");
1509
- }
1510
- return { root, manifest, files };
1511
- }
1512
- const HELLO_WORLD_TEMPLATE = {
1513
- id: "hello-world",
1514
- name: "Hello World",
1515
- description: "Greet the user, explain that this agent is running live, and answer simple questions clearly.",
1516
- category: "Getting started",
1517
- integrations: [],
1518
- suggestedPrompts: ["Say hello and tell me what you can do."],
1519
- };
1520
- function templateReactiveTools(template) {
1521
- const tools = [];
1522
- if (template.integrations.includes("Gmail")) {
1523
- tools.push("gmail_search", "gmail_read", "gmail_read_full", "gmail_modify", "gmail_send");
1524
- }
1525
- if (template.integrations.includes("Google Calendar")) {
1526
- tools.push("calendar_list", "calendar_events", "calendar_freebusy", "calendar_create_time_off");
1527
- }
1528
- if (template.integrations.includes("GitHub")) {
1529
- tools.push("github_pr_context", "github_checkout");
1530
- }
1531
- if (tools.length) {
1532
- tools.push("opencomputer_connections_list", "opencomputer_connections_request");
1533
- }
1534
- return tools;
1535
- }
1536
313
  export async function assertStarterTarget(directory) {
1537
314
  const root = resolve(directory);
1538
315
  if (!(await exists(root))) {
@@ -1561,12 +338,11 @@ export async function initializeAgentProject(directory, project) {
1561
338
  const root = resolve(directory);
1562
339
  const agentRoot = resolve(root, "opencomputer", "agents", "hello-world");
1563
340
  await assertStarterTarget(root);
1564
- await initializeTemplateAgentProject(HELLO_WORLD_TEMPLATE, agentRoot);
341
+ await mkdir(agentRoot, { recursive: true });
1565
342
  const manifest = {
1566
343
  schema: 1,
1567
344
  id: project?.agentId ?? "hello-world",
1568
345
  name: "Hello World",
1569
- template: "hello-world",
1570
346
  };
1571
347
  for (const path of [
1572
348
  "opencomputer.toml",
@@ -1616,12 +392,12 @@ export default function Agent() {
1616
392
  deploy: "opencomputer deploy",
1617
393
  },
1618
394
  dependencies: {
1619
- "@opencomputer/agent": "^0.1.0",
395
+ "@opencomputer/agent": "^0.2.0",
1620
396
  react: "^19.2.0",
1621
397
  "react-dom": "^19.2.0",
1622
398
  },
1623
399
  devDependencies: {
1624
- "@opencomputer/cli": "^0.4.2",
400
+ "@opencomputer/cli": "^0.4.4",
1625
401
  "@types/node": "^24.0.0",
1626
402
  "@types/react": "^19.2.0",
1627
403
  "@types/react-dom": "^19.2.0",
@@ -1990,7 +766,18 @@ function literalHookIds(source, hook) {
1990
766
  return [...source.matchAll(pattern)].map((match) => match[1]).sort();
1991
767
  }
1992
768
  function definedMcpServerIds(source) {
1993
- return [...source.matchAll(/\bdefineMcpServer\s*\(\s*\{[\s\S]*?\bid\s*:\s*["']([^"']+)["'][\s\S]*?\}\s*\)/g)].map((match) => match[1]).sort();
769
+ return [
770
+ ...source.matchAll(/\bdefineMcpServer\s*\(\s*\{[\s\S]*?\bid\s*:\s*["']([^"']+)["'][\s\S]*?\}\s*\)/g),
771
+ ]
772
+ .map((match) => match[1])
773
+ .sort();
774
+ }
775
+ function definedToolIds(source) {
776
+ return [
777
+ ...source.matchAll(/\bdefineTool(?:<[^>]+>)?\s*\(\s*\{[\s\S]*?\bname\s*:\s*["']([^"']+)["'][\s\S]*?\}\s*\)/g),
778
+ ]
779
+ .map((match) => match[1])
780
+ .sort();
1994
781
  }
1995
782
  function agentApiRuntimeSource() {
1996
783
  return `function hooks() {
@@ -2009,6 +796,14 @@ export const defineMcpServer = (input) => {
2009
796
  if (url.protocol !== "https:") throw new Error("MCP server URLs must use HTTPS");
2010
797
  return Object.freeze({ kind: "mcp", ...input, id: id(input.id, "defineMcpServer"), url: url.toString() });
2011
798
  };
799
+ export const defineTool = (input) => {
800
+ const toolId = id(input.name, "defineTool");
801
+ if (!/^[a-zA-Z0-9_-]+$/.test(toolId)) throw new Error("Invalid tool id " + JSON.stringify(toolId));
802
+ if (!String(input.description).trim()) throw new Error("defineTool requires a non-empty description");
803
+ if (input.input && typeof input.input !== "object") throw new Error("defineTool input must be a JSON Schema object");
804
+ if (input.output && typeof input.output !== "object") throw new Error("defineTool output must be a JSON Schema object");
805
+ return Object.freeze({ kind: "tool", version: 1, ...input, id: toolId, name: toolId });
806
+ };
2012
807
  export const useInput = () => hooks().useInput();
2013
808
  export const useCurrentInput = useInput;
2014
809
  export const useModel = (model) => hooks().useModel(model);
@@ -2079,17 +874,13 @@ the product or support surface presented to users.
2079
874
  },
2080
875
  }, null, 2)}\n`);
2081
876
  }
2082
- for (const directory of ["skills", "tools"]) {
2083
- const source = resolve(root, directory);
2084
- if (await exists(source)) {
2085
- await mkdir(resolve(runtime, ".opencode"), { recursive: true });
2086
- await cp(source, resolve(runtime, ".opencode", directory), {
2087
- recursive: true,
2088
- });
2089
- }
877
+ const skills = resolve(root, "skills");
878
+ if (await exists(skills)) {
879
+ await mkdir(resolve(runtime, ".opencode"), { recursive: true });
880
+ await cp(skills, resolve(runtime, ".opencode", "skills"), {
881
+ recursive: true,
882
+ });
2090
883
  }
2091
- await mkdir(resolve(runtime, ".opencode", "tools"), { recursive: true });
2092
- await writeFile(resolve(runtime, ".opencode", "tools", "opencomputer-connections.ts"), connectionControlToolSource());
2093
884
  const workspace = resolve(root, "workspace");
2094
885
  if (await exists(workspace)) {
2095
886
  await cp(workspace, runtime, { recursive: true });
@@ -2114,36 +905,79 @@ the product or support surface presented to users.
2114
905
  const compiledSource = compiledAgent.outputText.replace(/(["'])@opencomputer\/agent\1/g, '"./opencomputer-agent.js"');
2115
906
  await writeFile(resolve(runtime, "agent.js"), compiledSource);
2116
907
  await writeFile(resolve(runtime, "opencomputer-agent.js"), agentApiRuntimeSource());
2117
- const toolEntries = await readdir(resolve(runtime, ".opencode", "tools"), {
2118
- withFileTypes: true,
2119
- });
908
+ const toolSources = [
909
+ {
910
+ filename: "opencomputer-connections.ts",
911
+ source: connectionControlToolSource(),
912
+ },
913
+ ];
914
+ const sourceTools = resolve(root, "tools");
915
+ if (await exists(sourceTools)) {
916
+ const entries = await readdir(sourceTools, { withFileTypes: true });
917
+ for (const entry of entries) {
918
+ if (!entry.isFile() || !/\.[cm]?[jt]s$/.test(entry.name))
919
+ continue;
920
+ toolSources.push({
921
+ filename: entry.name,
922
+ source: await readFile(resolve(sourceTools, entry.name), "utf8"),
923
+ });
924
+ }
925
+ }
2120
926
  const reactiveTools = [];
2121
- for (const entry of toolEntries.filter((candidate) => candidate.isFile())) {
2122
- const stem = entry.name
2123
- .replace(/\.[^.]+$/, "")
2124
- .replace(/[^a-zA-Z0-9_]+/g, "_");
2125
- const source = await readFile(resolve(runtime, ".opencode", "tools", entry.name), "utf8");
2126
- for (const match of source.matchAll(/export\s+const\s+([a-zA-Z0-9_]+)\s*=/g)) {
2127
- reactiveTools.push(`${stem}_${match[1]}`);
927
+ const toolModules = [];
928
+ await mkdir(resolve(runtime, "tools"), { recursive: true });
929
+ for (const candidate of toolSources) {
930
+ const ids = definedToolIds(candidate.source);
931
+ const calls = [
932
+ ...candidate.source.matchAll(/\bdefineTool(?:<[^>]+>)?\s*\(/g),
933
+ ]
934
+ .length;
935
+ if (ids.length !== calls) {
936
+ throw new Error(`${candidate.filename} must give every defineTool() a literal string name`);
2128
937
  }
938
+ const compiledTool = transpile(candidate.source, candidate.filename);
939
+ const toolDiagnostics = compiledTool.diagnostics ?? [];
940
+ if (toolDiagnostics.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)) {
941
+ throw new Error(`${candidate.filename} could not be compiled: ${toolDiagnostics
942
+ .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, " "))
943
+ .join("; ")}`);
944
+ }
945
+ const outputName = candidate.filename.replace(/\.[^.]+$/, ".js");
946
+ const output = compiledTool.outputText.replace(/(["'])@opencomputer\/agent\1/g, '"../opencomputer-agent.js"');
947
+ await writeFile(resolve(runtime, "tools", outputName), output);
948
+ if (ids.length > 0) {
949
+ reactiveTools.push(...ids);
950
+ toolModules.push(`../tools/${outputName}`);
951
+ }
952
+ }
953
+ const duplicateTool = reactiveTools.find((id, index) => reactiveTools.indexOf(id) !== index);
954
+ if (duplicateTool) {
955
+ throw new Error(`Tool id ${JSON.stringify(duplicateTool)} is defined more than once`);
2129
956
  }
2130
957
  await mkdir(resolve(runtime, ".opencomputer"), { recursive: true });
2131
958
  await writeFile(resolve(runtime, ".opencomputer", "reactive.json"), `${JSON.stringify({
2132
959
  version: 2,
2133
960
  entry: "../agent.js",
2134
- tools: [...new Set([
961
+ tools: [
962
+ ...new Set([
2135
963
  ...reactiveTools,
2136
964
  ...literalHookIds(agentSource, "useTool"),
2137
- ])].sort(),
965
+ ]),
966
+ ].sort(),
967
+ toolModules: toolModules.sort(),
2138
968
  subagents: literalHookIds(agentSource, "useSubagent"),
2139
- connections: [...new Set([
969
+ connections: [
970
+ ...new Set([
2140
971
  ...literalHookIds(agentSource, "connection"),
2141
972
  ...literalHookIds(agentSource, "useConnection"),
2142
- ])].sort(),
2143
- mcpServers: [...new Set([
973
+ ]),
974
+ ].sort(),
975
+ mcpServers: [
976
+ ...new Set([
2144
977
  ...definedMcpServerIds(agentSource),
2145
978
  ...literalHookIds(agentSource, "useMcpServer"),
2146
- ])].sort(),
979
+ ]),
980
+ ].sort(),
2147
981
  }, null, 2)}\n`);
2148
982
  return runtime;
2149
983
  }
@@ -2175,41 +1009,9 @@ async function collectFiles(root, directory = root) {
2175
1009
  }
2176
1010
  return result;
2177
1011
  }
2178
- async function validateTemplateRequirements(root, manifest) {
2179
- if (manifest.template === "pr-review-readiness") {
2180
- for (const path of [
2181
- "tools/github.ts",
2182
- "connections/github.json",
2183
- "skills/review-pr/SKILL.md",
2184
- "evals/pr-review-cases.md",
2185
- ]) {
2186
- if (!(await exists(resolve(root, path)))) {
2187
- throw new Error(`PR review readiness file is missing: ${path}`);
2188
- }
2189
- }
2190
- return;
2191
- }
2192
- if (manifest.template !== "pto-calendar")
2193
- return;
2194
- let calendarDeclared = false;
2195
- try {
2196
- const declaration = JSON.parse(await readFile(resolve(root, "connections", "google.json"), "utf8"));
2197
- calendarDeclared =
2198
- Array.isArray(declaration.services) &&
2199
- declaration.services.includes("calendar");
2200
- }
2201
- catch {
2202
- // Report one actionable error below for an incomplete PTO project.
2203
- }
2204
- if (!(await exists(resolve(root, "tools", "calendar.ts"))) ||
2205
- !calendarDeclared) {
2206
- throw new Error("PTO calendar tools are missing. Run `opencomputer tools add calendar` before deploying.");
2207
- }
2208
- }
2209
1012
  export async function buildAgentArtifact(root, agentId) {
2210
1013
  const startedAt = performance.now();
2211
1014
  const manifest = await readManifest(root);
2212
- await validateTemplateRequirements(root, manifest);
2213
1015
  const runtime = await prepareAgent(root);
2214
1016
  const channels = await collectNames(root, "channels");
2215
1017
  const connections = await collectNames(root, "connections");