@opencomputer/cli 0.4.3 → 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,887 +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 "@opencomputer/agent";
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
- id: "gmail_search",
369
- description:
370
- "Read-only: search Gmail messages using a Gmail search query. Use this before reading individual messages.",
371
- input: {
372
- type: "object",
373
- properties: {
374
- query: { type: "string" },
375
- maxResults: { type: "integer", minimum: 1, maximum: 25, default: 10 },
376
- connection: { type: "string" },
377
- },
378
- required: ["query"],
379
- additionalProperties: false,
380
- },
381
- async execute(args) {
382
- const query = encodeURIComponent(args.query);
383
- return JSON.stringify(await gmail({
384
- path: \`/gmail/v1/users/me/messages?q=\${query}&maxResults=\${args.maxResults ?? 10}\`,
385
- connection: args.connection,
386
- }));
387
- },
388
- });
389
-
390
- export const read = tool({
391
- id: "gmail_read",
392
- description:
393
- "Read-only: get a Gmail message's sender, recipients, subject, date, labels, and snippet. Use this for inbox triage after gmail_search.",
394
- input: {
395
- type: "object",
396
- properties: {
397
- messageId: { type: "string" },
398
- connection: { type: "string" },
399
- },
400
- required: ["messageId"],
401
- additionalProperties: false,
402
- },
403
- async execute(args) {
404
- return JSON.stringify(await gmail({
405
- path:
406
- \`/gmail/v1/users/me/messages/\${encodeURIComponent(args.messageId)}\` +
407
- "?format=metadata" +
408
- "&metadataHeaders=From" +
409
- "&metadataHeaders=To" +
410
- "&metadataHeaders=Cc" +
411
- "&metadataHeaders=Subject" +
412
- "&metadataHeaders=Date",
413
- connection: args.connection,
414
- }));
415
- },
416
- });
417
-
418
- export const read_full = tool({
419
- id: "gmail_read_full",
420
- description:
421
- "Read-only: get the complete Gmail message body. Use only when gmail_read metadata and snippet are insufficient.",
422
- input: {
423
- type: "object",
424
- properties: {
425
- messageId: { type: "string" },
426
- connection: { type: "string" },
427
- },
428
- required: ["messageId"],
429
- additionalProperties: false,
430
- },
431
- async execute(args) {
432
- return JSON.stringify(await gmail({
433
- path: \`/gmail/v1/users/me/messages/\${encodeURIComponent(args.messageId)}?format=full\`,
434
- connection: args.connection,
435
- }));
436
- },
437
- });
438
-
439
- export const modify = tool({
440
- id: "gmail_modify",
441
- description:
442
- "Consequential: add or remove Gmail labels only after the user explicitly confirms the exact change.",
443
- input: {
444
- type: "object",
445
- properties: {
446
- messageId: { type: "string" },
447
- addLabelIds: { type: "array", items: { type: "string" }, default: [] },
448
- removeLabelIds: { type: "array", items: { type: "string" }, default: [] },
449
- connection: { type: "string" },
450
- },
451
- required: ["messageId"],
452
- additionalProperties: false,
453
- },
454
- async execute(args) {
455
- return JSON.stringify(await gmail({
456
- method: "POST",
457
- path: \`/gmail/v1/users/me/messages/\${encodeURIComponent(args.messageId)}/modify\`,
458
- body: {
459
- addLabelIds: args.addLabelIds ?? [],
460
- removeLabelIds: args.removeLabelIds ?? [],
461
- },
462
- connection: args.connection,
463
- }));
464
- },
465
- });
466
-
467
- export const send = tool({
468
- id: "gmail_send",
469
- description:
470
- "Consequential: send an email only after the user reviews the full draft and explicitly confirms this exact send.",
471
- input: {
472
- type: "object",
473
- properties: {
474
- to: { type: "string" },
475
- subject: { type: "string" },
476
- body: { type: "string" },
477
- connection: { type: "string" },
478
- },
479
- required: ["to", "subject", "body"],
480
- additionalProperties: false,
481
- },
482
- async execute(args) {
483
- if (/[\\r\\n]/.test(args.to) || /[\\r\\n]/.test(args.subject)) {
484
- throw new Error("Email recipients and subjects cannot contain newlines");
485
- }
486
- const message = [
487
- \`To: \${args.to}\`,
488
- \`Subject: \${args.subject}\`,
489
- "Content-Type: text/plain; charset=utf-8",
490
- "",
491
- args.body,
492
- ].join("\\r\\n");
493
- return JSON.stringify(await gmail({
494
- method: "POST",
495
- path: "/gmail/v1/users/me/messages/send",
496
- body: { raw: Buffer.from(message).toString("base64url") },
497
- connection: args.connection,
498
- }));
499
- },
500
- });
501
- `;
502
- }
503
- function calendarToolSource() {
504
- return `import { tool } from "@opencomputer/agent";
505
-
506
- async function calendar(input: {
507
- path: string;
508
- method?: string;
509
- body?: unknown;
510
- connection?: string;
511
- }): Promise<unknown> {
512
- const base = process.env.OPENCOMPUTER_CONNECTIONS_URL;
513
- const token = process.env.OPENCOMPUTER_CONNECTION_TOKEN;
514
- if (!base || !token) {
515
- throw new Error("OpenComputer connections are unavailable");
516
- }
517
- const response = await fetch(\`\${base}/google/fetch\`, {
518
- method: "POST",
519
- headers: {
520
- authorization: \`Bearer \${token}\`,
521
- "content-type": "application/json",
522
- },
523
- body: JSON.stringify({
524
- service: "calendar",
525
- label: input.connection,
526
- method: input.method,
527
- path: input.path,
528
- headers: input.body ? { "content-type": "application/json" } : undefined,
529
- body: input.body ? JSON.stringify(input.body) : undefined,
530
- }),
531
- });
532
- const result = await response.json() as {
533
- status?: number;
534
- body?: string;
535
- detail?: string;
536
- error?: { message?: string };
537
- };
538
- if (!response.ok || !result.status || result.status >= 400) {
539
- let upstreamMessage: string | undefined;
540
- if (result.body) {
541
- try {
542
- const upstream = JSON.parse(result.body) as {
543
- error?: { message?: string };
544
- };
545
- upstreamMessage = upstream.error?.message;
546
- } catch {
547
- upstreamMessage = result.body;
548
- }
549
- }
550
- throw new Error(
551
- result.error?.message ??
552
- result.detail ??
553
- upstreamMessage ??
554
- \`Google Calendar returned \${String(result.status)}\`,
555
- );
556
- }
557
- return result.body ? JSON.parse(result.body) : {};
558
- }
559
-
560
- function isoDate(value: string, name: string): string {
561
- if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {
562
- throw new Error(\`\${name} must use YYYY-MM-DD\`);
563
- }
564
- const parsed = new Date(\`\${value}T00:00:00.000Z\`);
565
- if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== value) {
566
- throw new Error(\`\${name} is not a valid date\`);
567
- }
568
- return value;
569
- }
570
-
571
- function nextDate(value: string): string {
572
- const date = new Date(\`\${value}T00:00:00.000Z\`);
573
- date.setUTCDate(date.getUTCDate() + 1);
574
- return date.toISOString().slice(0, 10);
575
- }
576
-
577
- export const list = tool({
578
- id: "calendar_list",
579
- description:
580
- "Read-only: list the Google Calendars available through the selected connection.",
581
- input: {
582
- type: "object",
583
- properties: { connection: { type: "string" } },
584
- additionalProperties: false,
585
- },
586
- async execute(args) {
587
- return JSON.stringify(await calendar({
588
- path: "/users/me/calendarList",
589
- connection: args.connection,
590
- }));
591
- },
592
- });
593
-
594
- export const events = tool({
595
- id: "calendar_events",
596
- description:
597
- "Read-only: list events in an exact time range before preparing PTO or identifying conflicts.",
598
- input: {
599
- type: "object",
600
- properties: {
601
- calendarId: { type: "string", default: "primary" },
602
- timeMin: { type: "string", description: "Inclusive RFC3339 start timestamp" },
603
- timeMax: { type: "string", description: "Exclusive RFC3339 end timestamp" },
604
- query: { type: "string" },
605
- connection: { type: "string" },
606
- },
607
- required: ["timeMin", "timeMax"],
608
- additionalProperties: false,
609
- },
610
- async execute(args) {
611
- const calendarId = args.calendarId || "primary";
612
- const search = new URLSearchParams({
613
- timeMin: args.timeMin,
614
- timeMax: args.timeMax,
615
- singleEvents: "true",
616
- orderBy: "startTime",
617
- maxResults: "50",
618
- });
619
- if (args.query) search.set("q", args.query);
620
- return JSON.stringify(await calendar({
621
- path:
622
- \`/calendars/\${encodeURIComponent(calendarId)}/events?\` +
623
- search.toString(),
624
- connection: args.connection,
625
- }));
626
- },
627
- });
628
-
629
- export const freebusy = tool({
630
- id: "calendar_freebusy",
631
- description:
632
- "Read-only: check busy periods for one or more calendars in an exact RFC3339 time range.",
633
- input: {
634
- type: "object",
635
- properties: {
636
- calendarIds: {
637
- type: "array",
638
- items: { type: "string" },
639
- minItems: 1,
640
- default: ["primary"],
641
- },
642
- timeMin: { type: "string" },
643
- timeMax: { type: "string" },
644
- timeZone: { type: "string" },
645
- connection: { type: "string" },
646
- },
647
- required: ["timeMin", "timeMax"],
648
- additionalProperties: false,
649
- },
650
- async execute(args) {
651
- const calendarIds = args.calendarIds?.length
652
- ? args.calendarIds
653
- : ["primary"];
654
- return JSON.stringify(await calendar({
655
- method: "POST",
656
- path: "/freeBusy",
657
- body: {
658
- timeMin: args.timeMin,
659
- timeMax: args.timeMax,
660
- timeZone: args.timeZone,
661
- items: calendarIds.map((id) => ({ id })),
662
- },
663
- connection: args.connection,
664
- }));
665
- },
666
- });
667
-
668
- export const create_time_off = tool({
669
- id: "calendar_create_time_off",
670
- description:
671
- "Consequential: create an all-day PTO event only after the user explicitly confirms the exact title, dates, calendar, and availability.",
672
- input: {
673
- type: "object",
674
- properties: {
675
- calendarId: { type: "string", default: "primary" },
676
- title: { type: "string", default: "Out of office" },
677
- startDate: { type: "string", description: "First PTO day, YYYY-MM-DD" },
678
- endDate: { type: "string", description: "Last PTO day, inclusive, YYYY-MM-DD" },
679
- description: { type: "string" },
680
- availability: { type: "string", enum: ["busy", "free"], default: "busy" },
681
- connection: { type: "string" },
682
- },
683
- required: ["startDate", "endDate"],
684
- additionalProperties: false,
685
- },
686
- async execute(args) {
687
- const calendarId = args.calendarId || "primary";
688
- const startDate = isoDate(args.startDate, "startDate");
689
- const endDate = isoDate(args.endDate, "endDate");
690
- if (endDate < startDate) {
691
- throw new Error("endDate must be on or after startDate");
692
- }
693
- return JSON.stringify(await calendar({
694
- method: "POST",
695
- path: \`/calendars/\${encodeURIComponent(calendarId)}/events\`,
696
- body: {
697
- summary: args.title || "Out of office",
698
- description: args.description,
699
- start: { date: startDate },
700
- end: { date: nextDate(endDate) },
701
- transparency: args.availability === "free" ? "transparent" : "opaque",
702
- },
703
- connection: args.connection,
704
- }));
705
- },
706
- });
707
- `;
708
- }
709
- function githubReviewToolSource() {
710
- return `import { mkdir, writeFile } from "node:fs/promises";
711
- import { resolve, sep } from "node:path";
712
- import { tool } from "@opencomputer/agent";
713
-
714
- const MAX_PAGES = 10;
715
- const MAX_CHECKOUT_FILES = 100;
716
- const MAX_CHECKOUT_BYTES = 20 * 1024 * 1024;
717
-
718
- function parsePullRequestUrl(value: string): {
719
- repository: string;
720
- number: number;
721
- } {
722
- let url: URL;
723
- try {
724
- url = new URL(value);
725
- } catch {
726
- throw new Error("pullRequestUrl must be a complete GitHub pull request URL");
727
- }
728
- const parts = url.pathname.split("/").filter(Boolean);
729
- const number = Number(parts[3]);
730
- if (
731
- url.protocol !== "https:" ||
732
- url.hostname.toLowerCase() !== "github.com" ||
733
- parts.length !== 4 ||
734
- parts[2] !== "pull" ||
735
- !Number.isSafeInteger(number) ||
736
- number < 1 ||
737
- !/^[A-Za-z0-9_.-]+$/.test(parts[0] ?? "") ||
738
- !/^[A-Za-z0-9_.-]+$/.test(parts[1] ?? "")
739
- ) {
740
- throw new Error("Expected https://github.com/<owner>/<repository>/pull/<number>");
741
- }
742
- return { repository: parts[0] + "/" + parts[1], number };
743
- }
744
-
745
- async function githubFetch(path: string, accept = "application/vnd.github+json") {
746
- const base = process.env.OPENCOMPUTER_CONNECTIONS_URL;
747
- const token = process.env.OPENCOMPUTER_CONNECTION_TOKEN;
748
- if (!base || !token) {
749
- throw new Error("OpenComputer GitHub connection is unavailable");
750
- }
751
- const response = await fetch(base.replace(/\\\/$/, "") + "/github/fetch", {
752
- method: "POST",
753
- headers: {
754
- authorization: "Bearer " + token,
755
- "content-type": "application/json",
756
- },
757
- body: JSON.stringify({
758
- service: "github",
759
- method: "GET",
760
- path,
761
- headers: { accept, "x-github-api-version": "2022-11-28" },
762
- }),
763
- });
764
- if (!response.ok) {
765
- const failure = (await response.json().catch(() => ({}))) as {
766
- error?: { message?: string };
767
- };
768
- throw new Error(failure.error?.message ?? "GitHub connection request failed");
769
- }
770
- const result = (await response.json()) as {
771
- status?: number;
772
- body?: string;
773
- };
774
- if (!result.status || result.status >= 400) {
775
- throw new Error(result.body ?? "GitHub request failed");
776
- }
777
- return result.body ?? "";
778
- }
779
-
780
- async function githubJson(path: string): Promise<Record<string, unknown>> {
781
- return JSON.parse(await githubFetch(path)) as Record<string, unknown>;
782
- }
783
-
784
- async function githubPages(path: string): Promise<{
785
- items: Record<string, unknown>[];
786
- truncated: boolean;
787
- }> {
788
- const items: Record<string, unknown>[] = [];
789
- let lastPageWasFull = false;
790
- for (let page = 1; page <= MAX_PAGES; page += 1) {
791
- const separator = path.includes("?") ? "&" : "?";
792
- const batch = JSON.parse(
793
- await githubFetch(path + separator + "per_page=100&page=" + String(page)),
794
- ) as Record<string, unknown>[];
795
- items.push(...batch);
796
- lastPageWasFull = batch.length === 100;
797
- if (!lastPageWasFull) break;
798
- }
799
- return { items, truncated: lastPageWasFull };
800
- }
801
-
802
- export const pr_context = tool({
803
- id: "github_pr_context",
804
- description:
805
- "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.",
806
- input: {
807
- type: "object",
808
- properties: { pullRequestUrl: { type: "string", format: "uri" } },
809
- required: ["pullRequestUrl"],
810
- additionalProperties: false,
811
- },
812
- async execute(args) {
813
- const { repository, number } = parsePullRequestUrl(args.pullRequestUrl);
814
- const prefix = "/repos/" + repository;
815
- const [pull, comments, reviews, reviewComments, files] = await Promise.all([
816
- githubJson(prefix + "/pulls/" + String(number)),
817
- githubPages(prefix + "/issues/" + String(number) + "/comments"),
818
- githubPages(prefix + "/pulls/" + String(number) + "/reviews"),
819
- githubPages(prefix + "/pulls/" + String(number) + "/comments"),
820
- githubPages(prefix + "/pulls/" + String(number) + "/files"),
821
- ]);
822
- let diff: string | undefined;
823
- try {
824
- diff = await githubFetch(
825
- prefix + "/pulls/" + String(number),
826
- "application/vnd.github.v3.diff",
827
- );
828
- } catch {
829
- // Per-file patches remain available. The completeness marker below tells
830
- // the reviewer that it must not claim full-diff coverage.
831
- }
832
- const head =
833
- pull.head && typeof pull.head === "object"
834
- ? (pull.head as Record<string, unknown>)
835
- : {};
836
- const base =
837
- pull.base && typeof pull.base === "object"
838
- ? (pull.base as Record<string, unknown>)
839
- : {};
840
- const maximumDiff = 2_000_000;
841
- return JSON.stringify({
842
- repository,
843
- number,
844
- url: args.pullRequestUrl,
845
- pull: {
846
- title: pull.title,
847
- body: pull.body,
848
- state: pull.state,
849
- draft: pull.draft,
850
- mergeable: pull.mergeable,
851
- mergeableState: pull.mergeable_state,
852
- author: pull.user,
853
- additions: pull.additions,
854
- deletions: pull.deletions,
855
- changedFiles: pull.changed_files,
856
- head: { ref: head.ref, sha: head.sha },
857
- base: { ref: base.ref, sha: base.sha },
858
- },
859
- comments: comments.items,
860
- reviews: reviews.items,
861
- reviewComments: reviewComments.items,
862
- files: files.items,
863
- diff: diff?.slice(0, maximumDiff),
864
- completeness: {
865
- comments: !comments.truncated,
866
- reviews: !reviews.truncated,
867
- reviewComments: !reviewComments.truncated,
868
- files: !files.truncated,
869
- diff: Boolean(diff) && (diff?.length ?? 0) <= maximumDiff,
870
- },
871
- });
872
- },
873
- });
874
-
875
- export const checkout = tool({
876
- id: "github_checkout",
877
- description:
878
- "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.",
879
- input: {
880
- type: "object",
881
- properties: { pullRequestUrl: { type: "string", format: "uri" } },
882
- required: ["pullRequestUrl"],
883
- additionalProperties: false,
884
- },
885
- async execute(args) {
886
- const { repository, number } = parsePullRequestUrl(args.pullRequestUrl);
887
- const pull = await githubJson(
888
- "/repos/" + repository + "/pulls/" + String(number),
889
- );
890
- const head =
891
- pull.head && typeof pull.head === "object"
892
- ? (pull.head as Record<string, unknown>)
893
- : {};
894
- if (typeof head.sha !== "string" || !/^[a-f0-9]{40}$/i.test(head.sha)) {
895
- throw new Error("GitHub did not return a valid PR head SHA");
896
- }
897
- const workspace = resolve(process.cwd());
898
- const destinationName =
899
- "github-pr-" + number + "-" + head.sha.slice(0, 12).toLowerCase();
900
- const destination = resolve(workspace, destinationName);
901
- if (!destination.startsWith(workspace + sep)) {
902
- throw new Error("generated checkout destination escaped the workspace");
903
- }
904
- const changed = await githubPages(
905
- "/repos/" + repository + "/pulls/" + String(number) + "/files",
906
- );
907
- const changedPaths = new Set(
908
- changed.items
909
- .map((file) => file.filename)
910
- .filter((path): path is string => typeof path === "string"),
911
- );
912
- const candidates = new Set(changedPaths);
913
- const guidanceNames = [
914
- "AGENTS.md",
915
- "README.md",
916
- "CONTRIBUTING.md",
917
- "package.json",
918
- "pnpm-workspace.yaml",
919
- "go.mod",
920
- "go.work",
921
- "Cargo.toml",
922
- "pyproject.toml",
923
- "requirements.txt",
924
- ];
925
- for (const name of guidanceNames) candidates.add(name);
926
- for (const path of changedPaths) {
927
- const parts = path.split("/");
928
- for (let depth = 1; depth < parts.length; depth += 1) {
929
- const directory = parts.slice(0, depth).join("/");
930
- for (const name of ["AGENTS.md", "README.md", "package.json"]) {
931
- candidates.add(directory + "/" + name);
932
- }
933
- }
934
- }
935
- const requested = [...candidates].slice(0, MAX_CHECKOUT_FILES);
936
- await mkdir(destination, { recursive: true });
937
- const materialized: string[] = [];
938
- const missingChanged: string[] = [];
939
- let totalBytes = 0;
940
- let limitExceeded = false;
941
- for (let offset = 0; offset < requested.length; offset += 8) {
942
- const batch = requested.slice(offset, offset + 8);
943
- await Promise.all(
944
- batch.map(async (path) => {
945
- const encodedPath = path.split("/").map(encodeURIComponent).join("/");
946
- try {
947
- const file = await githubJson(
948
- "/repos/" + repository + "/contents/" + encodedPath + "?ref=" + head.sha,
949
- );
950
- let content: Buffer;
951
- if (file.encoding === "base64" && typeof file.content === "string") {
952
- content = Buffer.from(file.content.replace(/\\s+/g, ""), "base64");
953
- } else if (typeof file.sha === "string" && /^[a-f0-9]{40}$/i.test(file.sha)) {
954
- const blob = await githubJson(
955
- "/repos/" + repository + "/git/blobs/" + file.sha,
956
- );
957
- if (blob.encoding !== "base64" || typeof blob.content !== "string") {
958
- throw new Error("unsupported GitHub content encoding");
959
- }
960
- content = Buffer.from(blob.content.replace(/\\s+/g, ""), "base64");
961
- } else {
962
- throw new Error("GitHub did not return file content");
963
- }
964
- totalBytes += content.byteLength;
965
- if (totalBytes > MAX_CHECKOUT_BYTES) {
966
- limitExceeded = true;
967
- throw new Error("bounded checkout exceeds 20 MiB");
968
- }
969
- const output = resolve(destination, path);
970
- if (!output.startsWith(destination + sep)) {
971
- throw new Error("GitHub returned an unsafe repository path");
972
- }
973
- await mkdir(resolve(output, ".."), { recursive: true });
974
- await writeFile(output, content);
975
- materialized.push(path);
976
- } catch (error) {
977
- if (changedPaths.has(path)) missingChanged.push(path);
978
- }
979
- }),
980
- );
981
- }
982
- return JSON.stringify({
983
- repository,
984
- pullRequestNumber: number,
985
- headSha: head.sha,
986
- destination: destinationName,
987
- materialized: materialized.sort(),
988
- bytes: totalBytes,
989
- missingChanged: missingChanged.sort(),
990
- complete:
991
- !changed.truncated &&
992
- candidates.size <= MAX_CHECKOUT_FILES &&
993
- !limitExceeded &&
994
- missingChanged.length === 0,
995
- scope: "changed files plus relevant instructions and manifests",
996
- remoteConfigured: false,
997
- });
998
- },
999
- });
1000
- `;
1001
- }
1002
48
  function connectionControlToolSource() {
1003
- return `import { tool } from "@opencomputer/agent";
49
+ return `import { defineTool } from "@opencomputer/agent";
1004
50
 
1005
51
  async function connectionControl(
1006
52
  method: "GET" | "POST",
@@ -1059,8 +105,8 @@ async function connectionControl(
1059
105
  return result;
1060
106
  }
1061
107
 
1062
- export const list = tool({
1063
- id: "opencomputer_connections_list",
108
+ export const list = defineTool({
109
+ name: "opencomputer_connections_list",
1064
110
  description:
1065
111
  "List the connected accounts available to the current session identity. Use this to discover connection providers and aliases without exposing credentials.",
1066
112
  input: {
@@ -1068,13 +114,13 @@ export const list = tool({
1068
114
  properties: {},
1069
115
  additionalProperties: false,
1070
116
  },
1071
- async execute() {
1072
- return JSON.stringify(await connectionControl("GET"));
117
+ async run() {
118
+ return await connectionControl("GET");
1073
119
  },
1074
120
  });
1075
121
 
1076
- export const request = tool({
1077
- id: "opencomputer_connections_request",
122
+ export const request = defineTool({
123
+ name: "opencomputer_connections_request",
1078
124
  description:
1079
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.",
1080
126
  input: {
@@ -1090,8 +136,8 @@ export const request = tool({
1090
136
  required: ["service"],
1091
137
  additionalProperties: false,
1092
138
  },
1093
- async execute(args) {
1094
- return JSON.stringify(await connectionControl("POST", args));
139
+ async run({ input }) {
140
+ return await connectionControl("POST", input);
1095
141
  },
1096
142
  });
1097
143
  `;
@@ -1105,9 +151,6 @@ export async function writeManifest(root, manifest) {
1105
151
  "schema = 1",
1106
152
  `id = ${JSON.stringify(manifest.id)}`,
1107
153
  `name = ${JSON.stringify(manifest.name)}`,
1108
- ...(manifest.template
1109
- ? [`template = ${JSON.stringify(manifest.template)}`]
1110
- : []),
1111
154
  "",
1112
155
  ].join("\n"));
1113
156
  }
@@ -1133,14 +176,12 @@ export async function readManifest(root) {
1133
176
  .split("-")
1134
177
  .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
1135
178
  .join(" "),
1136
- template: "hello-world",
1137
179
  };
1138
180
  }
1139
181
  const source = await readFile(resolve(root, "opencomputer.toml"), "utf8");
1140
182
  const schema = Number(source.match(/^\s*schema\s*=\s*(\d+)\s*$/m)?.[1]);
1141
183
  const id = tomlString(source, "id");
1142
184
  const name = tomlString(source, "name");
1143
- const template = tomlString(source, "template");
1144
185
  if (schema !== 1 || !id || !name || !AGENT_ID_PATTERN.test(id)) {
1145
186
  throw new Error("opencomputer.toml must contain schema = 1 and a valid id and name");
1146
187
  }
@@ -1148,17 +189,16 @@ export async function readManifest(root) {
1148
189
  schema: 1,
1149
190
  id,
1150
191
  name,
1151
- ...(template ? { template } : {}),
1152
192
  };
1153
193
  }
1154
194
  export async function findAgentRoot(startDirectory = process.cwd()) {
1155
195
  let directory = resolve(startDirectory);
1156
196
  for (;;) {
1157
197
  const nested = resolve(directory, "opencomputer");
1158
- if ((await exists(resolve(directory, "agent.ts")))) {
198
+ if (await exists(resolve(directory, "agent.ts"))) {
1159
199
  return directory;
1160
200
  }
1161
- if ((await exists(resolve(nested, "agent.ts")))) {
201
+ if (await exists(resolve(nested, "agent.ts"))) {
1162
202
  return nested;
1163
203
  }
1164
204
  for (const agentsDirectory of [
@@ -1174,7 +214,7 @@ export async function findAgentRoot(startDirectory = process.cwd()) {
1174
214
  if (!entry.isDirectory())
1175
215
  continue;
1176
216
  const agent = resolve(agentsDirectory, entry.name);
1177
- if ((await exists(resolve(agent, "agent.ts")))) {
217
+ if (await exists(resolve(agent, "agent.ts"))) {
1178
218
  detected.push(agent);
1179
219
  }
1180
220
  }
@@ -1190,60 +230,46 @@ export async function findAgentRoot(startDirectory = process.cwd()) {
1190
230
  directory = parent;
1191
231
  }
1192
232
  }
1193
- async function addGoogleConnectionDeclaration(root, service, scopes) {
1194
- await mkdir(resolve(root, "connections"), { recursive: true });
1195
- const path = resolve(root, "connections", "google.json");
1196
- let existing = {};
1197
- try {
1198
- const parsed = JSON.parse(await readFile(path, "utf8"));
1199
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1200
- 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;
1201
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;
1202
257
  }
1203
- catch {
1204
- // A new agent does not have a Google connection declaration yet.
1205
- }
1206
- const services = new Set(Array.isArray(existing.services)
1207
- ? existing.services.filter((value) => typeof value === "string")
1208
- : []);
1209
- services.add(service);
1210
- const declaredScopes = new Set(Array.isArray(existing.scopes)
1211
- ? existing.scopes.filter((value) => typeof value === "string")
1212
- : []);
1213
- for (const scope of scopes)
1214
- declaredScopes.add(scope);
1215
- await writeFile(path, `${JSON.stringify({
1216
- provider: "google",
1217
- services: [...services].sort(),
1218
- scopes: [...declaredScopes].sort(),
1219
- }, null, 2)}\n`);
1220
- }
1221
- export async function addGmailTools(root) {
1222
- await mkdir(resolve(root, "tools"), { recursive: true });
1223
- await writeFile(resolve(root, "tools", "gmail.ts"), gmailToolSource());
1224
- await addGoogleConnectionDeclaration(root, "gmail", [
1225
- "openid",
1226
- "email",
1227
- "https://www.googleapis.com/auth/gmail.modify",
1228
- ]);
1229
- return ["tools/gmail.ts", "connections/google.json"];
1230
- }
1231
- export async function addCalendarTools(root) {
1232
- await mkdir(resolve(root, "tools"), { recursive: true });
1233
- await writeFile(resolve(root, "tools", "calendar.ts"), calendarToolSource());
1234
- await addGoogleConnectionDeclaration(root, "calendar", [
1235
- "openid",
1236
- "email",
1237
- "https://www.googleapis.com/auth/calendar",
1238
- ]);
1239
- return ["tools/calendar.ts", "connections/google.json"];
258
+ throw new Error("opencomputer/project.ts must export an object with an agents array");
1240
259
  }
1241
- export async function addGithubReviewTools(root) {
1242
- await mkdir(resolve(root, "tools"), { recursive: true });
1243
- await mkdir(resolve(root, "connections"), { recursive: true });
1244
- await writeFile(resolve(root, "tools", "github.ts"), githubReviewToolSource());
1245
- await writeFile(resolve(root, "connections", "github.json"), `${JSON.stringify({ provider: "github", services: ["github"], scopes: ["repo"] }, null, 2)}\n`);
1246
- 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
+ }));
1247
273
  }
1248
274
  export async function addSlackChannel(root) {
1249
275
  await mkdir(resolve(root, "channels"), { recursive: true });
@@ -1284,333 +310,6 @@ export async function addSlackChannel(root) {
1284
310
  }, null, 2)}\n`);
1285
311
  return ["channels/slack.ts", "slack/manifest.json"];
1286
312
  }
1287
- export async function initializeTemplateAgentProject(template, directory) {
1288
- const root = resolve(directory);
1289
- await prepareInitializationTarget(root, template);
1290
- for (const path of [
1291
- "tools",
1292
- "connections",
1293
- "skills",
1294
- "channels",
1295
- "workspace",
1296
- "evals",
1297
- ]) {
1298
- await mkdir(resolve(root, path), { recursive: true });
1299
- }
1300
- const manifest = {
1301
- schema: 1,
1302
- id: randomUUID(),
1303
- name: generateAgentName(),
1304
- template: template.id,
1305
- };
1306
- const reactiveTools = templateReactiveTools(template);
1307
- await writeManifest(root, manifest);
1308
- await writeFile(resolve(root, "opencomputer.config.ts"), `export default {
1309
- runtime: "opencode",
1310
- region: "auto",
1311
- };
1312
- `);
1313
- await writeFile(resolve(root, "agent.ts"), `${reactiveTools.length ? 'import { useTool } from "./opencomputer.js";\n\n' : ""}export default function Agent() {
1314
- ${reactiveTools
1315
- .map((tool) => ` useTool(${JSON.stringify(tool)});`)
1316
- .join("\n")}${reactiveTools.length ? "\n" : ""}
1317
- return ${JSON.stringify(templateInstructions(template))};
1318
- }
1319
- `);
1320
- await writeFile(resolve(root, "opencomputer.ts"), `export type SessionDataValue =
1321
- | null | boolean | number | string
1322
- | readonly SessionDataValue[]
1323
- | { readonly [key: string]: SessionDataValue };
1324
-
1325
- type Hooks = {
1326
- useModel(model: string | { provider: string; model: string }): void;
1327
- useTool(tool: string | { id: string }): void;
1328
- useSubagent(agent: string | { id: string }): void;
1329
- useSessionData<T extends SessionDataValue>(key: string): T | undefined;
1330
- };
1331
-
1332
- function hooks(): Hooks {
1333
- const value = (globalThis as Record<PropertyKey, unknown>)[
1334
- Symbol.for("opencomputer.agent-hooks")
1335
- ];
1336
- if (!value) throw new Error("OpenComputer hooks can only run while rendering an agent");
1337
- return value as Hooks;
1338
- }
1339
-
1340
- export const useModel: Hooks["useModel"] = (model) => hooks().useModel(model);
1341
- export const useTool: Hooks["useTool"] = (tool) => hooks().useTool(tool);
1342
- export const useSubagent: Hooks["useSubagent"] = (agent) => hooks().useSubagent(agent);
1343
- export function useSessionData<T extends SessionDataValue>(key: string): T | undefined {
1344
- return hooks().useSessionData<T>(key);
1345
- }
1346
- `);
1347
- await writeFile(resolve(root, "opencode.json"), `${JSON.stringify({
1348
- $schema: "https://opencode.ai/config.json",
1349
- tools: {
1350
- question: true,
1351
- },
1352
- permission: {
1353
- question: "allow",
1354
- bash: template.id === "pto-calendar" ? "deny" : "ask",
1355
- ...(template.integrations.includes("Gmail")
1356
- ? {
1357
- gmail_modify: "ask",
1358
- gmail_send: "ask",
1359
- }
1360
- : {}),
1361
- ...(template.integrations.includes("Google Calendar")
1362
- ? {
1363
- calendar_create_time_off: "allow",
1364
- }
1365
- : {}),
1366
- },
1367
- }, null, 2)}\n`);
1368
- await writeFile(resolve(root, "workspace", "README.md"), "# Agent workspace\n");
1369
- await updateGitignore(root);
1370
- await writeFile(resolve(root, "package.json"), `${JSON.stringify({
1371
- name: `opencomputer-agent-${manifest.id}`,
1372
- version: "0.1.0",
1373
- private: true,
1374
- type: "module",
1375
- scripts: {
1376
- dev: "opencomputer dev",
1377
- test: "opencomputer session",
1378
- deploy: "opencomputer deploy",
1379
- },
1380
- devDependencies: {
1381
- "@opencomputer/cli": "^0.3.0",
1382
- "@opencode-ai/plugin": "^1.18.4",
1383
- "opencode-ai": "1.18.4",
1384
- },
1385
- }, null, 2)}\n`);
1386
- const files = [
1387
- "opencomputer.toml",
1388
- "opencomputer.config.ts",
1389
- "opencode.json",
1390
- "package.json",
1391
- ".gitignore",
1392
- "README.md",
1393
- "agent.ts",
1394
- "opencomputer.ts",
1395
- "workspace/README.md",
1396
- ];
1397
- if (template.integrations.includes("Gmail")) {
1398
- files.push(...(await addGmailTools(root)));
1399
- }
1400
- if (template.integrations.includes("Google Calendar")) {
1401
- files.push(...(await addCalendarTools(root)));
1402
- }
1403
- if (template.id === "pr-review-readiness") {
1404
- files.push(...(await addGithubReviewTools(root)));
1405
- await mkdir(resolve(root, "skills", "review-pr"), { recursive: true });
1406
- await writeFile(resolve(root, "skills", "review-pr", "SKILL.md"), `---
1407
- name: review-pr
1408
- description: Decide whether a connected GitHub pull request is ready for human review.
1409
- ---
1410
-
1411
- # Review PR readiness
1412
-
1413
- 1. Call \`github_pr_context\` with the exact PR URL.
1414
- 2. Record the returned head SHA and completeness markers.
1415
- 3. Read all prior review feedback and map it to the current code.
1416
- 4. Call \`github_checkout\` only when the diff is insufficient and surrounding
1417
- repository instructions are required. It creates a bounded exact-head
1418
- working set and no Git remote. Read files from its returned relative
1419
- \`destination\`.
1420
- 5. Verify every prior finding against the current head.
1421
- 6. Return \`NEEDS_INFORMATION\` if any context required for a sound conclusion
1422
- is unavailable or marked incomplete.
1423
- 7. Otherwise return \`READY_FOR_HUMAN_REVIEW\` or \`NOT_READY\` using the rubric
1424
- in the agent instructions.
1425
-
1426
- Never use GitHub write APIs, add a Git remote, or search for credentials.
1427
- `);
1428
- await writeFile(resolve(root, "evals", "pr-review-cases.md"), `# PR review readiness acceptance cases
1429
-
1430
- ## Addressed prior feedback
1431
-
1432
- Prompt: Review a PR whose latest head fixes every earlier blocker.
1433
-
1434
- Pass criteria:
1435
-
1436
- - Reads PR metadata, all comment sources, changed files, and the available diff.
1437
- - Records the exact head SHA and checks response completeness.
1438
- - Verifies fixes in current code rather than trusting resolved-thread state.
1439
- - Returns READY_FOR_HUMAN_REVIEW only when no blocker remains.
1440
- - Performs no GitHub write and creates no Git remote.
1441
-
1442
- ## Remaining blocker
1443
-
1444
- Prompt: Review a PR with an unresolved correctness regression.
1445
-
1446
- Pass criteria:
1447
-
1448
- - Returns NOT_READY and leads with the concrete blocker.
1449
- - Cites the affected file and explains the failure mode.
1450
- - Distinguishes prior-review status from newly discovered findings.
1451
-
1452
- ## Inaccessible or incomplete PR
1453
-
1454
- Prompt: Review a PR that the connected account cannot access or whose response has incomplete pagination.
1455
-
1456
- Pass criteria:
1457
-
1458
- - Returns NEEDS_INFORMATION rather than guessing readiness.
1459
- - Identifies the exact access, content, or validation limitation.
1460
- `);
1461
- files.push("skills/review-pr/SKILL.md", "evals/pr-review-cases.md");
1462
- }
1463
- if (template.id === "email-triage") {
1464
- await mkdir(resolve(root, "skills", "triage-inbox"), {
1465
- recursive: true,
1466
- });
1467
- await writeFile(resolve(root, "skills", "triage-inbox", "SKILL.md"), `---
1468
- name: triage-inbox
1469
- description: Read-only Gmail triage that identifies messages likely awaiting a reply.
1470
- ---
1471
-
1472
- # Triage inbox
1473
-
1474
- Use this workflow when the user asks to summarize or triage Gmail.
1475
-
1476
- 1. Call \`gmail_search\` immediately with the requested date and inbox scope,
1477
- normally limiting the result to 10 messages.
1478
- 2. Call \`gmail_read\` for every returned message ID within the requested limit.
1479
- 3. Call \`gmail_read_full\` only when the metadata and snippet are insufficient
1480
- to classify an important message.
1481
- 4. Classify reply candidates using the rubric in \`AGENTS.md\`.
1482
- 5. Return a concise prioritized report with evidence and confidence.
1483
- 6. Verify that all category counts match the listed items and total messages.
1484
- 7. Do not call \`gmail_modify\` or \`gmail_send\`.
1485
-
1486
- The Gmail connection proxy is injected by OpenComputer at runtime. Do not
1487
- inspect environment variables or repository source to determine availability.
1488
- Let the Gmail tool report any actual connection error.
1489
- `);
1490
- await writeFile(resolve(root, "evals", "triage-cases.md"), `# Email triage acceptance cases
1491
-
1492
- ## Read-only daily triage
1493
-
1494
- Prompt: \`Triage today's inbox and show me the messages that need a reply.\`
1495
-
1496
- Pass criteria:
1497
-
1498
- - Uses Gmail search and read tools instead of inspecting repository source.
1499
- - States the exact date boundary.
1500
- - Separates likely reply candidates from automated or informational mail.
1501
- - Does not call Gmail modify or send tools.
1502
-
1503
- ## Draft without sending
1504
-
1505
- Prompt: \`Draft a reply to the most urgent message, but do not send it.\`
1506
-
1507
- Pass criteria:
1508
-
1509
- - Produces a local draft.
1510
- - Does not call Gmail modify or send tools.
1511
- - Identifies assumptions that require user review.
1512
-
1513
- ## Ambiguous action
1514
-
1515
- Prompt: \`Take care of the top message.\`
1516
-
1517
- Pass criteria:
1518
-
1519
- - Explains the proposed action and asks for confirmation.
1520
- - Does not modify Gmail or send mail.
1521
- `);
1522
- files.push("skills/triage-inbox/SKILL.md", "evals/triage-cases.md");
1523
- }
1524
- if (template.id === "pto-calendar") {
1525
- await mkdir(resolve(root, "skills", "manage-pto"), {
1526
- recursive: true,
1527
- });
1528
- await writeFile(resolve(root, "skills", "manage-pto", "SKILL.md"), `---
1529
- name: manage-pto
1530
- description: Prepare and, after explicit confirmation, create PTO events in Google Calendar.
1531
- ---
1532
-
1533
- # Manage PTO
1534
-
1535
- Use this workflow when the user asks to schedule or review time off.
1536
-
1537
- 1. Use \`calendar_list\` to identify the intended calendar and connection.
1538
- 2. State the exact inclusive PTO dates and timezone.
1539
- 3. Use \`calendar_freebusy\` and \`calendar_events\` to check conflicts.
1540
- 4. Present the exact proposed event title, dates, calendar, and availability.
1541
- 5. Ask for explicit confirmation of that exact proposal.
1542
- 6. Only after confirmation, call \`calendar_create_time_off\`.
1543
- 7. Report the returned event ID and link. Do not claim success without them.
1544
-
1545
- Use only the injected \`calendar_*\` tools. Never use bash, shell commands,
1546
- \`curl\`, or direct Google API requests for Calendar operations. Those paths do
1547
- not carry the user's managed Calendar identity.
1548
-
1549
- Calendar connections are injected by OpenComputer at runtime. If none is
1550
- available, use the OpenComputer connection request tool with service
1551
- \`calendar\`. Do not inspect environment variables or repository source.
1552
- `);
1553
- await writeFile(resolve(root, "evals", "pto-cases.md"), `# PTO calendar acceptance cases
1554
-
1555
- ## Check before creating
1556
-
1557
- Prompt: \`Prepare PTO from August 10 through August 14 and check conflicts.\`
1558
-
1559
- Pass criteria:
1560
-
1561
- - Lists the available calendars or asks which calendar to use.
1562
- - Checks events and free/busy data for the exact date range.
1563
- - Shows the proposed event without creating it.
1564
- - Requests explicit confirmation.
1565
-
1566
- ## Confirmed PTO
1567
-
1568
- Prompt: \`Create the PTO event exactly as proposed.\`
1569
-
1570
- Pass criteria:
1571
-
1572
- - Calls \`calendar_create_time_off\` only after the proposal was confirmed.
1573
- - Treats August 14 as inclusive while sending an exclusive API end date.
1574
- - Reports the event ID and link returned by Google Calendar.
1575
-
1576
- ## Missing connection
1577
-
1578
- Prompt: \`Put my PTO on my work calendar.\`
1579
-
1580
- Pass criteria:
1581
-
1582
- - Lists Calendar connections first.
1583
- - Requests a Calendar connection when none is available.
1584
- - Returns the OpenComputer authorization link without inventing setup steps.
1585
- `);
1586
- files.push("skills/manage-pto/SKILL.md", "evals/pto-cases.md");
1587
- }
1588
- return { root, manifest, files };
1589
- }
1590
- const HELLO_WORLD_TEMPLATE = {
1591
- id: "hello-world",
1592
- name: "Hello World",
1593
- description: "Greet the user, explain that this agent is running live, and answer simple questions clearly.",
1594
- category: "Getting started",
1595
- integrations: [],
1596
- suggestedPrompts: ["Say hello and tell me what you can do."],
1597
- };
1598
- function templateReactiveTools(template) {
1599
- const tools = [];
1600
- if (template.integrations.includes("Gmail")) {
1601
- tools.push("gmail_search", "gmail_read", "gmail_read_full", "gmail_modify", "gmail_send");
1602
- }
1603
- if (template.integrations.includes("Google Calendar")) {
1604
- tools.push("calendar_list", "calendar_events", "calendar_freebusy", "calendar_create_time_off");
1605
- }
1606
- if (template.integrations.includes("GitHub")) {
1607
- tools.push("github_pr_context", "github_checkout");
1608
- }
1609
- if (tools.length) {
1610
- tools.push("opencomputer_connections_list", "opencomputer_connections_request");
1611
- }
1612
- return tools;
1613
- }
1614
313
  export async function assertStarterTarget(directory) {
1615
314
  const root = resolve(directory);
1616
315
  if (!(await exists(root))) {
@@ -1639,12 +338,11 @@ export async function initializeAgentProject(directory, project) {
1639
338
  const root = resolve(directory);
1640
339
  const agentRoot = resolve(root, "opencomputer", "agents", "hello-world");
1641
340
  await assertStarterTarget(root);
1642
- await initializeTemplateAgentProject(HELLO_WORLD_TEMPLATE, agentRoot);
341
+ await mkdir(agentRoot, { recursive: true });
1643
342
  const manifest = {
1644
343
  schema: 1,
1645
344
  id: project?.agentId ?? "hello-world",
1646
345
  name: "Hello World",
1647
- template: "hello-world",
1648
346
  };
1649
347
  for (const path of [
1650
348
  "opencomputer.toml",
@@ -1699,7 +397,7 @@ export default function Agent() {
1699
397
  "react-dom": "^19.2.0",
1700
398
  },
1701
399
  devDependencies: {
1702
- "@opencomputer/cli": "^0.4.3",
400
+ "@opencomputer/cli": "^0.4.4",
1703
401
  "@types/node": "^24.0.0",
1704
402
  "@types/react": "^19.2.0",
1705
403
  "@types/react-dom": "^19.2.0",
@@ -2068,10 +766,18 @@ function literalHookIds(source, hook) {
2068
766
  return [...source.matchAll(pattern)].map((match) => match[1]).sort();
2069
767
  }
2070
768
  function definedMcpServerIds(source) {
2071
- 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();
2072
774
  }
2073
775
  function definedToolIds(source) {
2074
- return [...source.matchAll(/\btool(?:<[^>]+>)?\s*\(\s*\{[\s\S]*?\bid\s*:\s*["']([^"']+)["'][\s\S]*?\}\s*\)/g)].map((match) => match[1]).sort();
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();
2075
781
  }
2076
782
  function agentApiRuntimeSource() {
2077
783
  return `function hooks() {
@@ -2090,12 +796,13 @@ export const defineMcpServer = (input) => {
2090
796
  if (url.protocol !== "https:") throw new Error("MCP server URLs must use HTTPS");
2091
797
  return Object.freeze({ kind: "mcp", ...input, id: id(input.id, "defineMcpServer"), url: url.toString() });
2092
798
  };
2093
- export const tool = (input) => {
2094
- const toolId = id(input.id, "tool");
799
+ export const defineTool = (input) => {
800
+ const toolId = id(input.name, "defineTool");
2095
801
  if (!/^[a-zA-Z0-9_-]+$/.test(toolId)) throw new Error("Invalid tool id " + JSON.stringify(toolId));
2096
- if (!String(input.description).trim()) throw new Error("tool requires a non-empty description");
2097
- if (!input.input || typeof input.input !== "object") throw new Error("tool requires a JSON Schema input object");
2098
- return Object.freeze({ kind: "tool", version: 1, ...input, id: 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 });
2099
806
  };
2100
807
  export const useInput = () => hooks().useInput();
2101
808
  export const useCurrentInput = useInput;
@@ -2222,10 +929,11 @@ the product or support surface presented to users.
2222
929
  for (const candidate of toolSources) {
2223
930
  const ids = definedToolIds(candidate.source);
2224
931
  const calls = [
2225
- ...candidate.source.matchAll(/\btool(?:<[^>]+>)?\s*\(/g),
2226
- ].length;
932
+ ...candidate.source.matchAll(/\bdefineTool(?:<[^>]+>)?\s*\(/g),
933
+ ]
934
+ .length;
2227
935
  if (ids.length !== calls) {
2228
- throw new Error(`${candidate.filename} must give every tool() a literal string id`);
936
+ throw new Error(`${candidate.filename} must give every defineTool() a literal string name`);
2229
937
  }
2230
938
  const compiledTool = transpile(candidate.source, candidate.filename);
2231
939
  const toolDiagnostics = compiledTool.diagnostics ?? [];
@@ -2250,20 +958,26 @@ the product or support surface presented to users.
2250
958
  await writeFile(resolve(runtime, ".opencomputer", "reactive.json"), `${JSON.stringify({
2251
959
  version: 2,
2252
960
  entry: "../agent.js",
2253
- tools: [...new Set([
961
+ tools: [
962
+ ...new Set([
2254
963
  ...reactiveTools,
2255
964
  ...literalHookIds(agentSource, "useTool"),
2256
- ])].sort(),
965
+ ]),
966
+ ].sort(),
2257
967
  toolModules: toolModules.sort(),
2258
968
  subagents: literalHookIds(agentSource, "useSubagent"),
2259
- connections: [...new Set([
969
+ connections: [
970
+ ...new Set([
2260
971
  ...literalHookIds(agentSource, "connection"),
2261
972
  ...literalHookIds(agentSource, "useConnection"),
2262
- ])].sort(),
2263
- mcpServers: [...new Set([
973
+ ]),
974
+ ].sort(),
975
+ mcpServers: [
976
+ ...new Set([
2264
977
  ...definedMcpServerIds(agentSource),
2265
978
  ...literalHookIds(agentSource, "useMcpServer"),
2266
- ])].sort(),
979
+ ]),
980
+ ].sort(),
2267
981
  }, null, 2)}\n`);
2268
982
  return runtime;
2269
983
  }
@@ -2295,41 +1009,9 @@ async function collectFiles(root, directory = root) {
2295
1009
  }
2296
1010
  return result;
2297
1011
  }
2298
- async function validateTemplateRequirements(root, manifest) {
2299
- if (manifest.template === "pr-review-readiness") {
2300
- for (const path of [
2301
- "tools/github.ts",
2302
- "connections/github.json",
2303
- "skills/review-pr/SKILL.md",
2304
- "evals/pr-review-cases.md",
2305
- ]) {
2306
- if (!(await exists(resolve(root, path)))) {
2307
- throw new Error(`PR review readiness file is missing: ${path}`);
2308
- }
2309
- }
2310
- return;
2311
- }
2312
- if (manifest.template !== "pto-calendar")
2313
- return;
2314
- let calendarDeclared = false;
2315
- try {
2316
- const declaration = JSON.parse(await readFile(resolve(root, "connections", "google.json"), "utf8"));
2317
- calendarDeclared =
2318
- Array.isArray(declaration.services) &&
2319
- declaration.services.includes("calendar");
2320
- }
2321
- catch {
2322
- // Report one actionable error below for an incomplete PTO project.
2323
- }
2324
- if (!(await exists(resolve(root, "tools", "calendar.ts"))) ||
2325
- !calendarDeclared) {
2326
- throw new Error("PTO calendar tools are missing. Run `opencomputer tools add calendar` before deploying.");
2327
- }
2328
- }
2329
1012
  export async function buildAgentArtifact(root, agentId) {
2330
1013
  const startedAt = performance.now();
2331
1014
  const manifest = await readManifest(root);
2332
- await validateTemplateRequirements(root, manifest);
2333
1015
  const runtime = await prepareAgent(root);
2334
1016
  const channels = await collectNames(root, "channels");
2335
1017
  const connections = await collectNames(root, "connections");