bunnyquery 1.2.2 → 1.3.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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Error detection + message extraction (pure). Moved verbatim from the
3
+ * agent.vue / bunnyquery chatbox so both consumers share one implementation.
4
+ */
5
+
6
+ export function getErrorMessage(input: any): string {
7
+ if (!input) return 'Something went wrong.';
8
+ if (typeof input === 'string') return input;
9
+ if (input.error && input.error.message) return input.error.message;
10
+ if (input.body && input.body.error && input.body.error.message) return input.body.error.message;
11
+ if (input.body && typeof input.body.message === 'string') return input.body.message;
12
+ if (input.message) return input.message;
13
+ return 'Something went wrong.';
14
+ }
15
+
16
+ export function isErrorResponseBody(response: any): boolean {
17
+ if (!response || typeof response !== 'object') return false;
18
+ if (typeof response.status_code === 'number' && response.status_code >= 400) return true;
19
+ if (response.type === 'error') return true;
20
+ if (response.error && (response.error.message || response.error.type)) return true;
21
+ var body = response.body;
22
+ if (body && typeof body === 'object') {
23
+ if (body.type === 'error') return true;
24
+ if (body.error && (body.error.message || body.error.type)) return true;
25
+ }
26
+ if (typeof response.message === 'string' && response.message.length) {
27
+ var hasClaude = Array.isArray(response.content);
28
+ var hasOpenAI =
29
+ typeof response.output_text === 'string' ||
30
+ Array.isArray(response.output) ||
31
+ Array.isArray(response.choices);
32
+ if (!hasClaude && !hasOpenAI) return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ export function isAuthExpiredError(input: any): boolean {
38
+ if (!input) return false;
39
+ var blobs: string[] = [];
40
+ var push = function (v: any) { if (typeof v === 'string' && v) blobs.push(v); };
41
+ if (typeof input === 'string') push(input);
42
+ else {
43
+ push(input.message); push(input.code);
44
+ if (input.error) { push(input.error.message); push(input.error.code); push(input.error.type); }
45
+ if (input.body) {
46
+ push(input.body.message);
47
+ if (input.body.error) { push(input.body.error.message); push(input.body.error.code); push(input.body.error.type); }
48
+ }
49
+ if (typeof input.status === 'number' && input.status === 401) return true;
50
+ if (typeof input.status_code === 'number' && input.status_code === 401) return true;
51
+ }
52
+ var hay = blobs.join(' | ').toLowerCase();
53
+ if (!hay) return false;
54
+ return hay.indexOf('token has expired') !== -1 || hay.indexOf('token is expired') !== -1 ||
55
+ hay.indexOf('expired_token') !== -1 || hay.indexOf('invalid_token') !== -1 ||
56
+ hay.indexOf('unauthorized') !== -1 || hay.indexOf('not authorized') !== -1 ||
57
+ (hay.indexOf('invalid_request') !== -1 && hay.indexOf('token') !== -1);
58
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * History mapping (pure). Moved verbatim from the chatbox. The clear-horizon
3
+ * timestamp and the "Indexing: …" display label are INJECTED (clearedAt param,
4
+ * formatIndexingLabel callback) so the engine touches neither localStorage nor
5
+ * view-specific display formatting. serviceId is passed for link sanitization.
6
+ */
7
+ import { extractClaudeText, extractOpenAIText } from './requests';
8
+ import { isErrorResponseBody, getErrorMessage } from './errors';
9
+ import { sanitizeAttachmentLinksForHistory } from './links';
10
+
11
+ export function filterListByClearHorizon(list: any[], clearedAt: number): any[] {
12
+ if (!clearedAt) return list;
13
+ return list.filter(function (item) {
14
+ var updated = Number(item && item.updated);
15
+ return isFinite(updated) && updated > clearedAt;
16
+ });
17
+ }
18
+
19
+ export function normalizeTextContent(content: any): string {
20
+ if (typeof content === 'string') return content;
21
+ if (Array.isArray(content)) {
22
+ return content.map(function (part: any) {
23
+ if (typeof part === 'string') return part;
24
+ if (part && (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text')) return part.text || '';
25
+ return '';
26
+ }).join('\n').trim();
27
+ }
28
+ return '';
29
+ }
30
+
31
+ export function extractLastUserTextFromRequest(requestBody: any): string {
32
+ var arr = requestBody && Array.isArray(requestBody.messages) ? requestBody.messages
33
+ : (requestBody && Array.isArray(requestBody.input) ? requestBody.input : []);
34
+ for (var i = arr.length - 1; i >= 0; i--) {
35
+ if (arr[i] && arr[i].role === 'user') {
36
+ var t = normalizeTextContent(arr[i].content);
37
+ if (t) return t;
38
+ }
39
+ }
40
+ return '';
41
+ }
42
+
43
+ export type MapHistoryOptions = {
44
+ clearedAt: number;
45
+ serviceId: string;
46
+ /** View-side display formatter for "Indexing:/Reindexing: …" bubbles. */
47
+ formatIndexingLabel: (name: string, mime?: string, size?: number | null, storagePath?: string) => string;
48
+ };
49
+
50
+ export function mapHistoryListToMessages(list: any[], platform: 'claude' | 'openai', opts: MapHistoryOptions) {
51
+ var mapped: any[] = [], runningItemIds: string[] = [];
52
+ var extractAssistantText = platform === 'openai' ? extractOpenAIText : extractClaudeText;
53
+ var filtered = filterListByClearHorizon(list, opts.clearedAt);
54
+ filtered.slice().reverse().forEach(function (item) {
55
+ var requestBody = item && item.request_body;
56
+ var isInProcess = item && item.status === 'running';
57
+ var isQueued = item && item.status === 'pending';
58
+ var isCancelledItem = item && item.status === 'cancelled';
59
+ var isPending = isInProcess || isQueued;
60
+ var isFailed = item && item.status === 'failed';
61
+ var response = isFailed ? (item.error != null ? item.error : item.response_body)
62
+ : (item && item.response_body != null ? item.response_body : item && item.error);
63
+ var userText = extractLastUserTextFromRequest(requestBody);
64
+ var assistantText = isPending ? '' : ((extractAssistantText(response) || '').trim() || '');
65
+ var isErrorResponse = !isPending && (isFailed || isErrorResponseBody(response));
66
+ var serverItemId = item && typeof item.id === 'string' && item.id ? item.id : undefined;
67
+
68
+ if (userText) {
69
+ var displayContent;
70
+ if (item._isBgTask) {
71
+ var nameMatch = userText.match(/^- name: (.+)$/m);
72
+ if (nameMatch) {
73
+ var mimeMatch = userText.match(/^- mime type: (.+)$/m);
74
+ var sizeMatch = userText.match(/^- size \(bytes\): (\d+)$/m);
75
+ var pathMatch = userText.match(/^- storage path: (.+)$/m);
76
+ displayContent = opts.formatIndexingLabel(
77
+ nameMatch[1].trim(),
78
+ mimeMatch ? mimeMatch[1].trim() : '',
79
+ sizeMatch ? Number(sizeMatch[1]) : null,
80
+ pathMatch ? pathMatch[1].trim() : undefined
81
+ );
82
+ } else {
83
+ displayContent = userText;
84
+ }
85
+ } else {
86
+ displayContent = sanitizeAttachmentLinksForHistory(userText, opts.serviceId);
87
+ }
88
+ var userMsg: any = { role: 'user', content: displayContent };
89
+ if (isInProcess) userMsg.isPendingInProcess = true;
90
+ if (isQueued) userMsg.isPendingQueued = true;
91
+ if (isCancelledItem) userMsg.isCancelled = true;
92
+ if (item._isBgTask) userMsg.isBackgroundTask = true;
93
+ if (item._isOnBgQueue) userMsg._useBgQueue = true;
94
+ if (serverItemId !== undefined) userMsg._serverItemId = serverItemId;
95
+ mapped.push(userMsg);
96
+ }
97
+ if (isCancelledItem) { /* no assistant bubble */ }
98
+ else if (isInProcess) {
99
+ var ph: any = { role: 'assistant', content: '', isPending: true, isPendingInProcess: true };
100
+ if (item._isBgTask) ph.isBackgroundTask = true;
101
+ if (serverItemId !== undefined) { ph._serverItemId = serverItemId; runningItemIds.push(serverItemId); }
102
+ mapped.push(ph);
103
+ } else if (isQueued) { /* no assistant placeholder */ }
104
+ else if (isErrorResponse) {
105
+ var em: any = { role: 'assistant', content: getErrorMessage(response), isError: true };
106
+ if (item._isBgTask) em.isBackgroundTask = true;
107
+ if (serverItemId !== undefined) em._serverItemId = serverItemId;
108
+ mapped.push(em);
109
+ } else if (assistantText) {
110
+ var okm: any = { role: 'assistant', content: assistantText };
111
+ if (item._isBgTask) okm.isBackgroundTask = true;
112
+ if (serverItemId !== undefined) okm._serverItemId = serverItemId;
113
+ mapped.push(okm);
114
+ }
115
+ });
116
+ return { messages: mapped, runningItemIds: runningItemIds };
117
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * ChatSession host adapter + state types.
3
+ *
4
+ * ChatSession is DOM-free and Vue-free; the consumer (bunnyquery widget or the
5
+ * agent.vue chatbox) implements `ChatHost` to bridge identity, rendering, scroll,
6
+ * and the skapi cancel/refresh surface. Everything the session needs that would
7
+ * otherwise touch the DOM or a framework goes through a host hook.
8
+ */
9
+
10
+ export interface ChatIdentity {
11
+ serviceId: string;
12
+ owner: string;
13
+ /** Per-user queue name (falls back to serviceId). */
14
+ userId: string;
15
+ platform: 'claude' | 'openai' | 'none';
16
+ model?: string;
17
+ serviceName?: string;
18
+ serviceDescription?: string;
19
+ }
20
+
21
+ export interface ChatMessage {
22
+ role: 'user' | 'assistant';
23
+ content: string; // raw markdown — never HTML (the view parses for display)
24
+ isPending?: boolean;
25
+ isPendingInProcess?: boolean;
26
+ isPendingQueued?: boolean;
27
+ isPendingOlder?: boolean;
28
+ isSendingToServer?: boolean;
29
+ isCancelled?: boolean;
30
+ isError?: boolean;
31
+ isBackgroundTask?: boolean;
32
+ _useBgQueue?: boolean;
33
+ _serverItemId?: string;
34
+ _localId?: string;
35
+ _cancelling?: boolean;
36
+ _cancelError?: string;
37
+ }
38
+
39
+ export interface ChatState {
40
+ messages: ChatMessage[];
41
+ /** Pending/uploaded attachment objects (view-shaped); the engine mutates
42
+ * status/progress during upload, the view renders them. */
43
+ attachments: any[];
44
+ uploadingAttachments: boolean;
45
+ sending: boolean;
46
+ typing: boolean;
47
+ typingAbort: boolean;
48
+ loadingHistory: boolean;
49
+ loadingOlderHistory: boolean;
50
+ historyEndOfList: boolean;
51
+ historyStartKeyHistory: string[];
52
+ historyRequestToken: number;
53
+ gateRefreshToken: number;
54
+ }
55
+
56
+ export interface ChatHost {
57
+ /** Read live (platform/model/name can change between sends). */
58
+ getIdentity(): ChatIdentity;
59
+ /** The chat system prompt (consumer-built; agent.vue uses a formatted id). */
60
+ buildSystemPrompt(): string;
61
+
62
+ // --- render / scroll (the ONLY view surface) ---
63
+ /** Re-render the whole message list (coalesced). */
64
+ notify(): void;
65
+ /** Re-render a single message bubble in place (typewriter ticks). */
66
+ refreshMessageBubble(idx: number): void;
67
+ scrollToBottom(smooth?: boolean): Promise<void> | void;
68
+ /** Scroll only if the user is pinned to the bottom (does not force-pin). */
69
+ scrollToBottomIfSticky(smooth?: boolean): Promise<void> | void;
70
+
71
+ // --- skapi surface beyond configureChatEngine() ---
72
+ cancelRequest(opts: {
73
+ url: string; method: string; id: string; queue: string; service: string; owner: string;
74
+ }): Promise<{ removed?: boolean; message?: string } | any>;
75
+ refreshSession(): Promise<boolean>;
76
+
77
+ // --- bg-indexing display ---
78
+ /** Build the "Indexing:/Reindexing: …" label (view-side display formatting). */
79
+ formatIndexingLabel(name: string, mime?: string, size?: number | null, storagePath?: string, reindex?: boolean): string;
80
+ /** drainBgTaskQueue is a no-op until the chat view is mounted. */
81
+ isViewMounted(): boolean;
82
+
83
+ /** Clear-horizon timestamp (localStorage, per service#platform) — view-owned. */
84
+ getClearedAt(): number;
85
+
86
+ // --- attachment upload I/O (consumer-specific bytes path: agent.vue uses the
87
+ // Service class, bunnyquery uses get-signed-url). The session owns the
88
+ // upload ORCHESTRATION (per-member loop, overwrite/reindex flow, indexing,
89
+ // status lifecycle); these hooks do the actual I/O + chip rendering. ---
90
+ uploadFile(args: {
91
+ file: File;
92
+ storagePath: string;
93
+ checkExistence: boolean;
94
+ onProgress?: (p: any) => void;
95
+ setAbort?: (abort: () => void) => void;
96
+ }): Promise<any>;
97
+ /** Mint a temporary CDN URL for a stored file. */
98
+ getTemporaryUrl(storagePath: string): Promise<string>;
99
+ /** Map a relative path to the consumer's db storage key (e.g. uid-prefixed). */
100
+ storagePathFor(relPath: string): string;
101
+ getMimeType(name: string): string | null;
102
+ /** Non-dismissible "file exists" prompt → keep+reindex vs overwrite. */
103
+ promptOverwrite(filename: string): Promise<'overwrite' | 'reindex'>;
104
+ /** Clear the "apply to all" overwrite choice at the start of a batch. */
105
+ resetOverwriteBatch(): void;
106
+ /** Re-render the attachment chip row (progress / status). */
107
+ renderAttachmentChips(): void;
108
+ /** Enable/disable composer controls during an upload batch. */
109
+ updateComposerControls(): void;
110
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @skapi/chat-engine — framework-agnostic chat engine.
3
+ *
4
+ * Tier-1 (this barrel): pure transport/logic shared by agent.vue and the
5
+ * BunnyQuery widget. DOM-free and Vue-free. Consumers inject the skapi
6
+ * transport + MCP base URL via configureChatEngine() and (for markdown / DOM
7
+ * rendering) keep their own view layer.
8
+ */
9
+
10
+ export {
11
+ configureChatEngine,
12
+ chatEngineConfig,
13
+ type ChatEngineConfig,
14
+ } from './config';
15
+
16
+ export {
17
+ isOfficeFile,
18
+ makeExtractPlaceholder,
19
+ composeUserMessage,
20
+ type ExtractDirective,
21
+ type ComposedUserMessage,
22
+ } from './office';
23
+
24
+ export * from './prompts';
25
+
26
+ // Pure helpers (Tier-1.5): error detection, token budgeting, link/path
27
+ // normalization, and history mapping — shared so both consumers stay identical.
28
+ export { getErrorMessage, isErrorResponseBody, isAuthExpiredError } from './errors';
29
+ export * from './budget';
30
+ export * from './links';
31
+ export {
32
+ filterListByClearHorizon,
33
+ normalizeTextContent,
34
+ extractLastUserTextFromRequest,
35
+ mapHistoryListToMessages,
36
+ type MapHistoryOptions,
37
+ } from './history';
38
+
39
+ // Tier-2: the stateful chat orchestration (queue/poll/cancel, typewriter,
40
+ // bg-task drain, resolution). DOM-free; the consumer implements ChatHost.
41
+ export { ChatSession } from './session';
42
+ export type { ChatHost, ChatIdentity, ChatState, ChatMessage } from './host';
43
+
44
+ export {
45
+ // constants
46
+ POLL_INTERVAL,
47
+ BG_INDEXING_QUEUE_SUFFIX,
48
+ MCP_NAME,
49
+ DEFAULT_CLAUDE_MODEL,
50
+ DEFAULT_OPENAI_MODEL,
51
+ // request builders + dispatch
52
+ callClaudeWithMcp,
53
+ callClaudeWithPublicMcp,
54
+ callOpenAIWithPublicMcp,
55
+ notifyAgentSaveAttachment,
56
+ listClaudeModels,
57
+ listOpenAIModels,
58
+ getChatHistory,
59
+ // response extraction
60
+ extractClaudeText,
61
+ extractOpenAIText,
62
+ // content transforms
63
+ transformContentWithImages,
64
+ transformContentWithOpenAIImages,
65
+ // types
66
+ type ClaudeRole,
67
+ type ClaudeMessage,
68
+ type OpenAIMessage,
69
+ type ClaudeMcpToolConfig,
70
+ type ClaudeMcpServerRequest,
71
+ type CallClaudeWithMcpParams,
72
+ type AttachmentSaveInfo,
73
+ type BgTaskEntry,
74
+ } from './requests';
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure link/path helpers (no DOM, no marked). Moved verbatim from the chatbox.
3
+ * `serviceId` is passed as a PARAMETER (the original read it from a global) so
4
+ * the engine stays consumer-agnostic. The HTML-emitting helpers
5
+ * (buildLinkPartFromGroups, linkToAnchorHtml, fileToAnchorHtml, parseMsgParts*)
6
+ * stay in each VIEW — only these pure pieces move here.
7
+ */
8
+
9
+ export var EXPIRED_ATTACHMENT_URL_HOST = '_expired_.url';
10
+ export var EXPIRED_ATTACHMENT_URL_ORIGIN = 'https://' + EXPIRED_ATTACHMENT_URL_HOST;
11
+ export var LINK_LABEL_MAX_DISPLAY_CHARS = 32;
12
+
13
+ export function createInlineLinkRegex(): RegExp {
14
+ return /src::(\S+)|\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]+|\([^\s()]*\))+)\)|\[([^\]\n]+)\]\(((?:[^()\n]+|\([^()\n]*\))+)\)|(https?:\/\/[^\s<>"']+)/g;
15
+ }
16
+
17
+ export function safeDecodeURIComponent(v: string): string {
18
+ try { return decodeURIComponent(v); } catch (e) { return v; }
19
+ }
20
+
21
+ export function encodePathSegments(path: string): string {
22
+ return path.split('/').filter(Boolean).map(function (s) { return encodeURIComponent(s); }).join('/');
23
+ }
24
+
25
+ export function normalizeAttachmentPathCandidate(value: string): string {
26
+ return safeDecodeURIComponent((value || '').trim()).replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
27
+ }
28
+
29
+ export function extractRemotePathFromAttachmentHref(href: string, serviceId: string): string | null {
30
+ try {
31
+ var parsed = new URL(href);
32
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
33
+ var path = normalizeAttachmentPathCandidate(parsed.pathname || '');
34
+ var segs = path.split('/').filter(Boolean);
35
+ if (!segs.length) return null;
36
+ var HEX = /^[a-f0-9]{32,}$/i;
37
+ var sid = serviceId || '';
38
+ var start = 0;
39
+ while (start < segs.length) {
40
+ var seg = segs[start];
41
+ if (seg === sid || HEX.test(seg)) { start++; continue; }
42
+ break;
43
+ }
44
+ var real = segs.slice(start).join('/');
45
+ return real || null;
46
+ } catch (e) { return null; }
47
+ }
48
+
49
+ export function getExpiredAttachmentVisiblePath(remotePath: string, fallback?: string): string {
50
+ var n = normalizeAttachmentPathCandidate(remotePath);
51
+ if (n) return n;
52
+ return normalizeAttachmentPathCandidate(fallback || 'file') || 'file';
53
+ }
54
+
55
+ export function buildDisplayExpiredAttachmentHref(remotePath: string, fallback?: string): string {
56
+ return EXPIRED_ATTACHMENT_URL_ORIGIN + '/' + encodePathSegments(getExpiredAttachmentVisiblePath(remotePath, fallback));
57
+ }
58
+
59
+ export function sanitizeAttachmentLinksForHistory(content: string, serviceId: string): string {
60
+ if (!content || content.indexOf('Attached files:') === -1) return content;
61
+ return content.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, function (_m: string, label: string, href: string) {
62
+ var remotePath = extractRemotePathFromAttachmentHref(href, serviceId);
63
+ var labelPath = normalizeAttachmentPathCandidate(label);
64
+ var fullPath = remotePath || labelPath;
65
+ if (!fullPath) return '[' + label + '](' + EXPIRED_ATTACHMENT_URL_ORIGIN + '/file)';
66
+ return '[' + label + '](' + buildDisplayExpiredAttachmentHref(fullPath, label) + ')';
67
+ });
68
+ }
69
+
70
+ export function truncateLabelForDisplay(label: string): string {
71
+ if (!label) return label;
72
+ if (label.length <= LINK_LABEL_MAX_DISPLAY_CHARS) return label;
73
+ return '…' + label.slice(label.length - (LINK_LABEL_MAX_DISPLAY_CHARS - 1));
74
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Office-file server-side extraction helpers.
3
+ *
4
+ * Office documents (Microsoft .docx/.xlsx/.pptx, Hancom .hwpx, etc.) can't be
5
+ * read by web_fetch (binary/zip). The proxy worker downloads them from db
6
+ * storage, extracts their text server-side, and substitutes that text for a
7
+ * placeholder token in the request body (carried under the reserved
8
+ * `_skapi_extract` key, which the producer strips before the upstream call).
9
+ */
10
+
11
+ // A directive telling the proxy worker which office file to download + extract
12
+ // and which placeholder token in the request body to substitute.
13
+ export type ExtractDirective = {
14
+ /** db storage path of the file, e.g. "folder/report.docx" (also the src:: value). */
15
+ path: string;
16
+ /** The exact token in the request body to replace with the extracted text. */
17
+ placeholder: string;
18
+ /** Original filename — informational (server logs only). */
19
+ name?: string;
20
+ /** MIME type — informational (server logs only). */
21
+ mime?: string;
22
+ };
23
+
24
+ // Office formats whose text the model cannot read via web_fetch. OOXML
25
+ // (.docx/.xlsx/.pptx) and Hancom .hwpx are extracted server-side; the other
26
+ // (legacy/macro/binary) extensions are still flagged so the worker returns a
27
+ // graceful note instead of the model fetching binary garbage.
28
+ const OFFICE_FILE_EXTENSIONS = new Set([
29
+ 'doc', 'docx', 'docm',
30
+ 'xls', 'xlsx', 'xlsm',
31
+ 'ppt', 'pptx', 'pptm',
32
+ 'hwp', 'hwpx',
33
+ ]);
34
+
35
+ export function isOfficeFile(name?: string, mime?: string): boolean {
36
+ const ext = (name || '').split('.').pop()?.toLowerCase() || '';
37
+ if (OFFICE_FILE_EXTENSIONS.has(ext)) return true;
38
+ const m = (mime || '').toLowerCase();
39
+ return (
40
+ m.includes('officedocument') ||
41
+ m.includes('hwp') ||
42
+ m === 'application/msword' ||
43
+ m === 'application/vnd.ms-excel' ||
44
+ m === 'application/vnd.ms-powerpoint'
45
+ );
46
+ }
47
+
48
+ // Monotonic counter so placeholders are unique even for same-named files in one
49
+ // request. Token shape must match the worker's _EXTRACT_PLACEHOLDER_RE:
50
+ // {{SKAPI_FILE_CONTENT::<id>}}.
51
+ let _extractPlaceholderSeq = 0;
52
+ export function makeExtractPlaceholder(seed: string): string {
53
+ _extractPlaceholderSeq += 1;
54
+ const slug = (seed || 'file').replace(/[^a-zA-Z0-9]+/g, '_').slice(-48);
55
+ return `{{SKAPI_FILE_CONTENT::${slug}-${_extractPlaceholderSeq}}}`;
56
+ }
57
+
58
+ export interface ComposedUserMessage {
59
+ /** Clean display/history copy (attachment links, NO extraction placeholders). */
60
+ composed: string;
61
+ /** LLM-bound copy — `composed` plus inline office-extraction placeholders. */
62
+ composedForLlm: string;
63
+ /** Office-extraction directives for the proxy worker (undefined if no office files). */
64
+ extractContent?: ExtractDirective[];
65
+ }
66
+
67
+ // Compose the user's chat message from the typed text + uploaded attachment URLs.
68
+ // Identical for every consumer (agent.vue + the BunnyQuery widget): appends a
69
+ // markdown "Attached files" link block, and for office files
70
+ // (.docx/.xlsx/.pptx/.hwpx) adds inline extraction placeholders to the LLM copy
71
+ // ONLY (the proxy worker substitutes their text server-side; the display/history
72
+ // copy stays clean so stale tokens never accumulate across replayed turns).
73
+ // The directive `path` is the db storage path (uid-prefixed where applicable),
74
+ // NOT the link label — it falls back to the label when no storagePath is given
75
+ // (agent.vue uploads FLAT, so storagePath === name there).
76
+ export function composeUserMessage(
77
+ text: string,
78
+ attachmentUrls: Array<{ name: string; url: string; storagePath?: string }>,
79
+ ): ComposedUserMessage {
80
+ let composed = text;
81
+ if (attachmentUrls.length > 0) {
82
+ const lines = attachmentUrls.map((u) => `- [${u.name}](${u.url})`);
83
+ composed = `${text}\n\nAttached files:\n${lines.join('\n')}`;
84
+ }
85
+ let composedForLlm = composed;
86
+ let extractContent: ExtractDirective[] | undefined;
87
+ if (attachmentUrls.length > 0) {
88
+ const officeFiles = attachmentUrls.filter((u) => isOfficeFile(u.name));
89
+ if (officeFiles.length > 0) {
90
+ const directives: ExtractDirective[] = [];
91
+ const sections = officeFiles.map((u) => {
92
+ const storagePath = u.storagePath || u.name;
93
+ const placeholder = makeExtractPlaceholder(storagePath);
94
+ directives.push({ path: storagePath, placeholder, name: u.name });
95
+ return `===== ${u.name} =====\n----- BEGIN FILE CONTENT -----\n${placeholder}\n----- END FILE CONTENT -----`;
96
+ });
97
+ extractContent = directives;
98
+ composedForLlm =
99
+ `${composed}\n\nExtracted content of attached office files ` +
100
+ `(read inline below; do NOT fetch their URLs):\n\n` +
101
+ sections.join('\n\n');
102
+ }
103
+ }
104
+ return { composed, composedForLlm, extractContent };
105
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * BASE PROMPT — Chat assistant
3
+ * ============================================================================
4
+ * System prompt sent on every chat turn. Rebuilt fresh on every send because
5
+ * the project name/description can change at any time.
6
+ *
7
+ * The `${...}` placeholders are filled from the live project (service):
8
+ * formattedServiceId -> the project ID the assistant is scoped to
9
+ * serviceName -> project display name (only added if a description exists)
10
+ * serviceDescription -> project description (only added if present)
11
+ */
12
+
13
+ export type ChatSystemPromptParams = {
14
+ /** The project/service ID this assistant is scoped to (formatted form). */
15
+ formattedServiceId: string;
16
+ /** Project display name. Only appended when a description is also present. */
17
+ serviceName?: string;
18
+ /** Project description. When present, name + description are appended. */
19
+ serviceDescription?: string;
20
+ };
21
+
22
+ export function buildChatSystemPrompt(params: ChatSystemPromptParams): string {
23
+ const { formattedServiceId, serviceName, serviceDescription } = params;
24
+
25
+ let systemPrompt = `
26
+ You are a dedicated assistant for the project ID: "${formattedServiceId}".
27
+ 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.
28
+ 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.
29
+ 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.
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
+ - Office documents (Microsoft .docx/.xlsx/.pptx, Hancom .hwpx, etc.) cannot be read by web_fetch (they are binary/zip). When one is attached, the server has ALREADY extracted its text and inlined it in the same message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for that file. A "[skapi: ...]" note in that block means the file could not be extracted.
32
+ - For all other file types (text, code, csv, json, pdf, etc.), 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.
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
+ 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
+ \`\`\`filename.csv
37
+ item,qty,total
38
+ Carrots,55,$38.50
39
+ Mushrooms,41,$73.80
40
+ Zucchini,29,$43.50
41
+ \`\`\`
42
+ The same pattern applies to any format — name the block after the file you intend: \`\`\`my-data.json, \`\`\`index.html, \`\`\`sample.txt, and so on.`;
43
+
44
+ if (serviceDescription) {
45
+ systemPrompt += `
46
+ Project name: "${serviceName ?? ''}"
47
+ Project description: """${serviceDescription}"""`;
48
+ }
49
+
50
+ return systemPrompt;
51
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * AI base prompts — tweakable in one place, shared by both consumers.
3
+ * chat_system_prompt.ts -> chat assistant (system prompt)
4
+ * indexing_system_prompt.ts -> background file-indexing agent (system prompt)
5
+ * indexing_user_message.ts -> background file-indexing agent (user message)
6
+ */
7
+
8
+ export { buildChatSystemPrompt, type ChatSystemPromptParams } from './chat_system_prompt';
9
+ export { buildIndexingSystemPrompt, type IndexingSystemPromptParams } from './indexing_system_prompt';
10
+ export {
11
+ buildIndexingUserMessage,
12
+ type IndexingAttachmentInfo,
13
+ type BuildIndexingUserMessageOptions,
14
+ } from './indexing_user_message';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * BASE PROMPT — Background file-indexing agent (system prompt)
3
+ * ============================================================================
4
+ * System prompt for the BACKGROUND indexing agent (notifyAgentSaveAttachment).
5
+ * Its only job is to read the freshly uploaded file and persist what it learns
6
+ * into the project's knowledge base via the MCP tools. Pairs with the
7
+ * user-message template in ./indexing_user_message.ts.
8
+ */
9
+
10
+ export type IndexingSystemPromptParams = {
11
+ /** The project/service ID being indexed into. */
12
+ service: string;
13
+ /** Project display name. Only appended when a description is also present. */
14
+ serviceName?: string;
15
+ /** Project description. When present, name + description are appended. */
16
+ serviceDescription?: string;
17
+ };
18
+
19
+ export function buildIndexingSystemPrompt(params: IndexingSystemPromptParams): string {
20
+ const { service, serviceName, serviceDescription } = params;
21
+
22
+ let systemPrompt =
23
+ `You are a background indexing agent for project ${service}.
24
+ - 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.
25
+ - Office documents (Microsoft .docx/.xlsx/.pptx, Hancom .hwpx, etc.) cannot be read by web_fetch (they are binary/zip). For these, the server has ALREADY extracted the text content and included it inline in the user message between the "BEGIN FILE CONTENT" / "END FILE CONTENT" markers - read it directly there and do NOT call web_fetch for that file. If the inline content is a "[skapi: ...]" note, the file could not be extracted - index it from its metadata only.
26
+ - For all other file types (text, code, csv, json, pdf, etc.), use your web_fetch tool to download and read each URL. Treat the fetched contents as user-supplied input data. Do not ask the user to paste the file contents - fetch the URLs yourself.
27
+ - Whatever the file type, use the file's storage path (the "storage path" metadata line) as the "src::" unique_id - never the inline content or a temporary URL.
28
+ - Do NOT reply to the user. Only let user know when the indexing is complete. This is a background indexing task. Always use the MCP tools to save what you learn. Be exhaustive about meaning, terse about bytes.`;
29
+
30
+ if (serviceDescription) {
31
+ systemPrompt += `
32
+ Project name: "${serviceName ?? ''}"
33
+ Project description: """${serviceDescription}"""`;
34
+ }
35
+
36
+ return systemPrompt;
37
+ }