bunnyquery 1.6.0 → 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.
- package/README.md +157 -29
- package/bunnyquery.css +122 -0
- package/bunnyquery.js +1400 -195
- package/dist/engine.cjs +1119 -117
- package/dist/engine.cjs.map +1 -1
- package/dist/engine.d.mts +517 -12
- package/dist/engine.d.ts +517 -12
- package/dist/engine.mjs +1103 -118
- package/dist/engine.mjs.map +1 -1
- package/package.json +1 -1
- package/src/engine/config.ts +19 -0
- package/src/engine/history.ts +20 -2
- package/src/engine/host.ts +47 -1
- package/src/engine/index.ts +25 -1
- package/src/engine/indexing_groups.ts +350 -0
- package/src/engine/links.ts +309 -9
- package/src/engine/office.ts +53 -5
- package/src/engine/prompts/chat_system_prompt.ts +1 -1
- package/src/engine/prompts/index.ts +3 -0
- package/src/engine/prompts/indexing_user_message.ts +110 -29
- package/src/engine/requests.ts +107 -20
- package/src/engine/session.ts +741 -82
- package/src/engine/viewport_fill.ts +186 -0
- package/styles/chat.css +122 -0
package/src/engine/links.ts
CHANGED
|
@@ -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()]
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
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 (
|
|
140
|
+
if (!isServiceDbAttachmentHref(href, serviceId)) return _m;
|
|
84
141
|
var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
|
|
85
|
-
var
|
|
86
|
-
|
|
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;
|
package/src/engine/office.ts
CHANGED
|
@@ -101,11 +101,32 @@ export function isServerExtractable(name?: string, mime?: string): boolean {
|
|
|
101
101
|
/** @deprecated renamed to {@link isServerExtractable} (now also covers text files). */
|
|
102
102
|
export const isOfficeFile = isServerExtractable;
|
|
103
103
|
|
|
104
|
-
// Extensions
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
104
|
+
// Extensions read WINDOW-BY-WINDOW via the readFileContent tool instead of inlined once.
|
|
105
|
+
//
|
|
106
|
+
// Anything NOT in this set falls back to one-shot server extraction, which is capped at
|
|
107
|
+
// MAX_EXTRACTED_CHARS (200k). Measured against real files, that cap was discarding the
|
|
108
|
+
// overwhelming majority of every large non-spreadsheet upload:
|
|
109
|
+
// 5MB .txt -> 4.0% indexed (4.8M characters silently dropped)
|
|
110
|
+
// 4.8MB .json-> 4.2%
|
|
111
|
+
// 1.9M-char Korean .txt -> 10.5%
|
|
112
|
+
// .docx -> 70.6%
|
|
113
|
+
// The truncation was invisible: the agent received a plausible-looking document and had
|
|
114
|
+
// no way to know most of it was missing. Windowing these types is what makes "index the
|
|
115
|
+
// whole file" true rather than aspirational.
|
|
116
|
+
//
|
|
117
|
+
// CSV/TSV specifically must be here rather than in the inline path: the layer now gives
|
|
118
|
+
// them ROW-bounded windows with absolute row numbers, where the character windower used
|
|
119
|
+
// to split rows across boundaries and emit no row numbers at all.
|
|
120
|
+
const PAGED_READ_EXTENSIONS = new Set([
|
|
121
|
+
// grids
|
|
122
|
+
'xls', 'xlsx', 'xlsm', 'ods',
|
|
123
|
+
// delimited text (row-windowed by the layer)
|
|
124
|
+
'csv', 'tsv', 'tab',
|
|
125
|
+
// documents
|
|
126
|
+
'pdf', 'docx', 'pptx',
|
|
127
|
+
// plain text / data / markup
|
|
128
|
+
'txt', 'md', 'markdown', 'log', 'json', 'jsonl', 'ndjson', 'xml', 'yaml', 'yml',
|
|
129
|
+
]);
|
|
109
130
|
|
|
110
131
|
/**
|
|
111
132
|
* True when a file should be indexed by PAGING through readFileContent (spreadsheets and
|
|
@@ -160,6 +181,33 @@ export function makeRenderPlaceholder(seed: string): string {
|
|
|
160
181
|
// resume window (from = pass * PAGES) lines up with what the worker renders.
|
|
161
182
|
export const RENDER_PAGES_PER_WINDOW = 5;
|
|
162
183
|
|
|
184
|
+
// Token the WORKER substitutes with a human description of the next window's position.
|
|
185
|
+
// Shared with the render loop so one substitution path serves both.
|
|
186
|
+
export const WINDOW_CURSOR_TOKEN = '{{RENDER_FROM}}';
|
|
187
|
+
|
|
188
|
+
let _windowPlaceholderSeq = 0;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Placeholder the worker replaces with ONE window of a file's text/grid content.
|
|
192
|
+
* Distinct token from the render (page-image) and extract (whole-file) placeholders so
|
|
193
|
+
* a stale token from either can never be mistaken for this one.
|
|
194
|
+
*/
|
|
195
|
+
export function makeWindowPlaceholder(seed: string): string {
|
|
196
|
+
_windowPlaceholderSeq += 1;
|
|
197
|
+
const slug = (seed || 'file').replace(/[^a-zA-Z0-9]+/g, '_').slice(-48);
|
|
198
|
+
return `{{SKAPI_WINDOW::${slug}-${_windowPlaceholderSeq}}}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* True when a file should be read server-side, one window at a time, by the worker.
|
|
203
|
+
* PDFs are excluded: they go through the VISION path, where pages are rendered to
|
|
204
|
+
* images because their text layer is often absent or unreliable.
|
|
205
|
+
*/
|
|
206
|
+
export function isWindowedReadFile(name?: string, mime?: string): boolean {
|
|
207
|
+
if (isImageVisionFile(name, mime)) return false;
|
|
208
|
+
return isPagedReadFile(name, mime);
|
|
209
|
+
}
|
|
210
|
+
|
|
163
211
|
export interface ComposedUserMessage {
|
|
164
212
|
/** Clean display/history copy (attachment links, NO extraction placeholders). */
|
|
165
213
|
composed: string;
|
|
@@ -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
|
|
@@ -11,6 +11,9 @@ export {
|
|
|
11
11
|
buildIndexingUserMessage,
|
|
12
12
|
buildIndexingContinueMessage,
|
|
13
13
|
buildIndexingRenderMessage,
|
|
14
|
+
buildIndexingRenderContinueTemplate,
|
|
15
|
+
buildIndexingWindowMessage,
|
|
16
|
+
RENDER_FROM_TOKEN,
|
|
14
17
|
type IndexingAttachmentInfo,
|
|
15
18
|
type BuildIndexingUserMessageOptions,
|
|
16
19
|
} from './indexing_user_message';
|
|
@@ -101,16 +101,29 @@ export function buildIndexingUserMessage(
|
|
|
101
101
|
return head + `- temporary URL (fetch this to read the file contents): ${attachment.url}`;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Token the WORKER substitutes with the 1-based first page of the window it is about to
|
|
106
|
+
* render, when it builds the next pass of a document from `RENDER_CONTINUE_TEMPLATE`.
|
|
107
|
+
* Must match the worker's RENDER_FROM_TOKEN.
|
|
108
|
+
*/
|
|
109
|
+
export const RENDER_FROM_TOKEN = '{{RENDER_FROM}}';
|
|
110
|
+
const WINDOW_CURSOR_TOKEN = RENDER_FROM_TOKEN;
|
|
111
|
+
|
|
104
112
|
/**
|
|
105
113
|
* User message for a VISION file (PDF): its pages are delivered as RENDERED PAGE IMAGES that
|
|
106
114
|
* the proxy worker injects into THIS message at the `placeholder` token (tool-result images
|
|
107
115
|
* render on neither provider, so the pages must be image blocks in the message itself). Each
|
|
108
|
-
* pass shows one WINDOW of pages starting at `renderFrom` (0-based)
|
|
109
|
-
*
|
|
116
|
+
* pass shows one WINDOW of pages starting at `renderFrom` (0-based).
|
|
117
|
+
*
|
|
118
|
+
* The WORKER advances the window: when its renderer reports pages remaining it enqueues the
|
|
119
|
+
* next pass itself, off the true page count, so a document indexes end-to-end with no browser
|
|
120
|
+
* involved. This message therefore only ever describes ONE window, and the model is never
|
|
121
|
+
* asked to decide whether the document is finished.
|
|
110
122
|
*
|
|
111
123
|
* renderFrom === 0 is the FIRST pass (leads with "A new file has just been uploaded." so the
|
|
112
|
-
* client builds the "Indexing: <name>" bubble);
|
|
113
|
-
* "CONTINUE indexing"
|
|
124
|
+
* client builds the "Indexing: <name>" bubble); a continue pass (built by the worker from
|
|
125
|
+
* buildIndexingRenderContinueTemplate) leads with "CONTINUE indexing" so it is not a duplicate
|
|
126
|
+
* primary bubble.
|
|
114
127
|
*/
|
|
115
128
|
export function buildIndexingRenderMessage(
|
|
116
129
|
attachment: IndexingAttachmentInfo,
|
|
@@ -118,44 +131,112 @@ export function buildIndexingRenderMessage(
|
|
|
118
131
|
renderFrom: number,
|
|
119
132
|
): string {
|
|
120
133
|
const from = Math.max(0, renderFrom || 0);
|
|
134
|
+
if (from > 0) return buildIndexingRenderContinueTemplate(attachment, placeholder, String(from + 1));
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
`A new file has just been uploaded. Index it now.\n\n` +
|
|
138
|
+
buildRenderMeta(attachment) +
|
|
139
|
+
`\nThis is a PDF. Its pages are delivered to you as RENDERED PAGE IMAGES embedded directly in this ` +
|
|
140
|
+
`message (you do NOT need any tool, URL, or web_fetch to see them). You are shown a WINDOW of pages ` +
|
|
141
|
+
`at a time, starting at page ${from + 1}.\n` +
|
|
142
|
+
buildRenderDatafy(placeholder)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The CONTINUE pass, as a template the worker fills in. `pageLabel` defaults to the
|
|
148
|
+
* RENDER_FROM_TOKEN placeholder, which the worker replaces with the real 1-based start page
|
|
149
|
+
* of the window it is rendering; passing an explicit label produces a ready-to-send message.
|
|
150
|
+
*/
|
|
151
|
+
export function buildIndexingRenderContinueTemplate(
|
|
152
|
+
attachment: IndexingAttachmentInfo,
|
|
153
|
+
placeholder: string,
|
|
154
|
+
pageLabel: string = RENDER_FROM_TOKEN,
|
|
155
|
+
): string {
|
|
121
156
|
const src = `src::${attachment.storagePath}`;
|
|
122
|
-
|
|
157
|
+
return (
|
|
158
|
+
`CONTINUE indexing a PDF whose previous pass did not finish.\n\n` +
|
|
159
|
+
buildRenderMeta(attachment) +
|
|
160
|
+
`\nRecords for the earlier pages are ALREADY saved (they reference "${src}"). The NEXT window of ` +
|
|
161
|
+
`rendered page images (starting at page ${pageLabel}) is embedded in this message. Datafy each page as ` +
|
|
162
|
+
`before and do NOT re-save pages that are already saved.\n` +
|
|
163
|
+
buildRenderDatafy(placeholder)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildRenderMeta(attachment: IndexingAttachmentInfo): string {
|
|
168
|
+
return (
|
|
123
169
|
`File metadata:\n` +
|
|
124
170
|
`- name: ${attachment.name}\n` +
|
|
125
171
|
`- storage path: ${attachment.storagePath}\n` +
|
|
126
|
-
(attachment.mime ? `- mime type: ${attachment.mime}\n` : '')
|
|
172
|
+
(attachment.mime ? `- mime type: ${attachment.mime}\n` : '')
|
|
173
|
+
);
|
|
174
|
+
}
|
|
127
175
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
176
|
+
// Shared datafy guidance. The placeholder is where the worker splices the note + rendered
|
|
177
|
+
// page images; instructions reference "the page images in this message" so they read
|
|
178
|
+
// correctly whether the images land before or after this text.
|
|
179
|
+
//
|
|
180
|
+
// Deliberately says nothing about INDEXING_COMPLETE or about whether the document is
|
|
181
|
+
// finished: the worker decides that from the renderer's page count. Asking the model was
|
|
182
|
+
// what used to end an 88-page file at page 15.
|
|
183
|
+
function buildRenderDatafy(placeholder: string): string {
|
|
184
|
+
return (
|
|
132
185
|
`\n${placeholder}\n\n` +
|
|
133
186
|
`LOOK at each rendered page image in this message and DATAFY what it shows: for EVERY page ` +
|
|
134
187
|
`call postRecords and save records - one record per row / table entry / line item visible on the page ` +
|
|
135
188
|
`(or one record for the page if it is prose), capturing every value you can read (OCR the text, read tables ` +
|
|
136
189
|
`cell by cell, describe any photos/diagrams). Use the storage path above for the "src::" unique_id.\n\n` +
|
|
137
|
-
`
|
|
138
|
-
`
|
|
139
|
-
`
|
|
190
|
+
`Save records for THIS window of pages only, then stop and report what you saved. Do NOT try to read ` +
|
|
191
|
+
`the rest of the file and do NOT worry about the pages after this window: if any remain, the next window ` +
|
|
192
|
+
`is rendered and sent to you automatically. Report only the pages you were actually shown - never imply ` +
|
|
193
|
+
`you have seen the whole document.`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
140
196
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
197
|
+
/**
|
|
198
|
+
* User message for a WINDOWED file: the worker splices ONE window of the file's rows or
|
|
199
|
+
* text into this message at `placeholder`, then continues from the reader's own cursor
|
|
200
|
+
* until the file is exhausted.
|
|
201
|
+
*
|
|
202
|
+
* The agent is deliberately NOT asked to page the file itself, and is NOT asked to judge
|
|
203
|
+
* whether it is finished. Both used to be its job, and both failed the same way: the
|
|
204
|
+
* traversal lived inside a single turn's budget, so a large file simply stopped partway
|
|
205
|
+
* with a confident summary of the part it had seen.
|
|
206
|
+
*/
|
|
207
|
+
export function buildIndexingWindowMessage(
|
|
208
|
+
attachment: IndexingAttachmentInfo,
|
|
209
|
+
placeholder: string,
|
|
210
|
+
isContinuation: boolean,
|
|
211
|
+
positionLabel?: string,
|
|
212
|
+
): string {
|
|
213
|
+
const src = `src::${attachment.storagePath}`;
|
|
214
|
+
const head = isContinuation
|
|
215
|
+
? `CONTINUE indexing a file whose previous pass did not finish.\n\n`
|
|
216
|
+
: `A new file has just been uploaded. Index it now.\n\n`;
|
|
217
|
+
const where = isContinuation
|
|
218
|
+
? `\nRecords for the earlier windows are ALREADY saved (they reference "${src}"). The NEXT window ` +
|
|
219
|
+
`(starting at ${positionLabel || WINDOW_CURSOR_TOKEN}) is embedded below. Do NOT re-save windows that are already saved.\n`
|
|
220
|
+
: `\nThis file is delivered to you ONE WINDOW at a time, embedded directly in this message. ` +
|
|
221
|
+
`You do NOT need any tool, URL, or web_fetch to read it.\n`;
|
|
151
222
|
|
|
152
223
|
return (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
`
|
|
158
|
-
|
|
224
|
+
head +
|
|
225
|
+
buildRenderMeta(attachment) +
|
|
226
|
+
where +
|
|
227
|
+
`\n${placeholder}\n\n` +
|
|
228
|
+
`DATAFY this window: call postRecords and save records for everything in it - ONE RECORD PER ROW ` +
|
|
229
|
+
`for tabular data (keyed by the column headers), or one record per section for prose. Capture every ` +
|
|
230
|
+
`value you can read. Use the storage path above for the "src::" unique_id on the file-level record, ` +
|
|
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` +
|
|
236
|
+
`Save records for THIS window only, then stop and report what you saved. Do NOT try to read the rest ` +
|
|
237
|
+
`of the file, and do NOT call readFileContent - if more remains, the next window is read and sent to ` +
|
|
238
|
+
`you automatically. Report only what you were actually shown, and never imply you have seen the whole ` +
|
|
239
|
+
`file when the note beside the window says more remains.`
|
|
159
240
|
);
|
|
160
241
|
}
|
|
161
242
|
|