bunnyquery 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,8 +10,45 @@ export var EXPIRED_ATTACHMENT_URL_HOST = '_expired_.url';
10
10
  export var EXPIRED_ATTACHMENT_URL_ORIGIN = 'https://' + EXPIRED_ATTACHMENT_URL_HOST;
11
11
  export var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
12
12
 
13
+ /**
14
+ * Lifetime of the url minted when a user clicks an expired attachment chip.
15
+ *
16
+ * Mint it as a PLAIN get-db presign, never with generate_temporary_cdn_url: the
17
+ * cdn branch ignores `expires` entirely and hands back a url good for the rest of
18
+ * the current UTC day plus the next one, so a "20 minute" link would in fact live
19
+ * 24 to 48 hours. The dashboard has always done this correctly and the widget did
20
+ * not, which is precisely the kind of divergence a shared constant exists to stop.
21
+ */
22
+ export var EXPIRED_LINK_REFRESH_EXPIRES_SECONDS = 20 * 60;
23
+
24
+ /**
25
+ * How long a client may keep serving an href it already minted before dropping
26
+ * back to the placeholder and re-minting.
27
+ *
28
+ * DERIVED from the TTL above, with five minutes of headroom, because the
29
+ * invariant "the cache must expire before the url does" used to be a comment
30
+ * next to two independent literals. If it is ever violated a client serves a
31
+ * dead url with no way to notice; deriving it makes that unrepresentable.
32
+ */
33
+ export var LINK_REFRESH_WINDOW_MS = (EXPIRED_LINK_REFRESH_EXPIRES_SECONDS - 5 * 60) * 1000;
34
+
35
+ // The two "balanced parens" groups match ONE CHARACTER per step, never a `+`
36
+ // run, so each position has exactly one way to be matched: `[^()\n]` cannot
37
+ // start with `(`, and the nested-paren alternative always does. That disjointness
38
+ // is load-bearing, not style.
39
+ //
40
+ // The obvious spelling — `(?:[^()\n]+|\([^()\n]*\))+` — is a nested quantifier:
41
+ // a run of N plain characters can be split across the outer `+` in 2^N ways, and
42
+ // the engine tries EVERY one before it can conclude the branch failed. A branch
43
+ // fails on ordinary input: a link whose url contains a space (the url branch
44
+ // forbids spaces), a link broken across a newline, or a reply truncated
45
+ // mid-link. Measured on the unfixed pattern: `[label](` plus 30 characters with
46
+ // no closing paren took 62 SECONDS, 45 characters never finished. Since this
47
+ // regex is scanned over the whole reply by the typewriter (session.ts) and by
48
+ // every message render (parseMsgParts), that is a permanently frozen tab the
49
+ // moment such a message arrives. Same matches either way, linear time.
13
50
  export function createInlineLinkRegex(): RegExp {
14
- return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]+|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]+|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
51
+ return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
15
52
  }
16
53
 
17
54
  export function safeDecodeURIComponent(v: string): string {
@@ -70,25 +107,288 @@ export function isServiceDbAttachmentHref(href: string, serviceId: string): bool
70
107
  } catch (e) { return false; }
71
108
  }
72
109
 
110
+ /**
111
+ * Read the storage path back out of an `_expired_.url` placeholder.
112
+ *
113
+ * The placeholder is not a display detail: sanitizeAttachmentLinksForHistory
114
+ * writes it into PERSISTED history, and buildBoundedChatMessages replays it into
115
+ * the model's context. So it round-trips constantly and MUST be recognised on the
116
+ * way back in. Returns null for anything that is not the carrier.
117
+ */
118
+ export function readExpiredAttachmentHref(href: string): string | null {
119
+ if (!href) return null;
120
+ try {
121
+ var parsed = new URL(href);
122
+ if (parsed.hostname !== EXPIRED_ATTACHMENT_URL_HOST) return null;
123
+ return normalizeAttachmentPathCandidate(parsed.pathname || '') || null;
124
+ } catch (e) { return null; }
125
+ }
126
+
73
127
  // Replace volatile attachment URLs with their durable `_expired_.url/<path>`
74
128
  // placeholder so a stored/replayed copy re-mints on demand instead of going stale.
75
- // USER turns: the "Attached files:" block gates the (broad) rewrite — every url
76
- // there is a db link. ASSISTANT turns (forAssistant=true): no marker exists and the
77
- // text may contain external citation urls, so restrict the rewrite to THIS
78
- // service's db attachment urls and leave every other link untouched.
129
+ //
130
+ // Only THIS service's db urls are rewritten, whichever role wrote them. The user
131
+ // branch used to rewrite every url in any message that carried an "Attached
132
+ // files:" block, which quietly destroyed a third-party link the user happened to
133
+ // paste in the same message: it became a placeholder for a storage path that
134
+ // never existed. We can only re-mint what we host, so we only rewrite what we
135
+ // host.
79
136
  export function sanitizeAttachmentLinksForHistory(content: string, serviceId: string, forAssistant?: boolean): string {
80
137
  if (!content) return content;
81
138
  if (!forAssistant && content.indexOf('Attached files:') === -1) return content;
82
139
  return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function (_m: string, label: string, href: string) {
83
- if (forAssistant && !isServiceDbAttachmentHref(href, serviceId)) return _m;
140
+ if (!isServiceDbAttachmentHref(href, serviceId)) return _m;
84
141
  var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
85
- var labelPath = normalizeAttachmentPathCandidate(label);
86
- var fullPath = remotePath || labelPath;
87
- if (!fullPath) return forAssistant ? _m : '[' + label + '](' + EXPIRED_ATTACHMENT_URL_ORIGIN + '/file)';
142
+ var fullPath = remotePath || normalizeAttachmentPathCandidate(label);
143
+ if (!fullPath) return _m;
88
144
  return '[' + label + '](' + buildDisplayExpiredAttachmentHref(fullPath, label) + ')';
89
145
  });
90
146
  }
91
147
 
148
+ /**
149
+ * Is this markdown link target a URL rather than a db storage path?
150
+ *
151
+ * The inline-link regex decides that by whether the target contains whitespace:
152
+ * its url branch forbids it, its bare-path branch allows it (a db path really can
153
+ * contain spaces). So a url that picked up a stray space anywhere in transit
154
+ * falls out of the url branch and is claimed by the path branch, and the view
155
+ * renders it as an `_expired_.url/https%3A/…` attachment chip that resolves to
156
+ * nothing. The view asks this FIRST, so what a link IS never depends on damage.
157
+ */
158
+ export function isHttpUrlLike(target: string): boolean {
159
+ return /^https?:\/\//i.test((target || '').trim());
160
+ }
161
+
162
+ /**
163
+ * Repair whitespace inside a url. RFC 3986 has no legal whitespace anywhere in a
164
+ * URI, so a space in an href is always damage, never content.
165
+ *
166
+ * Two repairs, because the right one differs:
167
+ * - Our own `/download/<id>` capability links (skapi-mcp file-download.js) are
168
+ * base64url, optionally with a single `.` separating the payload and hmac of
169
+ * the older self-describing token. That alphabet cannot contain whitespace,
170
+ * so the spaces are purely damage and REMOVING them restores the exact link,
171
+ * which is what makes an already-sent message clickable again. A model
172
+ * reproducing one of these into its reply is exactly where the spaces come
173
+ * from, which is also why the id is now short.
174
+ * - Anything else keeps every character and only has the whitespace encoded,
175
+ * the same thing a browser does with a space in an href. Stripping would be
176
+ * wrong there: `…/exports/my report.csv` is a real file whose name has a
177
+ * space in it, and deleting it points at a file that does not exist.
178
+ */
179
+ export function repairUrlWhitespace(href: string): string {
180
+ if (!href || !/\s/.test(href)) return href;
181
+ var stripped = href.replace(/\s+/g, '');
182
+ if (/^https?:\/\/[^/\s]+\/download\/[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)?$/i.test(stripped)) return stripped;
183
+ return href.trim().replace(/\s/g, '%20');
184
+ }
185
+
186
+ /**
187
+ * Trim punctuation and unmatched wrappers that cling to a token in prose.
188
+ * `src::a/b.pdf).` -> `src::a/b.pdf`, while a balanced `file (v2).pdf` is kept.
189
+ */
190
+ export function normalizeTrailingInlineToken(value: string): string {
191
+ if (!value) return value;
192
+ var out = value.replace(/[.,;:!?]+$/, '');
193
+ var trimUnmatched = function (openCh: string, closeCh: string) {
194
+ while (out.charAt(out.length - 1) === closeCh) {
195
+ var openCount = (out.match(new RegExp('\\' + openCh, 'g')) || []).length;
196
+ var closeCount = (out.match(new RegExp('\\' + closeCh, 'g')) || []).length;
197
+ if (closeCount > openCount) out = out.slice(0, -1); else break;
198
+ }
199
+ };
200
+ trimUnmatched('(', ')');
201
+ trimUnmatched('[', ']');
202
+ trimUnmatched('{', '}');
203
+ out = out.replace(/[`'"*>]+$/, '');
204
+ return out;
205
+ }
206
+
207
+ /** A link the view renders. `expired` means the href is the `_expired_.url`
208
+ * placeholder and a click must mint a fresh one from `remotePath`. */
209
+ export interface InlineLinkPart {
210
+ type: 'link';
211
+ label: string;
212
+ fullLabel: string;
213
+ href: string;
214
+ expired: boolean;
215
+ expiredHref?: string;
216
+ remotePath?: string;
217
+ }
218
+
219
+ export interface InlineLinkContext {
220
+ /** Current project id: the leading segment to strip off a db url. */
221
+ serviceId: string;
222
+ /** `https://db.<hostDomain>` for this deployment. */
223
+ dbHostPrefix: string;
224
+ /** A fresh url already minted for this placeholder, if the view cached one. */
225
+ resolveFreshHref?: (expiredHref: string) => string | undefined;
226
+ }
227
+
228
+ /**
229
+ * Decide what ONE inline-link regex match actually is, and how to render it.
230
+ *
231
+ * This is the single place that answers "is this an external url, this project's
232
+ * db file, or a bare storage path", for every consumer. It used to live twice,
233
+ * once in agent.vue and once in the widget, and both copies had to be found and
234
+ * corrected for each of the link bugs this file's history records. A view now
235
+ * supplies its own context (project id, db host, cached-href lookup) and does
236
+ * nothing but turn the returned part into markup.
237
+ *
238
+ * `groups` is [g1..g6] from createInlineLinkRegex, in that order:
239
+ * g1 src::<token> g2/g3 [label](url) g4/g5 [label](path) g6 bare url
240
+ */
241
+ export function classifyInlineLink(
242
+ full: string,
243
+ groups: Array<string | undefined>,
244
+ ctx: InlineLinkContext,
245
+ ): { part: InlineLinkPart; tail?: string } | null {
246
+ var g1 = groups[0], g2 = groups[1], g3 = groups[2], g4 = groups[3], g5 = groups[4], g6 = groups[5];
247
+ var dbHostPrefix = (ctx.dbHostPrefix || '').toLowerCase();
248
+ var fresh = function (expiredHref: string): string | undefined {
249
+ return ctx.resolveFreshHref ? ctx.resolveFreshHref(expiredHref) : undefined;
250
+ };
251
+ var isDbHost = function (url: string): boolean {
252
+ return !!dbHostPrefix && url.toLowerCase().indexOf(dbHostPrefix) === 0;
253
+ };
254
+ // A db path rendered as the placeholder the click handler resolves.
255
+ var asStoredFile = function (remotePath: string, label: string): { part: InlineLinkPart } | null {
256
+ if (!remotePath) return null;
257
+ var expiredHref = buildDisplayExpiredAttachmentHref(remotePath, label);
258
+ var cached = fresh(expiredHref);
259
+ return {
260
+ part: {
261
+ type: 'link',
262
+ label: truncateLabelForDisplay(label),
263
+ fullLabel: label,
264
+ href: cached || expiredHref,
265
+ expired: !cached,
266
+ expiredHref: expiredHref,
267
+ remotePath: remotePath,
268
+ },
269
+ };
270
+ };
271
+
272
+ // src::<token> — a path, or a url the model copied out of a record.
273
+ if (g1) {
274
+ var rawPath = normalizeTrailingInlineToken(g1);
275
+ var tail = full.slice(('src::' + rawPath).length);
276
+ var srcIsUrl = isHttpUrlLike(rawPath);
277
+ // `src::` values come straight out of a record's unique_id, and the prompt
278
+ // says that may be "the file's storage path or original URL". http:// is as
279
+ // much a url as https://; testing only for https sent every plain-http
280
+ // source into the storage-path branch, where it became a chip pointing at
281
+ // this project for someone else's file.
282
+ if (srcIsUrl && !isDbHost(rawPath) && !readExpiredAttachmentHref(rawPath)) {
283
+ return {
284
+ part: { type: 'link', label: truncateLabelForDisplay(rawPath), fullLabel: rawPath, href: rawPath, expired: false },
285
+ tail: tail,
286
+ };
287
+ }
288
+ var srcPath = readExpiredAttachmentHref(rawPath)
289
+ || (srcIsUrl
290
+ ? (extractRemotePathFromAttachmentHref(rawPath, ctx.serviceId) || normalizeAttachmentPathCandidate(rawPath))
291
+ : normalizeAttachmentPathCandidate(rawPath));
292
+ var srcBuilt = asStoredFile(srcPath, srcPath);
293
+ return srcBuilt ? { part: srcBuilt.part, tail: tail } : null;
294
+ }
295
+
296
+ // [label](target) where target is NOT a url by the regex's reckoning.
297
+ if (g4 && g5) {
298
+ // An EXPLICIT db target, `[label](db:folder/file.csv)`, says what it is
299
+ // instead of leaving us to infer it from the absence of "http". That is the
300
+ // only form here that cannot be confused with anything else, and it matches
301
+ // the scheme the backend already uses internally (db:<service>/<key>).
302
+ // Accepted now so the clients tolerate it everywhere before anything starts
303
+ // EMITTING it; until then this branch simply never fires.
304
+ var dbTarget = /^db:(.+)$/i.exec(g5.trim());
305
+ if (dbTarget) {
306
+ var declared = asStoredFile(normalizeAttachmentPathCandidate(dbTarget[1]), g4);
307
+ if (!declared) return null;
308
+ declared.part.label = truncateLabelForDisplay(g4);
309
+ declared.part.fullLabel = g4;
310
+ return declared;
311
+ }
312
+ // ...except when it is one. It only lands here because it contains
313
+ // whitespace, which the url branch forbids and this one allows, and
314
+ // reading a damaged url as a storage path is how a download link became a
315
+ // dead chip. Repair it and classify it as what it is.
316
+ if (isHttpUrlLike(g5)) {
317
+ return classifyInlineLink(full, [undefined, g4, repairUrlWhitespace(g5), undefined, undefined, undefined], ctx);
318
+ }
319
+ // Any OTHER scheme, or a fragment, is a link the user wrote and not a file
320
+ // we host. `mailto:`, `tel:` and `#section` were all being turned into
321
+ // download chips for storage paths of that literal text, so clicking one
322
+ // asked this project for a file called "mailto:a@b.com".
323
+ var trimmedTarget = g5.trim();
324
+ if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedTarget) || trimmedTarget.charAt(0) === '#') {
325
+ return {
326
+ part: { type: 'link', label: truncateLabelForDisplay(g4), fullLabel: g4, href: trimmedTarget, expired: false },
327
+ };
328
+ }
329
+ var built = asStoredFile(normalizeAttachmentPathCandidate(g5), g4);
330
+ if (!built) return null;
331
+ // The label is the model's, not the path: keep it verbatim.
332
+ built.part.label = truncateLabelForDisplay(g4);
333
+ built.part.fullLabel = g4;
334
+ return built;
335
+ }
336
+
337
+ // [label](url) and bare urls.
338
+ var originalHref = g3 || g6 || '';
339
+ if (!originalHref) return null;
340
+ // A bare url swallows the punctuation that ends the sentence it sits in, so
341
+ // `see https://host/a.pdf.` linked to `a.pdf.` and 404'd. Trim it and hand the
342
+ // trimmed text back as `tail`, exactly as the src:: branch does.
343
+ var urlTail: string | undefined;
344
+ if (!g3 && g6) {
345
+ var trimmedUrl = normalizeTrailingInlineToken(originalHref);
346
+ if (trimmedUrl !== originalHref) urlTail = originalHref.slice(trimmedUrl.length);
347
+ originalHref = trimmedUrl;
348
+ }
349
+ var withTail = function (r: { part: InlineLinkPart }): { part: InlineLinkPart; tail?: string } {
350
+ return urlTail ? { part: r.part, tail: urlTail } : r;
351
+ };
352
+ var urlLabel = g2 || originalHref;
353
+
354
+ // THE PLACEHOLDER, read back. sanitizeAttachmentLinksForHistory writes this
355
+ // form into stored history and buildBoundedChatMessages replays it to the
356
+ // model, so it arrives here constantly: as a rebuilt bubble on every reload,
357
+ // and as text the model copied out of its own context. It has to be checked
358
+ // BEFORE the generic https branch, because it IS https and it is NOT the db
359
+ // host, so that branch claimed it and rendered `expired: false` — a link to a
360
+ // hostname that does not resolve, with no way to ever refresh it. Every stored
361
+ // attachment link went dead on reload for exactly that reason.
362
+ var carried = readExpiredAttachmentHref(originalHref);
363
+ if (carried) {
364
+ var carriedBuilt = asStoredFile(carried, g2 || carried);
365
+ if (carriedBuilt) {
366
+ if (g2) { carriedBuilt.part.label = truncateLabelForDisplay(g2); carriedBuilt.part.fullLabel = g2; }
367
+ return withTail(carriedBuilt);
368
+ }
369
+ }
370
+
371
+ // This project's own db url: volatile, so render it re-mintable. A db url for
372
+ // a DIFFERENT project is not ours to mint, so it stays an ordinary link rather
373
+ // than a chip that would query this project for someone else's key.
374
+ if (isServiceDbAttachmentHref(originalHref, ctx.serviceId)) {
375
+ var remotePath = extractRemotePathFromAttachmentHref(originalHref, ctx.serviceId);
376
+ if (remotePath) {
377
+ var dbBuilt = asStoredFile(remotePath, getExpiredAttachmentVisiblePath(remotePath, urlLabel));
378
+ if (dbBuilt) return withTail(dbBuilt);
379
+ }
380
+ }
381
+
382
+ // Everything else is a link, not a path. The old rule tested for `https://`
383
+ // specifically and treated ANY other target as db storage, so a plain
384
+ // `http://` citation, a `mailto:`, a `#anchor` and a `/relative` link all
385
+ // rendered as download chips for storage paths that never existed, and
386
+ // clicking one raised "failed to refresh" on a file the user never had.
387
+ return withTail({
388
+ part: { type: 'link', label: truncateLabelForDisplay(urlLabel), fullLabel: urlLabel, href: originalHref, expired: false },
389
+ });
390
+ }
391
+
92
392
  export function truncateLabelForDisplay(label: string): string {
93
393
  if (!label) return label;
94
394
  if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
@@ -30,7 +30,7 @@ File attachments: When a user message contains an "Attached files:" section with
30
30
  - Image files (.jpg, .jpeg, .png, .gif, .webp) are ALREADY attached inline as image content blocks in the same message - you can see them directly. Do NOT call web_fetch on image URLs; that will fail or return garbage. Just look at the image block and answer.
31
31
  - Most attached files (office documents like .docx/.xlsx/.pptx/.hwp/.hwpx/.ods, and text/data/code files like .csv/.tsv/.json/.xml/.txt/.md and source code) have ALREADY had their text extracted on the server and inlined in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for those files. A "[skapi: ...]" note in that block means the file could not be extracted.
32
32
  - For any file given to you as a URL instead of inline content (e.g. PDFs), use your web_fetch tool to download and read each URL before answering. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
33
- File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix — do NOT show it. Format: [filename](path/to/file) for storage paths, or [filename](https://...) for external URLs. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand — so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
33
+ File links: When you find a record whose unique_id starts with "src::", the part after "src::" is the file's storage path or original URL. Always present it as a markdown link so the user can access it. Strip the "src::" prefix — do NOT show it. Format: [filename](db:path/to/file) for storage paths, or [filename](https://...) for external URLs. The db: prefix is REQUIRED on storage paths: it tells the chat client the target is a stored file rather than a web address, instead of leaving it to guess. Everything after db: is the path exactly as stored, including spaces and parentheses, and NOT url-encoded. Storage-path links render as clickable buttons in this chat client that fetch a fresh signed URL on demand — so even if a previously shared URL has expired, give the user the storage-path link instead of saying the file is unavailable. Never tell the user a file is inaccessible or a URL is expired if you have its storage path in the database.
34
34
  File lookup: When the user asks to see, list, or show files (e.g. "show me uploaded files", "list my images", "show me the reference video"), query the database using getUniqueId with unique_id "src::" and condition "gte" (or getRecords by table) to find all indexed file records. Present each result as a markdown link as described above. Never say you cannot access file storage — the file paths are indexed in the database and are always reachable through it.
35
35
  File generation: When the user asks you to generate a file — or to produce specifically-formatted text such as HTML, CSV, JSON, or Markdown — put the file's full contents inside a fenced code block whose info string is the intended filename WITH its extension (e.g. report.csv), NOT a language name like "csv". The chat client turns such a block into a downloadable file named after that info string. Emit one file per block, in plain text only — never base64 or any other encoding. Example for CSV:
36
36
  \`\`\`filename.csv
@@ -229,6 +229,10 @@ export function buildIndexingWindowMessage(
229
229
  `for tabular data (keyed by the column headers), or one record per section for prose. Capture every ` +
230
230
  `value you can read. Use the storage path above for the "src::" unique_id on the file-level record, ` +
231
231
  `and link every row/section record to it by reference.\n\n` +
232
+ `If this window has PHOTOS attached as images, LOOK at each one and datafy what it actually shows ` +
233
+ `into the record for the row it is anchored to (a «PHOTO A88» marker in the grid text only says WHERE ` +
234
+ `a picture sits - the picture itself is attached to this message). Never report that photo contents ` +
235
+ `could not be extracted when images are attached here.\n\n` +
232
236
  `Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest ` +
233
237
  `of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to ` +
234
238
  `you automatically. Report only what you were actually shown, and never imply you have seen the whole ` +
@@ -33,7 +33,7 @@ const OPENAI_WEB_SEARCH_EXTERNAL_WEB_ACCESS = true;
33
33
  export const MCP_NAME = 'BunnyQuery';
34
34
 
35
35
  export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6';
36
- export const DEFAULT_OPENAI_MODEL = 'gpt-5.4';
36
+ export const DEFAULT_OPENAI_MODEL = 'gpt-5.6-luna';
37
37
 
38
38
  const mcpUrl = () => chatEngineConfig().mcpBaseUrl;
39
39
  const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretRequest(opts);