@yeyuan98/opencode-bioresearcher-plugin 1.7.0 → 1.7.1

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.
@@ -1,10 +1,24 @@
1
1
  import { tool } from '@opencode-ai/plugin/tool';
2
2
  import * as fs from 'fs';
3
- import * as XLSX from 'xlsx';
3
+ import * as path from 'path';
4
+ import * as XLSX from '../../vendor/xlsx.mjs';
5
+ XLSX.set_fs(fs);
4
6
  import * as z from 'zod';
5
- import { processCellValue, formatError, resolvePath, getSheet, checkFileSize, writeAtomic, iterativeMin, iterativeMax, DEFAULT_MAX_ROW_CAP, DEFAULT_MAX_RANGE_CELLS, } from './utils';
6
- const CELL_ADDRESS_REGEX = /^[A-Za-z]+\d+$/;
7
+ import { formatError, resolvePath, writeAtomic, invalidateWorkbookCache, DEFAULT_MAX_ROW_CAP, } from './utils';
8
+ import { createBackend } from './backends/factory';
7
9
  const FORCE_DESC = "Override file size / row count limits. Use when you need to work with large files and accept the performance implications.";
10
+ function resolveSheetIndex(backend, sheetName) {
11
+ if (!sheetName)
12
+ return 0;
13
+ const sheets = backend.listSheets();
14
+ const idx = sheets.findIndex(s => s.name === sheetName);
15
+ if (idx === -1)
16
+ throw new Error(`Sheet "${sheetName}" not found`);
17
+ return idx;
18
+ }
19
+ function getSheetName(backend, sheetIndex) {
20
+ return backend.listSheets()[sheetIndex].name;
21
+ }
8
22
  export const tableGetSheetPreview = tool({
9
23
  description: "Get first 6 rows of a worksheet to preview data structure",
10
24
  args: {
@@ -14,23 +28,24 @@ export const tableGetSheetPreview = tool({
14
28
  },
15
29
  execute: async (args, context) => {
16
30
  await context.ask({ permission: "tableGetSheetPreview", patterns: [args.file_path], always: ["*"], metadata: {} });
31
+ const resolvedPath = resolvePath(args.file_path, context.directory);
17
32
  try {
18
- const resolvedPath = resolvePath(args.file_path, context.directory);
19
- checkFileSize(resolvedPath, args.force);
20
- const workbook = XLSX.readFile(resolvedPath);
21
- const worksheet = getSheet(workbook, args.sheet_name);
22
- const data = XLSX.utils.sheet_to_json(worksheet, { header: 1, range: 6 });
23
- const previewRows = data.slice(0, 6);
24
- const ref = worksheet['!ref'] ? XLSX.utils.decode_range(worksheet['!ref']) : null;
25
- const totalRows = ref ? ref.e.r - ref.s.r + 1 : data.length;
26
- return JSON.stringify({
27
- sheet_name: args.sheet_name || workbook.SheetNames[0],
28
- total_rows: totalRows,
29
- preview: previewRows,
30
- }, null, 2);
33
+ const backend = createBackend(resolvedPath, args.force);
34
+ try {
35
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
36
+ const { rows: preview, totalRows } = backend.getPreview(sheetIndex, 6);
37
+ return JSON.stringify({
38
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
39
+ total_rows: totalRows,
40
+ preview,
41
+ }, null, 2);
42
+ }
43
+ finally {
44
+ backend.dispose();
45
+ }
31
46
  }
32
47
  catch (error) {
33
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "get_sheet_preview", error });
48
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "get_sheet_preview", error });
34
49
  }
35
50
  }
36
51
  });
@@ -42,14 +57,19 @@ export const tableListSheets = tool({
42
57
  },
43
58
  execute: async (args, context) => {
44
59
  await context.ask({ permission: "tableListSheets", patterns: [args.file_path], always: ["*"], metadata: {} });
60
+ const resolvedPath = resolvePath(args.file_path, context.directory);
45
61
  try {
46
- const resolvedPath = resolvePath(args.file_path, context.directory);
47
- checkFileSize(resolvedPath, args.force);
48
- const workbook = XLSX.readFile(resolvedPath);
49
- return JSON.stringify({ sheets: workbook.SheetNames }, null, 2);
62
+ const backend = createBackend(resolvedPath, args.force);
63
+ try {
64
+ const sheets = backend.listSheets();
65
+ return JSON.stringify({ sheets: sheets.map(s => s.name) }, null, 2);
66
+ }
67
+ finally {
68
+ backend.dispose();
69
+ }
50
70
  }
51
71
  catch (error) {
52
- return formatError({ file_path: args.file_path, operation: "list_sheets", error });
72
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, operation: "list_sheets", error });
53
73
  }
54
74
  }
55
75
  });
@@ -62,26 +82,24 @@ export const tableGetHeaders = tool({
62
82
  },
63
83
  execute: async (args, context) => {
64
84
  await context.ask({ permission: "tableGetHeaders", patterns: [args.file_path], always: ["*"], metadata: {} });
85
+ const resolvedPath = resolvePath(args.file_path, context.directory);
65
86
  try {
66
- const resolvedPath = resolvePath(args.file_path, context.directory);
67
- checkFileSize(resolvedPath, args.force);
68
- const workbook = XLSX.readFile(resolvedPath);
69
- const worksheet = getSheet(workbook, args.sheet_name);
70
- const ref = worksheet['!ref'] ? XLSX.utils.decode_range(worksheet['!ref']) : { s: { r: 0, c: 0 }, e: { r: 0, c: 0 } };
71
- const headers = [];
72
- for (let C = ref.s.c; C <= ref.e.c; C++) {
73
- const addr = XLSX.utils.encode_cell({ r: ref.s.r, c: C });
74
- const cell = worksheet[addr];
75
- headers.push(processCellValue(cell));
87
+ const backend = createBackend(resolvedPath, args.force);
88
+ try {
89
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
90
+ const { headers, columnCount } = backend.getHeaders(sheetIndex);
91
+ return JSON.stringify({
92
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
93
+ headers,
94
+ column_count: columnCount,
95
+ }, null, 2);
96
+ }
97
+ finally {
98
+ backend.dispose();
76
99
  }
77
- return JSON.stringify({
78
- sheet_name: args.sheet_name || workbook.SheetNames[0],
79
- headers: headers,
80
- column_count: headers.length,
81
- }, null, 2);
82
100
  }
83
101
  catch (error) {
84
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "get_headers", error });
102
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "get_headers", error });
85
103
  }
86
104
  }
87
105
  });
@@ -95,25 +113,25 @@ export const tableGetCell = tool({
95
113
  },
96
114
  execute: async (args, context) => {
97
115
  await context.ask({ permission: "tableGetCell", patterns: [args.file_path], always: ["*"], metadata: {} });
116
+ const resolvedPath = resolvePath(args.file_path, context.directory);
98
117
  try {
99
- if (!CELL_ADDRESS_REGEX.test(args.cell_address)) {
100
- throw new Error(`Invalid cell address "${args.cell_address}". Expected format like "A1", "B5", "AA123".`);
118
+ const backend = createBackend(resolvedPath, args.force);
119
+ try {
120
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
121
+ const { value, type } = backend.getCell(sheetIndex, args.cell_address);
122
+ return JSON.stringify({
123
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
124
+ cell_address: args.cell_address,
125
+ value,
126
+ type,
127
+ }, null, 2);
128
+ }
129
+ finally {
130
+ backend.dispose();
101
131
  }
102
- const resolvedPath = resolvePath(args.file_path, context.directory);
103
- checkFileSize(resolvedPath, args.force);
104
- const workbook = XLSX.readFile(resolvedPath);
105
- const worksheet = getSheet(workbook, args.sheet_name);
106
- const cell = worksheet[args.cell_address];
107
- const value = processCellValue(cell);
108
- return JSON.stringify({
109
- sheet_name: args.sheet_name || workbook.SheetNames[0],
110
- cell_address: args.cell_address,
111
- value: value,
112
- type: cell?.t || 'unknown',
113
- }, null, 2);
114
132
  }
115
133
  catch (error) {
116
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "get_cell", error });
134
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "get_cell", error });
117
135
  }
118
136
  }
119
137
  });
@@ -130,81 +148,35 @@ export const tableFilterRows = tool({
130
148
  },
131
149
  execute: async (args, context) => {
132
150
  await context.ask({ permission: "tableFilterRows", patterns: [args.file_path], always: ["*"], metadata: {} });
151
+ const resolvedPath = resolvePath(args.file_path, context.directory);
133
152
  try {
134
- const resolvedPath = resolvePath(args.file_path, context.directory);
135
- checkFileSize(resolvedPath, args.force);
136
- const workbook = XLSX.readFile(resolvedPath);
137
- const worksheet = getSheet(workbook, args.sheet_name);
138
- const effectiveMax = args.force ? args.max_results : Math.min(args.max_results, DEFAULT_MAX_ROW_CAP);
139
- const filteredRows = [];
140
- let totalMatchesFound = 0;
141
- const data = XLSX.utils.sheet_to_json(worksheet);
142
- for (let i = 0; i < data.length; i++) {
143
- const row = data[i];
144
- let cellValue = row[args.column];
145
- let comparisonValue = args.value;
146
- if (typeof cellValue === 'string' && cellValue.trim() !== '' && !isNaN(Number(cellValue))) {
147
- cellValue = Number(cellValue);
148
- }
149
- if (typeof comparisonValue === 'string' && comparisonValue.trim() !== '' && !isNaN(Number(comparisonValue))) {
150
- comparisonValue = Number(comparisonValue);
151
- }
152
- let matches = false;
153
- switch (args.operator) {
154
- case '=':
155
- matches = cellValue === comparisonValue;
156
- break;
157
- case '!=':
158
- matches = cellValue !== comparisonValue;
159
- break;
160
- case '>':
161
- matches = cellValue > comparisonValue;
162
- break;
163
- case '<':
164
- matches = cellValue < comparisonValue;
165
- break;
166
- case '>=':
167
- matches = cellValue >= comparisonValue;
168
- break;
169
- case '<=':
170
- matches = cellValue <= comparisonValue;
171
- break;
172
- case 'contains':
173
- matches = String(cellValue).toLowerCase().includes(String(comparisonValue).toLowerCase());
174
- break;
175
- case 'starts_with':
176
- matches = String(cellValue).toLowerCase().startsWith(String(comparisonValue).toLowerCase());
177
- break;
178
- case 'ends_with':
179
- matches = String(cellValue).toLowerCase().endsWith(String(comparisonValue).toLowerCase());
180
- break;
181
- }
182
- if (matches) {
183
- totalMatchesFound++;
184
- if (filteredRows.length < effectiveMax) {
185
- filteredRows.push(row);
186
- }
187
- }
188
- if (!args.force && i >= DEFAULT_MAX_ROW_CAP - 1) {
189
- break;
153
+ const backend = createBackend(resolvedPath, args.force);
154
+ try {
155
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
156
+ const totalDataRows = backend.getTotalDataRows(sheetIndex);
157
+ const maxRowsScan = args.force ? totalDataRows : Math.min(totalDataRows, DEFAULT_MAX_ROW_CAP);
158
+ const effectiveMax = args.force ? args.max_results : Math.min(args.max_results, DEFAULT_MAX_ROW_CAP);
159
+ const { matchedRows, totalMatchesFound, totalDataRows: backendTotalRows } = backend.filterRows(sheetIndex, args.column, args.operator, args.value, { maxResults: effectiveMax, maxRowsScan });
160
+ const truncated = !args.force && backendTotalRows > DEFAULT_MAX_ROW_CAP;
161
+ const result = {
162
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
163
+ filter: { column: args.column, operator: args.operator, value: args.value },
164
+ total_matches_found: totalMatchesFound,
165
+ returned_count: matchedRows.length,
166
+ matched_rows: matchedRows,
167
+ };
168
+ if (truncated) {
169
+ result.truncated = true;
170
+ result.message = `Processed ${DEFAULT_MAX_ROW_CAP} of ${backendTotalRows} rows. Set force: true to process all rows.`;
190
171
  }
172
+ return JSON.stringify(result, null, 2);
191
173
  }
192
- const truncated = !args.force && data.length > DEFAULT_MAX_ROW_CAP;
193
- const result = {
194
- sheet_name: args.sheet_name || workbook.SheetNames[0],
195
- filter: { column: args.column, operator: args.operator, value: args.value },
196
- total_matches_found: totalMatchesFound,
197
- returned_count: filteredRows.length,
198
- matched_rows: filteredRows,
199
- };
200
- if (truncated) {
201
- result.truncated = true;
202
- result.message = `Processed ${DEFAULT_MAX_ROW_CAP} of ${data.length} rows. Set force: true to process all rows.`;
174
+ finally {
175
+ backend.dispose();
203
176
  }
204
- return JSON.stringify(result, null, 2);
205
177
  }
206
178
  catch (error) {
207
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "filter_rows", error });
179
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "filter_rows", error });
208
180
  }
209
181
  }
210
182
  });
@@ -214,56 +186,45 @@ export const tableSearch = tool({
214
186
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
215
187
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
216
188
  search_term: z.string().describe("Term to search for"),
189
+ column: z.string().optional().describe("Limit search to a specific column name (much faster for wide tables)"),
217
190
  max_results: z.number().default(50).describe("Maximum number of results to return"),
218
191
  force: z.boolean().default(false).describe(FORCE_DESC),
219
192
  },
220
193
  execute: async (args, context) => {
221
194
  await context.ask({ permission: "tableSearch", patterns: [args.file_path], always: ["*"], metadata: {} });
195
+ const resolvedPath = resolvePath(args.file_path, context.directory);
222
196
  try {
223
- const resolvedPath = resolvePath(args.file_path, context.directory);
224
- checkFileSize(resolvedPath, args.force);
225
- const workbook = XLSX.readFile(resolvedPath);
226
- const worksheet = getSheet(workbook, args.sheet_name);
227
- const results = [];
228
- const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
229
- const effectiveMax = args.force ? args.max_results : Math.min(args.max_results, DEFAULT_MAX_ROW_CAP);
230
- let rowsScanned = 0;
231
- let totalMatchesFound = 0;
232
- for (let R = range.s.r; R <= range.e.r; ++R) {
233
- rowsScanned++;
234
- if (!args.force && rowsScanned > DEFAULT_MAX_ROW_CAP)
235
- break;
236
- for (let C = range.s.c; C <= range.e.c; ++C) {
237
- const address = XLSX.utils.encode_cell({ r: R, c: C });
238
- const cell = worksheet[address];
239
- if (cell && cell.v !== undefined) {
240
- const cellValue = String(processCellValue(cell)).toLowerCase();
241
- if (cellValue.includes(args.search_term.toLowerCase())) {
242
- totalMatchesFound++;
243
- if (results.length < effectiveMax) {
244
- results.push({ address, value: processCellValue(cell), type: cell.t || 'unknown', row_index: R });
245
- }
246
- }
247
- }
197
+ const backend = createBackend(resolvedPath, args.force);
198
+ try {
199
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
200
+ const totalDataRows = backend.getTotalDataRows(sheetIndex);
201
+ const maxRowsScan = args.force ? totalDataRows : Math.min(totalDataRows, DEFAULT_MAX_ROW_CAP);
202
+ const effectiveMax = args.force ? args.max_results : Math.min(args.max_results, DEFAULT_MAX_ROW_CAP);
203
+ const { results, totalMatchesFound, rowsScanned } = backend.search(sheetIndex, args.search_term, { column: args.column, maxResults: effectiveMax, maxRowsScan });
204
+ const truncated = !args.force && totalDataRows > DEFAULT_MAX_ROW_CAP;
205
+ const result = {
206
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
207
+ search_term: args.search_term,
208
+ total_matches_found: totalMatchesFound,
209
+ returned_count: results.length,
210
+ rows_scanned: rowsScanned,
211
+ results,
212
+ };
213
+ if (args.column) {
214
+ result.column = args.column;
215
+ }
216
+ if (truncated) {
217
+ result.truncated = true;
218
+ result.message = `Scanned ${DEFAULT_MAX_ROW_CAP} rows. Set force: true to scan all rows.`;
248
219
  }
220
+ return JSON.stringify(result, null, 2);
249
221
  }
250
- const truncated = !args.force && rowsScanned >= DEFAULT_MAX_ROW_CAP && range.e.r >= DEFAULT_MAX_ROW_CAP;
251
- const result = {
252
- sheet_name: args.sheet_name || workbook.SheetNames[0],
253
- search_term: args.search_term,
254
- total_matches_found: totalMatchesFound,
255
- returned_count: results.length,
256
- rows_scanned: rowsScanned,
257
- results,
258
- };
259
- if (truncated) {
260
- result.truncated = true;
261
- result.message = `Scanned ${DEFAULT_MAX_ROW_CAP} rows. Set force: true to scan all rows.`;
222
+ finally {
223
+ backend.dispose();
262
224
  }
263
- return JSON.stringify(result, null, 2);
264
225
  }
265
226
  catch (error) {
266
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "search", error });
227
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "search", error });
267
228
  }
268
229
  }
269
230
  });
@@ -277,53 +238,33 @@ export const tableGetRange = tool({
277
238
  },
278
239
  execute: async (args, context) => {
279
240
  await context.ask({ permission: "tableGetRange", patterns: [args.file_path], always: ["*"], metadata: {} });
241
+ const resolvedPath = resolvePath(args.file_path, context.directory);
280
242
  try {
281
- const resolvedPath = resolvePath(args.file_path, context.directory);
282
- checkFileSize(resolvedPath, args.force);
283
- const workbook = XLSX.readFile(resolvedPath);
284
- const worksheet = getSheet(workbook, args.sheet_name);
285
- const range = XLSX.utils.decode_range(args.range);
286
- const sheetName = args.sheet_name || workbook.SheetNames[0];
287
- const sheetRef = worksheet['!ref'] ? XLSX.utils.decode_range(worksheet['!ref']) : null;
288
- if (!sheetRef) {
289
- return JSON.stringify({ sheet_name: sheetName, range: args.range, rows: 0, columns: 0, data: [], out_of_bounds: true }, null, 2);
290
- }
291
- const clamped = {
292
- s: { r: Math.max(range.s.r, sheetRef.s.r), c: Math.max(range.s.c, sheetRef.s.c) },
293
- e: { r: Math.min(range.e.r, sheetRef.e.r), c: Math.min(range.e.c, sheetRef.e.c) },
294
- };
295
- if (clamped.s.r > clamped.e.r || clamped.s.c > clamped.e.c) {
296
- return JSON.stringify({ sheet_name: sheetName, range: args.range, rows: 0, columns: 0, data: [], out_of_bounds: true }, null, 2);
297
- }
298
- const totalCells = (clamped.e.r - clamped.s.r + 1) * (clamped.e.c - clamped.s.c + 1);
299
- if (!args.force && totalCells > DEFAULT_MAX_RANGE_CELLS) {
300
- throw new Error(`Range "${args.range}" exceeds ${DEFAULT_MAX_RANGE_CELLS} cell limit (${totalCells} cells). ` +
301
- `Set force: true to override.`);
302
- }
303
- const data = [];
304
- for (let R = clamped.s.r; R <= clamped.e.r; ++R) {
305
- const row = [];
306
- for (let C = clamped.s.c; C <= clamped.e.c; ++C) {
307
- const address = XLSX.utils.encode_cell({ r: R, c: C });
308
- const cell = worksheet[address];
309
- row.push(processCellValue(cell));
243
+ const backend = createBackend(resolvedPath, args.force);
244
+ try {
245
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
246
+ const rangeResult = backend.getRange(sheetIndex, args.range);
247
+ const result = {
248
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
249
+ range: args.range,
250
+ rows: rangeResult.rows,
251
+ columns: rangeResult.columns,
252
+ data: rangeResult.data,
253
+ };
254
+ if (rangeResult.out_of_bounds) {
255
+ result.out_of_bounds = true;
256
+ }
257
+ if (rangeResult.clamped_to) {
258
+ result.clamped_to = rangeResult.clamped_to;
310
259
  }
311
- data.push(row);
260
+ return JSON.stringify(result, null, 2);
312
261
  }
313
- const result = {
314
- sheet_name: sheetName,
315
- range: args.range,
316
- rows: data.length,
317
- columns: data[0]?.length || 0,
318
- data,
319
- };
320
- if (clamped.s.r !== range.s.r || clamped.s.c !== range.s.c || clamped.e.r !== range.e.r || clamped.e.c !== range.e.c) {
321
- result.clamped_to = XLSX.utils.encode_range(clamped);
262
+ finally {
263
+ backend.dispose();
322
264
  }
323
- return JSON.stringify(result, null, 2);
324
265
  }
325
266
  catch (error) {
326
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "get_range", error });
267
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "get_range", error });
327
268
  }
328
269
  }
329
270
  });
@@ -337,42 +278,33 @@ export const tableSummarize = tool({
337
278
  },
338
279
  execute: async (args, context) => {
339
280
  await context.ask({ permission: "tableSummarize", patterns: [args.file_path], always: ["*"], metadata: {} });
281
+ const resolvedPath = resolvePath(args.file_path, context.directory);
340
282
  try {
341
- const resolvedPath = resolvePath(args.file_path, context.directory);
342
- checkFileSize(resolvedPath, args.force);
343
- const workbook = XLSX.readFile(resolvedPath);
344
- const worksheet = getSheet(workbook, args.sheet_name);
345
- const data = XLSX.utils.sheet_to_json(worksheet);
346
- const effectiveData = args.force ? data : data.slice(0, DEFAULT_MAX_ROW_CAP);
347
- const columnsToAnalyze = args.columns && args.columns.length > 0 ? args.columns : Object.keys(effectiveData[0] || {});
348
- const summaries = {};
349
- columnsToAnalyze.forEach(col => {
350
- const values = effectiveData.map(row => row[col]).filter((v) => typeof v === 'number' && !isNaN(v));
351
- if (values.length > 0) {
352
- const sum = values.reduce((a, b) => a + b, 0);
353
- const avg = sum / values.length;
354
- const min = iterativeMin(values);
355
- const max = iterativeMax(values);
356
- const variance = values.reduce((acc, val) => acc + Math.pow(val - avg, 2), 0) / values.length;
357
- const stdDev = Math.sqrt(variance);
358
- summaries[col] = { sum, avg, min, max, std_dev: stdDev };
283
+ const backend = createBackend(resolvedPath, args.force);
284
+ try {
285
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
286
+ const totalDataRows = backend.getTotalDataRows(sheetIndex);
287
+ const maxRowsScan = args.force ? totalDataRows : Math.min(totalDataRows, DEFAULT_MAX_ROW_CAP);
288
+ const { summaries, rowCount } = backend.summarize(sheetIndex, args.columns, { maxRowsScan });
289
+ const truncated = !args.force && totalDataRows > DEFAULT_MAX_ROW_CAP;
290
+ const result = {
291
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
292
+ row_count: rowCount,
293
+ summaries,
294
+ };
295
+ if (truncated) {
296
+ result.total_rows = totalDataRows;
297
+ result.truncated = true;
298
+ result.message = `Summarized ${DEFAULT_MAX_ROW_CAP} of ${totalDataRows} rows. Set force: true to process all rows.`;
359
299
  }
360
- });
361
- const truncated = !args.force && data.length > DEFAULT_MAX_ROW_CAP;
362
- const result = {
363
- sheet_name: args.sheet_name || workbook.SheetNames[0],
364
- row_count: effectiveData.length,
365
- summaries,
366
- };
367
- if (truncated) {
368
- result.total_rows = data.length;
369
- result.truncated = true;
370
- result.message = `Summarized ${DEFAULT_MAX_ROW_CAP} of ${data.length} rows. Set force: true to process all rows.`;
300
+ return JSON.stringify(result, null, 2);
301
+ }
302
+ finally {
303
+ backend.dispose();
371
304
  }
372
- return JSON.stringify(result, null, 2);
373
305
  }
374
306
  catch (error) {
375
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "summarize", error });
307
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "summarize", error });
376
308
  }
377
309
  }
378
310
  });
@@ -388,73 +320,36 @@ export const tableGroupBy = tool({
388
320
  },
389
321
  execute: async (args, context) => {
390
322
  await context.ask({ permission: "tableGroupBy", patterns: [args.file_path], always: ["*"], metadata: {} });
323
+ const resolvedPath = resolvePath(args.file_path, context.directory);
391
324
  try {
392
- const resolvedPath = resolvePath(args.file_path, context.directory);
393
- checkFileSize(resolvedPath, args.force);
394
- const workbook = XLSX.readFile(resolvedPath);
395
- const worksheet = getSheet(workbook, args.sheet_name);
396
- const data = XLSX.utils.sheet_to_json(worksheet);
397
- const effectiveData = args.force ? data : data.slice(0, DEFAULT_MAX_ROW_CAP);
398
- const grouped = {};
399
- effectiveData.forEach(row => {
400
- const groupKey = String(row[args.group_column] || '');
401
- const aggValue = row[args.agg_column];
402
- if (!grouped[groupKey])
403
- grouped[groupKey] = [];
404
- if (args.agg_type === 'count') {
405
- grouped[groupKey].push(1);
406
- }
407
- else if (typeof aggValue === 'number' && !isNaN(aggValue)) {
408
- grouped[groupKey].push(aggValue);
409
- }
410
- });
411
- const result = {};
412
- Object.keys(grouped).forEach(groupKey => {
413
- const values = grouped[groupKey];
414
- if (values.length === 0) {
415
- result[groupKey] = 0;
416
- return;
417
- }
418
- switch (args.agg_type) {
419
- case 'sum':
420
- result[groupKey] = values.reduce((a, b) => a + b, 0);
421
- break;
422
- case 'count':
423
- result[groupKey] = values.length;
424
- break;
425
- case 'avg':
426
- result[groupKey] = values.reduce((a, b) => a + b, 0) / values.length;
427
- break;
428
- case 'min': {
429
- const m = iterativeMin(values);
430
- result[groupKey] = m !== undefined ? m : 0;
431
- break;
432
- }
433
- case 'max': {
434
- const m = iterativeMax(values);
435
- result[groupKey] = m !== undefined ? m : 0;
436
- break;
437
- }
325
+ const backend = createBackend(resolvedPath, args.force);
326
+ try {
327
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
328
+ const totalDataRows = backend.getTotalDataRows(sheetIndex);
329
+ const maxRowsScan = args.force ? totalDataRows : Math.min(totalDataRows, DEFAULT_MAX_ROW_CAP);
330
+ const { groups } = backend.groupBy(sheetIndex, args.group_column, args.agg_column, args.agg_type, { maxRowsScan });
331
+ const truncated = !args.force && totalDataRows > DEFAULT_MAX_ROW_CAP;
332
+ const response = {
333
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
334
+ group_column: args.group_column,
335
+ agg_column: args.agg_column,
336
+ agg_type: args.agg_type,
337
+ groups,
338
+ };
339
+ if (truncated) {
340
+ response.total_rows = totalDataRows;
341
+ response.processed_rows = maxRowsScan;
342
+ response.truncated = true;
343
+ response.message = `Processed ${DEFAULT_MAX_ROW_CAP} of ${totalDataRows} rows. Set force: true to process all rows.`;
438
344
  }
439
- });
440
- const truncated = !args.force && data.length > DEFAULT_MAX_ROW_CAP;
441
- const response = {
442
- sheet_name: args.sheet_name || workbook.SheetNames[0],
443
- group_column: args.group_column,
444
- agg_column: args.agg_column,
445
- agg_type: args.agg_type,
446
- groups: result,
447
- };
448
- if (truncated) {
449
- response.total_rows = data.length;
450
- response.processed_rows = effectiveData.length;
451
- response.truncated = true;
452
- response.message = `Processed ${DEFAULT_MAX_ROW_CAP} of ${data.length} rows. Set force: true to process all rows.`;
345
+ return JSON.stringify(response, null, 2);
346
+ }
347
+ finally {
348
+ backend.dispose();
453
349
  }
454
- return JSON.stringify(response, null, 2);
455
350
  }
456
351
  catch (error) {
457
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "group_by", error });
352
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "group_by", error });
458
353
  }
459
354
  }
460
355
  });
@@ -471,88 +366,38 @@ export const tablePivotSummary = tool({
471
366
  },
472
367
  execute: async (args, context) => {
473
368
  await context.ask({ permission: "tablePivotSummary", patterns: [args.file_path], always: ["*"], metadata: {} });
369
+ const resolvedPath = resolvePath(args.file_path, context.directory);
474
370
  try {
475
- const resolvedPath = resolvePath(args.file_path, context.directory);
476
- checkFileSize(resolvedPath, args.force);
477
- const workbook = XLSX.readFile(resolvedPath);
478
- const worksheet = getSheet(workbook, args.sheet_name);
479
- const data = XLSX.utils.sheet_to_json(worksheet);
480
- const effectiveData = args.force ? data : data.slice(0, DEFAULT_MAX_ROW_CAP);
481
- const pivot = {};
482
- const counts = {};
483
- const allCols = new Set();
484
- effectiveData.forEach(row => {
485
- const rowKey = String(row[args.row_field] || '');
486
- const colKey = String(row[args.col_field] || '');
487
- allCols.add(colKey);
488
- if (!pivot[rowKey])
489
- pivot[rowKey] = {};
490
- if (!counts[rowKey])
491
- counts[rowKey] = {};
492
- const value = row[args.value_field];
493
- const isNumeric = typeof value === 'number' && !isNaN(value);
494
- if (isNumeric) {
495
- if (!pivot[rowKey][colKey]) {
496
- if (args.agg === 'min') {
497
- pivot[rowKey][colKey] = Infinity;
498
- }
499
- else if (args.agg === 'max') {
500
- pivot[rowKey][colKey] = -Infinity;
501
- }
502
- else {
503
- pivot[rowKey][colKey] = 0;
504
- }
505
- }
506
- if (!counts[rowKey][colKey])
507
- counts[rowKey][colKey] = 0;
508
- counts[rowKey][colKey]++;
509
- switch (args.agg) {
510
- case 'sum':
511
- case 'avg':
512
- pivot[rowKey][colKey] += value;
513
- break;
514
- case 'min':
515
- pivot[rowKey][colKey] = Math.min(pivot[rowKey][colKey], value);
516
- break;
517
- case 'max':
518
- pivot[rowKey][colKey] = Math.max(pivot[rowKey][colKey], value);
519
- break;
520
- case 'count':
521
- pivot[rowKey][colKey]++;
522
- break;
523
- }
371
+ const backend = createBackend(resolvedPath, args.force);
372
+ try {
373
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
374
+ const totalDataRows = backend.getTotalDataRows(sheetIndex);
375
+ const maxRowsScan = args.force ? totalDataRows : Math.min(totalDataRows, DEFAULT_MAX_ROW_CAP);
376
+ const { columns, pivot } = backend.pivotSummary(sheetIndex, args.row_field, args.col_field, args.value_field, args.agg, { maxRowsScan });
377
+ const truncated = !args.force && totalDataRows > DEFAULT_MAX_ROW_CAP;
378
+ const response = {
379
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
380
+ row_field: args.row_field,
381
+ col_field: args.col_field,
382
+ value_field: args.value_field,
383
+ agg_type: args.agg,
384
+ columns,
385
+ pivot,
386
+ };
387
+ if (truncated) {
388
+ response.total_rows = totalDataRows;
389
+ response.processed_rows = maxRowsScan;
390
+ response.truncated = true;
391
+ response.message = `Processed ${DEFAULT_MAX_ROW_CAP} of ${totalDataRows} rows. Set force: true to process all rows.`;
524
392
  }
525
- });
526
- if (args.agg === 'avg') {
527
- Object.keys(pivot).forEach(rowKey => {
528
- Object.keys(pivot[rowKey]).forEach(colKey => {
529
- const count = counts[rowKey][colKey];
530
- if (count > 0) {
531
- pivot[rowKey][colKey] /= count;
532
- }
533
- });
534
- });
393
+ return JSON.stringify(response, null, 2);
535
394
  }
536
- const truncated = !args.force && data.length > DEFAULT_MAX_ROW_CAP;
537
- const response = {
538
- sheet_name: args.sheet_name || workbook.SheetNames[0],
539
- row_field: args.row_field,
540
- col_field: args.col_field,
541
- value_field: args.value_field,
542
- agg_type: args.agg,
543
- columns: Array.from(allCols).sort(),
544
- pivot,
545
- };
546
- if (truncated) {
547
- response.total_rows = data.length;
548
- response.processed_rows = effectiveData.length;
549
- response.truncated = true;
550
- response.message = `Processed ${DEFAULT_MAX_ROW_CAP} of ${data.length} rows. Set force: true to process all rows.`;
395
+ finally {
396
+ backend.dispose();
551
397
  }
552
- return JSON.stringify(response, null, 2);
553
398
  }
554
399
  catch (error) {
555
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "pivot_summary", error });
400
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "pivot_summary", error });
556
401
  }
557
402
  }
558
403
  });
@@ -566,33 +411,26 @@ export const tableAppendRows = tool({
566
411
  },
567
412
  execute: async (args, context) => {
568
413
  await context.ask({ permission: "tableAppendRows", patterns: [args.file_path], always: ["*"], metadata: {} });
414
+ const resolvedPath = resolvePath(args.file_path, context.directory);
569
415
  try {
570
- const resolvedPath = resolvePath(args.file_path, context.directory);
571
- checkFileSize(resolvedPath, args.force);
572
- const workbook = XLSX.readFile(resolvedPath);
573
- const worksheet = getSheet(workbook, args.sheet_name);
574
- if (args.rows.length > 0 && Array.isArray(args.rows[0])) {
575
- XLSX.utils.sheet_add_aoa(worksheet, args.rows, { origin: -1 });
416
+ const backend = createBackend(resolvedPath, args.force);
417
+ try {
418
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
419
+ backend.appendRows(sheetIndex, args.rows);
420
+ return JSON.stringify({
421
+ success: true,
422
+ file_path: args.file_path,
423
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
424
+ rows_appended: args.rows.length,
425
+ message: `Successfully appended ${args.rows.length} rows`,
426
+ }, null, 2);
576
427
  }
577
- else {
578
- const hasExistingData = !!worksheet['!ref'];
579
- const header = hasExistingData ? undefined : Object.keys(args.rows[0] || {});
580
- XLSX.utils.sheet_add_json(worksheet, args.rows, {
581
- origin: -1,
582
- header,
583
- });
428
+ finally {
429
+ backend.dispose();
584
430
  }
585
- writeAtomic(workbook, resolvedPath);
586
- return JSON.stringify({
587
- success: true,
588
- file_path: args.file_path,
589
- sheet_name: args.sheet_name || workbook.SheetNames[0],
590
- rows_appended: args.rows.length,
591
- message: `Successfully appended ${args.rows.length} rows`,
592
- }, null, 2);
593
431
  }
594
432
  catch (error) {
595
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "append_rows", error });
433
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "append_rows", error });
596
434
  }
597
435
  }
598
436
  });
@@ -607,67 +445,37 @@ export const tableUpdateCell = tool({
607
445
  },
608
446
  execute: async (args, context) => {
609
447
  await context.ask({ permission: "tableUpdateCell", patterns: [args.file_path], always: ["*"], metadata: {} });
448
+ const resolvedPath = resolvePath(args.file_path, context.directory);
610
449
  try {
611
- if (!CELL_ADDRESS_REGEX.test(args.cell_address)) {
612
- throw new Error(`Invalid cell address "${args.cell_address}". Expected format like "A1", "B5", "AA123".`);
613
- }
614
- const resolvedPath = resolvePath(args.file_path, context.directory);
615
- checkFileSize(resolvedPath, args.force);
616
- const workbook = XLSX.readFile(resolvedPath);
617
- const worksheet = getSheet(workbook, args.sheet_name);
618
- if (args.value === null) {
619
- delete worksheet[args.cell_address];
620
- }
621
- else {
622
- let cellType;
623
- if (typeof args.value === 'number')
624
- cellType = 'n';
625
- else if (typeof args.value === 'boolean')
626
- cellType = 'b';
627
- else
628
- cellType = 's';
629
- worksheet[args.cell_address] = { v: args.value, t: cellType };
630
- }
631
- const newCellAddr = XLSX.utils.decode_cell(args.cell_address);
632
- const currentRef = worksheet['!ref'];
633
- if (currentRef) {
634
- const currentRange = XLSX.utils.decode_range(currentRef);
635
- const rowDifference = newCellAddr.r - currentRange.e.r;
636
- const colDifference = newCellAddr.c - currentRange.e.c;
637
- if (newCellAddr.r > currentRange.e.r || newCellAddr.c > currentRange.e.c) {
638
- const maxReasonableRow = Math.max(currentRange.e.r + 1000, newCellAddr.r);
639
- const maxReasonableCol = Math.max(currentRange.e.c + 26, newCellAddr.c);
640
- const updatedRange = {
641
- s: {
642
- r: Math.min(currentRange.s.r, newCellAddr.r),
643
- c: Math.min(currentRange.s.c, newCellAddr.c),
644
- },
645
- e: {
646
- r: Math.min(Math.max(currentRange.e.r, newCellAddr.r), maxReasonableRow),
647
- c: Math.min(Math.max(currentRange.e.c, newCellAddr.c), maxReasonableCol),
648
- },
649
- };
650
- worksheet['!ref'] = XLSX.utils.encode_range(updatedRange);
651
- }
450
+ const backend = createBackend(resolvedPath, args.force);
451
+ try {
452
+ const sheetIndex = resolveSheetIndex(backend, args.sheet_name);
453
+ backend.updateCell(sheetIndex, args.cell_address, args.value);
454
+ return JSON.stringify({
455
+ success: true,
456
+ file_path: args.file_path,
457
+ sheet_name: args.sheet_name || getSheetName(backend, sheetIndex),
458
+ cell_address: args.cell_address,
459
+ new_value: args.value,
460
+ message: `Successfully updated cell ${args.cell_address}`,
461
+ }, null, 2);
652
462
  }
653
- else {
654
- worksheet['!ref'] = XLSX.utils.encode_range({ s: newCellAddr, e: newCellAddr });
463
+ finally {
464
+ backend.dispose();
655
465
  }
656
- writeAtomic(workbook, resolvedPath);
657
- return JSON.stringify({
658
- success: true,
659
- file_path: args.file_path,
660
- sheet_name: args.sheet_name || workbook.SheetNames[0],
661
- cell_address: args.cell_address,
662
- new_value: args.value,
663
- message: `Successfully updated cell ${args.cell_address}`,
664
- }, null, 2);
665
466
  }
666
467
  catch (error) {
667
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "update_cell", error });
468
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "update_cell", error });
668
469
  }
669
470
  }
670
471
  });
472
+ function escapeCSVField(field, delimiter) {
473
+ const str = String(field ?? '');
474
+ if (str.includes(delimiter) || str.includes('"') || str.includes('\n') || str.includes('\r')) {
475
+ return '"' + str.replace(/"/g, '""') + '"';
476
+ }
477
+ return str;
478
+ }
671
479
  export const tableCreateFile = tool({
672
480
  description: "Create a new table file from data",
673
481
  args: {
@@ -678,13 +486,14 @@ export const tableCreateFile = tool({
678
486
  },
679
487
  execute: async (args, context) => {
680
488
  await context.ask({ permission: "tableCreateFile", patterns: [args.file_path], always: ["*"], metadata: {} });
489
+ const resolvedPath = resolvePath(args.file_path, context.directory);
681
490
  try {
682
- const resolvedPath = resolvePath(args.file_path, context.directory);
683
491
  if (fs.existsSync(resolvedPath) && !args.overwrite) {
684
492
  throw new Error(`File already exists. Set overwrite: true to replace it.`);
685
493
  }
686
- const workbook = XLSX.utils.book_new();
687
- let worksheet;
494
+ if (args.overwrite) {
495
+ invalidateWorkbookCache(resolvedPath);
496
+ }
688
497
  let parsedData = args.data;
689
498
  if (typeof parsedData === 'string') {
690
499
  try {
@@ -697,27 +506,51 @@ export const tableCreateFile = tool({
697
506
  if (!Array.isArray(parsedData)) {
698
507
  throw new Error(`Data must be an array, received: ${typeof parsedData}`);
699
508
  }
700
- if (parsedData.length === 0) {
701
- worksheet = XLSX.utils.aoa_to_sheet([]);
702
- }
703
- else if (Array.isArray(parsedData[0])) {
704
- worksheet = XLSX.utils.aoa_to_sheet(parsedData);
509
+ const ext = path.extname(resolvedPath).toLowerCase();
510
+ if (ext === '.csv' || ext === '.tsv' || ext === '.txt') {
511
+ const delimiter = ext === '.tsv' ? '\t' : ',';
512
+ const lines = [];
513
+ if (parsedData.length > 0) {
514
+ if (Array.isArray(parsedData[0])) {
515
+ for (const row of parsedData) {
516
+ lines.push(row.map((v) => escapeCSVField(v, delimiter)).join(delimiter));
517
+ }
518
+ }
519
+ else {
520
+ const keys = Object.keys(parsedData[0]);
521
+ lines.push(keys.map(k => escapeCSVField(k, delimiter)).join(delimiter));
522
+ for (const row of parsedData) {
523
+ lines.push(keys.map(k => escapeCSVField(row[k], delimiter)).join(delimiter));
524
+ }
525
+ }
526
+ }
527
+ fs.writeFileSync(resolvedPath, lines.join('\n'));
705
528
  }
706
529
  else {
707
- worksheet = XLSX.utils.json_to_sheet(parsedData);
530
+ const workbook = XLSX.utils.book_new();
531
+ let worksheet;
532
+ if (parsedData.length === 0) {
533
+ worksheet = XLSX.utils.aoa_to_sheet([]);
534
+ }
535
+ else if (Array.isArray(parsedData[0])) {
536
+ worksheet = XLSX.utils.aoa_to_sheet(parsedData);
537
+ }
538
+ else {
539
+ worksheet = XLSX.utils.json_to_sheet(parsedData);
540
+ }
541
+ XLSX.utils.book_append_sheet(workbook, worksheet, args.sheet_name);
542
+ writeAtomic(workbook, resolvedPath);
708
543
  }
709
- XLSX.utils.book_append_sheet(workbook, worksheet, args.sheet_name);
710
- writeAtomic(workbook, resolvedPath);
711
544
  return JSON.stringify({
712
545
  success: true,
713
546
  file_path: args.file_path,
714
547
  sheet_name: args.sheet_name,
715
548
  rows_created: parsedData.length,
716
- message: `Successfully created Excel file with ${parsedData.length} rows`,
549
+ message: `Successfully created file with ${parsedData.length} rows`,
717
550
  }, null, 2);
718
551
  }
719
552
  catch (error) {
720
- return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "create_file", error });
553
+ return formatError({ file_path: args.file_path, resolved_path: resolvedPath, sheet_name: args.sheet_name, operation: "create_file", error });
721
554
  }
722
555
  }
723
556
  });