@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/README.md +4 -4
- package/dist/api.d.ts +0 -9
- package/dist/api.js +0 -4
- package/dist/api.js.map +1 -1
- package/dist/commands.js +3 -17
- package/dist/commands.js.map +1 -1
- package/dist/dev.d.ts +6 -1
- package/dist/dev.js +30 -14
- package/dist/dev.js.map +1 -1
- package/dist/dev.test.js +38 -2
- package/dist/dev.test.js.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/project.d.ts +6 -11
- package/dist/project.js +155 -1353
- package/dist/project.js.map +1 -1
- package/dist/project.test.js +41 -240
- package/dist/project.test.js.map +1 -1
- package/dist/slack.test.js +13 -21
- package/dist/slack.test.js.map +1 -1
- package/package.json +1 -1
package/dist/project.js
CHANGED
|
@@ -1,41 +1,8 @@
|
|
|
1
|
-
import { createHash
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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 =
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
|
1016
|
-
return
|
|
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 (
|
|
198
|
+
if (await exists(resolve(directory, "agent.ts"))) {
|
|
1081
199
|
return directory;
|
|
1082
200
|
}
|
|
1083
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
1116
|
-
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
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
|
|
1154
|
-
|
|
1155
|
-
await
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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 [
|
|
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
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
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
|
|
2118
|
-
|
|
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
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
const
|
|
2126
|
-
|
|
2127
|
-
|
|
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: [
|
|
961
|
+
tools: [
|
|
962
|
+
...new Set([
|
|
2135
963
|
...reactiveTools,
|
|
2136
964
|
...literalHookIds(agentSource, "useTool"),
|
|
2137
|
-
])
|
|
965
|
+
]),
|
|
966
|
+
].sort(),
|
|
967
|
+
toolModules: toolModules.sort(),
|
|
2138
968
|
subagents: literalHookIds(agentSource, "useSubagent"),
|
|
2139
|
-
connections: [
|
|
969
|
+
connections: [
|
|
970
|
+
...new Set([
|
|
2140
971
|
...literalHookIds(agentSource, "connection"),
|
|
2141
972
|
...literalHookIds(agentSource, "useConnection"),
|
|
2142
|
-
])
|
|
2143
|
-
|
|
973
|
+
]),
|
|
974
|
+
].sort(),
|
|
975
|
+
mcpServers: [
|
|
976
|
+
...new Set([
|
|
2144
977
|
...definedMcpServerIds(agentSource),
|
|
2145
978
|
...literalHookIds(agentSource, "useMcpServer"),
|
|
2146
|
-
])
|
|
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");
|