logisheets-logician 1.12.0 → 1.13.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.
@@ -807,6 +807,22 @@ function ensureOk(effect, label) {
807
807
  throw transactionFailure(label, effect);
808
808
  }
809
809
  }
810
+ function cellDisplay(c) {
811
+ const v = c?.value;
812
+ if (v === void 0 || v === "empty") return "";
813
+ if (typeof v === "object" && "value" in v) {
814
+ return String(v.value);
815
+ }
816
+ return "";
817
+ }
818
+ function cellTextOnly(c) {
819
+ const v = c?.value;
820
+ if (v === void 0 || v === "empty") return "";
821
+ if (typeof v === "object" && "type" in v && v.type === "str") {
822
+ return String(v.value);
823
+ }
824
+ return "";
825
+ }
810
826
  function stringifyForBlockInput(v) {
811
827
  if (v === null || v === void 0) return "";
812
828
  if (typeof v === "string") return v;
@@ -2412,7 +2428,9 @@ var convertToBlock = {
2412
2428
  "",
2413
2429
  "This is how you adopt a workbook someone hands you. `create_block` is for new tables and refuses to write over existing data; this one takes the data as it stands and gives it a name, fields and row keys, so you can address it as (block, row_key, field) and reference it from formulas by name instead of by coordinate.",
2414
2430
  "",
2415
- "Give `position` and the counts for the DATA only, leaving out any header row, then either `header_row` to read the field names from the titles or `fields` to state them. The first field is the row-key column, so put the column that identifies each record first.",
2431
+ "Give `position` and the counts for the DATA only, leaving out any header row. Field names come from the header row \u2014 pass `header_row`, or leave it out and the row directly above the data is used when it looks like titles. `fields` states them outright instead.",
2432
+ "",
2433
+ "The row key is inferred: the first column whose values are all present and all different. Pass `key_field` to choose. Whatever is inferred is reported back, so check it \u2014 the key is how formulas and later edits name a row.",
2416
2434
  "",
2417
2435
  "Converting is a one-time cost: the block and its schema survive saving and reloading, and afterwards the region behaves exactly like one created as a block."
2418
2436
  ].join("\n"),
@@ -2439,7 +2457,11 @@ var convertToBlock = {
2439
2457
  header_row: {
2440
2458
  type: "integer",
2441
2459
  minimum: 0,
2442
- description: "Row holding the column titles; field names are read from it. Usually one row above `position`."
2460
+ description: "Row holding the column titles; field names are read from it. Omit it and the row directly above `position` is used when it looks like a header \u2014 all text, over data that is not."
2461
+ },
2462
+ key_field: {
2463
+ type: "string",
2464
+ description: "Which field identifies a row. Omit and the first column whose values are all present and all different is used."
2443
2465
  },
2444
2466
  fields: {
2445
2467
  type: "array",
@@ -2482,29 +2504,77 @@ var convertToBlock = {
2482
2504
  );
2483
2505
  }
2484
2506
  }
2507
+ const width = input.col_count;
2508
+ const dataRes = await client.getCells({
2509
+ sheetIdx,
2510
+ startRow: row,
2511
+ startCol: col,
2512
+ endRow,
2513
+ endCol
2514
+ });
2515
+ if (isErrorMessage(dataRes)) {
2516
+ throw new Error(`reading the table failed: ${dataRes.msg}`);
2517
+ }
2518
+ const data = dataRes;
2519
+ const at = (r, c) => data[r * width + c];
2520
+ const anyData = data.some((c) => cellDisplay(c) !== "" || c.formula);
2521
+ if (!anyData) {
2522
+ throw new Error(
2523
+ `there is nothing in the region at row ${row}, col ${col} (${input.row_count}x${input.col_count}) \u2014 this adopts a table that already exists; use create_block for a new one`
2524
+ );
2525
+ }
2526
+ let headerRow = input.header_row;
2527
+ let headerSource = "stated";
2528
+ let headerDeclined = "there is no row above the data";
2529
+ if (headerRow === void 0 && input.fields === void 0 && row > 0) {
2530
+ const above = await client.getCells({
2531
+ sheetIdx,
2532
+ startRow: row - 1,
2533
+ startCol: col,
2534
+ endRow: row - 1,
2535
+ endCol
2536
+ });
2537
+ if (!isErrorMessage(above)) {
2538
+ const titles = above;
2539
+ const allText = titles.length === width && titles.every((c) => cellTextOnly(c).trim() !== "");
2540
+ const dataAllText = Array.from(
2541
+ { length: width },
2542
+ (_, i) => cellTextOnly(at(0, i))
2543
+ ).every((t) => t.trim() !== "");
2544
+ if (allText && !dataAllText) {
2545
+ headerRow = row - 1;
2546
+ headerSource = "inferred from the row above the data";
2547
+ } else if (allText && dataAllText) {
2548
+ headerDeclined = `the row above is all text but so is the first data row, so which one is titles cannot be told apart \u2014 pass \`header_row\` (probably ${row - 1}) or \`fields\``;
2549
+ } else {
2550
+ headerDeclined = "the row above the data is not all text";
2551
+ }
2552
+ }
2553
+ }
2485
2554
  let fields;
2555
+ let fieldSource;
2486
2556
  if (input.fields !== void 0) {
2487
2557
  fields = [...input.fields];
2488
- } else if (input.header_row !== void 0) {
2558
+ fieldSource = "stated";
2559
+ } else if (headerRow !== void 0) {
2489
2560
  const cells = await client.getCells({
2490
2561
  sheetIdx,
2491
- startRow: input.header_row,
2562
+ startRow: headerRow,
2492
2563
  startCol: col,
2493
- endRow: input.header_row,
2564
+ endRow: headerRow,
2494
2565
  endCol
2495
2566
  });
2496
2567
  if (isErrorMessage(cells)) {
2497
2568
  throw new Error(`reading the header row failed: ${cells.msg}`);
2498
2569
  }
2499
2570
  fields = cells.map((c, i) => {
2500
- const v = c.value;
2501
- const text = v === "empty" || v === void 0 ? "" : typeof v === "object" && "value" in v ? String(v.value) : "";
2502
- return text.trim() === "" ? `field_${i + 1}` : text.trim();
2571
+ const text = cellDisplay(c).trim();
2572
+ return text === "" ? `field_${i + 1}` : text;
2503
2573
  });
2574
+ fieldSource = `read from row ${headerRow} (${headerSource})`;
2504
2575
  } else {
2505
- throw new Error(
2506
- "give either `header_row` to read the field names from, or `fields` to state them"
2507
- );
2576
+ fields = Array.from({ length: width }, (_, i) => `field_${i + 1}`);
2577
+ fieldSource = `generated, because ${headerDeclined}`;
2508
2578
  }
2509
2579
  if (fields.length !== input.col_count) {
2510
2580
  throw new Error(
@@ -2517,6 +2587,32 @@ var convertToBlock = {
2517
2587
  `duplicate field name(s) ${[...new Set(dup)].map((d) => `"${d}"`).join(", ")} \u2014 fields address cells, so they must be unique. Pass \`fields\` explicitly to disambiguate.`
2518
2588
  );
2519
2589
  }
2590
+ const columnCanKey = (c) => {
2591
+ const seen = /* @__PURE__ */ new Set();
2592
+ for (let r = 0; r < input.row_count; r++) {
2593
+ const text = cellDisplay(at(r, c)).trim();
2594
+ if (text === "" || seen.has(text)) return false;
2595
+ seen.add(text);
2596
+ }
2597
+ return true;
2598
+ };
2599
+ let keyIdx;
2600
+ let keySource;
2601
+ if (input.key_field !== void 0) {
2602
+ keyIdx = fields.indexOf(input.key_field);
2603
+ if (keyIdx < 0) {
2604
+ throw new Error(
2605
+ `key_field "${input.key_field}" is not one of the fields ${fields.map((f) => `"${f}"`).join(", ")}`
2606
+ );
2607
+ }
2608
+ keySource = "stated";
2609
+ } else {
2610
+ const found = Array.from({ length: width }, (_, i) => i).find(
2611
+ columnCanKey
2612
+ );
2613
+ keyIdx = found ?? 0;
2614
+ keySource = found === void 0 ? "fell back to the first column \u2014 no column has a value in every row that is also unique" : "inferred: its values are all present and all different";
2615
+ }
2520
2616
  const idRes = await client.getAvailableBlockId({ sheetIdx });
2521
2617
  if (isErrorMessage(idRes)) {
2522
2618
  throw new Error(`getAvailableBlockId failed: ${idRes.msg}`);
@@ -2531,14 +2627,21 @@ var convertToBlock = {
2531
2627
  },
2532
2628
  {
2533
2629
  type: "bindFormSchema",
2534
- value: new BindFormSchemaBuilder().refName(input.name).sheetIdx(sheetIdx).blockId(blockId).fieldFrom(0).keyIdx(0).fields(fields).renderIds(fields.map((_, i) => `${input.name}__f${i}`)).fieldFormulas([]).validationFormulas([]).editabilityFormulas([]).row(true).build()
2630
+ value: new BindFormSchemaBuilder().refName(input.name).sheetIdx(sheetIdx).blockId(blockId).fieldFrom(0).keyIdx(keyIdx).fields(fields).renderIds(fields.map((_, i) => `${input.name}__f${i}`)).fieldFormulas([]).validationFormulas([]).editabilityFormulas([]).row(true).build()
2535
2631
  }
2536
2632
  ],
2537
2633
  `convert_to_block("${input.name}")`
2538
2634
  );
2539
2635
  return {
2540
- data: { block_id: blockId, fields },
2541
- display: `Converted the table at row ${row}, col ${col} into block "${input.name}" (${input.row_count} row(s), fields ${fields.map((f) => `"${f}"`).join(", ")}). Values untouched.`
2636
+ data: {
2637
+ block_id: blockId,
2638
+ fields,
2639
+ key_field: fields[keyIdx],
2640
+ header_row: headerRow ?? null
2641
+ },
2642
+ display: `Converted the table at row ${row}, col ${col} into block "${input.name}" (${input.row_count} row(s)). Values untouched.
2643
+ Fields: ${fields.map((f) => `"${f}"`).join(", ")} \u2014 ${fieldSource}.
2644
+ Row key: "${fields[keyIdx]}" \u2014 ${keySource}.`
2542
2645
  };
2543
2646
  }
2544
2647
  };
@@ -2897,7 +3000,7 @@ var previewChanges = {
2897
3000
  throw transactionFailure(`preview_changes (${where})`, result);
2898
3001
  }
2899
3002
  if (watchCoords.length > 0) {
2900
- const watched = [];
3003
+ const values = [];
2901
3004
  for (const w of watchCoords) {
2902
3005
  const cells = await client.getCells({
2903
3006
  sheetIdx: w.sheetIdx,
@@ -2910,12 +3013,11 @@ var previewChanges = {
2910
3013
  throw new Error(`getCells failed: ${cells.msg}`);
2911
3014
  }
2912
3015
  const info = cells[0];
2913
- watched.push({
2914
- target: w.target,
2915
- value: info === void 0 ? null : flattenValue2(info.value)
2916
- });
3016
+ values.push(
3017
+ info === void 0 ? null : flattenValue2(info.value)
3018
+ );
2917
3019
  }
2918
- results.push({ label: scenario.label, watched });
3020
+ results.push({ label: scenario.label, values });
2919
3021
  } else {
2920
3022
  const diffRes = await client.getTempStatusChanges();
2921
3023
  if (isErrorMessage(diffRes)) {
@@ -2965,7 +3067,10 @@ var previewChanges = {
2965
3067
  return `${results.length} scenarios previewed.`;
2966
3068
  };
2967
3069
  return {
2968
- data: single !== void 0 ? { scenarios: results, diff: single } : { scenarios: results },
3070
+ data: single !== void 0 ? { scenarios: results, diff: single } : watchCoords.length > 0 ? {
3071
+ watching: watchCoords.map((w) => w.target),
3072
+ scenarios: results
3073
+ } : { scenarios: results },
2969
3074
  display: describe()
2970
3075
  };
2971
3076
  }
@@ -2980,7 +3085,7 @@ function flattenValue2(v) {
2980
3085
  case "bool":
2981
3086
  return v.value;
2982
3087
  case "error":
2983
- return `#ERR:${v.value}`;
3088
+ return v.value;
2984
3089
  }
2985
3090
  }
2986
3091
  function locateInBlock(sheetIdx, row, col, blocks) {
@@ -3948,7 +4053,7 @@ function valueToScalar(v) {
3948
4053
  case "bool":
3949
4054
  return v.value;
3950
4055
  case "error":
3951
- return `#${v.value}`;
4056
+ return v.value;
3952
4057
  default:
3953
4058
  return null;
3954
4059
  }
@@ -275,10 +275,19 @@ interface ConvertToBlockInput {
275
275
  header_row?: number;
276
276
  /** Field names, when there is no header row to read them from. */
277
277
  fields?: string[];
278
+ /**
279
+ * Which field identifies a row. Omit and it is inferred: the first column
280
+ * whose values are all present and all different. The key is how every
281
+ * formula and every later edit names a row, so getting it from the data
282
+ * beats defaulting to whatever column happens to be first.
283
+ */
284
+ key_field?: string;
278
285
  }
279
286
  export declare const convertToBlock: Tool<ConvertToBlockInput, {
280
287
  block_id: number;
281
288
  fields: string[];
289
+ key_field: string;
290
+ header_row: number | null;
282
291
  }>;
283
292
  export declare const BUILDER_TOOLS: Tool[];
284
293
  export {};
@@ -26,6 +26,31 @@ function ensureOk(effect, label) {
26
26
  throw transactionFailure(label, effect);
27
27
  }
28
28
  }
29
+ /** A cell's value as a string — number, boolean or text alike. What a row key
30
+ * is compared on, since a key column of years or ids is perfectly ordinary. */
31
+ function cellDisplay(c) {
32
+ const v = c === null || c === void 0 ? void 0 : c.value;
33
+ if (v === undefined || v === 'empty')
34
+ return '';
35
+ if (typeof v === 'object' && 'value' in v) {
36
+ return String(v.value);
37
+ }
38
+ return '';
39
+ }
40
+ /** A cell's value only when it is TEXT, else ''. Distinct from
41
+ * {@link cellDisplay} because deciding whether a row is a header turns on
42
+ * exactly that difference: titles are words, the data under them usually is
43
+ * not. Treating a number as text made every numeric first row look like a
44
+ * second header, and the header above it went unread. */
45
+ function cellTextOnly(c) {
46
+ const v = c === null || c === void 0 ? void 0 : c.value;
47
+ if (v === undefined || v === 'empty')
48
+ return '';
49
+ if (typeof v === 'object' && 'type' in v && v.type === 'str') {
50
+ return String(v.value);
51
+ }
52
+ return '';
53
+ }
29
54
  /** Coerce arbitrary JSON values into the string form BlockInput expects.
30
55
  * Numbers / booleans / null are stringified; strings pass through. */
31
56
  function stringifyForBlockInput(v) {
@@ -2010,7 +2035,9 @@ export const convertToBlock = {
2010
2035
  '',
2011
2036
  "This is how you adopt a workbook someone hands you. `create_block` is for new tables and refuses to write over existing data; this one takes the data as it stands and gives it a name, fields and row keys, so you can address it as (block, row_key, field) and reference it from formulas by name instead of by coordinate.",
2012
2037
  '',
2013
- 'Give `position` and the counts for the DATA only, leaving out any header row, then either `header_row` to read the field names from the titles or `fields` to state them. The first field is the row-key column, so put the column that identifies each record first.',
2038
+ 'Give `position` and the counts for the DATA only, leaving out any header row. Field names come from the header row — pass `header_row`, or leave it out and the row directly above the data is used when it looks like titles. `fields` states them outright instead.',
2039
+ '',
2040
+ 'The row key is inferred: the first column whose values are all present and all different. Pass `key_field` to choose. Whatever is inferred is reported back, so check it — the key is how formulas and later edits name a row.',
2014
2041
  '',
2015
2042
  'Converting is a one-time cost: the block and its schema survive saving and reloading, and afterwards the region behaves exactly like one created as a block.',
2016
2043
  ].join('\n'),
@@ -2037,7 +2064,11 @@ export const convertToBlock = {
2037
2064
  header_row: {
2038
2065
  type: 'integer',
2039
2066
  minimum: 0,
2040
- description: 'Row holding the column titles; field names are read from it. Usually one row above `position`.',
2067
+ description: 'Row holding the column titles; field names are read from it. Omit it and the row directly above `position` is used when it looks like a header — all text, over data that is not.',
2068
+ },
2069
+ key_field: {
2070
+ type: 'string',
2071
+ description: 'Which field identifies a row. Omit and the first column whose values are all present and all different is used.',
2041
2072
  },
2042
2073
  fields: {
2043
2074
  type: 'array',
@@ -2081,34 +2112,100 @@ export const convertToBlock = {
2081
2112
  throw new Error(`that region overlaps the existing block "${(_b = (_a = b.schema) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : `block#${b.blockId}`}" at row ${b.rowStart}, col ${b.colStart}`);
2082
2113
  }
2083
2114
  }
2084
- // Field names: from the header row if given, else stated outright.
2115
+ // The data itself, read once: the key has to be inferred from it, and
2116
+ // the header check wants to know whether the first data row is text.
2117
+ // `getCells` returns the rectangle row-major.
2118
+ const width = input.col_count;
2119
+ const dataRes = await client.getCells({
2120
+ sheetIdx,
2121
+ startRow: row,
2122
+ startCol: col,
2123
+ endRow,
2124
+ endCol,
2125
+ });
2126
+ if (isErrorMessage(dataRes)) {
2127
+ throw new Error(`reading the table failed: ${dataRes.msg}`);
2128
+ }
2129
+ const data = dataRes;
2130
+ const at = (r, c) => data[r * width + c];
2131
+ // Adopting a region with nothing in it is a mistake worth refusing.
2132
+ // This tool exists to take data that is already there and name it; an
2133
+ // empty rectangle would become a block of `field_1..n` with no rows,
2134
+ // which is what `create_block` is for.
2135
+ const anyData = data.some((c) => cellDisplay(c) !== '' || c.formula);
2136
+ if (!anyData) {
2137
+ throw new Error(`there is nothing in the region at row ${row}, col ${col} ` +
2138
+ `(${input.row_count}x${input.col_count}) — this adopts a table that ` +
2139
+ 'already exists; use create_block for a new one');
2140
+ }
2141
+ // Which row holds the titles. Stated, or the row directly above the
2142
+ // data when it looks like titles: text all the way across, over a first
2143
+ // data row that is not all text. Guessing wrong here is cheap to spot —
2144
+ // the field names come back in the result — and the fallback is
2145
+ // `field_1..n`, which is obviously not a header.
2146
+ let headerRow = input.header_row;
2147
+ let headerSource = 'stated';
2148
+ // Why the guess was declined, when it was, so the caller knows what to
2149
+ // pass instead.
2150
+ let headerDeclined = 'there is no row above the data';
2151
+ if (headerRow === undefined && input.fields === undefined && row > 0) {
2152
+ const above = await client.getCells({
2153
+ sheetIdx,
2154
+ startRow: row - 1,
2155
+ startCol: col,
2156
+ endRow: row - 1,
2157
+ endCol,
2158
+ });
2159
+ if (!isErrorMessage(above)) {
2160
+ const titles = above;
2161
+ const allText = titles.length === width &&
2162
+ titles.every((c) => cellTextOnly(c).trim() !== '');
2163
+ const dataAllText = Array.from({ length: width }, (_, i) => cellTextOnly(at(0, i))).every((t) => t.trim() !== '');
2164
+ if (allText && !dataAllText) {
2165
+ headerRow = row - 1;
2166
+ headerSource = 'inferred from the row above the data';
2167
+ }
2168
+ else if (allText && dataAllText) {
2169
+ // Both are words, so there is nothing to tell them apart.
2170
+ // Guessing here is the one guess that could name the fields
2171
+ // after a row of data.
2172
+ headerDeclined =
2173
+ `the row above is all text but so is the first data row, ` +
2174
+ `so which one is titles cannot be told apart — pass ` +
2175
+ `\`header_row\` (probably ${row - 1}) or \`fields\``;
2176
+ }
2177
+ else {
2178
+ headerDeclined = 'the row above the data is not all text';
2179
+ }
2180
+ }
2181
+ }
2182
+ // Field names: stated, read from the header row, or generated.
2085
2183
  let fields;
2184
+ let fieldSource;
2086
2185
  if (input.fields !== undefined) {
2087
2186
  fields = [...input.fields];
2187
+ fieldSource = 'stated';
2088
2188
  }
2089
- else if (input.header_row !== undefined) {
2189
+ else if (headerRow !== undefined) {
2090
2190
  const cells = await client.getCells({
2091
2191
  sheetIdx,
2092
- startRow: input.header_row,
2192
+ startRow: headerRow,
2093
2193
  startCol: col,
2094
- endRow: input.header_row,
2194
+ endRow: headerRow,
2095
2195
  endCol,
2096
2196
  });
2097
2197
  if (isErrorMessage(cells)) {
2098
2198
  throw new Error(`reading the header row failed: ${cells.msg}`);
2099
2199
  }
2100
2200
  fields = cells.map((c, i) => {
2101
- const v = c.value;
2102
- const text = v === 'empty' || v === undefined
2103
- ? ''
2104
- : typeof v === 'object' && 'value' in v
2105
- ? String(v.value)
2106
- : '';
2107
- return text.trim() === '' ? `field_${i + 1}` : text.trim();
2201
+ const text = cellDisplay(c).trim();
2202
+ return text === '' ? `field_${i + 1}` : text;
2108
2203
  });
2204
+ fieldSource = `read from row ${headerRow} (${headerSource})`;
2109
2205
  }
2110
2206
  else {
2111
- throw new Error('give either `header_row` to read the field names from, or `fields` to state them');
2207
+ fields = Array.from({ length: width }, (_, i) => `field_${i + 1}`);
2208
+ fieldSource = `generated, because ${headerDeclined}`;
2112
2209
  }
2113
2210
  if (fields.length !== input.col_count) {
2114
2211
  throw new Error(`got ${fields.length} field name(s) for ${input.col_count} column(s)`);
@@ -2120,6 +2217,40 @@ export const convertToBlock = {
2120
2217
  .join(', ')} — fields address cells, so they must be unique. ` +
2121
2218
  'Pass `fields` explicitly to disambiguate.');
2122
2219
  }
2220
+ // Which column identifies a row. The key is how BLOCKREF names a row and
2221
+ // how every later edit addresses one, so it is worth deriving from the
2222
+ // data rather than assuming the leftmost column: the first column whose
2223
+ // values are all present and all different. A column with a gap cannot
2224
+ // address every row, and a repeat is worse than a gap — BLOCKREF would
2225
+ // resolve both rows to the first match.
2226
+ const columnCanKey = (c) => {
2227
+ const seen = new Set();
2228
+ for (let r = 0; r < input.row_count; r++) {
2229
+ const text = cellDisplay(at(r, c)).trim();
2230
+ if (text === '' || seen.has(text))
2231
+ return false;
2232
+ seen.add(text);
2233
+ }
2234
+ return true;
2235
+ };
2236
+ let keyIdx;
2237
+ let keySource;
2238
+ if (input.key_field !== undefined) {
2239
+ keyIdx = fields.indexOf(input.key_field);
2240
+ if (keyIdx < 0) {
2241
+ throw new Error(`key_field "${input.key_field}" is not one of the fields ` +
2242
+ `${fields.map((f) => `"${f}"`).join(', ')}`);
2243
+ }
2244
+ keySource = 'stated';
2245
+ }
2246
+ else {
2247
+ const found = Array.from({ length: width }, (_, i) => i).find(columnCanKey);
2248
+ keyIdx = found !== null && found !== void 0 ? found : 0;
2249
+ keySource =
2250
+ found === undefined
2251
+ ? 'fell back to the first column — no column has a value in every row that is also unique'
2252
+ : 'inferred: its values are all present and all different';
2253
+ }
2123
2254
  const idRes = await client.getAvailableBlockId({ sheetIdx });
2124
2255
  if (isErrorMessage(idRes)) {
2125
2256
  throw new Error(`getAvailableBlockId failed: ${idRes.msg}`);
@@ -2146,7 +2277,7 @@ export const convertToBlock = {
2146
2277
  .sheetIdx(sheetIdx)
2147
2278
  .blockId(blockId)
2148
2279
  .fieldFrom(0)
2149
- .keyIdx(0)
2280
+ .keyIdx(keyIdx)
2150
2281
  .fields(fields)
2151
2282
  .renderIds(fields.map((_, i) => `${input.name}__f${i}`))
2152
2283
  .fieldFormulas([])
@@ -2157,10 +2288,16 @@ export const convertToBlock = {
2157
2288
  },
2158
2289
  ], `convert_to_block("${input.name}")`);
2159
2290
  return {
2160
- data: { block_id: blockId, fields },
2291
+ data: {
2292
+ block_id: blockId,
2293
+ fields,
2294
+ key_field: fields[keyIdx],
2295
+ header_row: headerRow !== null && headerRow !== void 0 ? headerRow : null,
2296
+ },
2161
2297
  display: `Converted the table at row ${row}, col ${col} into block "${input.name}" ` +
2162
- `(${input.row_count} row(s), fields ${fields.map((f) => `"${f}"`).join(', ')}). ` +
2163
- 'Values untouched.',
2298
+ `(${input.row_count} row(s)). Values untouched.\n` +
2299
+ `Fields: ${fields.map((f) => `"${f}"`).join(', ')} — ${fieldSource}.\n` +
2300
+ `Row key: "${fields[keyIdx]}" — ${keySource}.`,
2164
2301
  };
2165
2302
  },
2166
2303
  };
@@ -29,7 +29,10 @@ function valueToScalar(v) {
29
29
  case 'bool':
30
30
  return v.value;
31
31
  case 'error':
32
- return `#${v.value}`;
32
+ // The engine's error value already carries its `#` — `#DIV/0!`,
33
+ // not `DIV/0!`. Prefixing another one produced `##DIV/0!`, which
34
+ // matches nothing an agent or a test would look for.
35
+ return v.value;
33
36
  default:
34
37
  return null;
35
38
  }
@@ -76,13 +76,16 @@ interface PreviewScenarioResult {
76
76
  label?: string;
77
77
  /** Every cell whose value changes. Present unless `watch` was given. */
78
78
  diff?: PreviewDiffEntry[];
79
- /** The watched cells' values under this scenario, in the order asked. */
80
- watched?: Array<{
81
- target: WatchTarget;
82
- value: CellValue;
83
- }>;
79
+ /** The watched cells' values, in the same order as `watching` on the
80
+ * result. Bare values: repeating the target on every scenario cost more
81
+ * bytes than all the answers put together on a 16-cell grid, and the
82
+ * caller named those targets in the first place. */
83
+ values?: CellValue[];
84
84
  }
85
85
  interface PreviewChangesOutput {
86
+ /** The watched targets, once, in the order their values appear on each
87
+ * scenario. Present only when `watch` was given. */
88
+ watching?: WatchTarget[];
86
89
  /** One entry per scenario, in the order given. */
87
90
  scenarios: PreviewScenarioResult[];
88
91
  /** The single scenario's diff, when called with `changes` and no `watch` —
@@ -374,7 +374,7 @@ export const previewChanges = {
374
374
  // Read the watched cells inside the branch, so a cell the
375
375
  // change did not move still reports its (unchanged) value
376
376
  // rather than going missing from a diff.
377
- const watched = [];
377
+ const values = [];
378
378
  for (const w of watchCoords) {
379
379
  const cells = await client.getCells({
380
380
  sheetIdx: w.sheetIdx,
@@ -387,14 +387,9 @@ export const previewChanges = {
387
387
  throw new Error(`getCells failed: ${cells.msg}`);
388
388
  }
389
389
  const info = cells[0];
390
- watched.push({
391
- target: w.target,
392
- value: info === undefined
393
- ? null
394
- : flattenValue(info.value),
395
- });
390
+ values.push(info === undefined ? null : flattenValue(info.value));
396
391
  }
397
- results.push({ label: scenario.label, watched });
392
+ results.push({ label: scenario.label, values });
398
393
  }
399
394
  else {
400
395
  const diffRes = await client.getTempStatusChanges();
@@ -452,7 +447,14 @@ export const previewChanges = {
452
447
  return `${results.length} scenarios previewed.`;
453
448
  };
454
449
  return {
455
- data: single !== undefined ? { scenarios: results, diff: single } : { scenarios: results },
450
+ data: single !== undefined
451
+ ? { scenarios: results, diff: single }
452
+ : watchCoords.length > 0
453
+ ? {
454
+ watching: watchCoords.map((w) => w.target),
455
+ scenarios: results,
456
+ }
457
+ : { scenarios: results },
456
458
  display: describe(),
457
459
  };
458
460
  },
@@ -468,7 +470,10 @@ function flattenValue(v) {
468
470
  case 'bool':
469
471
  return v.value;
470
472
  case 'error':
471
- return `#ERR:${v.value}`;
473
+ // The engine's error value is already spelled the way a
474
+ // spreadsheet spells it — `#DIV/0!`. Decorating it produced
475
+ // `#ERR:#DIV/0!`, which matches nothing anyone would look for.
476
+ return v.value;
472
477
  }
473
478
  }
474
479
  /** Which (block, row_key, field) a coordinate names, if any. Shared with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logisheets-logician",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "AI agent tools for LogiSheets — platform-agnostic LLM tool definitions, prompts, and skills for operating a real spreadsheet engine. Powers Watson (the in-app assistant) and logisheets-mcp; reusable in any agent host.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -20,8 +20,8 @@
20
20
  "prepack": "yarn build"
21
21
  },
22
22
  "dependencies": {
23
- "logisheets-core": "^1.12.0",
24
- "logisheets-web": "^1.12.0"
23
+ "logisheets-core": "^1.13.0",
24
+ "logisheets-web": "^1.13.0"
25
25
  },
26
26
  "devDependencies": {
27
27
  "esbuild": "^0.21.0",