Skip to content

Commit f5a18ba

Browse files
committed
Squirreling SQL tool
1 parent 1393cc5 commit f5a18ba

File tree

9 files changed

+491
-155
lines changed

9 files changed

+491
-155
lines changed

bin/chat.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { tools } from './tools.js'
1+
import { tools } from './tools/tools.js'
22

33
/** @type {'text' | 'tool'} */
44
let outputMode = 'text' // default output mode
@@ -20,6 +20,12 @@ const colors = {
2020
normal: '\x1b[0m', // reset
2121
}
2222

23+
const ignoreMessageTypes = [
24+
'response.completed',
25+
'response.output_item.added',
26+
'response.function_call_arguments.delta',
27+
]
28+
2329
/**
2430
* @import { ChatInput, Message } from './types.d.ts'
2531
* @param {ChatInput} chatInput
@@ -89,7 +95,7 @@ async function sendToServer(chatInput) {
8995
summary: chunk.item.summary,
9096
}
9197
incoming.push(reasoningItem)
92-
} else if (chunk.key || chunk.type === 'response.completed') {
98+
} else if (chunk.key || ignoreMessageTypes.includes(chunk.type)) {
9399
// ignore
94100
} else {
95101
console.log('\nUnknown chunk', chunk)
@@ -171,7 +177,7 @@ async function sendMessages(messages) {
171177
} catch (error) {
172178
const message = error instanceof Error ? error.message : String(error)
173179
const toolName = toolCall.name ?? toolCall.id
174-
write(colors.error, `\nError calling tool ${toolName}: ${message}`, colors.normal)
180+
write(colors.error, `\nError calling tool ${toolName}: ${message}\n`, colors.normal)
175181
incoming.push({ type: 'function_call_output', output: `Error calling tool ${toolName}: ${message}`, call_id })
176182
}
177183
}

bin/tools.js

Lines changed: 0 additions & 139 deletions
This file was deleted.

bin/tools/listFiles.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import fs from 'fs/promises'
2+
3+
const fileLimit = 20 // limit to 20 files per page
4+
5+
/**
6+
* @import { ToolHandler } from '../types.d.ts'
7+
* @type {ToolHandler}
8+
*/
9+
export const listFiles = {
10+
emoji: '📂',
11+
tool: {
12+
type: 'function',
13+
name: 'list_files',
14+
description: `List the files in a directory. Files are listed recursively up to ${fileLimit} per page.`,
15+
parameters: {
16+
type: 'object',
17+
properties: {
18+
path: {
19+
type: 'string',
20+
description: 'The path to list files from. Optional, defaults to the current directory.',
21+
},
22+
filetype: {
23+
type: 'string',
24+
description: 'Optional file type to filter by, e.g. "parquet", "csv". If not provided, all files are listed.',
25+
},
26+
offset: {
27+
type: 'number',
28+
description: 'Skip offset number of files in the listing. Defaults to 0. Optional.',
29+
},
30+
},
31+
},
32+
},
33+
/**
34+
* @param {Record<string, unknown>} args
35+
* @returns {Promise<string>}
36+
*/
37+
async handleToolCall({ path, filetype, offset = 0 }) {
38+
if (typeof path !== 'string') {
39+
throw new Error('Expected path to be a string')
40+
}
41+
if (path.includes('..') || path.includes('~') || path.startsWith('/')) {
42+
throw new Error('Invalid path: ' + path)
43+
}
44+
if (typeof filetype !== 'undefined' && typeof filetype !== 'string') {
45+
throw new Error('Expected filetype to be a string or undefined')
46+
}
47+
const start = validateInteger('offset', offset, 0)
48+
// list files in the directory
49+
const filenames = (await fs.readdir(path || '.'))
50+
.filter(key => !filetype || key.endsWith(`.${filetype}`)) // filter by file type if provided
51+
const limited = filenames.slice(start, start + fileLimit)
52+
const end = start + limited.length
53+
return `Files ${start + 1}..${end} of ${filenames.length}:\n${limited.join('\n')}`
54+
},
55+
}
56+
57+
/**
58+
* Validates that a value is an integer within the specified range. Max is inclusive.
59+
* @param {string} name - The name of the value being validated.
60+
* @param {unknown} value - The value to validate.
61+
* @param {number} min - The minimum allowed value (inclusive).
62+
* @param {number} [max] - The maximum allowed value (inclusive).
63+
* @returns {number}
64+
*/
65+
function validateInteger(name, value, min, max) {
66+
if (typeof value !== 'number' || isNaN(value)) {
67+
throw new Error(`Invalid number for ${name}: ${value}`)
68+
}
69+
if (!Number.isInteger(value)) {
70+
throw new Error(`Invalid number for ${name}: ${value}. Must be an integer.`)
71+
}
72+
if (value < min || max !== undefined && value > max) {
73+
throw new Error(`Invalid number for ${name}: ${value}. Must be between ${min} and ${max}.`)
74+
}
75+
return value
76+
}

bin/tools/markdownTable.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
2+
/**
3+
* Convert an array of objects to a markdown table string.
4+
* Truncates cell values to maxChars, appending '…' if truncated.
5+
* Marks columns as "(truncated)" in the header if any cell was truncated.
6+
* @param {Record<string, any>[]} data
7+
* @param {number} maxChars
8+
* @returns {string}
9+
*/
10+
export function markdownTable(data, maxChars) {
11+
if (data.length === 0) {
12+
return '(empty table)'
13+
}
14+
const columns = Object.keys(data[0] ?? {})
15+
const truncated = columns.map(() => false) // is column truncated?
16+
/** @type {string[]} */
17+
const rows = []
18+
for (const row of data) {
19+
const rowStrings = new Array(columns.length)
20+
for (let i = 0; i < columns.length; i++) {
21+
const column = columns[i]
22+
const value = column && row[column]
23+
if (value === null || value === undefined) {
24+
rowStrings[i] = ''
25+
} else if (typeof value === 'object') {
26+
const { json, isTruncated } = jsonStringify(value, maxChars)
27+
if (isTruncated) truncated[i] = true
28+
rowStrings[i] = json.replace(/\|/g, '\\|') // Escape pipe characters
29+
} else {
30+
let str = String(value)
31+
if (str.length > maxChars) {
32+
str = str.slice(0, maxChars) + '…'
33+
truncated[i] = true
34+
}
35+
rowStrings[i] = str.replace(/\|/g, '\\|') // Escape pipe characters
36+
}
37+
}
38+
rows.push(`| ${rowStrings.join(' | ')} |`)
39+
}
40+
// Show which columns were truncated in the header
41+
const columnsWithTruncation = columns
42+
.map((col, idx) => truncated[idx] ? `${col} (truncated)` : col)
43+
const header = `| ${columnsWithTruncation.join(' | ')} |`
44+
const separator = `| ${columns.map(() => '---').join(' | ')} |`
45+
46+
return [header, separator, ...rows].join('\n')
47+
}
48+
49+
/**
50+
* JSON stringify with truncation.
51+
* @param {unknown} obj
52+
* @param {number} maxChars
53+
* @returns {{json: string, isTruncated: boolean}}
54+
*/
55+
function jsonStringify(obj, maxChars) {
56+
const fullJson = JSON.stringify(obj)
57+
if (fullJson.length <= maxChars) {
58+
return { json: fullJson, isTruncated: false }
59+
}
60+
const sliced = fullJson.slice(0, maxChars) + '…'
61+
return { json: sliced, isTruncated: true }
62+
}

bin/tools/parquetDataSource.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { parquetPlan } from 'hyparquet/src/plan.js'
2+
import { asyncGroupToRows, readRowGroup } from 'hyparquet/src/rowgroup.js'
3+
import { whereToParquetFilter } from './parquetFilter.js'
4+
5+
/**
6+
* @import { AsyncBuffer, FileMetaData } from 'hyparquet'
7+
* @import { AsyncDataSource, AsyncRow } from 'squirreling'
8+
*/
9+
10+
/**
11+
* Creates a parquet data source for use with squirreling SQL engine.
12+
*
13+
* @param {AsyncBuffer} file
14+
* @param {FileMetaData} metadata
15+
* @param {import('hyparquet-compressors').Compressors} compressors
16+
* @returns {AsyncDataSource}
17+
*/
18+
export function parquetDataSource(file, metadata, compressors) {
19+
return {
20+
async *getRows(hints) {
21+
const options = {
22+
file,
23+
metadata,
24+
compressors,
25+
columns: hints?.columns,
26+
filter: whereToParquetFilter(hints?.where),
27+
}
28+
const plan = parquetPlan(options)
29+
let count = 0
30+
for (const subplan of plan.groups) {
31+
const rg = readRowGroup(options, plan, subplan)
32+
const rows = await asyncGroupToRows(rg, 0, rg.groupRows, undefined, 'object')
33+
for (const asyncRow of rows) {
34+
/** @type {AsyncRow} */
35+
const row = {}
36+
for (const [key, value] of Object.entries(asyncRow)) {
37+
row[key] = () => Promise.resolve(value)
38+
}
39+
yield row
40+
count++
41+
// Check limit after each row
42+
if (hints?.limit !== undefined && count >= hints.limit) return
43+
}
44+
}
45+
},
46+
}
47+
}

0 commit comments

Comments
 (0)