Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-xlsx-spreadsheets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Support reading XLSX spreadsheets as labelled tabular text
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-to-json-schema": "3.24.5"
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/src/kilocode/tool/read-extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Readable } from "stream"
import * as Docx from "./read-docx"
import * as Notebook from "./notebook"
import * as Xlsx from "./xlsx"

export function binary(filepath: string) {
return Docx.accepts(filepath) || Xlsx.is(filepath)
}

export async function open(filepath: string): Promise<Readable | undefined> {
if (Docx.accepts(filepath)) return Docx.open(filepath)
if (Xlsx.is(filepath)) return Xlsx.open(filepath)
if (Notebook.isFile(filepath)) return Notebook.open(filepath)
return undefined
}
78 changes: 78 additions & 0 deletions packages/opencode/src/kilocode/tool/xlsx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import path from "path"
import { Readable } from "stream"
import { read, utils, type CellObject, type WorkBook } from "xlsx"

const ROW_LIMIT = 50_000
const MAX_SIZE = 50 * 1024 * 1024
const MAX_SIZE_LABEL = `${MAX_SIZE / (1024 * 1024)} MB`

export function is(filepath: string) {
return path.extname(filepath).toLowerCase() === ".xlsx"
}

export async function open(filepath: string) {
const file = Bun.file(filepath)
if (file.size > MAX_SIZE) {
throw new Error(`Cannot read spreadsheet file: ${filepath} exceeds the ${MAX_SIZE_LABEL} size limit`)
}
const bytes = new Uint8Array(await file.arrayBuffer())
if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
throw new Error(`Cannot read spreadsheet file: ${filepath} is not a valid XLSX workbook`)
}

try {
const book = read(bytes, { type: "array", cellDates: true })
return Readable.from(lines(book))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Cannot read spreadsheet file: ${filepath}: ${message}`, { cause: err })
}
}

function cell(value: CellObject | undefined) {
if (!value) return ""
if (value.f) {
if (value.w !== undefined && value.w !== null) return value.w
if (value.v !== undefined && value.v !== null) return String(value.v)
return `[Formula: ${value.f}]`
}
if (value.v === undefined || value.v === null) return ""
if (value.t === "e") return `[Error: ${value.w ?? String(value.v)}]`
if (value.t === "d") return value.v instanceof Date ? value.v.toISOString().slice(0, 10) : String(value.v)
if (value.l?.Target) return `${value.w ?? String(value.v)} (${value.l.Target})`
return value.w ?? String(value.v)
}

function* lines(book: WorkBook) {
const sheets = book.SheetNames.filter((_, index) => {
const hidden = book.Workbook?.Sheets?.[index]?.Hidden
return hidden !== 1 && hidden !== 2
})
for (const [index, name] of sheets.entries()) {
if (index > 0) yield "\n"
yield `--- Sheet: ${name} ---\n`

const sheet = book.Sheets[name]
if (!sheet?.["!ref"]) continue
const range = utils.decode_range(sheet["!ref"])
const end = Math.min(range.e.r, ROW_LIMIT - 1)
const rows = new Map<number, Map<number, string>>()
for (const key of Object.keys(sheet)) {
if (key.startsWith("!")) continue
const pos = utils.decode_cell(key)
if (pos.r < range.s.r || pos.r > end || pos.c < range.s.c || pos.c > range.e.c) continue
const value = cell(sheet[key])
if (!value.trim()) continue
const row = rows.get(pos.r) ?? new Map<number, string>()
row.set(pos.c, value)
rows.set(pos.r, row)
}

for (const values of [...rows.entries()].sort((a, b) => a[0] - b[0]).map((entry) => entry[1])) {
Comment thread
marius-kilocode marked this conversation as resolved.
const last = Math.max(...values.keys())
const row = Array.from({ length: last - range.s.c + 1 }, (_, col) => values.get(col + range.s.c) ?? "")
yield row.join("\t") + "\n"
}
if (range.e.r > end) yield `[... truncated at row ${ROW_LIMIT} ...]\n`
}
}
21 changes: 9 additions & 12 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import { isPdfAttachment, sniffAttachmentMime } from "@/util/media"
// kilocode_change start
import * as Encoding from "../kilocode/encoding"
import * as TextStream from "../kilocode/text-stream"
import * as Notebook from "../kilocode/tool/notebook"
import * as Docx from "../kilocode/tool/read-docx"
import * as Extract from "../kilocode/tool/read-extract"
// kilocode_change end

const DEFAULT_READ_LIMIT = 2000
Expand Down Expand Up @@ -302,23 +301,20 @@ export const ReadTool = Tool.define(
}
}

// kilocode_change start - extract DOCX text before generic binary rejection
const docx = Docx.accepts(filepath)
const opts = { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }
const read = docx
? () => Docx.open(filepath).then((stream) => readLines(stream, opts))
: () => lines(filepath, opts)
if (!docx && isBinaryFile(filepath, sample)) {
// kilocode_change start - route extractable binary documents through lines()
if (!Extract.binary(filepath) && isBinaryFile(filepath, sample)) {
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
}

const file = yield* Effect.promise(read)
// kilocode_change end
const file = yield* Effect.promise(() =>
lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }),
)
if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) {
return yield* Effect.fail(
new Error(`Offset ${file.offset} is out of range for this file (${file.count} lines)`),
)
}
// kilocode_change end

let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>\n"].join("\n")
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
Expand Down Expand Up @@ -365,7 +361,8 @@ export const ReadTool = Tool.define(
// routed through TextStream.withFallback so non-UTF-8 files are decoded via
// iconv. The body otherwise matches upstream.
export async function lines(filepath: string, opts: { limit: number; offset: number }) {
if (Notebook.isFile(filepath)) return readLines(await Notebook.open(filepath), opts) // kilocode_change - extract readable notebook cells before paging
const extracted = await Extract.open(filepath) // kilocode_change - extract supported document contents before paging
if (extracted) return readLines(extracted, opts) // kilocode_change
return TextStream.withFallback(filepath, (stream) => readLines(stream, opts))
}

Expand Down
Loading
Loading