@meetopenbot/openbot 1.2.13 → 1.2.15

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.
@@ -5,6 +5,7 @@ import { askAgentTools } from "./tools/ask-agent.js";
5
5
  import { installAgentTools } from "./tools/install-agent.js";
6
6
  import { threadStatusTools } from "./tools/thread-status.js";
7
7
  import { threadTitleTools } from "./tools/thread-title.js";
8
+ import { previewTools } from "./tools/preview.js";
8
9
  import { wrapToolsWithApproval } from "./tools/approval.js";
9
10
  export const DEFAULT_APPROVAL_ACTIONS = ["update_channel_spec"];
10
11
  /**
@@ -20,6 +21,7 @@ export function buildOpenbotTools(args) {
20
21
  ...installAgentTools,
21
22
  ...threadStatusTools,
22
23
  ...threadTitleTools,
24
+ ...previewTools,
23
25
  ...args.context.tools,
24
26
  };
25
27
  const approvalConfig = args.context.config?.approval;
package/dist/images.js CHANGED
@@ -1,17 +1,32 @@
1
1
  import { BYOK_ENV_KEYS, shouldUseCreditsAuth } from '@meetopenbot/plugin-sdk';
2
+ import { expandModelString, isAutoModel, listAutoModelCandidates, } from './auto-model.js';
2
3
  export function isImageAttachment(file) {
3
4
  if (file.mimeType?.startsWith('image/'))
4
5
  return true;
5
6
  return /\.(png|jpe?g|gif|webp)$/i.test(file.name);
6
7
  }
7
- export function eventsHaveImages(events) {
8
- return events.some((event) => {
9
- if (event.meta?.parentToolCallId)
10
- return false;
11
- if (event.type !== 'agent:invoke')
12
- return false;
13
- return Boolean(event.data?.attachments?.some(isImageAttachment));
14
- });
8
+ function topLevelUserInvokes(events) {
9
+ return events.filter((event) => !event.meta?.parentToolCallId &&
10
+ event.type === 'agent:invoke' &&
11
+ Boolean(event.data?.role) &&
12
+ invokeRole(event.data.role) === 'user');
13
+ }
14
+ /** True when the newest top-level user message carries an image. */
15
+ export function latestUserMessageHasImages(events) {
16
+ const invokes = topLevelUserInvokes(events);
17
+ const latest = invokes[invokes.length - 1];
18
+ return Boolean(latest?.data?.attachments?.some(isImageAttachment));
19
+ }
20
+ /** True when any top-level user message carries an attachment. */
21
+ export function eventsHaveAttachments(events) {
22
+ return topLevelUserInvokes(events).some((event) => Boolean(event.data?.attachments?.length));
23
+ }
24
+ /** Providers whose models in the picker cannot take image input. */
25
+ const NO_IMAGE_INPUT_PROVIDERS = new Set(['deepseek']);
26
+ /** Whether the model for this turn can see images. Auto counts as its concrete pool model. */
27
+ export function modelSupportsImages(modelString, ctx) {
28
+ const provider = expandModelString(modelString, ctx).split('/')[0] ?? '';
29
+ return !NO_IMAGE_INPUT_PROVIDERS.has(provider);
15
30
  }
16
31
  function hasAnyKey(env, keys) {
17
32
  return Boolean(keys?.some((key) => Boolean(env[key]?.trim())));
@@ -33,8 +48,57 @@ export function pickVisionModel(authMode, env = process.env) {
33
48
  function invokeRole(role) {
34
49
  return role === 'agent' ? 'user' : role;
35
50
  }
36
- /** Put image files on matching user messages as AI SDK `file` parts. */
37
- export async function addImageParts(messages, events, read) {
51
+ /**
52
+ * Models to try this turn. Auto switches to a cheap vision model only when the
53
+ * newest user message has an image and a vision model is available. Never throws.
54
+ */
55
+ export function chooseTurnCandidates({ model, events, authMode, env = process.env, }) {
56
+ if (!isAutoModel(model))
57
+ return [model.trim()];
58
+ if (latestUserMessageHasImages(events)) {
59
+ const vision = pickVisionModel(authMode, env);
60
+ if (vision)
61
+ return [vision];
62
+ }
63
+ return listAutoModelCandidates({ authMode, env });
64
+ }
65
+ function formatSize(bytes) {
66
+ if (bytes < 1024)
67
+ return `${bytes} B`;
68
+ if (bytes < 1024 * 1024)
69
+ return `${Math.round(bytes / 1024)} KB`;
70
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
71
+ }
72
+ function fileType(file) {
73
+ if (file.mimeType)
74
+ return file.mimeType;
75
+ const match = /\.([a-z0-9]+)$/i.exec(file.name);
76
+ return match ? match[1].toLowerCase() : 'file';
77
+ }
78
+ /** Text note listing attached files with their full paths. */
79
+ export function attachmentNote(files) {
80
+ const lines = files.map((file) => {
81
+ const details = [fileType(file)];
82
+ if (typeof file.size === 'number' && file.size > 0)
83
+ details.push(formatSize(file.size));
84
+ const where = file.path ?? "in this thread's attachments";
85
+ const suffix = file.imageNotShown
86
+ ? ' — not shown to you; the current model cannot view images'
87
+ : '';
88
+ return `- ${file.name} (${details.join(', ')}): ${where}${suffix}`;
89
+ });
90
+ return [
91
+ '[Attached files, saved on this computer:',
92
+ ...lines,
93
+ 'You cannot open these files yourself. If a coding agent should use one, give them its full path.]',
94
+ ].join('\n');
95
+ }
96
+ /**
97
+ * Put attachments on matching user messages. Images go as AI SDK `file` parts
98
+ * when `includeImages` is set and the read succeeds; every other file (and any
99
+ * image not shown) becomes one text note with its full path.
100
+ */
101
+ export async function addAttachmentParts(messages, events, { includeImages, read, locate, }) {
38
102
  const result = [...messages];
39
103
  let userCursor = 0;
40
104
  const takeNextUserMessageIndex = () => {
@@ -46,6 +110,23 @@ export async function addImageParts(messages, events, read) {
46
110
  }
47
111
  return -1;
48
112
  };
113
+ const safeRead = async (name) => {
114
+ try {
115
+ const loaded = await read(name);
116
+ return loaded?.data.byteLength ? loaded : null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ };
122
+ const safeLocate = async (name) => {
123
+ try {
124
+ return (await locate(name)) || null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ };
49
130
  for (const event of events) {
50
131
  if (event.meta?.parentToolCallId)
51
132
  continue;
@@ -58,27 +139,39 @@ export async function addImageParts(messages, events, read) {
58
139
  if (invokeRole(event.data.role) !== 'user')
59
140
  continue;
60
141
  const index = takeNextUserMessageIndex();
61
- const files = attachments.filter(isImageAttachment);
62
- if (index === -1 || files.length === 0)
142
+ if (index === -1 || attachments.length === 0)
63
143
  continue;
64
144
  const current = result[index];
65
145
  const parts = [];
66
146
  if (typeof current.content === 'string' && current.content) {
67
147
  parts.push({ type: 'text', text: current.content });
68
148
  }
69
- for (const file of files) {
70
- const loaded = await read(file.name);
71
- if (!loaded?.data.byteLength)
72
- continue;
73
- parts.push({
74
- type: 'file',
75
- data: loaded.data,
76
- mediaType: loaded.mediaType || 'image/png',
149
+ const noted = [];
150
+ for (const file of attachments) {
151
+ const isImage = isImageAttachment(file);
152
+ if (isImage && includeImages) {
153
+ const loaded = await safeRead(file.name);
154
+ if (loaded) {
155
+ parts.push({
156
+ type: 'file',
157
+ data: loaded.data,
158
+ mediaType: loaded.mediaType || 'image/png',
159
+ });
160
+ continue;
161
+ }
162
+ }
163
+ noted.push({
164
+ name: file.name,
165
+ mimeType: file.mimeType,
166
+ size: file.size,
167
+ path: await safeLocate(file.name),
168
+ imageNotShown: isImage,
77
169
  });
78
170
  }
79
- if (parts.some((part) => part.type === 'file')) {
171
+ if (noted.length > 0)
172
+ parts.push({ type: 'text', text: attachmentNote(noted) });
173
+ if (parts.length > 0)
80
174
  result[index] = { role: 'user', content: parts };
81
- }
82
175
  }
83
176
  return result;
84
177
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { askAgentTools } from './tools/ask-agent.js';
10
10
  import { installAgentTools } from './tools/install-agent.js';
11
11
  import { threadStatusTools } from './tools/thread-status.js';
12
12
  import { threadTitleTools } from './tools/thread-title.js';
13
+ import { previewTools } from './tools/preview.js';
13
14
  import { buildOpenbotTools } from './build-tools.js';
14
15
  export const OPENBOT_PLUGIN_ID = '@meetopenbot/openbot';
15
16
  const modelField = await modelConfigField({
@@ -49,6 +50,7 @@ export const openbotPlugin = defineOpenbotPlugin({
49
50
  ...installAgentTools,
50
51
  ...threadStatusTools,
51
52
  ...threadTitleTools,
53
+ ...previewTools,
52
54
  },
53
55
  async *run(args) {
54
56
  const { context } = args;
package/dist/runtime.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { streamText, stepCountIs } from 'ai';
2
2
  import { BYOK_ENV_KEYS, isAuthErrorMessage, isCreditsErrorMessage, fetchModelRegistry, getProviderModelOptions, listApiKeyProviders, PROVIDER_API_KEY_LINKS, } from '@meetopenbot/plugin-sdk';
3
3
  import { eventsToModelMessages } from './history.js';
4
- import { addImageParts, eventsHaveImages, pickVisionModel } from './images.js';
4
+ import { addAttachmentParts, chooseTurnCandidates, eventsHaveAttachments, modelSupportsImages, } from './images.js';
5
5
  import { buildContext } from './context.js';
6
6
  import { OPENBOT_SYSTEM_PROMPT, withGithubGitInstructions, withUserInstructions, } from './system-prompt.js';
7
- import { AUTO_MODEL_ID, isAutoModel, isRetryableModelError, listAutoModelCandidates, missingByokApiKeyMessage, } from './auto-model.js';
7
+ import { AUTO_MODEL_ID, isAutoModel, isRetryableModelError, missingByokApiKeyMessage, } from './auto-model.js';
8
8
  import { reasoningForModel, resolveModel } from './model.js';
9
9
  import { errorFromStreamPart } from './stream-error.js';
10
10
  import { createAgentOutputBuffer, isOutputBoundaryPart } from './output-buffer.js';
@@ -100,18 +100,39 @@ export async function* runOpenbotTurn(args, options) {
100
100
  channelId: state.channelId ?? '',
101
101
  threadId,
102
102
  });
103
- const hasImages = eventsHaveImages(events);
104
- let candidates = isAutoModel(configuredModelString)
105
- ? listAutoModelCandidates({ authMode })
106
- : [configuredModelString];
107
- try {
108
- if (hasImages && isAutoModel(configuredModelString)) {
109
- const vision = pickVisionModel(authMode);
110
- if (!vision) {
111
- throw new Error('Looking at images needs an OpenAI, Google, or Anthropic API key. Auto\'s usual model cannot see pictures.');
112
- }
113
- candidates = [vision];
103
+ const hasAttachments = eventsHaveAttachments(events);
104
+ const candidates = chooseTurnCandidates({ model: configuredModelString, events, authMode });
105
+ const readImage = async (name) => {
106
+ try {
107
+ const file = await storage.readThreadAttachment({
108
+ channelId: state.channelId,
109
+ threadId,
110
+ name,
111
+ });
112
+ return { data: new Uint8Array(file.body), mediaType: file.mimeType };
113
+ }
114
+ catch (error) {
115
+ console.warn('[openbot] could not read attachment', name, error);
116
+ return null;
117
+ }
118
+ };
119
+ const locateAttachment = async (name) => {
120
+ if (typeof storage.getThreadAttachmentStat !== 'function')
121
+ return null;
122
+ try {
123
+ const stat = await storage.getThreadAttachmentStat({
124
+ channelId: state.channelId,
125
+ threadId,
126
+ name,
127
+ });
128
+ return typeof stat?.abs === 'string' ? stat.abs : null;
129
+ }
130
+ catch (error) {
131
+ console.warn('[openbot] could not locate attachment', name, error);
132
+ return null;
114
133
  }
134
+ };
135
+ try {
115
136
  const missingKey = missingByokApiKeyMessage(candidates[0], { authMode });
116
137
  if (missingKey)
117
138
  throw new Error(missingKey);
@@ -128,22 +149,14 @@ export async function* runOpenbotTurn(args, options) {
128
149
  const tools = toAiTools(toolDefinitions, args, flushPendingOutput);
129
150
  try {
130
151
  let messages = eventsToModelMessages(events);
131
- if (hasImages) {
132
- messages = await addImageParts(messages, events, async (name) => {
133
- try {
134
- const file = await storage.readThreadAttachment({
135
- channelId: state.channelId,
136
- threadId,
137
- name,
138
- });
139
- return { data: new Uint8Array(file.body), mediaType: file.mimeType };
140
- }
141
- catch (error) {
142
- console.warn('[openbot] could not read attachment', name, error);
143
- return null;
144
- }
152
+ if (hasAttachments) {
153
+ const includeImages = modelSupportsImages(candidate, { authMode });
154
+ messages = await addAttachmentParts(messages, events, {
155
+ includeImages,
156
+ read: readImage,
157
+ locate: locateAttachment,
145
158
  });
146
- console.info('[openbot] attached images for', candidate);
159
+ console.info('[openbot] attached files for', candidate, { images: includeImages });
147
160
  }
148
161
  const result = streamText({
149
162
  model: resolveModel(candidate, { authMode }),
@@ -50,11 +50,19 @@ export const OPENBOT_SYSTEM_PROMPT = [
50
50
  "- **Hub-and-spoke**: Specialists cannot talk to each other. You pass context from one to the next.",
51
51
  "- **Durable Memory**: Use the `remember` tool to store important facts, preferences, or project details that should persist across sessions.",
52
52
  "",
53
+ "# ATTACHMENTS",
54
+ "- Files the human attaches are saved on this computer. Their message lists each one with its full path.",
55
+ "- You see an image only when it is shown to you. You cannot open other files (PDF, documents, spreadsheets, archives, code).",
56
+ "- Never claim you read a file or saw an image you were not shown.",
57
+ "- When a coding agent should use a file, `ask_agent` them with the exact full path(s) and ask them to open the file there. Do not paraphrase or shorten the path.",
58
+ "- If an image was not shown to you and no specialist fits, say the current model cannot view images and suggest picking a vision-capable model.",
59
+ "",
53
60
  "# CLOUD COMPUTER",
54
61
  "This runtime is a remote cloud computer. The human is in the browser at openbot.one and cannot open localhost, 127.0.0.1, or any port on this machine.",
55
62
  "- Never tell them to visit localhost. They need a public HTTPS URL.",
56
63
  "- When you ask a coding agent to run a dev server, preview, or any local process the human should open, tell them to expose it with `cloudflared tunnel --url http://127.0.0.1:<port>` and to add `cloudflared` as a **project devDependency** (not a global install — this image is ephemeral and only workspace files persist).",
57
- "- Share the `https://*.trycloudflare.com` URL they print. Quick tunnels change on restart — pass the new one through. If they report a localhost URL, ask them to tunnel it.",
64
+ "- Share the `https://*.trycloudflare.com` URL they print, and call `open_preview` with it so it opens in the space browser. Quick tunnels change on restart — pass the new one through. If they report a localhost URL, ask them to tunnel it.",
65
+ "- For any other public page the human should see, call `open_preview` with that http(s) URL too. Never pass localhost, 127.0.0.1, or a port on this machine.",
58
66
  "",
59
67
  "# SPACE VARIABLES",
60
68
  "- Secrets live in OpenBot variables, not in chat. Workspace-wide keys: Settings → Variables. Per-space keys: Space menu → Variables (override workspace keys with the same name for shell commands in that space).",
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { loadAgentModelCatalogs, validateAskModel } from "../agent-models.js";
4
+ import { previewEventsForTunnelUrls } from "./preview.js";
4
5
  /**
5
6
  * `ask_agent` — OpenBot sends a message to another agent in its own harness.
6
7
  * Only the orchestrator may ask. Child events stream into this thread so the
@@ -103,6 +104,8 @@ async function runAskedAgent(ctx, args) {
103
104
  resolveNext = null;
104
105
  }
105
106
  });
107
+ const seenPreviewUrls = new Set();
108
+ const threadId = ctx.threadId ?? ctx.state.threadId;
106
109
  while (!isFinished || eventQueue.length > 0) {
107
110
  if (eventQueue.length === 0) {
108
111
  await new Promise((r) => {
@@ -110,7 +113,14 @@ async function runAskedAgent(ctx, args) {
110
113
  });
111
114
  }
112
115
  while (eventQueue.length > 0) {
113
- await ctx.emit(eventQueue.shift());
116
+ const nextEvent = eventQueue.shift();
117
+ await ctx.emit(nextEvent);
118
+ for (const preview of previewEventsForTunnelUrls(nextEvent, seenPreviewUrls, {
119
+ agentId: ctx.agentId,
120
+ threadId,
121
+ })) {
122
+ await ctx.emit(preview);
123
+ }
114
124
  }
115
125
  }
116
126
  await runPromise;
@@ -0,0 +1,129 @@
1
+ import z from 'zod';
2
+ const LOCAL_HOST = /^(?:localhost|.*\.localhost|0\.0\.0\.0|::1|127(?:\.\d{1,3}){3})$/i;
3
+ function isLocalHostname(hostname) {
4
+ const name = hostname.toLowerCase().replace(/\.$/, '');
5
+ return LOCAL_HOST.test(name);
6
+ }
7
+ /** Public http(s) URL the human's browser can open, or null. */
8
+ export function publicPreviewUrl(input) {
9
+ const trimmed = input.trim();
10
+ if (!trimmed)
11
+ return null;
12
+ const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
13
+ let url;
14
+ try {
15
+ url = new URL(withScheme);
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
21
+ return null;
22
+ if (url.username || url.password)
23
+ return null;
24
+ if (isLocalHostname(url.hostname))
25
+ return null;
26
+ return url.href;
27
+ }
28
+ const TUNNEL_URL = /https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.trycloudflare\.com(?::\d+)?(?:\/[^\s<>"'`)\\\]]*)?/gi;
29
+ const MAX_SCAN_CHARS = 250000;
30
+ function cleanMatch(raw) {
31
+ return raw.replace(/[.,;:!?)]+$/g, '');
32
+ }
33
+ /** Public Cloudflare quick-tunnel URLs mentioned in specialist output. */
34
+ export function tunnelPreviewUrls(value) {
35
+ let text;
36
+ try {
37
+ text = typeof value === 'string' ? value : JSON.stringify(value) ?? '';
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ if (!text)
43
+ return [];
44
+ if (text.length > MAX_SCAN_CHARS)
45
+ text = text.slice(0, MAX_SCAN_CHARS);
46
+ const seen = new Set();
47
+ const urls = [];
48
+ for (const match of text.matchAll(TUNNEL_URL)) {
49
+ const url = publicPreviewUrl(cleanMatch(match[0]));
50
+ if (!url || seen.has(url))
51
+ continue;
52
+ seen.add(url);
53
+ urls.push(url);
54
+ }
55
+ return urls;
56
+ }
57
+ export function previewWidgetEvent(args) {
58
+ return {
59
+ type: 'client:ui:widget',
60
+ data: {
61
+ widgetId: `preview:${args.url}`,
62
+ kind: 'message',
63
+ title: 'Preview',
64
+ body: args.url,
65
+ display: 'expanded',
66
+ state: 'open',
67
+ actions: [
68
+ {
69
+ id: 'open_preview',
70
+ label: 'Open in browser',
71
+ variant: 'primary',
72
+ value: { previewUrl: args.url },
73
+ },
74
+ ],
75
+ metadata: { autoOpen: true },
76
+ },
77
+ meta: {
78
+ agentId: args.agentId,
79
+ ...(args.threadId ? { threadId: args.threadId } : {}),
80
+ },
81
+ };
82
+ }
83
+ /** Preview widgets for tunnel URLs in `value` that `seen` has not emitted yet. */
84
+ export function previewEventsForTunnelUrls(value, seen, meta) {
85
+ const events = [];
86
+ for (const url of tunnelPreviewUrls(value)) {
87
+ if (seen.has(url))
88
+ continue;
89
+ seen.add(url);
90
+ events.push(previewWidgetEvent({ url, ...meta }));
91
+ }
92
+ return events;
93
+ }
94
+ async function openPreview(rawArgs, ctx) {
95
+ const channelId = ctx.channelId ?? ctx.state.channelId ?? '';
96
+ const threadId = ctx.threadId ?? ctx.state.threadId;
97
+ if (!channelId || !threadId) {
98
+ const message = 'Missing channelId or threadId for open_preview';
99
+ return { success: false, error: message, output: message };
100
+ }
101
+ const data = (rawArgs ?? {});
102
+ const rawUrl = typeof data.url === 'string' ? data.url : '';
103
+ const url = publicPreviewUrl(rawUrl);
104
+ if (!url) {
105
+ const message = 'Pass a public http(s) URL. Localhost is not reachable from the browser — tunnel it first.';
106
+ return { success: false, error: message, output: message };
107
+ }
108
+ await ctx.emit(previewWidgetEvent({
109
+ url,
110
+ agentId: ctx.agentId,
111
+ threadId,
112
+ }));
113
+ return { success: true, url, output: `Opened ${url} in the space browser.` };
114
+ }
115
+ export const toolDefinitions = {
116
+ open_preview: {
117
+ description: 'Open a public http(s) URL in the space browser beside the chat. Call this with a preview or tunnel URL the human should see, including https://*.trycloudflare.com. Never pass localhost, 127.0.0.1, or any address on this machine.',
118
+ inputSchema: z.object({
119
+ url: z.string().min(1).describe('Public http(s) URL to open in the space browser.'),
120
+ }),
121
+ },
122
+ };
123
+ export const tools = {
124
+ open_preview: {
125
+ ...toolDefinitions.open_preview,
126
+ execute: openPreview,
127
+ },
128
+ };
129
+ export const previewTools = tools;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetopenbot/openbot",
3
- "version": "1.2.13",
3
+ "version": "1.2.15",
4
4
  "description": "OpenBot coordinator runtime: ask specialists, route work into Spaces, and track todos.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",