From f5a18ba6a69a0a6eec71e8f71809463145396727 Mon Sep 17 00:00:00 2001 From: Kenny Daniel Date: Mon, 8 Dec 2025 21:27:26 -0800 Subject: [PATCH] Squirreling SQL tool --- bin/chat.js | 12 ++- bin/tools.js | 139 -------------------------- bin/tools/listFiles.js | 76 ++++++++++++++ bin/tools/markdownTable.js | 62 ++++++++++++ bin/tools/parquetDataSource.js | 47 +++++++++ bin/tools/parquetFilter.js | 175 +++++++++++++++++++++++++++++++++ bin/tools/parquetSql.js | 97 ++++++++++++++++++ bin/tools/tools.js | 11 +++ package.json | 27 ++--- 9 files changed, 491 insertions(+), 155 deletions(-) delete mode 100644 bin/tools.js create mode 100644 bin/tools/listFiles.js create mode 100644 bin/tools/markdownTable.js create mode 100644 bin/tools/parquetDataSource.js create mode 100644 bin/tools/parquetFilter.js create mode 100644 bin/tools/parquetSql.js create mode 100644 bin/tools/tools.js diff --git a/bin/chat.js b/bin/chat.js index fe8049d8..e2b06a92 100644 --- a/bin/chat.js +++ b/bin/chat.js @@ -1,4 +1,4 @@ -import { tools } from './tools.js' +import { tools } from './tools/tools.js' /** @type {'text' | 'tool'} */ let outputMode = 'text' // default output mode @@ -20,6 +20,12 @@ const colors = { normal: '\x1b[0m', // reset } +const ignoreMessageTypes = [ + 'response.completed', + 'response.output_item.added', + 'response.function_call_arguments.delta', +] + /** * @import { ChatInput, Message } from './types.d.ts' * @param {ChatInput} chatInput @@ -89,7 +95,7 @@ async function sendToServer(chatInput) { summary: chunk.item.summary, } incoming.push(reasoningItem) - } else if (chunk.key || chunk.type === 'response.completed') { + } else if (chunk.key || ignoreMessageTypes.includes(chunk.type)) { // ignore } else { console.log('\nUnknown chunk', chunk) @@ -171,7 +177,7 @@ async function sendMessages(messages) { } catch (error) { const message = error instanceof Error ? error.message : String(error) const toolName = toolCall.name ?? toolCall.id - write(colors.error, `\nError calling tool ${toolName}: ${message}`, colors.normal) + write(colors.error, `\nError calling tool ${toolName}: ${message}\n`, colors.normal) incoming.push({ type: 'function_call_output', output: `Error calling tool ${toolName}: ${message}`, call_id }) } } diff --git a/bin/tools.js b/bin/tools.js deleted file mode 100644 index 3fd446f7..00000000 --- a/bin/tools.js +++ /dev/null @@ -1,139 +0,0 @@ -import fs from 'fs/promises' -import { asyncBufferFromFile, parquetQuery, toJson } from 'hyparquet' -import { compressors } from 'hyparquet-compressors' - -const fileLimit = 20 // limit to 20 files per page -/** - * @import { ToolHandler } from './types.d.ts' - * @type {ToolHandler[]} - */ -export const tools = [ - { - emoji: '📂', - tool: { - type: 'function', - name: 'list_files', - description: `List the files in a directory. Files are listed recursively up to ${fileLimit} per page.`, - parameters: { - type: 'object', - properties: { - path: { - type: 'string', - description: 'The path to list files from. Optional, defaults to the current directory.', - }, - filetype: { - type: 'string', - description: 'Optional file type to filter by, e.g. "parquet", "csv". If not provided, all files are listed.', - }, - offset: { - type: 'number', - description: 'Skip offset number of files in the listing. Defaults to 0. Optional.', - }, - }, - }, - }, - /** - * @param {Record} args - * @returns {Promise} - */ - async handleToolCall({ path = '.', filetype, offset = 0 }) { - if (typeof path !== 'string') { - throw new Error('Expected path to be a string') - } - if (path.includes('..') || path.includes('~') || path.startsWith('/')) { - throw new Error('Invalid path: ' + path) - } - if (typeof filetype !== 'undefined' && typeof filetype !== 'string') { - throw new Error('Expected filetype to be a string or undefined') - } - const start = validateInteger('offset', offset, 0) - // list files in the directory - const filenames = (await fs.readdir(path)) - .filter(key => !filetype || key.endsWith(`.${filetype}`)) // filter by file type if provided - const limited = filenames.slice(start, start + fileLimit) - const end = start + limited.length - return `Files ${start + 1}..${end} of ${filenames.length}:\n${limited.join('\n')}` - }, - }, - { - emoji: '📄', - tool: { - type: 'function', - name: 'parquet_get_rows', - description: 'Get up to 5 rows of data from a parquet file.', - parameters: { - type: 'object', - properties: { - filename: { - type: 'string', - description: 'The name of the parquet file to read.', - }, - offset: { - type: 'number', - description: 'The starting row index to fetch (0-indexed).', - }, - limit: { - type: 'number', - description: 'The number of rows to fetch. Default 5. Maximum 5.', - }, - orderBy: { - type: 'string', - description: 'The column name to sort by.', - }, - }, - required: ['filename'], - }, - }, - /** - * @param {Record} args - * @returns {Promise} - */ - async handleToolCall({ filename, offset = 0, limit = 5, orderBy }) { - if (typeof filename !== 'string') { - throw new Error('Expected filename to be a string') - } - const rowStart = validateInteger('offset', offset, 0) - const rowEnd = rowStart + validateInteger('limit', limit, 1, 5) - if (typeof orderBy !== 'undefined' && typeof orderBy !== 'string') { - throw new Error('Expected orderBy to be a string') - } - const file = await asyncBufferFromFile(filename) - const rows = await parquetQuery({ file, rowStart, rowEnd, orderBy, compressors }) - let content = '' - for (let i = rowStart; i < rowEnd; i++) { - content += `Row ${i}: ${stringify(rows[i])}\n` - } - return content - }, - }, -] - -/** - * Validates that a value is an integer within the specified range. Max is inclusive. - * @param {string} name - The name of the value being validated. - * @param {unknown} value - The value to validate. - * @param {number} min - The minimum allowed value (inclusive). - * @param {number} [max] - The maximum allowed value (inclusive). - * @returns {number} - */ -function validateInteger(name, value, min, max) { - if (typeof value !== 'number' || isNaN(value)) { - throw new Error(`Invalid number for ${name}: ${value}`) - } - if (!Number.isInteger(value)) { - throw new Error(`Invalid number for ${name}: ${value}. Must be an integer.`) - } - if (value < min || max !== undefined && value > max) { - throw new Error(`Invalid number for ${name}: ${value}. Must be between ${min} and ${max}.`) - } - return value -} - -/** - * @param {unknown} obj - * @param {number} [limit=1000] - */ -function stringify(obj, limit = 1000) { - const str = JSON.stringify(toJson(obj)) - return str.length <= limit ? str : str.slice(0, limit) + '…' -} diff --git a/bin/tools/listFiles.js b/bin/tools/listFiles.js new file mode 100644 index 00000000..3d283242 --- /dev/null +++ b/bin/tools/listFiles.js @@ -0,0 +1,76 @@ +import fs from 'fs/promises' + +const fileLimit = 20 // limit to 20 files per page + +/** + * @import { ToolHandler } from '../types.d.ts' + * @type {ToolHandler} + */ +export const listFiles = { + emoji: '📂', + tool: { + type: 'function', + name: 'list_files', + description: `List the files in a directory. Files are listed recursively up to ${fileLimit} per page.`, + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: 'The path to list files from. Optional, defaults to the current directory.', + }, + filetype: { + type: 'string', + description: 'Optional file type to filter by, e.g. "parquet", "csv". If not provided, all files are listed.', + }, + offset: { + type: 'number', + description: 'Skip offset number of files in the listing. Defaults to 0. Optional.', + }, + }, + }, + }, + /** + * @param {Record} args + * @returns {Promise} + */ + async handleToolCall({ path, filetype, offset = 0 }) { + if (typeof path !== 'string') { + throw new Error('Expected path to be a string') + } + if (path.includes('..') || path.includes('~') || path.startsWith('/')) { + throw new Error('Invalid path: ' + path) + } + if (typeof filetype !== 'undefined' && typeof filetype !== 'string') { + throw new Error('Expected filetype to be a string or undefined') + } + const start = validateInteger('offset', offset, 0) + // list files in the directory + const filenames = (await fs.readdir(path || '.')) + .filter(key => !filetype || key.endsWith(`.${filetype}`)) // filter by file type if provided + const limited = filenames.slice(start, start + fileLimit) + const end = start + limited.length + return `Files ${start + 1}..${end} of ${filenames.length}:\n${limited.join('\n')}` + }, +} + +/** + * Validates that a value is an integer within the specified range. Max is inclusive. + * @param {string} name - The name of the value being validated. + * @param {unknown} value - The value to validate. + * @param {number} min - The minimum allowed value (inclusive). + * @param {number} [max] - The maximum allowed value (inclusive). + * @returns {number} + */ +function validateInteger(name, value, min, max) { + if (typeof value !== 'number' || isNaN(value)) { + throw new Error(`Invalid number for ${name}: ${value}`) + } + if (!Number.isInteger(value)) { + throw new Error(`Invalid number for ${name}: ${value}. Must be an integer.`) + } + if (value < min || max !== undefined && value > max) { + throw new Error(`Invalid number for ${name}: ${value}. Must be between ${min} and ${max}.`) + } + return value +} diff --git a/bin/tools/markdownTable.js b/bin/tools/markdownTable.js new file mode 100644 index 00000000..62ce10d2 --- /dev/null +++ b/bin/tools/markdownTable.js @@ -0,0 +1,62 @@ + +/** + * Convert an array of objects to a markdown table string. + * Truncates cell values to maxChars, appending '…' if truncated. + * Marks columns as "(truncated)" in the header if any cell was truncated. + * @param {Record[]} data + * @param {number} maxChars + * @returns {string} + */ +export function markdownTable(data, maxChars) { + if (data.length === 0) { + return '(empty table)' + } + const columns = Object.keys(data[0] ?? {}) + const truncated = columns.map(() => false) // is column truncated? + /** @type {string[]} */ + const rows = [] + for (const row of data) { + const rowStrings = new Array(columns.length) + for (let i = 0; i < columns.length; i++) { + const column = columns[i] + const value = column && row[column] + if (value === null || value === undefined) { + rowStrings[i] = '' + } else if (typeof value === 'object') { + const { json, isTruncated } = jsonStringify(value, maxChars) + if (isTruncated) truncated[i] = true + rowStrings[i] = json.replace(/\|/g, '\\|') // Escape pipe characters + } else { + let str = String(value) + if (str.length > maxChars) { + str = str.slice(0, maxChars) + '…' + truncated[i] = true + } + rowStrings[i] = str.replace(/\|/g, '\\|') // Escape pipe characters + } + } + rows.push(`| ${rowStrings.join(' | ')} |`) + } + // Show which columns were truncated in the header + const columnsWithTruncation = columns + .map((col, idx) => truncated[idx] ? `${col} (truncated)` : col) + const header = `| ${columnsWithTruncation.join(' | ')} |` + const separator = `| ${columns.map(() => '---').join(' | ')} |` + + return [header, separator, ...rows].join('\n') +} + +/** + * JSON stringify with truncation. + * @param {unknown} obj + * @param {number} maxChars + * @returns {{json: string, isTruncated: boolean}} + */ +function jsonStringify(obj, maxChars) { + const fullJson = JSON.stringify(obj) + if (fullJson.length <= maxChars) { + return { json: fullJson, isTruncated: false } + } + const sliced = fullJson.slice(0, maxChars) + '…' + return { json: sliced, isTruncated: true } +} diff --git a/bin/tools/parquetDataSource.js b/bin/tools/parquetDataSource.js new file mode 100644 index 00000000..d4d837ea --- /dev/null +++ b/bin/tools/parquetDataSource.js @@ -0,0 +1,47 @@ +import { parquetPlan } from 'hyparquet/src/plan.js' +import { asyncGroupToRows, readRowGroup } from 'hyparquet/src/rowgroup.js' +import { whereToParquetFilter } from './parquetFilter.js' + +/** + * @import { AsyncBuffer, FileMetaData } from 'hyparquet' + * @import { AsyncDataSource, AsyncRow } from 'squirreling' + */ + +/** + * Creates a parquet data source for use with squirreling SQL engine. + * + * @param {AsyncBuffer} file + * @param {FileMetaData} metadata + * @param {import('hyparquet-compressors').Compressors} compressors + * @returns {AsyncDataSource} + */ +export function parquetDataSource(file, metadata, compressors) { + return { + async *getRows(hints) { + const options = { + file, + metadata, + compressors, + columns: hints?.columns, + filter: whereToParquetFilter(hints?.where), + } + const plan = parquetPlan(options) + let count = 0 + for (const subplan of plan.groups) { + const rg = readRowGroup(options, plan, subplan) + const rows = await asyncGroupToRows(rg, 0, rg.groupRows, undefined, 'object') + for (const asyncRow of rows) { + /** @type {AsyncRow} */ + const row = {} + for (const [key, value] of Object.entries(asyncRow)) { + row[key] = () => Promise.resolve(value) + } + yield row + count++ + // Check limit after each row + if (hints?.limit !== undefined && count >= hints.limit) return + } + } + }, + } +} diff --git a/bin/tools/parquetFilter.js b/bin/tools/parquetFilter.js new file mode 100644 index 00000000..6413474e --- /dev/null +++ b/bin/tools/parquetFilter.js @@ -0,0 +1,175 @@ +/** + * Converts a WHERE clause AST to hyparquet filter format. + * Returns undefined if the expression cannot be fully converted. + * + * @param {import('squirreling/src/types.js').ExprNode | undefined} where + * @returns {import('hyparquet').ParquetQueryFilter | undefined} + */ +export function whereToParquetFilter(where) { + if (!where) return undefined + return convertExpr(where, false) +} + +/** + * Converts an expression node to filter format + * + * @param {import('squirreling/src/types.js').ExprNode} node + * @param {boolean} negate + * @returns {import('hyparquet').ParquetQueryFilter | undefined} + */ +function convertExpr(node, negate) { + if (node.type === 'unary' && node.op === 'NOT') { + return convertExpr(node.argument, !negate) + } + if (node.type === 'binary') { + return convertBinary(node, negate) + } + if (node.type === 'in valuelist') { + return convertInValues(node, negate) + } + if (node.type === 'cast') { + return convertExpr(node.expr, negate) + } + // Non-convertible types - return undefined to skip optimization + return undefined +} + +/** + * Converts a binary expression to filter format + * + * @param {import('squirreling/src/types.js').BinaryNode} node + * @param {boolean} negate + * @returns {import('hyparquet').ParquetQueryFilter | undefined} + */ +function convertBinary({ op, left, right }, negate) { + if (op === 'AND') { + const leftFilter = convertExpr(left, negate) + const rightFilter = convertExpr(right, negate) + if (!leftFilter || !rightFilter) return + return negate + ? { $or: [leftFilter, rightFilter] } + : { $and: [leftFilter, rightFilter] } + } + if (op === 'OR') { + const leftFilter = convertExpr(left, false) + const rightFilter = convertExpr(right, false) + if (!leftFilter || !rightFilter) return + return negate + ? { $nor: [leftFilter, rightFilter] } + : { $or: [leftFilter, rightFilter] } + } + + // LIKE is not supported by hyparquet filters + if (op === 'LIKE') return + + // Comparison operators: need identifier on one side and literal on the other + const { column, value, flipped } = extractColumnAndValue(left, right) + if (!column || value === undefined) return + + // Map SQL operator to MongoDB operator + const mongoOp = mapOperator(op, flipped, negate) + if (!mongoOp) return + return { [column]: { [mongoOp]: value } } +} + +/** + * Extracts column name and literal value from binary operands. + * Handles both "column op value" and "value op column" patterns. + * + * @param {import('squirreling/src/types.js').ExprNode} left + * @param {import('squirreling/src/types.js').ExprNode} right + * @returns {{ column: string | undefined; value: import('squirreling/src/types.js').SqlPrimitive | undefined; flipped: boolean }} + */ +function extractColumnAndValue(left, right) { + // column op value + if (left.type === 'identifier' && right.type === 'literal') { + return { column: left.name, value: right.value, flipped: false } + } + // value op column (flipped) + if (left.type === 'literal' && right.type === 'identifier') { + return { column: right.name, value: left.value, flipped: true } + } + // Neither pattern matches + return { column: undefined, value: undefined, flipped: false } +} + +/** + * Maps SQL operator to MongoDB operator, accounting for flipped operands + * + * @param {import('squirreling/src/types.js').BinaryOp} op + * @param {boolean} flipped + * @param {boolean} negate + * @returns {string | undefined} + */ +function mapOperator(op, flipped, negate) { + if (!isBinaryOp(op)) return + + let mappedOp = op + if (negate) mappedOp = neg(mappedOp) + if (flipped) mappedOp = flip(mappedOp) + // Symmetric operators (same when flipped) + if (mappedOp === '=') return '$eq' + if (mappedOp === '!=' || mappedOp === '<>') return '$ne' + if (mappedOp === '<') return '$lt' + if (mappedOp === '<=') return '$lte' + if (mappedOp === '>') return '$gt' + if (mappedOp === '>=') return '$gte' +} + +/** + * @param {import('squirreling/src/types.js').ComparisonOp} op + * @returns {import('squirreling/src/types.js').ComparisonOp} + */ +function neg(op) { + if (op === '<') return '>=' + if (op === '<=') return '>' + if (op === '>') return '<=' + if (op === '>=') return '<' + if (op === '=') return '!=' + if (op === '!=') return '=' + if (op === '<>') return '=' + throw new Error(`Unexpected comparison operator: ${op}`) +} + +/** + * @param {import('squirreling/src/types.js').ComparisonOp} op + * @returns {import('squirreling/src/types.js').ComparisonOp} + */ +function flip(op) { + if (op === '<') return '>' + if (op === '<=') return '>=' + if (op === '>') return '<' + if (op === '>=') return '<=' + if (op === '=') return '=' + if (op === '!=') return '!=' + if (op === '<>') return '=' + throw new Error(`Unexpected comparison operator: ${op}`) +} + +/** + * @param {string} op + * @returns {op is import('squirreling/src/types.js').ComparisonOp} + */ +export function isBinaryOp(op) { + return ['AND', 'OR', 'LIKE', '=', '!=', '<>', '<', '>', '<=', '>='].includes(op) +} + +/** + * Converts IN/NOT IN value list expression to filter format + * + * @param {import('squirreling/src/types.js').InValuesNode} node + * @param {boolean} negate + * @returns {import('hyparquet').ParquetQueryFilter | undefined} + */ +function convertInValues(node, negate) { + if (node.expr.type !== 'identifier') return + + // All values must be literals + const values = [] + for (const val of node.values) { + if (val.type !== 'literal') return + values.push(val.value) + } + + return { [node.expr.name]: { [negate ? '$nin' : '$in']: values } } +} diff --git a/bin/tools/parquetSql.js b/bin/tools/parquetSql.js new file mode 100644 index 00000000..407d045b --- /dev/null +++ b/bin/tools/parquetSql.js @@ -0,0 +1,97 @@ +import { asyncBufferFromFile, parquetMetadataAsync } from 'hyparquet' +import { compressors } from 'hyparquet-compressors' +import { collect, executeSql } from 'squirreling' +import { parquetDataSource } from './parquetDataSource.js' +import { markdownTable } from './markdownTable.js' + +const maxRows = 100 + +/** + * @import { ToolHandler } from '../types.d.ts' + * @type {ToolHandler} + */ +export const parquetSql = { + emoji: '🗃️', + tool: { + type: 'function', + name: 'parquet_sql', + description: 'Execute SQL queries against a parquet file using ANSI SQL syntax.' + + ' Cell values are truncated by default to 1000 characters (or 10,000 if truncate=false).' + + ' If a cell is truncated due to length, the column header will say "(truncated)".' + + ' You can get subsequent pages of long text by using SUBSTR on long columns.' + + ' Examples:' + + '\n - `SELECT * FROM table LIMIT 10`' + + '\n - `SELECT "First Name", "Last Name" FROM table WHERE age > 30 ORDER BY age DESC`.' + + '\n - `SELECT country, COUNT(*) as total FROM table GROUP BY country`.' + + '\n - `SELECT SUBSTR(long_column, 10001, 20000) as short_column FROM table`.', + parameters: { + type: 'object', + properties: { + filename: { + type: 'string', + description: 'The name of the parquet file to query.', + }, + query: { + type: 'string', + description: 'The SQL query string. Use standard SQL syntax with WHERE clauses, ORDER BY, LIMIT, GROUP BY, aggregate functions, etc. Wrap column names containing spaces in double quotes: "column name". String literals should be single-quoted. Always use "table" as the table name in your FROM clause.', + }, + truncate: { + type: 'boolean', + description: 'Whether to truncate long string values in the results. If true (default), each string cell is limited to 1000 characters. If false, each string cell is limited to 10,000 characters.', + }, + }, + required: ['filename', 'query'], + }, + }, + /** + * @param {Record} args + * @returns {Promise} + */ + async handleToolCall({ filename, query, truncate = true }) { + if (typeof filename !== 'string') { + throw new Error('Expected filename to be a string') + } + if (typeof query !== 'string' || query.trim().length === 0) { + throw new Error('Query parameter must be a non-empty string') + } + + try { + const startTime = performance.now() + + // Load parquet file and create data source + const file = await asyncBufferFromFile(filename) + const metadata = await parquetMetadataAsync(file) + const table = parquetDataSource(file, metadata, compressors) + + // Execute SQL query + const results = await collect(executeSql({ tables: { table }, query })) + const queryTime = (performance.now() - startTime) / 1000 + + // Handle empty results + if (results.length === 0) { + return `Query executed successfully but returned no results in ${queryTime.toFixed(1)} seconds.` + } + + // Format results + const rowCount = results.length + const displayRows = results.slice(0, maxRows) + + // Determine max characters per string cell based on truncate parameter + const maxChars = truncate ? 1000 : 10000 + + // Convert to formatted string + let content = `Query returned ${rowCount} row${rowCount === 1 ? '' : 's'} in ${queryTime.toFixed(1)} seconds.` + content += '\n\n' + content += markdownTable(displayRows, maxChars) + + if (rowCount > maxRows) { + content += `\n\n... and ${rowCount - maxRows} more row${rowCount - maxRows === 1 ? '' : 's'} (showing first ${maxRows} rows)` + } + + return content + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return `SQL query failed: ${message}` + } + }, +} diff --git a/bin/tools/tools.js b/bin/tools/tools.js new file mode 100644 index 00000000..357273f3 --- /dev/null +++ b/bin/tools/tools.js @@ -0,0 +1,11 @@ +import { listFiles } from './listFiles.js' +import { parquetSql } from './parquetSql.js' + +/** + * @import { ToolHandler } from '../types.js' + * @type {ToolHandler[]} + */ +export const tools = [ + listFiles, + parquetSql, +] diff --git a/package.json b/package.json index db321c81..d7b4b2d8 100644 --- a/package.json +++ b/package.json @@ -58,33 +58,34 @@ "hightable": "0.24.1", "hyparquet": "1.22.1", "hyparquet-compressors": "1.1.1", - "icebird": "0.3.1" + "icebird": "0.3.1", + "squirreling": "0.4.8" }, "devDependencies": { "@eslint/js": "9.39.1", - "@storybook/react-vite": "10.1.2", + "@storybook/react-vite": "10.1.4", "@testing-library/react": "16.3.0", - "@types/node": "24.10.1", + "@types/node": "24.10.2", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "5.1.1", - "@vitest/coverage-v8": "4.0.14", + "@vitejs/plugin-react": "5.1.2", + "@vitest/coverage-v8": "4.0.15", "eslint": "9.39.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-refresh": "0.4.24", - "eslint-plugin-storybook": "10.1.2", + "eslint-plugin-storybook": "10.1.4", "globals": "16.5.0", - "jsdom": "27.2.0", + "jsdom": "27.3.0", "nodemon": "3.1.11", "npm-run-all": "4.1.5", - "react": "19.2.0", - "react-dom": "19.2.0", - "storybook": "10.1.2", + "react": "19.2.1", + "react-dom": "19.2.1", + "storybook": "10.1.4", "typescript": "5.9.3", - "typescript-eslint": "8.48.0", - "vite": "7.2.6", - "vitest": "4.0.14" + "typescript-eslint": "8.49.0", + "vite": "7.2.7", + "vitest": "4.0.15" }, "peerDependencies": { "react": "^18.3.1 || ^19",