@yeyuan98/opencode-bioresearcher-plugin 1.6.8-alpha.2 → 1.7.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.
@@ -1,25 +1,32 @@
1
1
  import { tool } from '@opencode-ai/plugin/tool';
2
+ import * as fs from 'fs';
2
3
  import * as XLSX from 'xlsx';
3
- import { z } from 'zod';
4
- import { processCellValue, formatError, resolvePath, getSheet } from './utils';
4
+ 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
+ const FORCE_DESC = "Override file size / row count limits. Use when you need to work with large files and accept the performance implications.";
5
8
  export const tableGetSheetPreview = tool({
6
9
  description: "Get first 6 rows of a worksheet to preview data structure",
7
10
  args: {
8
11
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
9
- sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)")
12
+ sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
13
+ force: z.boolean().default(false).describe(FORCE_DESC),
10
14
  },
11
15
  execute: async (args, context) => {
12
16
  await context.ask({ permission: "tableGetSheetPreview", patterns: [args.file_path], always: ["*"], metadata: {} });
13
17
  try {
14
18
  const resolvedPath = resolvePath(args.file_path, context.directory);
19
+ checkFileSize(resolvedPath, args.force);
15
20
  const workbook = XLSX.readFile(resolvedPath);
16
21
  const worksheet = getSheet(workbook, args.sheet_name);
17
- const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
22
+ const data = XLSX.utils.sheet_to_json(worksheet, { header: 1, range: 6 });
18
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;
19
26
  return JSON.stringify({
20
27
  sheet_name: args.sheet_name || workbook.SheetNames[0],
21
- total_rows: data.length,
22
- preview: previewRows
28
+ total_rows: totalRows,
29
+ preview: previewRows,
23
30
  }, null, 2);
24
31
  }
25
32
  catch (error) {
@@ -30,12 +37,14 @@ export const tableGetSheetPreview = tool({
30
37
  export const tableListSheets = tool({
31
38
  description: "List all worksheet names in a table file",
32
39
  args: {
33
- file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)")
40
+ file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
41
+ force: z.boolean().default(false).describe(FORCE_DESC),
34
42
  },
35
43
  execute: async (args, context) => {
36
44
  await context.ask({ permission: "tableListSheets", patterns: [args.file_path], always: ["*"], metadata: {} });
37
45
  try {
38
46
  const resolvedPath = resolvePath(args.file_path, context.directory);
47
+ checkFileSize(resolvedPath, args.force);
39
48
  const workbook = XLSX.readFile(resolvedPath);
40
49
  return JSON.stringify({ sheets: workbook.SheetNames }, null, 2);
41
50
  }
@@ -48,20 +57,27 @@ export const tableGetHeaders = tool({
48
57
  description: "Get column headers (first row) from a worksheet",
49
58
  args: {
50
59
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
51
- sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)")
60
+ sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
61
+ force: z.boolean().default(false).describe(FORCE_DESC),
52
62
  },
53
63
  execute: async (args, context) => {
54
64
  await context.ask({ permission: "tableGetHeaders", patterns: [args.file_path], always: ["*"], metadata: {} });
55
65
  try {
56
66
  const resolvedPath = resolvePath(args.file_path, context.directory);
67
+ checkFileSize(resolvedPath, args.force);
57
68
  const workbook = XLSX.readFile(resolvedPath);
58
69
  const worksheet = getSheet(workbook, args.sheet_name);
59
- const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
60
- const headers = data[0] || [];
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));
76
+ }
61
77
  return JSON.stringify({
62
78
  sheet_name: args.sheet_name || workbook.SheetNames[0],
63
79
  headers: headers,
64
- column_count: headers.length
80
+ column_count: headers.length,
65
81
  }, null, 2);
66
82
  }
67
83
  catch (error) {
@@ -74,12 +90,17 @@ export const tableGetCell = tool({
74
90
  args: {
75
91
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
76
92
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
77
- cell_address: z.string().describe("Cell address (e.g., 'A1', 'B5')")
93
+ cell_address: z.string().describe("Cell address (e.g., 'A1', 'B5')"),
94
+ force: z.boolean().default(false).describe(FORCE_DESC),
78
95
  },
79
96
  execute: async (args, context) => {
80
97
  await context.ask({ permission: "tableGetCell", patterns: [args.file_path], always: ["*"], metadata: {} });
81
98
  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".`);
101
+ }
82
102
  const resolvedPath = resolvePath(args.file_path, context.directory);
103
+ checkFileSize(resolvedPath, args.force);
83
104
  const workbook = XLSX.readFile(resolvedPath);
84
105
  const worksheet = getSheet(workbook, args.sheet_name);
85
106
  const cell = worksheet[args.cell_address];
@@ -88,7 +109,7 @@ export const tableGetCell = tool({
88
109
  sheet_name: args.sheet_name || workbook.SheetNames[0],
89
110
  cell_address: args.cell_address,
90
111
  value: value,
91
- type: cell?.t || 'unknown'
112
+ type: cell?.t || 'unknown',
92
113
  }, null, 2);
93
114
  }
94
115
  catch (error) {
@@ -97,49 +118,90 @@ export const tableGetCell = tool({
97
118
  }
98
119
  });
99
120
  export const tableFilterRows = tool({
100
- description: "Filter rows based on a condition (e.g., {column: 'Age', operator: '>', value: 25})",
121
+ description: "Filter rows by a column value using an operator. Preferred over tableSearch when targeting a specific column. Supports: exact match (=), comparison (>, <, >=, <=), and text matching (contains, starts_with, ends_with). Returns full matching rows. All text matching is case-insensitive.",
101
122
  args: {
102
123
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
103
124
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
104
125
  column: z.string().describe("Column name to filter on"),
105
- operator: z.enum(['=', '!=', '>', '<', '>=', '<=', 'contains']).describe("Comparison operator"),
106
- value: z.any().describe("Value to compare against"),
107
- max_results: z.number().default(100).describe("Maximum number of results to return")
126
+ operator: z.enum(['=', '!=', '>', '<', '>=', '<=', 'contains', 'starts_with', 'ends_with']).describe("Comparison operator"),
127
+ value: z.union([z.string(), z.number(), z.boolean()]).describe("Value to compare against"),
128
+ max_results: z.number().default(100).describe("Maximum number of results to return"),
129
+ force: z.boolean().default(false).describe(FORCE_DESC),
108
130
  },
109
131
  execute: async (args, context) => {
110
132
  await context.ask({ permission: "tableFilterRows", patterns: [args.file_path], always: ["*"], metadata: {} });
111
133
  try {
112
134
  const resolvedPath = resolvePath(args.file_path, context.directory);
135
+ checkFileSize(resolvedPath, args.force);
113
136
  const workbook = XLSX.readFile(resolvedPath);
114
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;
115
141
  const data = XLSX.utils.sheet_to_json(worksheet);
116
- const filteredRows = data.filter(row => {
142
+ for (let i = 0; i < data.length; i++) {
143
+ const row = data[i];
117
144
  let cellValue = row[args.column];
118
145
  let comparisonValue = args.value;
119
- // Type coercion for numeric comparisons
120
146
  if (typeof cellValue === 'string' && cellValue.trim() !== '' && !isNaN(Number(cellValue))) {
121
147
  cellValue = Number(cellValue);
122
148
  }
123
149
  if (typeof comparisonValue === 'string' && comparisonValue.trim() !== '' && !isNaN(Number(comparisonValue))) {
124
150
  comparisonValue = Number(comparisonValue);
125
151
  }
152
+ let matches = false;
126
153
  switch (args.operator) {
127
- case '=': return cellValue === comparisonValue;
128
- case '!=': return cellValue !== comparisonValue;
129
- case '>': return cellValue > comparisonValue;
130
- case '<': return cellValue < comparisonValue;
131
- case '>=': return cellValue >= comparisonValue;
132
- case '<=': return cellValue <= comparisonValue;
133
- case 'contains': return String(cellValue).includes(String(comparisonValue));
134
- default: return false;
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;
135
181
  }
136
- }).slice(0, args.max_results);
137
- return JSON.stringify({
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;
190
+ }
191
+ }
192
+ const truncated = !args.force && data.length > DEFAULT_MAX_ROW_CAP;
193
+ const result = {
138
194
  sheet_name: args.sheet_name || workbook.SheetNames[0],
139
195
  filter: { column: args.column, operator: args.operator, value: args.value },
140
- total_matches: filteredRows.length,
141
- matched_rows: filteredRows
142
- }, null, 2);
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.`;
203
+ }
204
+ return JSON.stringify(result, null, 2);
143
205
  }
144
206
  catch (error) {
145
207
  return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "filter_rows", error });
@@ -147,43 +209,58 @@ export const tableFilterRows = tool({
147
209
  }
148
210
  });
149
211
  export const tableSearch = tool({
150
- description: "Search for a term across all cells in a worksheet",
212
+ description: "Search for a term across all cells in a worksheet using case-insensitive substring matching. Returns individual cell matches (address, value, row index) — not full rows. Use when you don't know which column is relevant. For column-specific filtering, use tableFilterRows.",
151
213
  args: {
152
214
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
153
215
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
154
216
  search_term: z.string().describe("Term to search for"),
155
- max_results: z.number().default(50).describe("Maximum number of results to return")
217
+ max_results: z.number().default(50).describe("Maximum number of results to return"),
218
+ force: z.boolean().default(false).describe(FORCE_DESC),
156
219
  },
157
220
  execute: async (args, context) => {
158
221
  await context.ask({ permission: "tableSearch", patterns: [args.file_path], always: ["*"], metadata: {} });
159
222
  try {
160
223
  const resolvedPath = resolvePath(args.file_path, context.directory);
224
+ checkFileSize(resolvedPath, args.force);
161
225
  const workbook = XLSX.readFile(resolvedPath);
162
226
  const worksheet = getSheet(workbook, args.sheet_name);
163
227
  const results = [];
164
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;
165
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;
166
236
  for (let C = range.s.c; C <= range.e.c; ++C) {
167
- if (results.length >= args.max_results)
168
- break;
169
237
  const address = XLSX.utils.encode_cell({ r: R, c: C });
170
238
  const cell = worksheet[address];
171
239
  if (cell && cell.v !== undefined) {
172
240
  const cellValue = String(processCellValue(cell)).toLowerCase();
173
241
  if (cellValue.includes(args.search_term.toLowerCase())) {
174
- results.push({ address, value: processCellValue(cell), type: cell.t || 'unknown' });
242
+ totalMatchesFound++;
243
+ if (results.length < effectiveMax) {
244
+ results.push({ address, value: processCellValue(cell), type: cell.t || 'unknown', row_index: R });
245
+ }
175
246
  }
176
247
  }
177
248
  }
178
- if (results.length >= args.max_results)
179
- break;
180
249
  }
181
- return JSON.stringify({
250
+ const truncated = !args.force && rowsScanned >= DEFAULT_MAX_ROW_CAP && range.e.r >= DEFAULT_MAX_ROW_CAP;
251
+ const result = {
182
252
  sheet_name: args.sheet_name || workbook.SheetNames[0],
183
253
  search_term: args.search_term,
184
- total_found: results.length,
185
- results
186
- }, null, 2);
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.`;
262
+ }
263
+ return JSON.stringify(result, null, 2);
187
264
  }
188
265
  catch (error) {
189
266
  return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "search", error });
@@ -195,32 +272,55 @@ export const tableGetRange = tool({
195
272
  args: {
196
273
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
197
274
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
198
- range: z.string().describe("Cell range (e.g., 'A1:C10', 'A1:B20')")
275
+ range: z.string().describe("Cell range (e.g., 'A1:C10', 'A1:B20')"),
276
+ force: z.boolean().default(false).describe(FORCE_DESC),
199
277
  },
200
278
  execute: async (args, context) => {
201
279
  await context.ask({ permission: "tableGetRange", patterns: [args.file_path], always: ["*"], metadata: {} });
202
280
  try {
203
281
  const resolvedPath = resolvePath(args.file_path, context.directory);
282
+ checkFileSize(resolvedPath, args.force);
204
283
  const workbook = XLSX.readFile(resolvedPath);
205
284
  const worksheet = getSheet(workbook, args.sheet_name);
206
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
+ }
207
303
  const data = [];
208
- for (let R = range.s.r; R <= range.e.r; ++R) {
304
+ for (let R = clamped.s.r; R <= clamped.e.r; ++R) {
209
305
  const row = [];
210
- for (let C = range.s.c; C <= range.e.c; ++C) {
306
+ for (let C = clamped.s.c; C <= clamped.e.c; ++C) {
211
307
  const address = XLSX.utils.encode_cell({ r: R, c: C });
212
308
  const cell = worksheet[address];
213
309
  row.push(processCellValue(cell));
214
310
  }
215
311
  data.push(row);
216
312
  }
217
- return JSON.stringify({
218
- sheet_name: args.sheet_name || workbook.SheetNames[0],
313
+ const result = {
314
+ sheet_name: sheetName,
219
315
  range: args.range,
220
316
  rows: data.length,
221
317
  columns: data[0]?.length || 0,
222
- data
223
- }, null, 2);
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);
322
+ }
323
+ return JSON.stringify(result, null, 2);
224
324
  }
225
325
  catch (error) {
226
326
  return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "get_range", error });
@@ -232,34 +332,44 @@ export const tableSummarize = tool({
232
332
  args: {
233
333
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
234
334
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
235
- columns: z.array(z.string()).optional().describe("Specific columns to summarize (empty = all numeric columns)")
335
+ columns: z.array(z.string()).optional().describe("Specific columns to summarize (empty = all numeric columns)"),
336
+ force: z.boolean().default(false).describe(FORCE_DESC),
236
337
  },
237
338
  execute: async (args, context) => {
238
339
  await context.ask({ permission: "tableSummarize", patterns: [args.file_path], always: ["*"], metadata: {} });
239
340
  try {
240
341
  const resolvedPath = resolvePath(args.file_path, context.directory);
342
+ checkFileSize(resolvedPath, args.force);
241
343
  const workbook = XLSX.readFile(resolvedPath);
242
344
  const worksheet = getSheet(workbook, args.sheet_name);
243
345
  const data = XLSX.utils.sheet_to_json(worksheet);
244
- const columnsToAnalyze = args.columns && args.columns.length > 0 ? args.columns : Object.keys(data[0] || {});
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] || {});
245
348
  const summaries = {};
246
349
  columnsToAnalyze.forEach(col => {
247
- const values = data.map(row => row[col]).filter(v => typeof v === 'number' && !isNaN(v));
350
+ const values = effectiveData.map(row => row[col]).filter((v) => typeof v === 'number' && !isNaN(v));
248
351
  if (values.length > 0) {
249
352
  const sum = values.reduce((a, b) => a + b, 0);
250
353
  const avg = sum / values.length;
251
- const min = Math.min(...values);
252
- const max = Math.max(...values);
354
+ const min = iterativeMin(values);
355
+ const max = iterativeMax(values);
253
356
  const variance = values.reduce((acc, val) => acc + Math.pow(val - avg, 2), 0) / values.length;
254
357
  const stdDev = Math.sqrt(variance);
255
358
  summaries[col] = { sum, avg, min, max, std_dev: stdDev };
256
359
  }
257
360
  });
258
- return JSON.stringify({
361
+ const truncated = !args.force && data.length > DEFAULT_MAX_ROW_CAP;
362
+ const result = {
259
363
  sheet_name: args.sheet_name || workbook.SheetNames[0],
260
- row_count: data.length,
261
- summaries
262
- }, null, 2);
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.`;
371
+ }
372
+ return JSON.stringify(result, null, 2);
263
373
  }
264
374
  catch (error) {
265
375
  return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "summarize", error });
@@ -273,17 +383,20 @@ export const tableGroupBy = tool({
273
383
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
274
384
  group_column: z.string().describe("Column to group by"),
275
385
  agg_column: z.string().describe("Column to aggregate"),
276
- agg_type: z.enum(['sum', 'count', 'avg', 'min', 'max']).default('sum').describe("Aggregation type")
386
+ agg_type: z.enum(['sum', 'count', 'avg', 'min', 'max']).default('sum').describe("Aggregation type"),
387
+ force: z.boolean().default(false).describe(FORCE_DESC),
277
388
  },
278
389
  execute: async (args, context) => {
279
390
  await context.ask({ permission: "tableGroupBy", patterns: [args.file_path], always: ["*"], metadata: {} });
280
391
  try {
281
392
  const resolvedPath = resolvePath(args.file_path, context.directory);
393
+ checkFileSize(resolvedPath, args.force);
282
394
  const workbook = XLSX.readFile(resolvedPath);
283
395
  const worksheet = getSheet(workbook, args.sheet_name);
284
396
  const data = XLSX.utils.sheet_to_json(worksheet);
397
+ const effectiveData = args.force ? data : data.slice(0, DEFAULT_MAX_ROW_CAP);
285
398
  const grouped = {};
286
- data.forEach(row => {
399
+ effectiveData.forEach(row => {
287
400
  const groupKey = String(row[args.group_column] || '');
288
401
  const aggValue = row[args.agg_column];
289
402
  if (!grouped[groupKey])
@@ -298,6 +411,10 @@ export const tableGroupBy = tool({
298
411
  const result = {};
299
412
  Object.keys(grouped).forEach(groupKey => {
300
413
  const values = grouped[groupKey];
414
+ if (values.length === 0) {
415
+ result[groupKey] = 0;
416
+ return;
417
+ }
301
418
  switch (args.agg_type) {
302
419
  case 'sum':
303
420
  result[groupKey] = values.reduce((a, b) => a + b, 0);
@@ -308,21 +425,33 @@ export const tableGroupBy = tool({
308
425
  case 'avg':
309
426
  result[groupKey] = values.reduce((a, b) => a + b, 0) / values.length;
310
427
  break;
311
- case 'min':
312
- result[groupKey] = Math.min(...values);
428
+ case 'min': {
429
+ const m = iterativeMin(values);
430
+ result[groupKey] = m !== undefined ? m : 0;
313
431
  break;
314
- case 'max':
315
- result[groupKey] = Math.max(...values);
432
+ }
433
+ case 'max': {
434
+ const m = iterativeMax(values);
435
+ result[groupKey] = m !== undefined ? m : 0;
316
436
  break;
437
+ }
317
438
  }
318
439
  });
319
- return JSON.stringify({
440
+ const truncated = !args.force && data.length > DEFAULT_MAX_ROW_CAP;
441
+ const response = {
320
442
  sheet_name: args.sheet_name || workbook.SheetNames[0],
321
443
  group_column: args.group_column,
322
444
  agg_column: args.agg_column,
323
445
  agg_type: args.agg_type,
324
- groups: result
325
- }, null, 2);
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.`;
453
+ }
454
+ return JSON.stringify(response, null, 2);
326
455
  }
327
456
  catch (error) {
328
457
  return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "group_by", error });
@@ -337,33 +466,46 @@ export const tablePivotSummary = tool({
337
466
  row_field: z.string().describe("Field for row grouping"),
338
467
  col_field: z.string().describe("Field for column grouping"),
339
468
  value_field: z.string().describe("Field to aggregate"),
340
- agg: z.enum(['sum', 'count', 'avg', 'min', 'max']).default('sum').describe("Aggregation type")
469
+ agg: z.enum(['sum', 'count', 'avg', 'min', 'max']).default('sum').describe("Aggregation type"),
470
+ force: z.boolean().default(false).describe(FORCE_DESC),
341
471
  },
342
472
  execute: async (args, context) => {
343
473
  await context.ask({ permission: "tablePivotSummary", patterns: [args.file_path], always: ["*"], metadata: {} });
344
474
  try {
345
475
  const resolvedPath = resolvePath(args.file_path, context.directory);
476
+ checkFileSize(resolvedPath, args.force);
346
477
  const workbook = XLSX.readFile(resolvedPath);
347
478
  const worksheet = getSheet(workbook, args.sheet_name);
348
479
  const data = XLSX.utils.sheet_to_json(worksheet);
480
+ const effectiveData = args.force ? data : data.slice(0, DEFAULT_MAX_ROW_CAP);
349
481
  const pivot = {};
482
+ const counts = {};
350
483
  const allCols = new Set();
351
- data.forEach(row => {
484
+ effectiveData.forEach(row => {
352
485
  const rowKey = String(row[args.row_field] || '');
353
486
  const colKey = String(row[args.col_field] || '');
354
487
  allCols.add(colKey);
355
488
  if (!pivot[rowKey])
356
489
  pivot[rowKey] = {};
490
+ if (!counts[rowKey])
491
+ counts[rowKey] = {};
357
492
  const value = row[args.value_field];
358
- if (typeof value === 'number' && !isNaN(value)) {
493
+ const isNumeric = typeof value === 'number' && !isNaN(value);
494
+ if (isNumeric) {
359
495
  if (!pivot[rowKey][colKey]) {
360
496
  if (args.agg === 'min') {
361
497
  pivot[rowKey][colKey] = Infinity;
362
498
  }
499
+ else if (args.agg === 'max') {
500
+ pivot[rowKey][colKey] = -Infinity;
501
+ }
363
502
  else {
364
503
  pivot[rowKey][colKey] = 0;
365
504
  }
366
505
  }
506
+ if (!counts[rowKey][colKey])
507
+ counts[rowKey][colKey] = 0;
508
+ counts[rowKey][colKey]++;
367
509
  switch (args.agg) {
368
510
  case 'sum':
369
511
  case 'avg':
@@ -382,31 +524,32 @@ export const tablePivotSummary = tool({
382
524
  }
383
525
  });
384
526
  if (args.agg === 'avg') {
385
- const counts = {};
386
- data.forEach(row => {
387
- const rowKey = String(row[args.row_field] || '');
388
- const colKey = String(row[args.col_field] || '');
389
- if (!counts[rowKey])
390
- counts[rowKey] = {};
391
- if (!counts[rowKey][colKey])
392
- counts[rowKey][colKey] = 0;
393
- counts[rowKey][colKey]++;
394
- });
395
527
  Object.keys(pivot).forEach(rowKey => {
396
528
  Object.keys(pivot[rowKey]).forEach(colKey => {
397
- pivot[rowKey][colKey] /= counts[rowKey][colKey];
529
+ const count = counts[rowKey][colKey];
530
+ if (count > 0) {
531
+ pivot[rowKey][colKey] /= count;
532
+ }
398
533
  });
399
534
  });
400
535
  }
401
- return JSON.stringify({
536
+ const truncated = !args.force && data.length > DEFAULT_MAX_ROW_CAP;
537
+ const response = {
402
538
  sheet_name: args.sheet_name || workbook.SheetNames[0],
403
539
  row_field: args.row_field,
404
540
  col_field: args.col_field,
405
541
  value_field: args.value_field,
406
542
  agg_type: args.agg,
407
543
  columns: Array.from(allCols).sort(),
408
- pivot
409
- }, null, 2);
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.`;
551
+ }
552
+ return JSON.stringify(response, null, 2);
410
553
  }
411
554
  catch (error) {
412
555
  return formatError({ file_path: args.file_path, sheet_name: args.sheet_name, operation: "pivot_summary", error });
@@ -418,35 +561,34 @@ export const tableAppendRows = tool({
418
561
  args: {
419
562
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
420
563
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
421
- rows: z.array(z.union([z.array(z.any()), z.record(z.string(), z.any())])).describe("Rows to append (array of arrays or array of objects)")
564
+ rows: z.array(z.union([z.array(z.any()), z.record(z.string(), z.any())])).describe("Rows to append (array of arrays or array of objects)"),
565
+ force: z.boolean().default(false).describe(FORCE_DESC),
422
566
  },
423
567
  execute: async (args, context) => {
424
568
  await context.ask({ permission: "tableAppendRows", patterns: [args.file_path], always: ["*"], metadata: {} });
425
569
  try {
426
570
  const resolvedPath = resolvePath(args.file_path, context.directory);
571
+ checkFileSize(resolvedPath, args.force);
427
572
  const workbook = XLSX.readFile(resolvedPath);
428
573
  const worksheet = getSheet(workbook, args.sheet_name);
429
574
  if (args.rows.length > 0 && Array.isArray(args.rows[0])) {
430
575
  XLSX.utils.sheet_add_aoa(worksheet, args.rows, { origin: -1 });
431
576
  }
432
577
  else {
433
- // Check if sheet already has data (to avoid duplicate headers for object format)
434
- const existingData = XLSX.utils.sheet_to_json(worksheet);
435
- const hasExistingData = existingData.length > 0;
436
- // Only add header if sheet is empty
578
+ const hasExistingData = !!worksheet['!ref'];
437
579
  const header = hasExistingData ? undefined : Object.keys(args.rows[0] || {});
438
580
  XLSX.utils.sheet_add_json(worksheet, args.rows, {
439
581
  origin: -1,
440
- header
582
+ header,
441
583
  });
442
584
  }
443
- XLSX.writeFile(workbook, resolvedPath);
585
+ writeAtomic(workbook, resolvedPath);
444
586
  return JSON.stringify({
445
587
  success: true,
446
588
  file_path: args.file_path,
447
589
  sheet_name: args.sheet_name || workbook.SheetNames[0],
448
590
  rows_appended: args.rows.length,
449
- message: `Successfully appended ${args.rows.length} rows`
591
+ message: `Successfully appended ${args.rows.length} rows`,
450
592
  }, null, 2);
451
593
  }
452
594
  catch (error) {
@@ -455,49 +597,55 @@ export const tableAppendRows = tool({
455
597
  }
456
598
  });
457
599
  export const tableUpdateCell = tool({
458
- description: "Update a single cell value in a table file",
600
+ description: "Update a single cell value in a table file. Set value to null to clear a cell.",
459
601
  args: {
460
602
  file_path: z.string().describe("Path to table file (supports .xlsx, .ods, .csv formats)"),
461
603
  sheet_name: z.string().optional().describe("Worksheet name (optional, uses first sheet by default)"),
462
604
  cell_address: z.string().describe("Cell address (e.g., 'A1', 'B5')"),
463
- value: z.any().describe("Value to set")
605
+ value: z.union([z.string(), z.number(), z.boolean(), z.null()]).describe("Value to set (null clears the cell)"),
606
+ force: z.boolean().default(false).describe(FORCE_DESC),
464
607
  },
465
608
  execute: async (args, context) => {
466
609
  await context.ask({ permission: "tableUpdateCell", patterns: [args.file_path], always: ["*"], metadata: {} });
467
610
  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
+ }
468
614
  const resolvedPath = resolvePath(args.file_path, context.directory);
615
+ checkFileSize(resolvedPath, args.force);
469
616
  const workbook = XLSX.readFile(resolvedPath);
470
617
  const worksheet = getSheet(workbook, args.sheet_name);
471
- let cellType;
472
- if (typeof args.value === 'number')
473
- cellType = 'n';
474
- else if (typeof args.value === 'boolean')
475
- cellType = 'b';
476
- else
477
- cellType = 's';
478
- worksheet[args.cell_address] = { v: args.value, t: cellType };
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
+ }
479
631
  const newCellAddr = XLSX.utils.decode_cell(args.cell_address);
480
632
  const currentRef = worksheet['!ref'];
481
633
  if (currentRef) {
482
634
  const currentRange = XLSX.utils.decode_range(currentRef);
483
- // Only expand range if new cell is significantly beyond current data
484
- // Avoid creating sparse sheets by checking if expansion is reasonable
485
635
  const rowDifference = newCellAddr.r - currentRange.e.r;
486
636
  const colDifference = newCellAddr.c - currentRange.e.c;
487
637
  if (newCellAddr.r > currentRange.e.r || newCellAddr.c > currentRange.e.c) {
488
- // Limit expansion to avoid creating sparse sheets
489
- // Only expand if the new cell is reasonably close to existing data
490
638
  const maxReasonableRow = Math.max(currentRange.e.r + 1000, newCellAddr.r);
491
639
  const maxReasonableCol = Math.max(currentRange.e.c + 26, newCellAddr.c);
492
640
  const updatedRange = {
493
641
  s: {
494
642
  r: Math.min(currentRange.s.r, newCellAddr.r),
495
- c: Math.min(currentRange.s.c, newCellAddr.c)
643
+ c: Math.min(currentRange.s.c, newCellAddr.c),
496
644
  },
497
645
  e: {
498
646
  r: Math.min(Math.max(currentRange.e.r, newCellAddr.r), maxReasonableRow),
499
- c: Math.min(Math.max(currentRange.e.c, newCellAddr.c), maxReasonableCol)
500
- }
647
+ c: Math.min(Math.max(currentRange.e.c, newCellAddr.c), maxReasonableCol),
648
+ },
501
649
  };
502
650
  worksheet['!ref'] = XLSX.utils.encode_range(updatedRange);
503
651
  }
@@ -505,14 +653,14 @@ export const tableUpdateCell = tool({
505
653
  else {
506
654
  worksheet['!ref'] = XLSX.utils.encode_range({ s: newCellAddr, e: newCellAddr });
507
655
  }
508
- XLSX.writeFile(workbook, resolvedPath);
656
+ writeAtomic(workbook, resolvedPath);
509
657
  return JSON.stringify({
510
658
  success: true,
511
659
  file_path: args.file_path,
512
660
  sheet_name: args.sheet_name || workbook.SheetNames[0],
513
661
  cell_address: args.cell_address,
514
662
  new_value: args.value,
515
- message: `Successfully updated cell ${args.cell_address}`
663
+ message: `Successfully updated cell ${args.cell_address}`,
516
664
  }, null, 2);
517
665
  }
518
666
  catch (error) {
@@ -525,18 +673,22 @@ export const tableCreateFile = tool({
525
673
  args: {
526
674
  file_path: z.string().describe("Path for new table file (supports .xlsx, .ods, .csv formats)"),
527
675
  sheet_name: z.string().default("Sheet1").describe("Worksheet name"),
528
- data: z.any().describe("Data to write (array of arrays or array of objects)")
676
+ data: z.union([z.array(z.record(z.string(), z.any())), z.array(z.array(z.any())), z.string()]).describe("Data to write (array of arrays, array of objects, or JSON string)"),
677
+ overwrite: z.boolean().default(false).describe("Overwrite existing file. Set to true if the file already exists."),
529
678
  },
530
679
  execute: async (args, context) => {
531
680
  await context.ask({ permission: "tableCreateFile", patterns: [args.file_path], always: ["*"], metadata: {} });
532
681
  try {
533
682
  const resolvedPath = resolvePath(args.file_path, context.directory);
683
+ if (fs.existsSync(resolvedPath) && !args.overwrite) {
684
+ throw new Error(`File already exists. Set overwrite: true to replace it.`);
685
+ }
534
686
  const workbook = XLSX.utils.book_new();
535
687
  let worksheet;
536
688
  let parsedData = args.data;
537
- if (typeof args.data === 'string') {
689
+ if (typeof parsedData === 'string') {
538
690
  try {
539
- parsedData = JSON.parse(args.data);
691
+ parsedData = JSON.parse(parsedData);
540
692
  }
541
693
  catch (e) {
542
694
  throw new Error(`Failed to parse data string as JSON: ${e}`);
@@ -555,13 +707,13 @@ export const tableCreateFile = tool({
555
707
  worksheet = XLSX.utils.json_to_sheet(parsedData);
556
708
  }
557
709
  XLSX.utils.book_append_sheet(workbook, worksheet, args.sheet_name);
558
- XLSX.writeFile(workbook, resolvedPath);
710
+ writeAtomic(workbook, resolvedPath);
559
711
  return JSON.stringify({
560
712
  success: true,
561
713
  file_path: args.file_path,
562
714
  sheet_name: args.sheet_name,
563
715
  rows_created: parsedData.length,
564
- message: `Successfully created Excel file with ${parsedData.length} rows`
716
+ message: `Successfully created Excel file with ${parsedData.length} rows`,
565
717
  }, null, 2);
566
718
  }
567
719
  catch (error) {