bunnyquery 1.9.0 → 1.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunnyquery",
3
- "version": "1.9.0",
3
+ "version": "1.9.4",
4
4
  "description": "Embeddable BunnyQuery AI chat widget + its framework-agnostic chat engine",
5
5
  "main": "bunnyquery.js",
6
6
  "exports": {
@@ -20,6 +20,19 @@ export interface ChatIdentity {
20
20
  owner: string;
21
21
  /** Per-user queue name (falls back to projectId). */
22
22
  userId: string;
23
+ /**
24
+ * This chat is being used by a visitor with NO account, on a project whose
25
+ * owner allows that.
26
+ *
27
+ * It changes where the MCP tools point. A signed-in turn goes to the MCP
28
+ * server's root endpoint and authenticates with the caller's own token; an
29
+ * anonymous turn has no token to send, and an EMPTY one is worse than none
30
+ * (the server cannot identify a project from it, and an empty credential may
31
+ * be rejected by the provider before the request is even made). So an
32
+ * anonymous turn goes to the project-scoped endpoint instead, which is
33
+ * read-only, restricted to public records, and needs no credential at all.
34
+ */
35
+ anonymous?: boolean;
23
36
  platform: 'claude' | 'openai' | 'none';
24
37
  model?: string;
25
38
  serviceName?: string;
@@ -250,6 +263,22 @@ export interface ChatHost {
250
263
  * whichever pass got there first created the record and the others hoped it had.
251
264
  */
252
265
  ensureFileIndexRecord?(storagePath: string, meta?: { name?: string; mime?: string; size?: number }): Promise<any>;
266
+ /**
267
+ * The access group this file's records must be written at: the uploader's
268
+ * choice, which is the project default or a per-upload answer.
269
+ *
270
+ * Asked PER FILE, and asked AFTER ensureFileIndexRecord has run, because the
271
+ * host is what actually creates the "src::" record and it must report the
272
+ * group it really used. The engine threads the answer into the indexing
273
+ * prompts so the agent's own records land in the same group; a record saved
274
+ * under a different group is in a different table and never comes back with
275
+ * the rest of the file.
276
+ *
277
+ * Optional and may return a promise. A host without it (or one that returns
278
+ * nothing) gets "authorized", which is what every record used before the
279
+ * setting existed.
280
+ */
281
+ uploadAccessGroup?(storagePath: string): 'public' | 'authorized' | 'private' | undefined | Promise<'public' | 'authorized' | 'private' | undefined>;
253
282
  /** Map a relative path to the consumer's db storage key (e.g. uid-prefixed). */
254
283
  storagePathFor(relPath: string): string;
255
284
  getMimeType(name: string): string | null;
@@ -289,6 +289,23 @@ export type RunStubInfo = {
289
289
  platform?: 'claude' | 'openai';
290
290
  };
291
291
 
292
+ /** Canonical form of an indexing key for COMPARISON only (never for storage).
293
+ *
294
+ * The two sides of every stub-vs-group comparison come from one string but by two
295
+ * routes: the run:: record id keeps `attachment.storagePath` verbatim
296
+ * (requests.ts runIndexUniqueId), while the group's key is recovered from the pass
297
+ * prompt and trimmed on the way (history.ts parseIndexingRequestText). A path whose
298
+ * first or last character is whitespace therefore produced two keys for one file, the
299
+ * stub was never suppressed, and the file drew two rows.
300
+ *
301
+ * Trim only. NOT NFC: both keys derive from the same string with no normalization on
302
+ * either side, so no NFC/NFD divergence is reachable here, and normalizing a key that
303
+ * is also an S3 object key elsewhere is how storage paths get silently renamed.
304
+ */
305
+ export function canonIndexKey(s?: string): string {
306
+ return typeof s === 'string' && s ? s.trim() : '';
307
+ }
308
+
292
309
  /** A 'working' run record older than this with no live-queue confirmation is
293
310
  * treated as unknown rather than live: a chain that died without reaching any
294
311
  * error path leaves 'working' dangling, and a row must not spin forever on a
@@ -413,6 +430,10 @@ export function buildChatDisplayList(
413
430
  var openRunOfKey: { [key: string]: string } = {};
414
431
  var runsOfKey: { [key: string]: string[] } = {};
415
432
  var keyOfRun: { [runId: string]: string } = {};
433
+ // Newest pass timestamp seen for each open run, so a FIRST pass can be told from a
434
+ // re-index (it arrives AFTER the run it follows) apart from a misordered older pass
435
+ // (it arrives before). Only the second must not re-open.
436
+ var newestTsOfRun: { [runId: string]: number } = {};
416
437
  var runSeq = 0;
417
438
 
418
439
  for (var i = 0; i < list.length; i++) {
@@ -421,7 +442,18 @@ export function buildChatDisplayList(
421
442
 
422
443
  var runId: string | undefined;
423
444
  var ref = msg.role === 'user' ? readFileRef(msg) : null;
424
- if (ref) {
445
+ // A pass already attributed to a run stays with it, whatever its label says.
446
+ // Reading the label FIRST made attribution depend on walk order: a first pass
447
+ // ("A new file has just been uploaded") re-opened its file's run at the line
448
+ // below, so if history delivered a run's continuations before its first pass -
449
+ // which paging and the bg merge can do - the continuations opened a run, the
450
+ // late first pass deleted it and opened a second, and the abandoned one was
451
+ // force-finished. One run then rendered as TWO rows for the same file: a green
452
+ // "complete" and a yellow "in progress". The same applies to a duplicate copy
453
+ // of a pass. The id is the stable identity here; the label is a description.
454
+ if (msg._serverItemId && runByItemId[msg._serverItemId]) {
455
+ runId = runByItemId[msg._serverItemId];
456
+ } else if (ref) {
425
457
  // Path first — a file can be re-uploaded under a name that already
426
458
  // exists elsewhere. But a pass that supplied no path (a compact
427
459
  // continuation label recovered from an old history cache) is still the
@@ -431,7 +463,24 @@ export function buildChatDisplayList(
431
463
  // A FIRST pass ("A new file has just been uploaded") when this file
432
464
  // already has a run open starts a new one: it is a re-index, or a
433
465
  // re-upload over the same storage path. Continuations join the run.
434
- if (!ref.continued && openRunOfKey[key]) delete openRunOfKey[key];
466
+ //
467
+ // ...but only when this pass is genuinely NEW. A first pass whose id is
468
+ // already attributed is the same pass reached twice (a duplicate bubble, or
469
+ // a walk that reaches it after the continuations it opened), and re-opening
470
+ // on it splits one run in half.
471
+ var alreadySeen = !!(msg._serverItemId && runByItemId[msg._serverItemId]);
472
+ // ...and only when it is actually LATER than the run it would replace. A
473
+ // re-index's first pass follows the previous run; a misordered one precedes
474
+ // the continuations already walked, and re-opening on it abandons a live run
475
+ // (rendered green "complete") beside the fragment (yellow "in progress").
476
+ // Timestamps decide it when both are known; without them the old
477
+ // label-only behaviour stands, so the re-index case is unchanged.
478
+ var openId = openRunOfKey[key];
479
+ var notLater = false;
480
+ if (openId && typeof msg._ts === 'number' && typeof newestTsOfRun[openId] === 'number') {
481
+ notLater = (msg._ts as number) <= newestTsOfRun[openId];
482
+ }
483
+ if (!ref.continued && !alreadySeen && !notLater && openRunOfKey[key]) delete openRunOfKey[key];
435
484
  runId = openRunOfKey[key];
436
485
  if (!runId) {
437
486
  runId = 'run' + (runSeq++);
@@ -439,8 +488,6 @@ export function buildChatDisplayList(
439
488
  keyOfRun[runId] = key;
440
489
  (runsOfKey[key] || (runsOfKey[key] = [])).push(runId);
441
490
  }
442
- } else if (msg._serverItemId && runByItemId[msg._serverItemId]) {
443
- runId = runByItemId[msg._serverItemId];
444
491
  } else if (msg.role !== 'user') {
445
492
  // ADJACENT only. Both paths that create a pass emit its request and
446
493
  // response bubbles together, so an id-less response belongs to the
@@ -455,6 +502,10 @@ export function buildChatDisplayList(
455
502
  // or a response whose request is not loaded) stays an ordinary message
456
503
  // rather than being folded into whichever group happens to be nearest.
457
504
  if (!runId) continue;
505
+ if (typeof msg._ts === 'number') {
506
+ var prevTs = newestTsOfRun[runId];
507
+ if (typeof prevTs !== 'number' || (msg._ts as number) > prevTs) newestTsOfRun[runId] = msg._ts as number;
508
+ }
458
509
 
459
510
  var g = groups[runId];
460
511
  if (!g) {
@@ -780,16 +831,18 @@ export function buildChatDisplayList(
780
831
  var coveredPathlessNames: { [k: string]: boolean } = {};
781
832
  for (var ci = 0; ci < order.length; ci++) {
782
833
  var cg = groups[order[ci]];
783
- if (cg.path) { coveredPaths[cg.path] = true; if (cg.key) coveredPaths[cg.key] = true; }
784
- else if (cg.name) coveredPathlessNames[cg.name] = true;
785
- else if (cg.key) coveredPaths[cg.key] = true;
834
+ if (cg.path) {
835
+ coveredPaths[canonIndexKey(cg.path)] = true;
836
+ if (cg.key) coveredPaths[canonIndexKey(cg.key)] = true;
837
+ } else if (cg.name) coveredPathlessNames[canonIndexKey(cg.name)] = true;
838
+ else if (cg.key) coveredPaths[canonIndexKey(cg.key)] = true;
786
839
  }
787
840
  var now = opts && typeof opts.now === 'number' ? opts.now : Date.now();
788
841
  var stubClearedAt = (opts && typeof opts.stubClearedAt === 'number' && opts.stubClearedAt > 0)
789
842
  ? opts.stubClearedAt : 0;
790
843
  for (var sp in runStubs) {
791
844
  var rec = runStubs[sp];
792
- if (!sp || !rec || !rec.status || coveredPaths[sp]) continue;
845
+ if (!sp || !rec || !rec.status || coveredPaths[canonIndexKey(sp)]) continue;
793
846
  var fname = rec.filename || sp.split('/').pop() || sp;
794
847
  // A PATHLESS group (legacy compact label) can only be matched by
795
848
  // name, and that is still the right match: the file already has a
@@ -797,7 +850,7 @@ export function buildChatDisplayList(
797
850
  // void. What is gone is the reverse — a group WITH a path no longer
798
851
  // writes into the name namespace, so it can no longer delete a
799
852
  // same-basename stub from another folder.
800
- if (coveredPathlessNames[fname]) continue;
853
+ if (coveredPathlessNames[canonIndexKey(fname)]) continue;
801
854
  // A run recorded under the OTHER platform's chat belongs to that
802
855
  // conversation: its passes live in that history (so no real group
803
856
  // can ever cover this stub) and the queue probe is platform-scoped
@@ -808,7 +861,26 @@ export function buildChatDisplayList(
808
861
  // whatever the record says (a lagged update must not paint a verdict over
809
862
  // visible work), then the record's own terminal statuses, then the
810
863
  // queue's authoritative ABSENCE, and only then the stated wait.
811
- var live = !!liveIndexKeys[sp] || !!liveIndexKeys[fname];
864
+ // The bare-NAME disjunct is load-bearing: a legacy pass prompt may carry no
865
+ // storage path at all, so the name is the only key its run can appear under.
866
+ // But it also matched ANY live file sharing this basename, painting a
867
+ // finished run's stub yellow because a same-named file in another folder was
868
+ // indexing. Take the name hit only when it cannot belong to a different
869
+ // file - i.e. no OTHER live key is a path ending in this basename.
870
+ var live = !!liveIndexKeys[sp] || !!liveIndexKeys[canonIndexKey(sp)];
871
+ if (!live && (liveIndexKeys[fname] || liveIndexKeys[canonIndexKey(fname)])) {
872
+ var claimedByOther = false;
873
+ for (var lk in liveIndexKeys) {
874
+ if (!liveIndexKeys[lk]) continue;
875
+ var lkc = canonIndexKey(lk);
876
+ if (lkc === canonIndexKey(sp)) continue;
877
+ if (lkc.length > fname.length && lkc.slice(-(fname.length + 1)) === '/' + fname) {
878
+ claimedByOther = true;
879
+ break;
880
+ }
881
+ }
882
+ live = !claimedByOther;
883
+ }
812
884
  // Cleared-history horizon: a run that ended at or before the clear is
813
885
  // part of what the user asked to forget. A live queue hit survives it
814
886
  // (see BuildDisplayListOptions.stubClearedAt). A record carrying NO
@@ -825,7 +897,8 @@ export function buildChatDisplayList(
825
897
  // A done:: marker from the same sweep is as terminal as the record's
826
898
  // own 'done' — it settles a dangling 'working' the chain never
827
899
  // flipped (mirrors the real-group doneKeys disjunct).
828
- if (rec.status === 'done' || doneKeys[sp] || doneKeys[fname]) { st = 'done'; fin = true; }
900
+ if (rec.status === 'done' || doneKeys[sp] || doneKeys[canonIndexKey(sp)]
901
+ || doneKeys[fname] || doneKeys[canonIndexKey(fname)]) { st = 'done'; fin = true; }
829
902
  else if (rec.status === 'error') { st = 'error'; fin = true; }
830
903
  else if (rec.status === 'cancelled') { st = 'cancelled'; fin = true; }
831
904
  else if (liveIndexChecked) {
@@ -903,11 +976,14 @@ export function buildChatDisplayList(
903
976
  // the first pass paged in, and again when paging ended. A record with a
904
977
  // `started` places the run at ONE spot for as long as the record exists.
905
978
  var suppressAnchor: { [runId: string]: boolean } = {};
979
+ var stubByCanon: { [k: string]: RunStubInfo } = {};
980
+ if (runStubs) for (var ck in runStubs) stubByCanon[canonIndexKey(ck)] = runStubs[ck];
906
981
  if (runStubs) {
907
982
  for (var ti2 = 0; ti2 < order.length; ti2++) {
908
983
  var tg = groups[order[ti2]];
909
984
  if (!newestRunOfKey[order[ti2]]) continue;
910
- var trec = (tg.path && runStubs[tg.path]) || runStubs[tg.key];
985
+ var trec = (tg.path && (runStubs[tg.path] || stubByCanon[canonIndexKey(tg.path)]))
986
+ || runStubs[tg.key] || stubByCanon[canonIndexKey(tg.key)];
911
987
  if (!trec || typeof trec.started !== 'number') continue;
912
988
  if (stubPlatform && trec.platform && trec.platform !== stubPlatform) continue;
913
989
  suppressAnchor[order[ti2]] = true;
@@ -36,16 +36,35 @@ export type ChatSystemPromptParams = {
36
36
  * given when the caller says which one this is.
37
37
  */
38
38
  client?: 'console' | 'widget';
39
+ /**
40
+ * The access group THIS project's indexer writes its records at, from the
41
+ * project's `default_access_group` setting.
42
+ *
43
+ * The MCP auto-fills an index/tag query that names a table but no group with
44
+ * "authorized", which used to be right because every BunnyQuery record was
45
+ * hardcoded to it. Now a project can index at "public" (so an anonymous
46
+ * visitor can read it) or "private", and on those projects the auto-fill
47
+ * silently searches a group the data is not in and answers "nothing found".
48
+ * Defaults to 'authorized', which is what an unset project still uses.
49
+ */
50
+ indexAccessGroup?: string;
39
51
  };
40
52
 
41
53
  export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
42
54
  const { projectId, serviceName, serviceDescription, greeting, canUpload, client } = params;
55
+ // Rendered as the model would have to WRITE it in a tool call: the named
56
+ // aliases go in quotes, a raw group number does not.
57
+ const g = params.indexAccessGroup;
58
+ const indexGroupLiteral =
59
+ typeof g === 'number' ? String(g)
60
+ : (g === 'public' || g === 'private' || g === 'authorized' || g === 'admin') ? `"${g}"`
61
+ : '"authorized"';
43
62
 
44
63
  let systemPrompt = `
45
64
  You are a dedicated assistant for the project ID: "${projectId}".
46
65
  Scope: Only answer questions about this project and its data. Do not answer questions about other projects or topics unrelated to this project. When the user refers to "my database", "my data", or "my files", treat those as references to this project's database and file storage. The ONE exception is BunnyQuery itself - what this app is, what it can do, and how to use it - which is always in scope: answer it from the "About BunnyQuery" section at the end of this prompt.
47
66
  Knowledge lookup: Before saying you don't know or that something isn't in the chat history, ALWAYS query this project's database through the available MCP tools to look for the answer. The user's data is the source of truth - the chat transcript is not. Only respond with "I don't know" or "I couldn't find that" after you have actually searched the project's data and come back empty.
48
- Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized" (where the indexer writes; pass access_group explicitly, including 0, to search another group), while an index or tag WITHOUT table_name FAILS with an error instead of answering, so read the error rather than guessing. Reference is the exception: reference ALONE spans EVERY table and EVERY access group, so getRecords with reference "src::<the file's storage path>" is the one call that returns a whole file's records wherever the indexer put them. Adding table_name narrows it to that table; access_group WITHOUT table_name fails with '"table" is required'; table_name on its own returns that whole table across all access groups. For anything NOT scoped to a single file, call getTables FIRST, run the query once per table that could hold the answer, and combine the results. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "없어?", "하나도 없나?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset. If you already answered from one table and then realise another table holds more, do not simply apologise: re-run the sweep and give the complete answer.
67
+ Complete answers over stored data: The database holds one record per spreadsheet row, and each uploaded file becomes many records. ONE file is routinely SPLIT ACROSS SEVERAL TABLES - a summary row in one table, its page or row content in another, its extracted photos and other media in "__MEDIA__", and the indexer often invents a differently-named table on each pass. An index or tag filter matches inside ONE table only and requires table_name: on getRecords, an index or tag sent with table_name but no access_group is auto-filled with access_group "authorized", but THIS project indexes at access_group ${indexGroupLiteral}, so pass access_group ${indexGroupLiteral} EXPLICITLY on every index or tag query here - the auto-fill would search a group this project's data is not in and come back empty. Files uploaded before the project's setting changed may sit at another group, so when a scoped query comes back empty, retry it across the other groups (0, 1, "private") before concluding there is nothing, while an index or tag WITHOUT table_name FAILS with an error instead of answering, so read the error rather than guessing. Reference is the exception: reference ALONE spans EVERY table and EVERY access group, so getRecords with reference "src::<the file's storage path>" is the one call that returns a whole file's records wherever the indexer put them. Adding table_name narrows it to that table; access_group WITHOUT table_name fails with '"table" is required'; table_name on its own returns that whole table across all access groups. For anything NOT scoped to a single file, call getTables FIRST, run the query once per table that could hold the answer, and combine the results. For any request that counts, sums, totals, lists every match, compares across records, finds which one, or asks whether something is present or ABSENT (for example "how many", "total spent", "which card", "is there any", "없어?", "하나도 없나?"), you MUST read the COMPLETE matching set before answering. Query with fetch_all set to true, or page through getToolResponsePage until pagination.complete is true, across EVERY table and EVERY relevant file. A single default query returns only the first page (about 50 records). That is a SAMPLE. Never treat it as the whole dataset. If you already answered from one table and then realise another table holds more, do not simply apologise: re-run the sweep and give the complete answer.
49
68
  Never assert absence from a partial read. Do not say "there is no X", "none", "not found", or "아니요, 없습니다" until a complete scan has come back empty. If you have not finished scanning every relevant table and file, keep querying instead of guessing. A confident "no" that later turns out wrong is worse than telling the user you are still checking.
50
69
  Embedded values: a search term is often stored inside a larger string. A merchant "BAKSA" appears as "DNH*BAKSA#4070277042", and a card as "5860****5173". Server-side index filters match only exact values, leading prefixes, or trailing suffixes, and tag filters only EXACT whole-tag values - never a partial or interior substring - so filtering on such a field silently drops rows. When the value you are looking for may be embedded, do not trust a narrow filter to be complete. Fetch the full set with fetch_all and match the substring yourself.
51
70
  File attachments: When a user message contains an "Attached files:" section with markdown links, those links point to short-lived signed URLs in this project's db storage and will expire.
@@ -9,6 +9,7 @@ export { buildChatSystemPrompt, type ChatSystemPromptParams } from './chat_syste
9
9
  export { buildIndexingSystemPrompt, type IndexingSystemPromptParams } from './indexing_system_prompt';
10
10
  export {
11
11
  buildIndexingUserMessage,
12
+ indexingAccessGroup,
12
13
  buildIndexingContinueMessage,
13
14
  buildIndexingRenderMessage,
14
15
  buildIndexingRenderContinueTemplate,
@@ -14,10 +14,21 @@ export type IndexingSystemPromptParams = {
14
14
  serviceName?: string;
15
15
  /** Project description. When present, name + description are appended. */
16
16
  serviceDescription?: string;
17
+ /**
18
+ * Access group every record written during this run must carry. Chosen by the
19
+ * uploader (project default, or a per-upload prompt) and already applied to
20
+ * the "src::" file record before indexing starts. Defaults to "authorized",
21
+ * which is what every record written before this setting existed used.
22
+ */
23
+ accessGroup?: 'public' | 'authorized' | 'private';
17
24
  };
18
25
 
19
26
  export function buildIndexingSystemPrompt(params: IndexingSystemPromptParams): string {
20
27
  const { projectId, serviceName, serviceDescription } = params;
28
+ const accessGroup =
29
+ params.accessGroup === 'public' || params.accessGroup === 'private'
30
+ ? params.accessGroup
31
+ : 'authorized';
21
32
 
22
33
  let systemPrompt =
23
34
  `You are a background indexing agent for project ${projectId}.
@@ -28,7 +39,8 @@ export function buildIndexingSystemPrompt(params: IndexingSystemPromptParams): s
28
39
  - VISION: when the message (a readFileContent window, an embedded PDF page, or an inline attachment) includes IMAGES - scanned/rendered PDF pages, or photos embedded in a spreadsheet next to a row/block - LOOK at them and capture what they show as record data (the reading/values in a scanned table, the part/defect/condition visible in a photo). The image IS part of the data; correlate each photo with its labelled block ("PHOTO A3" markers tie a photo to that grid row).
29
40
  - TRANSCRIBE, DO NOT DESCRIBE. When an image contains ANY text - a label, tag, stamp, form field, serial/part number, handwriting - your FIRST job is to read the characters out and store them VERBATIM, not to describe the scene. A record saying "a red inspection tag with handwritten markings" is worthless: it is unsearchable and every such photo produces the same sentence. Put the characters you can actually read into these EXACT fields, not variations of them: "printed_text" (the pre-printed wording), "handwritten_text" (what a person wrote by hand), and, when you can resolve one, "part_no", "tag_id" and "date". Same reason as the fixed table names: a field called photo_text in one pass and visible_text_notes in the next cannot be queried together. Read PARTIAL values rather than skipping: "500.7402.52__" beats nothing. Only when a character is genuinely unreadable, leave that field null or mark the unreadable span - do NOT invent it, and do NOT replace the whole transcription with a description of what the object looks like. A scene description is a nice extra AFTER the text, never instead of it.
30
41
  - IMAGE FILES uploaded as the file itself: if ANY readable character appears ANYWHERE in the image (a label, a stamp, a sign in the background) it counts as an image WITH text - transcribe it per the rule above, and also capture the layout (what appears where) and every entity named. Only a truly text-free image gets description first: a one-line caption, then the objects present with their attributes (type, color, count, condition, position). Either way, save what you extract onto the file's "src::" record with updateRecords, TAG every entity and identifier visible, and INDEX the one number the image offers (a measured value, an amount, a count).
31
- - Whatever the file type, this file's identity is "src::" + its storage path (the "storage path" metadata line) - never the inline content or a temporary URL. That record ALREADY EXISTS: the upload pipeline creates it in table "file_summaries" (access group "authorized") before indexing starts, so posting it again is rejected as a duplicate unique_id. Reference it from every record you write, and add what you learn to it with updateRecords. If that update unexpectedly reports the record does not exist, post it yourself ONCE with that exact "src::" unique_id (table "file_summaries", access group "authorized") and carry on; this is the ONE exception to the do-NOT-post-the-file-record rules elsewhere in these instructions, because the source identity must never be dropped just because an update failed.
42
+ - Whatever the file type, this file's identity is "src::" + its storage path (the "storage path" metadata line) - never the inline content or a temporary URL. That record ALREADY EXISTS: the upload pipeline creates it in table "file_summaries" (access group "${accessGroup}") before indexing starts, so posting it again is rejected as a duplicate unique_id. Reference it from every record you write, and add what you learn to it with updateRecords. If that update unexpectedly reports the record does not exist, post it yourself ONCE with that exact "src::" unique_id (table "file_summaries", access group "${accessGroup}") and carry on; this is the ONE exception to the do-NOT-post-the-file-record rules elsewhere in these instructions, because the source identity must never be dropped just because an update failed.
43
+ - ACCESS GROUP (hard rule): every record you write for this file - the file record, per-row records, chapters, summaries, intermediates - MUST be posted with access group "${accessGroup}". Pass it explicitly on every postRecords call; do not leave it out and do not vary it between passes of the same file. An access group is part of a record's table key, so records saved under a different group than the file are in a different table and will not come back with the rest of it: a "public" file whose rows were saved as "authorized" is one an anonymous visitor can see the name of and none of the contents of, and a re-index cannot find the strays to clean them up. The one exception is the EXTRACTED MEDIA records in "__MEDIA__", which the pipeline creates for you - leave their group alone and only enrich them.
32
44
  - REACHABILITY (hard rule): every record you write while indexing this file MUST be reachable from the file's "src::<storage path>" record by following reference - either reference that record directly, or reference something that already reaches it. A record with no reference, or one pointing outside this file's chain, is an ORPHAN: deleting or re-indexing the file removes the reachable records and leaves the orphan behind forever, where it keeps turning up in later answers as stale data. If you create an intermediate record that OTHER records reference (a page record that rows hang off, a sheet or section record), set source.can_remove_referencing_records to true on it; the delete cascade passes a delete through a record only when that record carries the flag OR a unique_id starting "src::" (the file record cascades because its unique_id starts with "src::"; the intermediates you create carry no "src::" id, so they need the flag), and it cascades ONE LEVEL AT A TIME, so EVERY intermediate record in a chain needs its own marker - an unmarked link stops the cascade there and everything below it survives as orphans. When in doubt, reference the file record directly and keep the chain flat.
33
45
  - TABULAR data (any spreadsheet - .csv/.tsv/.xlsx/.xls/.ods, or sheet-like rows): you MUST save EVERY data row as its own record (ONE record per row) with that row's actual column values in the record's "data", keyed by the header names, in a table named EXACTLY "spreadsheet_rows". Do NOT summarize, sample only a few rows, or save just file metadata - index the whole sheet, window by window, until it ends. Make MULTIPLE postRecords calls in batches (e.g. 30-50 rows per call) rather than one oversized call. This per-row completeness OVERRIDES brevity. The file-level "src::" record ALREADY EXISTS - the upload pipeline creates it before indexing starts - so do NOT create it. Link EVERY per-row record to it via reference (set each row record's reference to exactly "src::" + the storage path, with NO sheet/window/summary suffix added; the row records themselves do NOT carry a src:: unique_id). Enrich that same record with sheet name(s), column headers and total row count via updateRecords rather than posting another one. The per-row records AND this reference linkage are BOTH mandatory: the linkage is what lets the whole sheet be found and cleaned up together when the file is re-indexed. INDEX each row record on the row's most useful NUMERIC column (named by its header) so rows sort and range-query; when the row has no numeric column, index the grid row number instead. TAG each row record with the sheet name, the file name, and the row's categorical values (a status, a category, a type) - tags are how rows are filtered without scanning the table.
34
46
  - ONE RECORD PER GRID ROW, ALWAYS. "Row" means the numbered row of the sheet (R37 is one record), never a visual block, item, section or left/right pair. Sheets that repeat the same columns side by side (an A/B block beside a C/D block, "paired" or "mirrored" layouts) still get ONE record per grid row, holding BOTH sides - suffix the keys to keep them apart (PART_NO_A / PART_NO_B). Collapsing a 16-row window into 2 or 3 "block" records is the single most damaging mistake here: it silently loses most of the cells and makes every later total wrong, because some windows were counted per row and others per block. If a window shows rows R37 to R52, you save records for R37..R52 and the count you report is the number of grid rows you actually wrote.
@@ -20,6 +20,18 @@ export type IndexingAttachmentInfo = {
20
20
  size?: number;
21
21
  /** Temporary signed URL the agent/MCP fetches to read the file contents. */
22
22
  url: string;
23
+ /**
24
+ * Access group every record extracted from this file must be written at.
25
+ *
26
+ * The uploader chooses it (project default, or a per-upload prompt), and the
27
+ * `src::` file record is already created at this group before indexing starts.
28
+ * The rows, chapters and summaries the agent writes have to MATCH it: skapi's
29
+ * access group is part of a record's table key, so a public file whose rows
30
+ * were saved as "authorized" is a file an anonymous visitor can see the name
31
+ * of and none of the contents of. Omitted means "authorized", which is what
32
+ * every record written before this setting existed used.
33
+ */
34
+ accessGroup?: 'public' | 'authorized' | 'private';
23
35
  };
24
36
 
25
37
  export type BuildIndexingUserMessageOptions = {
@@ -44,6 +56,15 @@ export type BuildIndexingUserMessageOptions = {
44
56
  pagedRead?: boolean;
45
57
  };
46
58
 
59
+ /**
60
+ * The access group to write this file's records at. One place, so the user
61
+ * message, the continue message and the system prompt cannot disagree.
62
+ */
63
+ export function indexingAccessGroup(attachment: { accessGroup?: string }): 'public' | 'authorized' | 'private' {
64
+ const g = attachment && attachment.accessGroup;
65
+ return g === 'public' || g === 'private' ? g : 'authorized';
66
+ }
67
+
47
68
  export function buildIndexingUserMessage(
48
69
  attachment: IndexingAttachmentInfo,
49
70
  options?: BuildIndexingUserMessageOptions,
@@ -54,7 +75,11 @@ export function buildIndexingUserMessage(
54
75
  `- name: ${attachment.name}\n` +
55
76
  `- storage path: ${attachment.storagePath}\n` +
56
77
  (attachment.mime ? `- mime type: ${attachment.mime}\n` : '') +
57
- (typeof attachment.size === 'number' ? `- size (bytes): ${attachment.size}\n` : '');
78
+ (typeof attachment.size === 'number' ? `- size (bytes): ${attachment.size}\n` : '') +
79
+ // Stated in the metadata block as well as the system prompt because this is
80
+ // the per-FILE value: one project can hold public and private files at once,
81
+ // and the system prompt is what is constant across the run.
82
+ `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}\n`;
58
83
 
59
84
  if (options?.inlineContent) {
60
85
  // Parsed client-side (an attachment-parser plugin). The content is already
@@ -169,7 +194,8 @@ function buildRenderMeta(attachment: IndexingAttachmentInfo): string {
169
194
  `File metadata:\n` +
170
195
  `- name: ${attachment.name}\n` +
171
196
  `- storage path: ${attachment.storagePath}\n` +
172
- (attachment.mime ? `- mime type: ${attachment.mime}\n` : '')
197
+ (attachment.mime ? `- mime type: ${attachment.mime}\n` : '') +
198
+ `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}\n`
173
199
  );
174
200
  }
175
201
 
@@ -262,6 +288,7 @@ export function buildIndexingContinueMessage(attachment: IndexingAttachmentInfo)
262
288
  `- name: ${attachment.name}\n` +
263
289
  `- storage path: ${attachment.storagePath}\n` +
264
290
  (attachment.mime ? `- mime type: ${attachment.mime}\n` : '') +
291
+ `- access group (use this for EVERY record you write for this file): ${indexingAccessGroup(attachment)}\n` +
265
292
  `\nRecords for the earlier windows/pages of this file are ALREADY saved (they reference "${src}"). ` +
266
293
  `First call getRecords with reference "${src}" to see how far the previous pass got (the furthest row/window already saved). The reference ALONE is the whole query: it returns every record written from this file across ALL tables and ALL access groups, so do NOT add table_name or access_group to narrow it. The response is PAGED, so keep fetching pages until it reports there are no more, and take the furthest point from the WHOLE set, never from the first page. ` +
267
294
  `Then call readFileContent with the storage path above and a CURSOR that RESUMES just after that point - do NOT start at the beginning. The cursor is derivable from what you already saved:\n` +
@@ -38,6 +38,38 @@ export const DEFAULT_CLAUDE_MODEL = 'claude-sonnet-5';
38
38
  export const DEFAULT_OPENAI_MODEL = 'gpt-5.6-luna';
39
39
 
40
40
  const mcpUrl = () => chatEngineConfig().mcpBaseUrl;
41
+
42
+ /**
43
+ * Where a chat turn's MCP tools point, and what they authenticate with.
44
+ *
45
+ * A SIGNED-IN turn uses the server's root endpoint with the literal
46
+ * '$ACCESS_TOKEN', which the backend substitutes from the caller's
47
+ * `x-access-token` header before the request leaves for the provider.
48
+ *
49
+ * An ANONYMOUS turn has no such header, so that substitution yields an EMPTY
50
+ * credential: `authorization_token: ""` for Claude, `Bearer ` for OpenAI. That is
51
+ * worse than sending none. The MCP server cannot identify a project from an empty
52
+ * token, so every tool call 401s — and a pass whose calls all 401 is exactly what
53
+ * the polling worker classifies as an auth outage, which STOPS the chain. A
54
+ * provider that validates the field would reject the whole request before that.
55
+ *
56
+ * So an anonymous turn points at the project-scoped endpoint `/p/<project id>`
57
+ * and sends NO credential. That route is anonymous by construction: read-only,
58
+ * one project, public records only, and a bearer on it is ignored rather than
59
+ * honoured.
60
+ *
61
+ * The project id is the PUBLIC compound token, matching the route's own pattern
62
+ * and the form the tools accept; the raw regional code would not match.
63
+ */
64
+ function mcpEndpointFor(
65
+ anonymous: boolean | undefined,
66
+ publicProjectId: string | undefined,
67
+ service: string,
68
+ ): { url: string; token?: string } {
69
+ if (!anonymous) return { url: mcpUrl(), token: '$ACCESS_TOKEN' };
70
+ const project = publicProjectId || service;
71
+ return { url: String(mcpUrl()).replace(/\/+$/, '') + '/p/' + project };
72
+ }
41
73
  const clientSecretRequest = (opts: any) => chatEngineConfig().clientSecretRequest(opts);
42
74
 
43
75
  // Resolve the per-image `detail` for OpenAI. The version match tolerates a
@@ -548,7 +580,9 @@ export async function callClaudeWithPublicMcp(
548
580
  fileUrls?: FileUrlDirective[],
549
581
  onResponse?: (res: any) => void,
550
582
  onError?: (err: any) => void,
583
+ mcpScope?: { anonymous?: boolean; publicProjectId?: string },
551
584
  ) {
585
+ const endpoint = mcpEndpointFor(mcpScope?.anonymous, mcpScope?.publicProjectId, service);
552
586
  return callClaudeWithMcp({
553
587
  prompt,
554
588
  messages,
@@ -562,8 +596,10 @@ export async function callClaudeWithPublicMcp(
562
596
  fileUrls,
563
597
  mcpServer: {
564
598
  name: MCP_NAME,
565
- url: mcpUrl(),
566
- authorizationToken: '$ACCESS_TOKEN',
599
+ url: endpoint.url,
600
+ // Omitted entirely for an anonymous turn; the `if (mcpServer.authorizationToken)`
601
+ // guard below drops the key rather than sending an empty one.
602
+ authorizationToken: endpoint.token,
567
603
  },
568
604
  onResponse,
569
605
  onError,
@@ -582,7 +618,9 @@ export async function callOpenAIWithPublicMcp(
582
618
  fileUrls?: FileUrlDirective[],
583
619
  onResponse?: (res: any) => void,
584
620
  onError?: (err: any) => void,
621
+ mcpScope?: { anonymous?: boolean; publicProjectId?: string },
585
622
  ) {
623
+ const endpoint = mcpEndpointFor(mcpScope?.anonymous, mcpScope?.publicProjectId, service);
586
624
  const resolvedModel = model || DEFAULT_OPENAI_MODEL;
587
625
  const imageDetail = getOpenAIImageDetail(resolvedModel);
588
626
  const messageList =
@@ -636,11 +674,14 @@ export async function callOpenAIWithPublicMcp(
636
674
  {
637
675
  type: 'mcp',
638
676
  server_label: MCP_NAME,
639
- server_url: mcpUrl(),
677
+ server_url: endpoint.url,
640
678
  require_approval: 'never',
641
- headers: {
642
- Authorization: 'Bearer $ACCESS_TOKEN',
643
- },
679
+ // No `headers` at all for an anonymous turn: `Bearer ` with an
680
+ // empty token is a credential the MCP server rejects, and the
681
+ // project-scoped endpoint needs none.
682
+ ...(endpoint.token
683
+ ? { headers: { Authorization: 'Bearer ' + endpoint.token } }
684
+ : {}),
644
685
  },
645
686
  ...(OPENAI_WEB_SEARCH_ENABLED
646
687
  ? [
@@ -681,6 +722,13 @@ export type AttachmentSaveInfo = {
681
722
  mime?: string;
682
723
  size?: number;
683
724
  url: string;
725
+ /**
726
+ * Access group this file's records are written at (the uploader's choice,
727
+ * already applied to the "src::" record). Threaded into the indexing
728
+ * prompts so the agent's own records land in the same group; omitted
729
+ * means "authorized", the group everything used before the setting existed.
730
+ */
731
+ accessGroup?: 'public' | 'authorized' | 'private';
684
732
  };
685
733
  /**
686
734
  * Content parsed CLIENT-SIDE by an attachment-parser plugin (e.g. an .hwp
@@ -892,6 +940,9 @@ export async function notifyAgentSaveAttachment(info: AttachmentSaveInfo) {
892
940
  projectId: info.publicProjectId || service,
893
941
  serviceName: info.serviceName,
894
942
  serviceDescription: info.serviceDescription,
943
+ // Per-FILE, not per-project: one project holds public and private files at
944
+ // once, so this travels on the attachment rather than the identity.
945
+ accessGroup: attachment.accessGroup,
895
946
  });
896
947
 
897
948
  if (platform === 'openai') {
@@ -798,10 +798,23 @@ export class ChatSession {
798
798
  return 'lid_' + this._lidSeq;
799
799
  }
800
800
 
801
+ /**
802
+ * The key every per-chat cache hangs off: the restored message cache, the
803
+ * hydrated-body memo, the live-index key and the per-file storage-path key.
804
+ *
805
+ * It carries the IDENTITY as well as the project and platform. A single
806
+ * browser can hold more than one conversation on one project without a
807
+ * reload — an anonymous visitor who signs in, or a dashboard user who logs
808
+ * out and back in as someone else — and with an identity-free key the
809
+ * previous conversation stayed in the cache and was re-rendered, and written
810
+ * back, as the new one's. `userId` is the same value the request queue is
811
+ * named after, so two identities that share a queue share a cache, which is
812
+ * exactly right.
813
+ */
801
814
  getHistoryCacheKey(): string {
802
815
  var id = this.host.getIdentity();
803
816
  if (!id.projectId || id.platform === 'none') return '';
804
- return id.projectId + '#' + id.platform;
817
+ return id.projectId + '#' + id.platform + '#' + (id.userId || '');
805
818
  }
806
819
 
807
820
  // ─── compact-stub hydration ─────────────────────────────────────────────
@@ -1059,9 +1072,17 @@ export class ChatSession {
1059
1072
  if (projectId === undefined) projectId = id.projectId;
1060
1073
  if (owner === undefined) owner = id.owner;
1061
1074
  }
1075
+ // Read LIVE, unlike projectId/owner above, and deliberately: this decides
1076
+ // which MCP endpoint the turn's tools point at, and a retry after the
1077
+ // visitor signed in should use the authenticated one rather than the
1078
+ // project-scoped anonymous route it was composed under. Getting it wrong
1079
+ // costs a failed turn, never a wider read: the anonymous route is
1080
+ // read-only and public-records-only whoever calls it.
1081
+ var liveId = this.host.getIdentity();
1082
+ var mcpScope = { anonymous: liveId.anonymous, publicProjectId: liveId.publicProjectId };
1062
1083
  return platform === 'openai'
1063
- ? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls)
1064
- : callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls);
1084
+ ? callOpenAIWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls, undefined, undefined, mcpScope)
1085
+ : callClaudeWithPublicMcp(prompt, projectId, owner, messages, system, model, userId, extractContent, fileUrls, undefined, undefined, mcpScope);
1065
1086
  }
1066
1087
 
1067
1088
  dispatchAgentRequest(params: any) {
@@ -4071,6 +4092,17 @@ export class ChatSession {
4071
4092
  size: member.file.size,
4072
4093
  })).catch(function () { });
4073
4094
  });
4095
+ // The access group the host actually recorded this file at. Read
4096
+ // AFTER ensureFileIndexRecord so the value here is the one the
4097
+ // "src::" record really carries, never a second guess at it.
4098
+ var accessGroup: 'public' | 'authorized' | 'private' | undefined;
4099
+ preIndex = preIndex.then(function () {
4100
+ if (alreadyIndexing) return;
4101
+ if (typeof self.host.uploadAccessGroup !== 'function') return;
4102
+ return Promise.resolve(self.host.uploadAccessGroup(member.storagePath))
4103
+ .then(function (g) { accessGroup = g || undefined; })
4104
+ .catch(function () { });
4105
+ });
4074
4106
  // Run a client-side attachment parser (e.g. .hwp) if one matches; its
4075
4107
  // output is inlined into the indexing request (falls back to office
4076
4108
  // extraction / web_fetch when no parser matches or it yields nothing).
@@ -4092,6 +4124,7 @@ export class ChatSession {
4092
4124
  attachment: {
4093
4125
  name: member.file.name, storagePath: member.storagePath,
4094
4126
  mime: mime || undefined, size: member.file.size, url: url,
4127
+ accessGroup: accessGroup,
4095
4128
  },
4096
4129
  parsedContent: parsedContent || undefined,
4097
4130
  }).then(function (ack: any) {
package/src/widget.css CHANGED
@@ -1013,6 +1013,39 @@
1013
1013
  }
1014
1014
  .bq-overwrite-applyall input { cursor: pointer; margin: 0; }
1015
1015
 
1016
+ /* Access-group chooser (who may read a file once it is indexed). A radio list
1017
+ rather than a row of buttons: the three answers are not equally weighted and
1018
+ the hint under each is what actually distinguishes them. */
1019
+ .bq-access-options {
1020
+ display: flex;
1021
+ flex-direction: column;
1022
+ gap: 0.15rem;
1023
+ margin: 0 0 1rem;
1024
+ }
1025
+ .bq-access-option {
1026
+ display: grid;
1027
+ grid-template-columns: auto 1fr;
1028
+ align-items: baseline;
1029
+ column-gap: 0.5rem;
1030
+ padding: 0.5rem 0.6rem;
1031
+ border: 1px solid transparent;
1032
+ cursor: pointer;
1033
+ user-select: none;
1034
+ }
1035
+ .bq-access-option:hover { background: var(--bq-hover-bg); }
1036
+ .bq-access-option input { cursor: pointer; margin: 0; }
1037
+ .bq-access-option-label {
1038
+ font-size: 0.88rem;
1039
+ color: var(--bq-ink);
1040
+ }
1041
+ .bq-access-option-hint {
1042
+ grid-column: 2;
1043
+ font-size: 0.76rem;
1044
+ line-height: 1.35;
1045
+ color: var(--bq-muted);
1046
+ margin-top: 0.15rem;
1047
+ }
1048
+
1016
1049
  /* ============================================================================
1017
1050
  * RESPONSIVE
1018
1051
  * ==========================================================================*/