logisheets-logician 1.11.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.
- package/README.md +89 -0
- package/dist/agent/loop.d.ts +101 -0
- package/dist/agent/loop.js +260 -0
- package/dist/conversation.d.ts +105 -0
- package/dist/conversation.js +10 -0
- package/dist/craft-interactions-api.d.ts +81 -0
- package/dist/craft-interactions-api.js +25 -0
- package/dist/craft-interactions-core.d.ts +14 -0
- package/dist/craft-interactions-core.js +33 -0
- package/dist/crafts/manifest.d.ts +39 -0
- package/dist/crafts/manifest.js +10 -0
- package/dist/crafts/skill-tools.d.ts +33 -0
- package/dist/crafts/skill-tools.js +146 -0
- package/dist/crafts/store.d.ts +47 -0
- package/dist/crafts/store.js +69 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +22 -0
- package/dist/index.node.js +5065 -0
- package/dist/projection.d.ts +129 -0
- package/dist/projection.js +249 -0
- package/dist/storage.d.ts +99 -0
- package/dist/storage.js +223 -0
- package/dist/tool.d.ts +137 -0
- package/dist/tool.js +57 -0
- package/dist/tools/block-ops.d.ts +30 -0
- package/dist/tools/block-ops.js +97 -0
- package/dist/tools/builder.d.ts +206 -0
- package/dist/tools/builder.js +1502 -0
- package/dist/tools/cells.d.ts +58 -0
- package/dist/tools/cells.js +263 -0
- package/dist/tools/comments.d.ts +54 -0
- package/dist/tools/comments.js +234 -0
- package/dist/tools/craft-interactions.d.ts +22 -0
- package/dist/tools/craft-interactions.js +454 -0
- package/dist/tools/edit.d.ts +55 -0
- package/dist/tools/edit.js +356 -0
- package/dist/tools/format.d.ts +58 -0
- package/dist/tools/format.js +208 -0
- package/dist/tools/history.d.ts +13 -0
- package/dist/tools/history.js +39 -0
- package/dist/tools/inspect.d.ts +93 -0
- package/dist/tools/inspect.js +488 -0
- package/dist/tools/links.d.ts +30 -0
- package/dist/tools/links.js +122 -0
- package/dist/tools/structure.d.ts +62 -0
- package/dist/tools/structure.js +174 -0
- package/dist/tools/taxonomy.d.ts +32 -0
- package/dist/tools/taxonomy.js +93 -0
- package/package.json +53 -0
|
@@ -0,0 +1,1502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builder tools — let the LLM construct block-shaped models in a workbook
|
|
3
|
+
* from a natural-language description.
|
|
4
|
+
*
|
|
5
|
+
* Surface (10 tools, block-only — no raw (sheet,row,col) writes):
|
|
6
|
+
* Structure : create_sheet, create_block, add_block_rows, delete_block_rows
|
|
7
|
+
* Rules : set_field_rule, define_enum_set
|
|
8
|
+
* Reflection : list_blocks, describe_block, eval_formula
|
|
9
|
+
* Safety : checkpoint / restore (one tool, two ops)
|
|
10
|
+
*
|
|
11
|
+
* Handlers below are intentionally thin — they describe the contract.
|
|
12
|
+
* Real implementations will dispatch to the workbook client + block manager.
|
|
13
|
+
*/
|
|
14
|
+
import { BindFormSchemaBuilder, BlockInputBuilder, CreateBlockBuilder, CreateSheetBuilder, DeleteRowsBuilder, DeleteRowsInBlockBuilder, InsertRowsBuilder, InsertRowsInBlockBuilder, UpsertFieldFormulasBuilder, acquireCraftCalc, isErrorMessage, } from 'logisheets-web/pure';
|
|
15
|
+
/** Narrow the workbook client to the concrete `Client` from logisheets-web.
|
|
16
|
+
* `ctx.workbook: WorkbookClient` is already a type alias for `Client` —
|
|
17
|
+
* this helper just removes the unsafe cast at every call site. */
|
|
18
|
+
function asClient(ctx) {
|
|
19
|
+
return ctx.workbook;
|
|
20
|
+
}
|
|
21
|
+
/** Throw a typed error if a transaction's status came back as 'err'. */
|
|
22
|
+
function ensureOk(effect, label) {
|
|
23
|
+
if (effect.status.type === 'err') {
|
|
24
|
+
throw new Error(`${label}: status code ${effect.status.value}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Coerce arbitrary JSON values into the string form BlockInput expects.
|
|
28
|
+
* Numbers / booleans / null are stringified; strings pass through. */
|
|
29
|
+
function stringifyForBlockInput(v) {
|
|
30
|
+
if (v === null || v === undefined)
|
|
31
|
+
return '';
|
|
32
|
+
if (typeof v === 'string')
|
|
33
|
+
return v;
|
|
34
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
35
|
+
return String(v);
|
|
36
|
+
return JSON.stringify(v);
|
|
37
|
+
}
|
|
38
|
+
/** Submit a single transaction; throw on 'err' or RPC failure. Wraps the
|
|
39
|
+
* `handleTransaction` RPC so callers focus on building payloads. */
|
|
40
|
+
async function commitTransaction(client, payloads, label, undoable = true) {
|
|
41
|
+
const tx = { payloads, undoable, temp: false };
|
|
42
|
+
const result = await client.handleTransaction({ transaction: tx });
|
|
43
|
+
if (isErrorMessage(result)) {
|
|
44
|
+
throw new Error(`${label}: ${result.msg}`);
|
|
45
|
+
}
|
|
46
|
+
ensureOk(result, label);
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Shared schema fragments
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
const FIELD_TYPE_ENUM = [
|
|
52
|
+
'string',
|
|
53
|
+
'number',
|
|
54
|
+
'boolean',
|
|
55
|
+
'enum',
|
|
56
|
+
'date',
|
|
57
|
+
'datetime',
|
|
58
|
+
];
|
|
59
|
+
const FIELD_SCHEMA = {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
name: { type: 'string', description: 'Field (column) name.' },
|
|
63
|
+
field_type: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
enum: [...FIELD_TYPE_ENUM],
|
|
66
|
+
description: "Optional. Set it explicitly when the field's MEANING implies a type — e.g. a column called deadline / due date / birthday / created_at is 'date' (or 'datetime' if it carries a time). 'date'/'datetime' render with a calendar-style format (override via num_fmt). If omitted, the type is inferred from initial_rows values: booleans → boolean, numerics → number, date-like strings → date, low-cardinality categorical strings → an auto-created enum set, otherwise string.",
|
|
67
|
+
},
|
|
68
|
+
num_fmt: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: 'Excel number format, e.g. "0", "0.00", "0.00%", "#,##0". Only meaningful when field_type=number.',
|
|
71
|
+
},
|
|
72
|
+
enum_id: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'Enum set id. Required when field_type=enum. Must match an id passed to define_enum_set.',
|
|
75
|
+
},
|
|
76
|
+
user_editable: {
|
|
77
|
+
type: 'boolean',
|
|
78
|
+
description: 'Whether the user can edit this cell directly. Use editability formula via set_field_rule for conditional editing.',
|
|
79
|
+
default: false,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ['name'],
|
|
83
|
+
};
|
|
84
|
+
const ROW_SCHEMA = {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
key: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
description: 'Row key (first column). Used as the row selector elsewhere. Must be unique within the block.',
|
|
90
|
+
},
|
|
91
|
+
values: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
description: 'Optional initial values keyed by field name. Fields with a value_formula should be omitted. For date/datetime fields, supply ISO strings ("YYYY-MM-DD" or "YYYY-MM-DDTHH:MM") — Watson converts them to the numeric form cells store.',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
required: ['key'],
|
|
97
|
+
};
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Field-type inference
|
|
100
|
+
//
|
|
101
|
+
// When create_block declares a field without an explicit `field_type`, we
|
|
102
|
+
// infer it from the field's initial-row values. This keeps blocks honest
|
|
103
|
+
// about their data shape (number formatting, boolean checkboxes, enum
|
|
104
|
+
// dropdowns, date formats) without the LLM having to classify every column
|
|
105
|
+
// by hand. Explicitly-typed fields are always respected as-is.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
function isBoolLike(v) {
|
|
108
|
+
if (typeof v === 'boolean')
|
|
109
|
+
return true;
|
|
110
|
+
if (typeof v !== 'string')
|
|
111
|
+
return false;
|
|
112
|
+
const s = v.trim().toLowerCase();
|
|
113
|
+
return s === 'true' || s === 'false';
|
|
114
|
+
}
|
|
115
|
+
function isNumberLike(v) {
|
|
116
|
+
if (typeof v === 'number')
|
|
117
|
+
return Number.isFinite(v);
|
|
118
|
+
if (typeof v !== 'string')
|
|
119
|
+
return false;
|
|
120
|
+
const s = v.trim();
|
|
121
|
+
if (s === '')
|
|
122
|
+
return false;
|
|
123
|
+
// Tolerate thousands separators / a single percent or currency sign.
|
|
124
|
+
const cleaned = s.replace(/[,$%]/g, '');
|
|
125
|
+
return cleaned !== '' && Number.isFinite(Number(cleaned));
|
|
126
|
+
}
|
|
127
|
+
/** Detect a uniform date format across the column. Returns the matching
|
|
128
|
+
* Excel number-format string, or null when the values aren't all dates.
|
|
129
|
+
* Dates are stored as `number` fields (the engine has no distinct date
|
|
130
|
+
* type) carrying a date formatter. */
|
|
131
|
+
function detectDateFormat(values) {
|
|
132
|
+
const patterns = [
|
|
133
|
+
{ re: /^\d{4}-\d{1,2}-\d{1,2}$/, fmt: 'yyyy-mm-dd' },
|
|
134
|
+
{ re: /^\d{4}\/\d{1,2}\/\d{1,2}$/, fmt: 'yyyy/mm/dd' },
|
|
135
|
+
{ re: /^\d{1,2}\/\d{1,2}\/\d{4}$/, fmt: 'mm/dd/yyyy' },
|
|
136
|
+
{
|
|
137
|
+
re: /^\d{4}-\d{1,2}-\d{1,2}[ T]\d{1,2}:\d{2}/,
|
|
138
|
+
fmt: 'yyyy-mm-dd hh:mm',
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
for (const { re, fmt } of patterns) {
|
|
142
|
+
if (values.every((v) => typeof v === 'string' && re.test(v.trim()))) {
|
|
143
|
+
return fmt;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Heuristic: a low-cardinality categorical string column reads as an enum.
|
|
149
|
+
* Needs enough rows to be confident, few distinct values, and meaningful
|
|
150
|
+
* repetition (distinct ≤ half the rows). */
|
|
151
|
+
function looksLikeEnum(distinct, total) {
|
|
152
|
+
return (total >= 4 &&
|
|
153
|
+
distinct.length >= 2 &&
|
|
154
|
+
distinct.length <= 8 &&
|
|
155
|
+
distinct.length <= total / 2);
|
|
156
|
+
}
|
|
157
|
+
/** Convert an ISO-ish date/datetime string to an Excel serial number (the
|
|
158
|
+
* numeric form block cells store for dates). Returns null when the value
|
|
159
|
+
* can't be parsed, so the caller can fall back to writing it verbatim.
|
|
160
|
+
*
|
|
161
|
+
* Excel's 1900 date system: serial 1 = 1900-01-01; 25569 is the day count
|
|
162
|
+
* from the serial epoch (1899-12-30) to the Unix epoch (1970-01-01).
|
|
163
|
+
* Components are read as UTC so the serial is timezone-independent. */
|
|
164
|
+
function toExcelSerial(value, withTime) {
|
|
165
|
+
if (typeof value === 'number')
|
|
166
|
+
return value; // already numeric
|
|
167
|
+
if (typeof value !== 'string')
|
|
168
|
+
return null;
|
|
169
|
+
const s = value.trim();
|
|
170
|
+
const m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
|
171
|
+
let y, mo, d, hh = 0, mm = 0, ss = 0;
|
|
172
|
+
if (m) {
|
|
173
|
+
y = +m[1];
|
|
174
|
+
mo = +m[2];
|
|
175
|
+
d = +m[3];
|
|
176
|
+
hh = m[4] ? +m[4] : 0;
|
|
177
|
+
mm = m[5] ? +m[5] : 0;
|
|
178
|
+
ss = m[6] ? +m[6] : 0;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// US-style M/D/Y as a fallback.
|
|
182
|
+
const us = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
|
183
|
+
if (!us)
|
|
184
|
+
return null;
|
|
185
|
+
mo = +us[1];
|
|
186
|
+
d = +us[2];
|
|
187
|
+
y = +us[3];
|
|
188
|
+
}
|
|
189
|
+
const ms = Date.UTC(y, mo - 1, d, hh, mm, ss);
|
|
190
|
+
if (Number.isNaN(ms))
|
|
191
|
+
return null;
|
|
192
|
+
const serial = ms / 86400000 + 25569;
|
|
193
|
+
return withTime ? serial : Math.floor(serial);
|
|
194
|
+
}
|
|
195
|
+
function autoEnumId(blockName, fieldName) {
|
|
196
|
+
const slug = (s) => s
|
|
197
|
+
.trim()
|
|
198
|
+
.toLowerCase()
|
|
199
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
200
|
+
.replace(/^_+|_+$/g, '');
|
|
201
|
+
return `${slug(blockName)}_${slug(fieldName)}_auto`;
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// 1. create_sheet
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
export const createSheet = {
|
|
207
|
+
namespace: 'build',
|
|
208
|
+
name: 'create_sheet',
|
|
209
|
+
description: 'Create a new sheet in the workbook. Returns the new sheet index. Idempotent: if a sheet with the same name exists, returns its index without creating a duplicate. Typically the agent does NOT call this directly — `create_block` will call it implicitly when the target sheet is missing. Use it only when you want an empty named sheet up front.',
|
|
210
|
+
mutates: true,
|
|
211
|
+
confirmation: 'never',
|
|
212
|
+
inputSchema: {
|
|
213
|
+
properties: {
|
|
214
|
+
name: { type: 'string', description: 'Sheet name.' },
|
|
215
|
+
},
|
|
216
|
+
required: ['name'],
|
|
217
|
+
},
|
|
218
|
+
handler: async ({ name }, ctx) => {
|
|
219
|
+
const client = asClient(ctx);
|
|
220
|
+
// Idempotent: if a sheet with this name already exists, return
|
|
221
|
+
// its index instead of creating a duplicate.
|
|
222
|
+
const beforeRes = await client.getAllSheetInfo();
|
|
223
|
+
if (isErrorMessage(beforeRes)) {
|
|
224
|
+
throw new Error(`getAllSheetInfo failed: ${beforeRes.msg}`);
|
|
225
|
+
}
|
|
226
|
+
const before = beforeRes;
|
|
227
|
+
const existing = before.findIndex((s) => s.name === name);
|
|
228
|
+
if (existing >= 0) {
|
|
229
|
+
return {
|
|
230
|
+
data: { sheet_idx: existing },
|
|
231
|
+
display: `Sheet "${name}" already exists at idx ${existing}.`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
// Append the new sheet at the end (idx = current count).
|
|
235
|
+
const newIdx = before.length;
|
|
236
|
+
await commitTransaction(client, [
|
|
237
|
+
{
|
|
238
|
+
type: 'createSheet',
|
|
239
|
+
value: new CreateSheetBuilder()
|
|
240
|
+
.idx(newIdx)
|
|
241
|
+
.newName(name)
|
|
242
|
+
.build(),
|
|
243
|
+
},
|
|
244
|
+
], `createSheet("${name}")`);
|
|
245
|
+
return {
|
|
246
|
+
data: { sheet_idx: newIdx },
|
|
247
|
+
display: `Created sheet "${name}" at idx ${newIdx}.`,
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
export const createBlock = {
|
|
252
|
+
namespace: 'build',
|
|
253
|
+
name: 'create_block',
|
|
254
|
+
description: [
|
|
255
|
+
'Create a structured block (table) on a sheet. fields[0] is the row-key column (always read-only). Block ref name (`name`) is used as the first arg to BLOCKREF/BLOCKREFS in formulas.',
|
|
256
|
+
'',
|
|
257
|
+
'Field types supported:',
|
|
258
|
+
" - 'string' / 'number' — plain text/numeric cells.",
|
|
259
|
+
" - 'boolean' — cell stores 0/1 or TRUE/FALSE; UI renders ✅/❌ if host has the widget set.",
|
|
260
|
+
" - 'enum' (+ enum_id) — cell stores variant id; UI renders dropdown if host has the widget set. Watson auto-injects a variant-whitelist validation formula on the field so out-of-set writes light up as warnings even without widget rendering. Requires a prior define_enum_set call with matching id.",
|
|
261
|
+
'',
|
|
262
|
+
'Rules (value_formula / validation / editability) are set separately via set_field_rule — this call only declares structure + initial rows. Auto-creates the target sheet if missing.',
|
|
263
|
+
].join('\n'),
|
|
264
|
+
mutates: true,
|
|
265
|
+
confirmation: 'never',
|
|
266
|
+
inputSchema: {
|
|
267
|
+
properties: {
|
|
268
|
+
sheet: { type: 'string', description: 'Target sheet name.' },
|
|
269
|
+
name: {
|
|
270
|
+
type: 'string',
|
|
271
|
+
description: 'Block ref name. Used as the first arg to BLOCKREF/BLOCKREFS in formulas. Must be unique within the workbook.',
|
|
272
|
+
},
|
|
273
|
+
position: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
properties: {
|
|
276
|
+
row: { type: 'integer', minimum: 0 },
|
|
277
|
+
col: { type: 'integer', minimum: 0 },
|
|
278
|
+
},
|
|
279
|
+
required: ['row', 'col'],
|
|
280
|
+
description: 'Top-left cell of the block (0-indexed).',
|
|
281
|
+
},
|
|
282
|
+
fields: {
|
|
283
|
+
type: 'array',
|
|
284
|
+
minItems: 1,
|
|
285
|
+
items: FIELD_SCHEMA,
|
|
286
|
+
description: 'Column definitions in order. fields[0] is the row-key column.',
|
|
287
|
+
},
|
|
288
|
+
initial_rows: {
|
|
289
|
+
type: 'array',
|
|
290
|
+
items: ROW_SCHEMA,
|
|
291
|
+
description: 'Optional initial rows.',
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
required: ['sheet', 'name', 'position', 'fields'],
|
|
295
|
+
},
|
|
296
|
+
handler: async (input, ctx) => {
|
|
297
|
+
var _a, _b, _c, _d;
|
|
298
|
+
const client = asClient(ctx);
|
|
299
|
+
// Resolve each field's type: explicit wins; otherwise infer from the
|
|
300
|
+
// column's initial-row values. Inferred enums auto-create a backing
|
|
301
|
+
// set (registered with the host blockManager when present, always
|
|
302
|
+
// cached locally) so the existing enum gate below picks them up.
|
|
303
|
+
const colValues = (fieldName) => {
|
|
304
|
+
var _a;
|
|
305
|
+
return ((_a = input.initial_rows) !== null && _a !== void 0 ? _a : [])
|
|
306
|
+
.map((r) => { var _a; return (_a = r.values) === null || _a === void 0 ? void 0 : _a[fieldName]; })
|
|
307
|
+
.filter((v) => v !== undefined && v !== null && v !== '');
|
|
308
|
+
};
|
|
309
|
+
const resolvedFields = input.fields.map((f, i) => {
|
|
310
|
+
if (f.field_type)
|
|
311
|
+
return { ...f, field_type: f.field_type };
|
|
312
|
+
// The key column (fields[0]) is a row identifier, not data.
|
|
313
|
+
if (i === 0)
|
|
314
|
+
return { ...f, field_type: 'string' };
|
|
315
|
+
const vals = colValues(f.name);
|
|
316
|
+
if (vals.length === 0)
|
|
317
|
+
return { ...f, field_type: 'string' };
|
|
318
|
+
if (vals.every(isBoolLike))
|
|
319
|
+
return { ...f, field_type: 'boolean' };
|
|
320
|
+
if (vals.every(isNumberLike))
|
|
321
|
+
return { ...f, field_type: 'number', num_fmt: f.num_fmt };
|
|
322
|
+
const dateFmt = detectDateFormat(vals);
|
|
323
|
+
if (dateFmt)
|
|
324
|
+
return { ...f, field_type: 'date', num_fmt: dateFmt };
|
|
325
|
+
const distinct = [...new Set(vals.map((v) => String(v)))];
|
|
326
|
+
if (looksLikeEnum(distinct, vals.length)) {
|
|
327
|
+
const enumId = autoEnumId(input.name, f.name);
|
|
328
|
+
const variants = distinct.map((v, vi) => ({
|
|
329
|
+
id: v,
|
|
330
|
+
value: v,
|
|
331
|
+
color: _ENUM_PALETTE[vi % _ENUM_PALETTE.length],
|
|
332
|
+
}));
|
|
333
|
+
const bm = tryGetBlockManager();
|
|
334
|
+
if (bm)
|
|
335
|
+
bm.enumSetManager.set(enumId, enumId, variants);
|
|
336
|
+
_enumSetCache.set(enumId, { id: enumId, name: enumId, variants });
|
|
337
|
+
return { ...f, field_type: 'enum', enum_id: enumId };
|
|
338
|
+
}
|
|
339
|
+
return { ...f, field_type: 'string' };
|
|
340
|
+
});
|
|
341
|
+
// Resolve enum_id → variant ids per enum field. Pull from
|
|
342
|
+
// _enumSetCache (filled by define_enum_set, or by inference above).
|
|
343
|
+
// Out-of-cache enum_ids fail fast — the LLM should have defined the
|
|
344
|
+
// set first.
|
|
345
|
+
const enumVariantsByField = new Map();
|
|
346
|
+
for (let i = 0; i < resolvedFields.length; i++) {
|
|
347
|
+
const f = resolvedFields[i];
|
|
348
|
+
if (f.field_type !== 'enum')
|
|
349
|
+
continue;
|
|
350
|
+
if (!f.enum_id) {
|
|
351
|
+
throw new Error(`field "${f.name}" has field_type='enum' but no enum_id — call define_enum_set first and pass its id`);
|
|
352
|
+
}
|
|
353
|
+
const set = _enumSetCache.get(f.enum_id);
|
|
354
|
+
if (!set) {
|
|
355
|
+
throw new Error(`enum_id "${f.enum_id}" not defined — call define_enum_set("${f.enum_id}", ...) first`);
|
|
356
|
+
}
|
|
357
|
+
enumVariantsByField.set(i, set.variants.map((v) => v.id));
|
|
358
|
+
}
|
|
359
|
+
// 1. Resolve target sheet (auto-create if missing).
|
|
360
|
+
const sheetInfosRes = await client.getAllSheetInfo();
|
|
361
|
+
if (isErrorMessage(sheetInfosRes)) {
|
|
362
|
+
throw new Error(`getAllSheetInfo failed: ${sheetInfosRes.msg}`);
|
|
363
|
+
}
|
|
364
|
+
const sheetInfos = sheetInfosRes;
|
|
365
|
+
let sheetIdx = sheetInfos.findIndex((s) => s.name === input.sheet);
|
|
366
|
+
if (sheetIdx < 0) {
|
|
367
|
+
sheetIdx = sheetInfos.length;
|
|
368
|
+
await commitTransaction(client, [
|
|
369
|
+
{
|
|
370
|
+
type: 'createSheet',
|
|
371
|
+
value: new CreateSheetBuilder()
|
|
372
|
+
.idx(sheetIdx)
|
|
373
|
+
.newName(input.sheet)
|
|
374
|
+
.build(),
|
|
375
|
+
},
|
|
376
|
+
], `auto-create sheet "${input.sheet}"`);
|
|
377
|
+
}
|
|
378
|
+
// 2. Mint a fresh block id.
|
|
379
|
+
const idRes = await client.getAvailableBlockId({ sheetIdx });
|
|
380
|
+
if (isErrorMessage(idRes)) {
|
|
381
|
+
throw new Error(`getAvailableBlockId failed: ${idRes.msg}`);
|
|
382
|
+
}
|
|
383
|
+
const blockId = idRes;
|
|
384
|
+
// 2b. Register each field with the host's FieldManager when
|
|
385
|
+
// available. This lets block-interface widgets (✅/❌ for
|
|
386
|
+
// boolean, dropdown for enum) resolve renderId → FieldInfo
|
|
387
|
+
// and render the right control. The returned `info.id` is
|
|
388
|
+
// the canonical renderId we then pass to BindFormSchema.
|
|
389
|
+
//
|
|
390
|
+
// Headless hosts (no globalThis.blockManager) skip this and
|
|
391
|
+
// fall back to Watson-minted opaque renderIds — cells then
|
|
392
|
+
// render as raw values (still functionally correct, just
|
|
393
|
+
// no widget chrome).
|
|
394
|
+
//
|
|
395
|
+
// fields[0] is the key column; we always declare it static-
|
|
396
|
+
// uneditable (matches factory-simulator / engine's "keys are
|
|
397
|
+
// row identifiers, never user data" convention).
|
|
398
|
+
const bm = tryGetBlockManager();
|
|
399
|
+
const sheetIdRes = await client.getSheetId({ sheetIdx });
|
|
400
|
+
if (isErrorMessage(sheetIdRes)) {
|
|
401
|
+
throw new Error(`getSheetId failed: ${sheetIdRes.msg}`);
|
|
402
|
+
}
|
|
403
|
+
const sheetId = sheetIdRes;
|
|
404
|
+
const renderIds = [];
|
|
405
|
+
for (let i = 0; i < resolvedFields.length; i++) {
|
|
406
|
+
const f = resolvedFields[i];
|
|
407
|
+
if (bm) {
|
|
408
|
+
const typeSpec = buildFieldTypeSpec(f, enumVariantsByField.get(i));
|
|
409
|
+
const fi = bm.fieldManager.create(sheetId, blockId, {
|
|
410
|
+
name: f.name,
|
|
411
|
+
type: typeSpec,
|
|
412
|
+
required: false,
|
|
413
|
+
unique: false,
|
|
414
|
+
userEditable: i === 0 ? false : (_a = f.user_editable) !== null && _a !== void 0 ? _a : true,
|
|
415
|
+
});
|
|
416
|
+
renderIds.push(fi.id);
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
renderIds.push(`${input.name}__f${i}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// 3. Compose the payload sequence:
|
|
423
|
+
// CreateBlock
|
|
424
|
+
// BlockInput(keys) ← keys before BindFormSchema so
|
|
425
|
+
// #KEY substitutions resolve to
|
|
426
|
+
// real values at template install
|
|
427
|
+
// BindFormSchema ← declares schema; engine auto-
|
|
428
|
+
// installs shadows from declared
|
|
429
|
+
// templates (none yet — empty
|
|
430
|
+
// vecs preserve nothing here)
|
|
431
|
+
// BlockInput(non-key values) ← AFTER BindFormSchema; templated
|
|
432
|
+
// cells reject these by design,
|
|
433
|
+
// non-templated commit normally
|
|
434
|
+
const fieldNames = resolvedFields.map((f) => f.name);
|
|
435
|
+
const initialRows = (_b = input.initial_rows) !== null && _b !== void 0 ? _b : [];
|
|
436
|
+
const rowCnt = Math.max(1, initialRows.length); // engine requires ≥1 row
|
|
437
|
+
const colCnt = resolvedFields.length;
|
|
438
|
+
const payloads = [];
|
|
439
|
+
payloads.push({
|
|
440
|
+
type: 'createBlock',
|
|
441
|
+
value: new CreateBlockBuilder()
|
|
442
|
+
.sheetIdx(sheetIdx)
|
|
443
|
+
.id(blockId)
|
|
444
|
+
.masterRow(input.position.row)
|
|
445
|
+
.masterCol(input.position.col)
|
|
446
|
+
.rowCnt(rowCnt)
|
|
447
|
+
.colCnt(colCnt)
|
|
448
|
+
.build(),
|
|
449
|
+
});
|
|
450
|
+
for (let i = 0; i < initialRows.length; i++) {
|
|
451
|
+
payloads.push({
|
|
452
|
+
type: 'blockInput',
|
|
453
|
+
value: new BlockInputBuilder()
|
|
454
|
+
.sheetIdx(sheetIdx)
|
|
455
|
+
.blockId(blockId)
|
|
456
|
+
.row(i)
|
|
457
|
+
.col(0)
|
|
458
|
+
.input(initialRows[i].key)
|
|
459
|
+
.build(),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
// Auto-inject a variant-whitelist validation formula for each
|
|
463
|
+
// enum field. Engine-managed via Phase 1+2: storing this in
|
|
464
|
+
// BindFormSchema.validationFormulas means the shadow is
|
|
465
|
+
// installed on every row automatically (and on every new row
|
|
466
|
+
// from InsertRowsInBlock).
|
|
467
|
+
const validationFormulas = resolvedFields.map((_, i) => {
|
|
468
|
+
const variants = enumVariantsByField.get(i);
|
|
469
|
+
if (!variants)
|
|
470
|
+
return '';
|
|
471
|
+
return enumWhitelistFormula(variants);
|
|
472
|
+
});
|
|
473
|
+
payloads.push({
|
|
474
|
+
type: 'bindFormSchema',
|
|
475
|
+
value: new BindFormSchemaBuilder()
|
|
476
|
+
.refName(input.name)
|
|
477
|
+
.sheetIdx(sheetIdx)
|
|
478
|
+
.blockId(blockId)
|
|
479
|
+
.fieldFrom(0)
|
|
480
|
+
.keyIdx(0)
|
|
481
|
+
.fields(fieldNames)
|
|
482
|
+
.renderIds(renderIds)
|
|
483
|
+
// No value templates at create time; rules are layered
|
|
484
|
+
// via set_field_rule. Empty strings normalize to None
|
|
485
|
+
// engine-side (matches the TS binding's `readonly
|
|
486
|
+
// string[]` shape).
|
|
487
|
+
.fieldFormulas(fieldNames.map(() => ''))
|
|
488
|
+
.validationFormulas(validationFormulas)
|
|
489
|
+
.editabilityFormulas([])
|
|
490
|
+
.row(true)
|
|
491
|
+
.build(),
|
|
492
|
+
});
|
|
493
|
+
for (let i = 0; i < initialRows.length; i++) {
|
|
494
|
+
const values = (_c = initialRows[i].values) !== null && _c !== void 0 ? _c : {};
|
|
495
|
+
for (const [fieldName, value] of Object.entries(values)) {
|
|
496
|
+
const colIdx = fieldNames.indexOf(fieldName);
|
|
497
|
+
if (colIdx < 0) {
|
|
498
|
+
throw new Error(`initial_rows[${i}].values references unknown field '${fieldName}'`);
|
|
499
|
+
}
|
|
500
|
+
if (colIdx === 0)
|
|
501
|
+
continue; // key already written above
|
|
502
|
+
// Date/datetime fields store an Excel serial number, not the
|
|
503
|
+
// raw string — convert here so the formatter renders a real
|
|
504
|
+
// date instead of leaving an unparsed string in the cell.
|
|
505
|
+
const ft = (_d = resolvedFields[colIdx]) === null || _d === void 0 ? void 0 : _d.field_type;
|
|
506
|
+
let inputStr;
|
|
507
|
+
if (ft === 'date' || ft === 'datetime') {
|
|
508
|
+
const serial = toExcelSerial(value, ft === 'datetime');
|
|
509
|
+
inputStr =
|
|
510
|
+
serial !== null
|
|
511
|
+
? String(serial)
|
|
512
|
+
: stringifyForBlockInput(value);
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
inputStr = stringifyForBlockInput(value);
|
|
516
|
+
}
|
|
517
|
+
payloads.push({
|
|
518
|
+
type: 'blockInput',
|
|
519
|
+
value: new BlockInputBuilder()
|
|
520
|
+
.sheetIdx(sheetIdx)
|
|
521
|
+
.blockId(blockId)
|
|
522
|
+
.row(i)
|
|
523
|
+
.col(colIdx)
|
|
524
|
+
.input(inputStr)
|
|
525
|
+
.build(),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
await commitTransaction(client, payloads, `create_block("${input.name}")`);
|
|
530
|
+
// Stamp the schema's refName onto every FieldInfo we just
|
|
531
|
+
// created. FieldManager doesn't know the refName at create-time
|
|
532
|
+
// — the host learns it from BindFormSchema, which commits as
|
|
533
|
+
// part of the tx above. Without this stamp the block-composer
|
|
534
|
+
// and similar UI lose the reverse refName→fields lookup.
|
|
535
|
+
if (bm) {
|
|
536
|
+
bm.fieldManager.setBlockRefName(sheetId, blockId, input.name);
|
|
537
|
+
}
|
|
538
|
+
const widgetNote = bm ? 'widget rendering on' : 'no widget host';
|
|
539
|
+
return {
|
|
540
|
+
data: { block_id: blockId },
|
|
541
|
+
display: `Created block "${input.name}" (id=${blockId}) at sheet "${input.sheet}" pos (${input.position.row},${input.position.col}) — ${input.fields.length} field(s) × ${rowCnt} row(s) (${widgetNote}).`,
|
|
542
|
+
};
|
|
543
|
+
},
|
|
544
|
+
};
|
|
545
|
+
/** Map Watson's flat field input to the `FieldTypeEnum` shape the host
|
|
546
|
+
* FieldManager expects. We only emit the variants we currently
|
|
547
|
+
* support; richer types (datetime, fieldRef, image, multiSelect...)
|
|
548
|
+
* are out of scope until Watson exposes their UX. */
|
|
549
|
+
function buildFieldTypeSpec(f, enumVariants) {
|
|
550
|
+
var _a, _b, _c;
|
|
551
|
+
switch (f.field_type) {
|
|
552
|
+
case 'string':
|
|
553
|
+
return { type: 'string', validation: '' };
|
|
554
|
+
case 'number':
|
|
555
|
+
return {
|
|
556
|
+
type: 'number',
|
|
557
|
+
validation: '',
|
|
558
|
+
formatter: (_a = f.num_fmt) !== null && _a !== void 0 ? _a : '',
|
|
559
|
+
};
|
|
560
|
+
// The engine has no distinct date type — dates are numbers carrying
|
|
561
|
+
// a date formatter. Honor an explicit num_fmt, else a sensible default.
|
|
562
|
+
case 'date':
|
|
563
|
+
return {
|
|
564
|
+
type: 'number',
|
|
565
|
+
validation: '',
|
|
566
|
+
formatter: (_b = f.num_fmt) !== null && _b !== void 0 ? _b : 'yyyy-mm-dd',
|
|
567
|
+
};
|
|
568
|
+
case 'datetime':
|
|
569
|
+
return {
|
|
570
|
+
type: 'number',
|
|
571
|
+
validation: '',
|
|
572
|
+
formatter: (_c = f.num_fmt) !== null && _c !== void 0 ? _c : 'yyyy-mm-dd hh:mm',
|
|
573
|
+
};
|
|
574
|
+
case 'boolean':
|
|
575
|
+
return { type: 'boolean' };
|
|
576
|
+
case 'enum':
|
|
577
|
+
// enumVariants check happened at the gate above; treat
|
|
578
|
+
// missing here as a programming error rather than a user
|
|
579
|
+
// error.
|
|
580
|
+
if (!f.enum_id || !enumVariants) {
|
|
581
|
+
throw new Error(`internal: missing enum_id/variants for enum field "${f.name}"`);
|
|
582
|
+
}
|
|
583
|
+
return { type: 'enum', id: f.enum_id };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
export const addBlockRows = {
|
|
587
|
+
namespace: 'build',
|
|
588
|
+
name: 'add_block_rows',
|
|
589
|
+
description: "Append rows to an existing block. Each row needs a key; values is an object keyed by field name. Fields with a value_formula are auto-materialized by the engine — don't pass them in values. Validation/editability shadows for the new rows are auto-installed by the engine at InsertRowsInBlock time, so no follow-up is needed. Also inserts the matching sheet rows (one block per sheet-row assumption — extends the sheet so downstream rows shift down).",
|
|
590
|
+
mutates: true,
|
|
591
|
+
confirmation: 'never',
|
|
592
|
+
inputSchema: {
|
|
593
|
+
properties: {
|
|
594
|
+
block: { type: 'string', description: 'Block ref name.' },
|
|
595
|
+
rows: { type: 'array', items: ROW_SCHEMA, minItems: 1 },
|
|
596
|
+
},
|
|
597
|
+
required: ['block', 'rows'],
|
|
598
|
+
},
|
|
599
|
+
handler: async (input, ctx) => {
|
|
600
|
+
var _a;
|
|
601
|
+
const client = asClient(ctx);
|
|
602
|
+
// 1. Resolve block ref name → (sheetIdx, blockId, current rowCnt,
|
|
603
|
+
// field name order in schema-declared order).
|
|
604
|
+
const all = await client.getAllBlocks({});
|
|
605
|
+
if (isErrorMessage(all)) {
|
|
606
|
+
throw new Error(`getAllBlocks failed: ${all.msg}`);
|
|
607
|
+
}
|
|
608
|
+
const block = all.find((b) => { var _a; return ((_a = b.schema) === null || _a === void 0 ? void 0 : _a.name) === input.block; });
|
|
609
|
+
if (!block) {
|
|
610
|
+
throw new Error(`No block with ref name "${input.block}"`);
|
|
611
|
+
}
|
|
612
|
+
const schema = block.schema;
|
|
613
|
+
if (!schema) {
|
|
614
|
+
throw new Error(`Block "${input.block}" has no schema — can't determine field order`);
|
|
615
|
+
}
|
|
616
|
+
const sheetIdx = block.sheetIdx;
|
|
617
|
+
const blockId = block.blockId;
|
|
618
|
+
const blockStart = block.rowCnt; // append after the last existing block row
|
|
619
|
+
// Sheet-absolute row where new rows physically land. Under our
|
|
620
|
+
// "one block per row" assumption the rows immediately after the
|
|
621
|
+
// block are free to shift down without colliding with another
|
|
622
|
+
// block; if a future relaxation allows side-by-side blocks the
|
|
623
|
+
// caller would need to pick a different insert site.
|
|
624
|
+
const sheetStart = block.rowStart + block.rowCnt;
|
|
625
|
+
const fieldNames = [...schema.fields]
|
|
626
|
+
.sort((a, b) => a.idx - b.idx)
|
|
627
|
+
.map((f) => f.field);
|
|
628
|
+
// 2. Compose payloads:
|
|
629
|
+
// InsertRows(sheetStart, cnt) ← physical sheet rows so
|
|
630
|
+
// downstream content shifts
|
|
631
|
+
// down and doesn't overlap
|
|
632
|
+
// the grown block range
|
|
633
|
+
// InsertRowsInBlock(start, cnt) ← engine extends block rows
|
|
634
|
+
// + auto-materializes
|
|
635
|
+
// value_formula + auto-
|
|
636
|
+
// installs validation /
|
|
637
|
+
// editability shadows
|
|
638
|
+
// BlockInput(key, ...values) ← one per (row × field with
|
|
639
|
+
// non-null value), key always
|
|
640
|
+
// goes in col 0
|
|
641
|
+
const cnt = input.rows.length;
|
|
642
|
+
const payloads = [];
|
|
643
|
+
payloads.push({
|
|
644
|
+
type: 'insertRows',
|
|
645
|
+
value: new InsertRowsBuilder()
|
|
646
|
+
.sheetIdx(sheetIdx)
|
|
647
|
+
.start(sheetStart)
|
|
648
|
+
.count(cnt)
|
|
649
|
+
.build(),
|
|
650
|
+
});
|
|
651
|
+
payloads.push({
|
|
652
|
+
type: 'insertRowsInBlock',
|
|
653
|
+
value: new InsertRowsInBlockBuilder()
|
|
654
|
+
.sheetIdx(sheetIdx)
|
|
655
|
+
.blockId(blockId)
|
|
656
|
+
.start(blockStart)
|
|
657
|
+
.cnt(cnt)
|
|
658
|
+
.build(),
|
|
659
|
+
});
|
|
660
|
+
for (let i = 0; i < input.rows.length; i++) {
|
|
661
|
+
const r = input.rows[i];
|
|
662
|
+
const row = blockStart + i;
|
|
663
|
+
// Key always goes to col 0.
|
|
664
|
+
payloads.push({
|
|
665
|
+
type: 'blockInput',
|
|
666
|
+
value: new BlockInputBuilder()
|
|
667
|
+
.sheetIdx(sheetIdx)
|
|
668
|
+
.blockId(blockId)
|
|
669
|
+
.row(row)
|
|
670
|
+
.col(0)
|
|
671
|
+
.input(r.key)
|
|
672
|
+
.build(),
|
|
673
|
+
});
|
|
674
|
+
const values = (_a = r.values) !== null && _a !== void 0 ? _a : {};
|
|
675
|
+
for (const [fieldName, value] of Object.entries(values)) {
|
|
676
|
+
const colIdx = fieldNames.indexOf(fieldName);
|
|
677
|
+
if (colIdx < 0) {
|
|
678
|
+
throw new Error(`rows[${i}].values references unknown field '${fieldName}' for block "${input.block}"`);
|
|
679
|
+
}
|
|
680
|
+
if (colIdx === 0)
|
|
681
|
+
continue; // key already written
|
|
682
|
+
payloads.push({
|
|
683
|
+
type: 'blockInput',
|
|
684
|
+
value: new BlockInputBuilder()
|
|
685
|
+
.sheetIdx(sheetIdx)
|
|
686
|
+
.blockId(blockId)
|
|
687
|
+
.row(row)
|
|
688
|
+
.col(colIdx)
|
|
689
|
+
.input(stringifyForBlockInput(value))
|
|
690
|
+
.build(),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
await commitTransaction(client, payloads, `add_block_rows("${input.block}")`);
|
|
695
|
+
return {
|
|
696
|
+
data: { added: cnt },
|
|
697
|
+
display: `Appended ${cnt} row(s) to "${input.block}" (block row ${blockStart}, sheet row ${sheetStart}).`,
|
|
698
|
+
};
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
export const deleteBlockRows = {
|
|
702
|
+
namespace: 'build',
|
|
703
|
+
name: 'delete_block_rows',
|
|
704
|
+
description: "Delete rows from a block by their key. Missing keys are silently ignored. Also deletes the matching sheet rows (one block per sheet-row assumption). If you try to delete every row, the last one is kept and its cells are cleared instead — the engine doesn't allow rowCnt=0 blocks.",
|
|
705
|
+
mutates: true,
|
|
706
|
+
// Removes records outright, same as clear_block — hosts should show this
|
|
707
|
+
// with a destructive affordance, not a plain write confirmation.
|
|
708
|
+
confirmation: 'destructive',
|
|
709
|
+
inputSchema: {
|
|
710
|
+
properties: {
|
|
711
|
+
block: { type: 'string' },
|
|
712
|
+
keys: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
713
|
+
},
|
|
714
|
+
required: ['block', 'keys'],
|
|
715
|
+
},
|
|
716
|
+
handler: async (input, ctx) => {
|
|
717
|
+
const client = asClient(ctx);
|
|
718
|
+
// 1. Resolve block + schema (need keys → block-row index map +
|
|
719
|
+
// field count for the "clear all cells" sentinel branch).
|
|
720
|
+
const all = await client.getAllBlocks({});
|
|
721
|
+
if (isErrorMessage(all)) {
|
|
722
|
+
throw new Error(`getAllBlocks failed: ${all.msg}`);
|
|
723
|
+
}
|
|
724
|
+
const block = all.find((b) => { var _a; return ((_a = b.schema) === null || _a === void 0 ? void 0 : _a.name) === input.block; });
|
|
725
|
+
if (!block) {
|
|
726
|
+
throw new Error(`No block with ref name "${input.block}"`);
|
|
727
|
+
}
|
|
728
|
+
const schema = block.schema;
|
|
729
|
+
if (!schema) {
|
|
730
|
+
throw new Error(`Block "${input.block}" has no schema — can't locate rows by key`);
|
|
731
|
+
}
|
|
732
|
+
const sheetIdx = block.sheetIdx;
|
|
733
|
+
const blockId = block.blockId;
|
|
734
|
+
const blockRowStart = block.rowStart;
|
|
735
|
+
const totalRows = block.rowCnt;
|
|
736
|
+
const colCnt = schema.fields.length;
|
|
737
|
+
// Map row-key → block-relative row index.
|
|
738
|
+
const keyToRow = new Map();
|
|
739
|
+
for (const k of schema.keys)
|
|
740
|
+
keyToRow.set(k.key, k.idx);
|
|
741
|
+
// Resolve requested keys to block-row indices. Drop unknowns
|
|
742
|
+
// silently (per description: "missing keys are ignored").
|
|
743
|
+
const targetRows = input.keys
|
|
744
|
+
.map((k) => keyToRow.get(k))
|
|
745
|
+
.filter((r) => r !== undefined);
|
|
746
|
+
if (targetRows.length === 0) {
|
|
747
|
+
return {
|
|
748
|
+
data: { removed: 0 },
|
|
749
|
+
display: `No matching rows in "${input.block}".`,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
// Engine rejects rowCnt=0 blocks: if the caller asked to delete
|
|
753
|
+
// EVERY row, keep one row as a sentinel and clear its cells
|
|
754
|
+
// instead. Pick the lowest target index to clear; remove the
|
|
755
|
+
// rest normally.
|
|
756
|
+
const willEmpty = targetRows.length >= totalRows;
|
|
757
|
+
const uniqueSortedDesc = Array.from(new Set(targetRows)).sort((a, b) => b - a);
|
|
758
|
+
const payloads = [];
|
|
759
|
+
if (willEmpty) {
|
|
760
|
+
// Delete all but the first remaining row, then blank that one.
|
|
761
|
+
const keepBlockRow = Math.min(...targetRows);
|
|
762
|
+
const toRemove = uniqueSortedDesc.filter((r) => r !== keepBlockRow);
|
|
763
|
+
// Delete from highest row downward so each delete's start
|
|
764
|
+
// index stays valid relative to the still-existing rows.
|
|
765
|
+
for (const r of toRemove) {
|
|
766
|
+
payloads.push({
|
|
767
|
+
type: 'deleteRowsInBlock',
|
|
768
|
+
value: new DeleteRowsInBlockBuilder()
|
|
769
|
+
.sheetIdx(sheetIdx)
|
|
770
|
+
.blockId(blockId)
|
|
771
|
+
.start(r)
|
|
772
|
+
.cnt(1)
|
|
773
|
+
.build(),
|
|
774
|
+
});
|
|
775
|
+
payloads.push({
|
|
776
|
+
type: 'deleteRows',
|
|
777
|
+
value: new DeleteRowsBuilder()
|
|
778
|
+
.sheetIdx(sheetIdx)
|
|
779
|
+
.start(blockRowStart + r)
|
|
780
|
+
.count(1)
|
|
781
|
+
.build(),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
// Clear remaining sentinel row (incl. key column — caller
|
|
785
|
+
// asked to remove it after all, so leave nothing behind).
|
|
786
|
+
for (let c = 0; c < colCnt; c++) {
|
|
787
|
+
payloads.push({
|
|
788
|
+
type: 'blockInput',
|
|
789
|
+
value: new BlockInputBuilder()
|
|
790
|
+
.sheetIdx(sheetIdx)
|
|
791
|
+
.blockId(blockId)
|
|
792
|
+
.row(keepBlockRow)
|
|
793
|
+
.col(c)
|
|
794
|
+
.input('')
|
|
795
|
+
.build(),
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
// Normal path: delete each requested row. Walk descending
|
|
801
|
+
// so the row numbers we already chose stay valid.
|
|
802
|
+
for (const r of uniqueSortedDesc) {
|
|
803
|
+
payloads.push({
|
|
804
|
+
type: 'deleteRowsInBlock',
|
|
805
|
+
value: new DeleteRowsInBlockBuilder()
|
|
806
|
+
.sheetIdx(sheetIdx)
|
|
807
|
+
.blockId(blockId)
|
|
808
|
+
.start(r)
|
|
809
|
+
.cnt(1)
|
|
810
|
+
.build(),
|
|
811
|
+
});
|
|
812
|
+
payloads.push({
|
|
813
|
+
type: 'deleteRows',
|
|
814
|
+
value: new DeleteRowsBuilder()
|
|
815
|
+
.sheetIdx(sheetIdx)
|
|
816
|
+
.start(blockRowStart + r)
|
|
817
|
+
.count(1)
|
|
818
|
+
.build(),
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
await commitTransaction(client, payloads, `delete_block_rows("${input.block}")`);
|
|
823
|
+
return {
|
|
824
|
+
data: { removed: targetRows.length },
|
|
825
|
+
display: willEmpty
|
|
826
|
+
? `Cleared "${input.block}" — removed ${targetRows.length - 1} row(s) and blanked the remaining sentinel row.`
|
|
827
|
+
: `Deleted ${uniqueSortedDesc.length} row(s) from "${input.block}".`,
|
|
828
|
+
};
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
export const setFieldRule = {
|
|
832
|
+
namespace: 'build',
|
|
833
|
+
name: 'set_field_rule',
|
|
834
|
+
description: [
|
|
835
|
+
'Attach declarative rules to a field. All three rule kinds (value_formula, validation, editability) are optional — pass only the ones you want to change. Omit a kind entirely to leave it untouched on this field; pass `null` to explicitly clear an existing rule.',
|
|
836
|
+
'',
|
|
837
|
+
'Placeholders supported in formulas:',
|
|
838
|
+
' #FIELD("name") — reference to the same row\'s cell in field "name"',
|
|
839
|
+
" #KEY — the row's key value (quoted as a string literal)",
|
|
840
|
+
' #PLACEHOLDER — reference to the cell itself (validation/editability only)',
|
|
841
|
+
'',
|
|
842
|
+
'Engine behaviour after this call:',
|
|
843
|
+
" - value_formula → cells in the field become engine-computed (no direct writes). Every row's formula is re-materialized.",
|
|
844
|
+
' - validation → a `ShadowKind::Validation` shadow is auto-installed on every row; warning markers refresh.',
|
|
845
|
+
' - editability → a `ShadowKind::UserEditable` shadow is auto-installed on every row; the host permission patch reads it to gate writes.',
|
|
846
|
+
'',
|
|
847
|
+
'Leading "=" on the formula body is optional; omit or include either way.',
|
|
848
|
+
].join('\n'),
|
|
849
|
+
mutates: true,
|
|
850
|
+
confirmation: 'never',
|
|
851
|
+
inputSchema: {
|
|
852
|
+
properties: {
|
|
853
|
+
block: { type: 'string' },
|
|
854
|
+
field: { type: 'string' },
|
|
855
|
+
value_formula: {
|
|
856
|
+
type: ['string', 'null'],
|
|
857
|
+
description: 'Formula template, e.g. "=#FIELD(\\"qty\\")*#FIELD(\\"price\\")". Pass null to clear; omit to leave existing untouched.',
|
|
858
|
+
},
|
|
859
|
+
validation: {
|
|
860
|
+
type: ['string', 'null'],
|
|
861
|
+
description: 'Boolean formula, e.g. "#PLACEHOLDER>=0". Pass null to clear; omit to leave existing untouched.',
|
|
862
|
+
},
|
|
863
|
+
editability: {
|
|
864
|
+
type: ['string', 'null'],
|
|
865
|
+
description: 'Boolean formula, e.g. "=#FIELD(\\"status\\")<>\\"locked\\"". Pass null to clear; omit to leave existing untouched.',
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
required: ['block', 'field'],
|
|
869
|
+
},
|
|
870
|
+
handler: async (input, ctx) => {
|
|
871
|
+
const client = asClient(ctx);
|
|
872
|
+
// Reject the no-op case so callers don't accidentally hit an
|
|
873
|
+
// empty transaction; surfacing "you didn't actually pass any
|
|
874
|
+
// rule" is friendlier than a silent OK.
|
|
875
|
+
const touched = [];
|
|
876
|
+
if (input.value_formula !== undefined)
|
|
877
|
+
touched.push('value_formula');
|
|
878
|
+
if (input.validation !== undefined)
|
|
879
|
+
touched.push('validation');
|
|
880
|
+
if (input.editability !== undefined)
|
|
881
|
+
touched.push('editability');
|
|
882
|
+
if (touched.length === 0) {
|
|
883
|
+
throw new Error('set_field_rule: pass at least one of value_formula, validation, editability');
|
|
884
|
+
}
|
|
885
|
+
// 1. Resolve block + schema, and remember the schema's field
|
|
886
|
+
// ORDER (positional — same order as the original
|
|
887
|
+
// BindFormSchema call; UpsertFieldFormulas indexes by this).
|
|
888
|
+
const all = await client.getAllBlocks({});
|
|
889
|
+
if (isErrorMessage(all)) {
|
|
890
|
+
throw new Error(`getAllBlocks failed: ${all.msg}`);
|
|
891
|
+
}
|
|
892
|
+
const block = all.find((b) => { var _a; return ((_a = b.schema) === null || _a === void 0 ? void 0 : _a.name) === input.block; });
|
|
893
|
+
if (!block) {
|
|
894
|
+
throw new Error(`No block with ref name "${input.block}"`);
|
|
895
|
+
}
|
|
896
|
+
const schema = block.schema;
|
|
897
|
+
if (!schema) {
|
|
898
|
+
throw new Error(`Block "${input.block}" has no schema — bind it first via create_block`);
|
|
899
|
+
}
|
|
900
|
+
const fieldPos = schema.fields.findIndex((f) => f.field === input.field);
|
|
901
|
+
if (fieldPos < 0) {
|
|
902
|
+
throw new Error(`No field named "${input.field}" in block "${input.block}"`);
|
|
903
|
+
}
|
|
904
|
+
const fieldEntries = schema.fields;
|
|
905
|
+
const buildVec = (updated, existing) => fieldEntries.map((f, i) => {
|
|
906
|
+
var _a;
|
|
907
|
+
return i === fieldPos
|
|
908
|
+
? updated === null
|
|
909
|
+
? ''
|
|
910
|
+
: normalizeFormula(updated)
|
|
911
|
+
: (_a = existing(f)) !== null && _a !== void 0 ? _a : '';
|
|
912
|
+
});
|
|
913
|
+
const fieldFormulas = input.value_formula === undefined
|
|
914
|
+
? []
|
|
915
|
+
: buildVec(input.value_formula, (f) => f.valueFormula);
|
|
916
|
+
const validationFormulas = input.validation === undefined
|
|
917
|
+
? []
|
|
918
|
+
: buildVec(input.validation, (f) => f.validationFormula);
|
|
919
|
+
const editabilityFormulas = input.editability === undefined
|
|
920
|
+
? []
|
|
921
|
+
: buildVec(input.editability, (f) => f.editabilityFormula);
|
|
922
|
+
const payloads = [
|
|
923
|
+
{
|
|
924
|
+
type: 'upsertFieldFormulas',
|
|
925
|
+
value: new UpsertFieldFormulasBuilder()
|
|
926
|
+
.sheetIdx(block.sheetIdx)
|
|
927
|
+
.blockId(block.blockId)
|
|
928
|
+
.fieldFormulas(fieldFormulas)
|
|
929
|
+
.validationFormulas(validationFormulas)
|
|
930
|
+
.editabilityFormulas(editabilityFormulas)
|
|
931
|
+
.build(),
|
|
932
|
+
},
|
|
933
|
+
];
|
|
934
|
+
await commitTransaction(client, payloads, `set_field_rule("${input.block}", "${input.field}")`);
|
|
935
|
+
return {
|
|
936
|
+
data: { applied: touched },
|
|
937
|
+
display: `Updated ${touched.join(' + ')} on ${input.block}.${input.field}.`,
|
|
938
|
+
};
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
/** Guard for the checkpoint ops that need a label; surface a clear
|
|
942
|
+
* error to the LLM rather than letting the RPC fail with a less
|
|
943
|
+
* contextual message. */
|
|
944
|
+
function requireLabel(input, op) {
|
|
945
|
+
var _a;
|
|
946
|
+
const label = (_a = input.label) === null || _a === void 0 ? void 0 : _a.trim();
|
|
947
|
+
if (!label) {
|
|
948
|
+
throw new Error(`checkpoint: op="${op}" requires \`label\``);
|
|
949
|
+
}
|
|
950
|
+
return label;
|
|
951
|
+
}
|
|
952
|
+
/** Strip optional leading `=` so formulas are stored without it; the
|
|
953
|
+
* engine accepts both shapes but the schema-internal representation
|
|
954
|
+
* is the body. */
|
|
955
|
+
function normalizeFormula(s) {
|
|
956
|
+
const t = s.trim();
|
|
957
|
+
if (!t)
|
|
958
|
+
return '';
|
|
959
|
+
return t.startsWith('=') ? t.slice(1) : t;
|
|
960
|
+
}
|
|
961
|
+
/** A small palette to auto-assign visually-distinct colors when callers
|
|
962
|
+
* don't pick. Repeats after N variants — acceptable for the LLM-driven
|
|
963
|
+
* flow where variant counts are typically small. */
|
|
964
|
+
const _ENUM_PALETTE = [
|
|
965
|
+
'#3b82f6', // blue
|
|
966
|
+
'#22c55e', // green
|
|
967
|
+
'#f59e0b', // amber
|
|
968
|
+
'#ef4444', // red
|
|
969
|
+
'#a855f7', // purple
|
|
970
|
+
'#06b6d4', // cyan
|
|
971
|
+
'#ec4899', // pink
|
|
972
|
+
'#84cc16', // lime
|
|
973
|
+
];
|
|
974
|
+
function tryGetBlockManager() {
|
|
975
|
+
var _a;
|
|
976
|
+
const g = globalThis;
|
|
977
|
+
return (_a = g.blockManager) !== null && _a !== void 0 ? _a : null;
|
|
978
|
+
}
|
|
979
|
+
/** Build a per-field validation_formula body that whitelists the given
|
|
980
|
+
* variant ids. Empty cells are allowed (UI lets the user clear the
|
|
981
|
+
* selection). Uses EXACT for case-sensitive match — variant ids in
|
|
982
|
+
* factory-simulator-style flows are stable ASCII strings, but the
|
|
983
|
+
* helper stays safe under CJK / mixed-case variants too. */
|
|
984
|
+
function enumWhitelistFormula(variantIds) {
|
|
985
|
+
if (variantIds.length === 0)
|
|
986
|
+
return '';
|
|
987
|
+
const clauses = variantIds.map((v) => {
|
|
988
|
+
// Excel string literals escape `"` as `""`.
|
|
989
|
+
const escaped = v.replace(/"/g, '""');
|
|
990
|
+
return `EXACT(#PLACEHOLDER,"${escaped}")`;
|
|
991
|
+
});
|
|
992
|
+
// `=OR(#PLACEHOLDER="", EXACT(...), EXACT(...))`
|
|
993
|
+
return `OR(#PLACEHOLDER="",${clauses.join(',')})`;
|
|
994
|
+
}
|
|
995
|
+
export const defineEnumSet = {
|
|
996
|
+
namespace: 'build',
|
|
997
|
+
name: 'define_enum_set',
|
|
998
|
+
description: [
|
|
999
|
+
"Define or overwrite an enum set — a named list of allowed values for a field. After this call, declare a field with `field_type: 'enum'` and `enum_id: '<this set's id>'` (via create_block or set_field_rule) to use the set. Cells store the variant `id` (not the label); host UI renders a dropdown of labels.",
|
|
1000
|
+
'',
|
|
1001
|
+
'Tips for the LLM:',
|
|
1002
|
+
' - Pick stable variant `id`s (snake_case, ASCII). They go into cells and persist.',
|
|
1003
|
+
' - `label` is what humans see in the dropdown — can be any language.',
|
|
1004
|
+
' - `color` is optional; a sensible palette is auto-assigned.',
|
|
1005
|
+
'',
|
|
1006
|
+
"If the host environment doesn't expose `blockManager` (headless / SDK mode), the set is still recorded in this Watson session so subsequent create_block / set_field_rule calls can reference it for validation — they just won't get the dropdown widget.",
|
|
1007
|
+
].join('\n'),
|
|
1008
|
+
mutates: true,
|
|
1009
|
+
confirmation: 'never',
|
|
1010
|
+
inputSchema: {
|
|
1011
|
+
properties: {
|
|
1012
|
+
id: {
|
|
1013
|
+
type: 'string',
|
|
1014
|
+
description: 'Set id (snake_case recommended). Stable across calls; overwrites an existing set with the same id.',
|
|
1015
|
+
},
|
|
1016
|
+
name: {
|
|
1017
|
+
type: 'string',
|
|
1018
|
+
description: 'Human-readable name shown in the composer / dropdown header. Defaults to the id.',
|
|
1019
|
+
},
|
|
1020
|
+
description: {
|
|
1021
|
+
type: 'string',
|
|
1022
|
+
description: 'Optional short description for the set.',
|
|
1023
|
+
},
|
|
1024
|
+
variants: {
|
|
1025
|
+
type: 'array',
|
|
1026
|
+
minItems: 1,
|
|
1027
|
+
items: {
|
|
1028
|
+
type: 'object',
|
|
1029
|
+
properties: {
|
|
1030
|
+
id: {
|
|
1031
|
+
type: 'string',
|
|
1032
|
+
description: 'Variant id stored in cells. Must be unique within the set.',
|
|
1033
|
+
},
|
|
1034
|
+
label: {
|
|
1035
|
+
type: 'string',
|
|
1036
|
+
description: 'Display label.',
|
|
1037
|
+
},
|
|
1038
|
+
color: {
|
|
1039
|
+
type: 'string',
|
|
1040
|
+
description: 'Hex color (#RRGGBB). Auto-assigned if omitted.',
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
required: ['id', 'label'],
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
required: ['id', 'variants'],
|
|
1048
|
+
},
|
|
1049
|
+
handler: async (input, _ctx) => {
|
|
1050
|
+
var _a;
|
|
1051
|
+
// Validate variant uniqueness up front so the error message is
|
|
1052
|
+
// clear (the underlying EnumSetManager.set throws too, but with
|
|
1053
|
+
// less context).
|
|
1054
|
+
const seenIds = new Set();
|
|
1055
|
+
const seenLabels = new Set();
|
|
1056
|
+
for (const v of input.variants) {
|
|
1057
|
+
if (seenIds.has(v.id))
|
|
1058
|
+
throw new Error(`duplicate variant id "${v.id}"`);
|
|
1059
|
+
if (seenLabels.has(v.label))
|
|
1060
|
+
throw new Error(`duplicate variant label "${v.label}"`);
|
|
1061
|
+
seenIds.add(v.id);
|
|
1062
|
+
seenLabels.add(v.label);
|
|
1063
|
+
}
|
|
1064
|
+
// Fill in colors from the palette for any variant that omitted one.
|
|
1065
|
+
const variants = input.variants.map((v, i) => {
|
|
1066
|
+
var _a;
|
|
1067
|
+
return ({
|
|
1068
|
+
id: v.id,
|
|
1069
|
+
value: v.label,
|
|
1070
|
+
color: (_a = v.color) !== null && _a !== void 0 ? _a : _ENUM_PALETTE[i % _ENUM_PALETTE.length],
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
const setName = (_a = input.name) !== null && _a !== void 0 ? _a : input.id;
|
|
1074
|
+
// Register with the host blockManager if available. This makes
|
|
1075
|
+
// the dropdown widget show up on cells using this enum.
|
|
1076
|
+
// Headless hosts simply skip — the enum spec still helps the
|
|
1077
|
+
// LLM-side workflow (create_block will auto-inject a variant
|
|
1078
|
+
// whitelist validation_formula even without widget rendering).
|
|
1079
|
+
const bm = tryGetBlockManager();
|
|
1080
|
+
let bound = false;
|
|
1081
|
+
if (bm) {
|
|
1082
|
+
bm.enumSetManager.set(input.id, setName, variants, input.description);
|
|
1083
|
+
bound = true;
|
|
1084
|
+
}
|
|
1085
|
+
// Always cache locally — the create_block handler reads this map
|
|
1086
|
+
// to auto-inject a whitelist validation formula for enum fields,
|
|
1087
|
+
// regardless of whether the host registered the set or not.
|
|
1088
|
+
_enumSetCache.set(input.id, {
|
|
1089
|
+
id: input.id,
|
|
1090
|
+
name: setName,
|
|
1091
|
+
description: input.description,
|
|
1092
|
+
variants,
|
|
1093
|
+
});
|
|
1094
|
+
return {
|
|
1095
|
+
data: {
|
|
1096
|
+
enum_id: input.id,
|
|
1097
|
+
variant_count: variants.length,
|
|
1098
|
+
variant_ids: variants.map((v) => v.id),
|
|
1099
|
+
},
|
|
1100
|
+
display: bound
|
|
1101
|
+
? `Defined enum set "${input.id}" with ${variants.length} variant(s); registered with host blockManager.`
|
|
1102
|
+
: `Defined enum set "${input.id}" with ${variants.length} variant(s); host blockManager not present, kept in Watson session only.`,
|
|
1103
|
+
};
|
|
1104
|
+
},
|
|
1105
|
+
};
|
|
1106
|
+
/** Watson-session enum registry. Mirror of what's in the host's
|
|
1107
|
+
* enumSetManager when available, plus a fallback for headless. Read by
|
|
1108
|
+
* the create_block handler when a field declares `field_type: 'enum'`
|
|
1109
|
+
* so it can auto-inject a variant whitelist validation. */
|
|
1110
|
+
export const _enumSetCache = new Map();
|
|
1111
|
+
export const listBlocks = {
|
|
1112
|
+
namespace: 'build',
|
|
1113
|
+
name: 'list_blocks',
|
|
1114
|
+
description: 'List blocks grouped by sheet, plus a suggested position for the next new block on each sheet. The `next_block_start` field is the row right after the last existing block (assuming blocks never share rows) — pass it as `position` to `create_block`. Omit `sheet` to scan the whole workbook; passing it restricts to one sheet.',
|
|
1115
|
+
mutates: false,
|
|
1116
|
+
confirmation: 'never',
|
|
1117
|
+
cost: 'cheap',
|
|
1118
|
+
inputSchema: {
|
|
1119
|
+
properties: {
|
|
1120
|
+
sheet: { type: 'string' },
|
|
1121
|
+
},
|
|
1122
|
+
},
|
|
1123
|
+
handler: async (input, ctx) => {
|
|
1124
|
+
var _a, _b, _c;
|
|
1125
|
+
const client = asClient(ctx);
|
|
1126
|
+
const sheetInfosRes = await client.getAllSheetInfo();
|
|
1127
|
+
if (isErrorMessage(sheetInfosRes)) {
|
|
1128
|
+
throw new Error(`getAllSheetInfo failed: ${sheetInfosRes.msg}`);
|
|
1129
|
+
}
|
|
1130
|
+
const sheetInfos = sheetInfosRes;
|
|
1131
|
+
// Resolve scope: a specific sheet, or every sheet in the workbook.
|
|
1132
|
+
let scope;
|
|
1133
|
+
if (input.sheet !== undefined) {
|
|
1134
|
+
const matched = sheetInfos.findIndex((s) => s.name === input.sheet);
|
|
1135
|
+
if (matched < 0) {
|
|
1136
|
+
throw new Error(`No sheet named "${input.sheet}"`);
|
|
1137
|
+
}
|
|
1138
|
+
scope = [matched];
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
scope = sheetInfos.map((_, i) => i);
|
|
1142
|
+
}
|
|
1143
|
+
// Pull blocks once (filtered or not), then group locally.
|
|
1144
|
+
const result = await client.getAllBlocks(input.sheet !== undefined ? { sheetIdx: scope[0] } : {});
|
|
1145
|
+
if (isErrorMessage(result)) {
|
|
1146
|
+
throw new Error(`getAllBlocks failed: ${result.msg}`);
|
|
1147
|
+
}
|
|
1148
|
+
const blocksByIdx = new Map();
|
|
1149
|
+
for (const b of result) {
|
|
1150
|
+
const arr = (_a = blocksByIdx.get(b.sheetIdx)) !== null && _a !== void 0 ? _a : [];
|
|
1151
|
+
arr.push({
|
|
1152
|
+
// BlockSchema.name is the block's refName (the first arg
|
|
1153
|
+
// to BLOCKREF / BLOCKREFS in formulas). Schema absent →
|
|
1154
|
+
// legacy / ad-hoc block, fall back to "block#<id>".
|
|
1155
|
+
name: (_c = (_b = b.schema) === null || _b === void 0 ? void 0 : _b.name) !== null && _c !== void 0 ? _c : `block#${b.blockId}`,
|
|
1156
|
+
block_id: b.blockId,
|
|
1157
|
+
position: { row: b.rowStart, col: b.colStart },
|
|
1158
|
+
row_count: b.rowCnt,
|
|
1159
|
+
col_count: b.colCnt,
|
|
1160
|
+
});
|
|
1161
|
+
blocksByIdx.set(b.sheetIdx, arr);
|
|
1162
|
+
}
|
|
1163
|
+
// One-row gap between adjacent blocks — pure aesthetic / debug
|
|
1164
|
+
// affordance so the canvas isn't a wall of touching rectangles.
|
|
1165
|
+
const BLOCK_GAP_ROWS = 1;
|
|
1166
|
+
const groups = scope.map((idx) => {
|
|
1167
|
+
var _a, _b, _c;
|
|
1168
|
+
const blocks = (_a = blocksByIdx.get(idx)) !== null && _a !== void 0 ? _a : [];
|
|
1169
|
+
// "Next" row = end of the bottom-most block + gap. col=0 by
|
|
1170
|
+
// the "one block per row range" assumption.
|
|
1171
|
+
let nextRow = 0;
|
|
1172
|
+
for (const b of blocks) {
|
|
1173
|
+
const endPlusGap = b.position.row + b.row_count + BLOCK_GAP_ROWS;
|
|
1174
|
+
if (endPlusGap > nextRow)
|
|
1175
|
+
nextRow = endPlusGap;
|
|
1176
|
+
}
|
|
1177
|
+
blocks.sort((a, b) => a.position.row - b.position.row);
|
|
1178
|
+
return {
|
|
1179
|
+
sheet_name: (_c = (_b = sheetInfos[idx]) === null || _b === void 0 ? void 0 : _b.name) !== null && _c !== void 0 ? _c : `sheet#${idx}`,
|
|
1180
|
+
sheet_idx: idx,
|
|
1181
|
+
blocks,
|
|
1182
|
+
next_block_start: { row: nextRow, col: 0 },
|
|
1183
|
+
};
|
|
1184
|
+
});
|
|
1185
|
+
return { data: groups };
|
|
1186
|
+
},
|
|
1187
|
+
};
|
|
1188
|
+
export const describeBlock = {
|
|
1189
|
+
namespace: 'build',
|
|
1190
|
+
name: 'describe_block',
|
|
1191
|
+
description: [
|
|
1192
|
+
"Return a block's full structure for the LLM: identity (name, sheet, position), per-field schema (name, position, value_formula, validation, editability rules — all from the Rust schema, the engine's authoritative source), and row keys in order.",
|
|
1193
|
+
'',
|
|
1194
|
+
'Pass `include_rows: true` to additionally include current cell values as `rows[].values[fieldName]`. Off by default to save tokens — use it when the agent actually needs to inspect data, not when it only needs the shape.',
|
|
1195
|
+
].join('\n'),
|
|
1196
|
+
mutates: false,
|
|
1197
|
+
confirmation: 'never',
|
|
1198
|
+
cost: 'cheap',
|
|
1199
|
+
inputSchema: {
|
|
1200
|
+
properties: {
|
|
1201
|
+
name: {
|
|
1202
|
+
type: 'string',
|
|
1203
|
+
description: 'Block ref name (the `name` of create_block).',
|
|
1204
|
+
},
|
|
1205
|
+
include_rows: {
|
|
1206
|
+
type: 'boolean',
|
|
1207
|
+
default: false,
|
|
1208
|
+
description: 'When true, include current cell values. Off by default to save tokens.',
|
|
1209
|
+
},
|
|
1210
|
+
},
|
|
1211
|
+
required: ['name'],
|
|
1212
|
+
},
|
|
1213
|
+
handler: async (input, ctx) => {
|
|
1214
|
+
var _a, _b, _c;
|
|
1215
|
+
const client = asClient(ctx);
|
|
1216
|
+
// One getAllBlocks + one getAllSheetInfo. We use the workbook-
|
|
1217
|
+
// wide variants so a single roundtrip pair fetches everything
|
|
1218
|
+
// (vs. resolve sheet idx first, then getBlockInfo per-sheet).
|
|
1219
|
+
const [allBlocks, sheetInfosRes] = await Promise.all([
|
|
1220
|
+
client.getAllBlocks({}),
|
|
1221
|
+
client.getAllSheetInfo(),
|
|
1222
|
+
]);
|
|
1223
|
+
if (isErrorMessage(allBlocks)) {
|
|
1224
|
+
throw new Error(`getAllBlocks failed: ${allBlocks.msg}`);
|
|
1225
|
+
}
|
|
1226
|
+
if (isErrorMessage(sheetInfosRes)) {
|
|
1227
|
+
throw new Error(`getAllSheetInfo failed: ${sheetInfosRes.msg}`);
|
|
1228
|
+
}
|
|
1229
|
+
const sheetInfos = sheetInfosRes;
|
|
1230
|
+
const block = allBlocks.find((b) => { var _a; return ((_a = b.schema) === null || _a === void 0 ? void 0 : _a.name) === input.name; });
|
|
1231
|
+
if (!block) {
|
|
1232
|
+
throw new Error(`No block with ref name "${input.name}"`);
|
|
1233
|
+
}
|
|
1234
|
+
const schema = block.schema;
|
|
1235
|
+
if (!schema) {
|
|
1236
|
+
throw new Error(`Block "${input.name}" has no schema — was BindFormSchema called?`);
|
|
1237
|
+
}
|
|
1238
|
+
const sheetName = (_b = (_a = sheetInfos[block.sheetIdx]) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : `sheet#${block.sheetIdx}`;
|
|
1239
|
+
const fields = schema.fields.map((f) => ({
|
|
1240
|
+
name: f.field,
|
|
1241
|
+
position: f.idx,
|
|
1242
|
+
value_formula: nonEmpty(f.valueFormula),
|
|
1243
|
+
validation: nonEmpty(f.validationFormula),
|
|
1244
|
+
editability: nonEmpty(f.editabilityFormula),
|
|
1245
|
+
}));
|
|
1246
|
+
// Row keys in block-row order (the `idx` on KeyEntry is the
|
|
1247
|
+
// row index; sorting by it gives natural top-to-bottom order).
|
|
1248
|
+
const keys = [...schema.keys]
|
|
1249
|
+
.sort((a, b) => a.idx - b.idx)
|
|
1250
|
+
.map((k) => k.key);
|
|
1251
|
+
const out = {
|
|
1252
|
+
block: input.name,
|
|
1253
|
+
sheet: sheetName,
|
|
1254
|
+
sheet_idx: block.sheetIdx,
|
|
1255
|
+
position: { row: block.rowStart, col: block.colStart },
|
|
1256
|
+
row_count: block.rowCnt,
|
|
1257
|
+
col_count: block.colCnt,
|
|
1258
|
+
fields,
|
|
1259
|
+
keys,
|
|
1260
|
+
};
|
|
1261
|
+
if (input.include_rows) {
|
|
1262
|
+
// `cells` is row-major: index = row * colCnt + col.
|
|
1263
|
+
const fieldByCol = new Map();
|
|
1264
|
+
for (const f of schema.fields)
|
|
1265
|
+
fieldByCol.set(f.idx, f.field);
|
|
1266
|
+
// Build a key lookup by row index so we can label each row
|
|
1267
|
+
// even when the key column isn't 0 (it always is today, but
|
|
1268
|
+
// the schema doesn't force that).
|
|
1269
|
+
const keyByRow = new Map();
|
|
1270
|
+
for (const k of schema.keys)
|
|
1271
|
+
keyByRow.set(k.idx, k.key);
|
|
1272
|
+
const rows = [];
|
|
1273
|
+
for (let r = 0; r < block.rowCnt; r++) {
|
|
1274
|
+
const values = {};
|
|
1275
|
+
for (let c = 0; c < block.colCnt; c++) {
|
|
1276
|
+
const fieldName = fieldByCol.get(c);
|
|
1277
|
+
if (!fieldName)
|
|
1278
|
+
continue;
|
|
1279
|
+
const cell = block.cells[r * block.colCnt + c];
|
|
1280
|
+
values[fieldName] = cell
|
|
1281
|
+
? flattenCellValue(cell.value)
|
|
1282
|
+
: null;
|
|
1283
|
+
}
|
|
1284
|
+
rows.push({
|
|
1285
|
+
key: (_c = keyByRow.get(r)) !== null && _c !== void 0 ? _c : '',
|
|
1286
|
+
values,
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
out.rows = rows;
|
|
1290
|
+
}
|
|
1291
|
+
return { data: out };
|
|
1292
|
+
},
|
|
1293
|
+
};
|
|
1294
|
+
/** Treat empty / whitespace-only templates as "no rule declared".
|
|
1295
|
+
* The engine normalizes empty → None on its side too. */
|
|
1296
|
+
function nonEmpty(s) {
|
|
1297
|
+
if (typeof s !== 'string')
|
|
1298
|
+
return null;
|
|
1299
|
+
const t = s.trim();
|
|
1300
|
+
return t === '' ? null : t;
|
|
1301
|
+
}
|
|
1302
|
+
/** Flatten an engine `Value` to the JSON-friendly shape used by
|
|
1303
|
+
* describe_block's row values. Errors get a `"#ERR:..."` prefix so
|
|
1304
|
+
* the LLM can tell them from legitimate strings. */
|
|
1305
|
+
function flattenCellValue(v) {
|
|
1306
|
+
if (v === 'empty')
|
|
1307
|
+
return null;
|
|
1308
|
+
switch (v.type) {
|
|
1309
|
+
case 'str':
|
|
1310
|
+
return v.value;
|
|
1311
|
+
case 'number':
|
|
1312
|
+
return v.value;
|
|
1313
|
+
case 'bool':
|
|
1314
|
+
return v.value;
|
|
1315
|
+
case 'error':
|
|
1316
|
+
return `#ERR:${v.value}`;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
/** One CraftCalc per Client — keep the handle alive for the session so
|
|
1320
|
+
* repeated eval_formula calls reuse the same ephemeral-id range. */
|
|
1321
|
+
const _craftCalcByClient = new WeakMap();
|
|
1322
|
+
function getCraftCalc(client) {
|
|
1323
|
+
const key = client;
|
|
1324
|
+
let calc = _craftCalcByClient.get(key);
|
|
1325
|
+
if (!calc) {
|
|
1326
|
+
calc = acquireCraftCalc(client);
|
|
1327
|
+
_craftCalcByClient.set(key, calc);
|
|
1328
|
+
}
|
|
1329
|
+
return calc;
|
|
1330
|
+
}
|
|
1331
|
+
/** Flatten an engine `Value` into a JSON-friendly `{type, value}` pair
|
|
1332
|
+
* the LLM can read directly without understanding the discriminated
|
|
1333
|
+
* union. */
|
|
1334
|
+
function flattenValue(v) {
|
|
1335
|
+
if (v === 'empty')
|
|
1336
|
+
return { type: 'empty', value: null };
|
|
1337
|
+
switch (v.type) {
|
|
1338
|
+
case 'str':
|
|
1339
|
+
return { type: 'str', value: v.value };
|
|
1340
|
+
case 'number':
|
|
1341
|
+
return { type: 'number', value: v.value };
|
|
1342
|
+
case 'bool':
|
|
1343
|
+
return { type: 'bool', value: v.value };
|
|
1344
|
+
case 'error':
|
|
1345
|
+
return { type: 'error', value: v.value };
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
export const evalFormula = {
|
|
1349
|
+
namespace: 'build',
|
|
1350
|
+
name: 'eval_formula',
|
|
1351
|
+
description: [
|
|
1352
|
+
'Evaluate an Excel-style formula in a private scratch cell and return the computed value. Nothing is written to user-visible cells. Returns `{type, value}` where type is one of:',
|
|
1353
|
+
' - "number" — value is a JS number',
|
|
1354
|
+
' - "str" — value is a string',
|
|
1355
|
+
' - "bool" — value is a JS boolean',
|
|
1356
|
+
' - "error" — value is the Excel error code (e.g. "#REF!", "#NAME?")',
|
|
1357
|
+
' - "empty" — value is null (formula returned an empty cell)',
|
|
1358
|
+
'',
|
|
1359
|
+
'Use for:',
|
|
1360
|
+
' - Quick checks: "=SUMIFS(OrderStatus, \\"金额\\", \\"*\\")" → total',
|
|
1361
|
+
' - Sanity-test a candidate template before set_field_rule',
|
|
1362
|
+
' - BLOCKREF / BLOCKREFS lookups against any block in the workbook',
|
|
1363
|
+
'',
|
|
1364
|
+
'Leading "=" is optional — it is added automatically if missing.',
|
|
1365
|
+
].join('\n'),
|
|
1366
|
+
mutates: false,
|
|
1367
|
+
confirmation: 'never',
|
|
1368
|
+
cost: 'cheap',
|
|
1369
|
+
inputSchema: {
|
|
1370
|
+
properties: {
|
|
1371
|
+
expr: {
|
|
1372
|
+
type: 'string',
|
|
1373
|
+
description: 'Formula, with or without leading "=". E.g. "SUM(A1:A10)" or "=BLOCKREF(\\"orders\\", \\"O001\\", \\"金额\\")".',
|
|
1374
|
+
},
|
|
1375
|
+
},
|
|
1376
|
+
required: ['expr'],
|
|
1377
|
+
},
|
|
1378
|
+
handler: async (input, ctx) => {
|
|
1379
|
+
const client = asClient(ctx);
|
|
1380
|
+
const calc = getCraftCalc(client);
|
|
1381
|
+
const v = await calc.calcOnce(input.expr);
|
|
1382
|
+
return {
|
|
1383
|
+
data: flattenValue(v),
|
|
1384
|
+
};
|
|
1385
|
+
},
|
|
1386
|
+
};
|
|
1387
|
+
export const checkpoint = {
|
|
1388
|
+
namespace: 'build',
|
|
1389
|
+
name: 'checkpoint',
|
|
1390
|
+
description: [
|
|
1391
|
+
'Snapshot the workbook so a multi-step build can roll back without chaining N undos. Engine-managed (Rust CheckpointManager) — each label points at a full Status snapshot. Cheap to create thanks to imbl persistent data structures.',
|
|
1392
|
+
'',
|
|
1393
|
+
'Operations:',
|
|
1394
|
+
' save — Take a snapshot under `label`. Overwrites if label exists. Does not touch the undo stack (the snapshot is a sidecar; the AI/user can keep working).',
|
|
1395
|
+
' restore — Replace the live workbook with the snapshot stored at `label`. **This itself is undoable**: the user can Ctrl-Z to reverse the restore. Fails loud (`label not found`) if `label` was never saved. Side effect: clears the redo stack (standard new-tx semantics).',
|
|
1396
|
+
' delete — Remove a saved snapshot by label.',
|
|
1397
|
+
' list — Enumerate saved checkpoints, newest first.',
|
|
1398
|
+
'',
|
|
1399
|
+
'Limits:',
|
|
1400
|
+
' - Session-only: snapshots are dropped on file save/load and on page reload.',
|
|
1401
|
+
' - Up to 20 checkpoints; saving when at capacity evicts the oldest. Save the same label again to refresh it back to the front of the FIFO.',
|
|
1402
|
+
' - Independent of user Ctrl-Z/Y history — checkpoints persist across user undo / redo.',
|
|
1403
|
+
].join('\n'),
|
|
1404
|
+
mutates: true,
|
|
1405
|
+
confirmation: 'never',
|
|
1406
|
+
inputSchema: {
|
|
1407
|
+
properties: {
|
|
1408
|
+
op: {
|
|
1409
|
+
type: 'string',
|
|
1410
|
+
enum: ['save', 'restore', 'delete', 'list'],
|
|
1411
|
+
},
|
|
1412
|
+
label: {
|
|
1413
|
+
type: 'string',
|
|
1414
|
+
description: 'Required for save / restore / delete. Ignored for list.',
|
|
1415
|
+
},
|
|
1416
|
+
description: {
|
|
1417
|
+
type: 'string',
|
|
1418
|
+
description: 'Optional note. Only meaningful for save (echoed back by list).',
|
|
1419
|
+
},
|
|
1420
|
+
},
|
|
1421
|
+
required: ['op'],
|
|
1422
|
+
},
|
|
1423
|
+
handler: async (input, ctx) => {
|
|
1424
|
+
const client = asClient(ctx);
|
|
1425
|
+
switch (input.op) {
|
|
1426
|
+
case 'save': {
|
|
1427
|
+
const label = requireLabel(input, 'save');
|
|
1428
|
+
const res = await client.saveCheckpoint({
|
|
1429
|
+
label,
|
|
1430
|
+
description: input.description,
|
|
1431
|
+
});
|
|
1432
|
+
if (isErrorMessage(res)) {
|
|
1433
|
+
throw new Error(`save checkpoint failed: ${res.msg}`);
|
|
1434
|
+
}
|
|
1435
|
+
return {
|
|
1436
|
+
data: { op: 'save', result: res },
|
|
1437
|
+
display: `Saved checkpoint "${label}" (${res} total).`,
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
case 'restore': {
|
|
1441
|
+
const label = requireLabel(input, 'restore');
|
|
1442
|
+
// Restore goes through the standard transaction pipeline
|
|
1443
|
+
// — it's a `restoreCheckpoint` payload, so it lands on
|
|
1444
|
+
// the undo stack. User can Ctrl-Z to reverse.
|
|
1445
|
+
await commitTransaction(client, [
|
|
1446
|
+
{
|
|
1447
|
+
type: 'restoreCheckpoint',
|
|
1448
|
+
value: { label },
|
|
1449
|
+
},
|
|
1450
|
+
], `checkpoint restore "${label}"`);
|
|
1451
|
+
return {
|
|
1452
|
+
data: { op: 'restore' },
|
|
1453
|
+
display: `Restored workbook to checkpoint "${label}". One Ctrl-Z reverses this.`,
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
case 'delete': {
|
|
1457
|
+
const label = requireLabel(input, 'delete');
|
|
1458
|
+
const existed = await client.deleteCheckpoint({
|
|
1459
|
+
label,
|
|
1460
|
+
});
|
|
1461
|
+
if (isErrorMessage(existed)) {
|
|
1462
|
+
throw new Error(`delete checkpoint failed: ${existed.msg}`);
|
|
1463
|
+
}
|
|
1464
|
+
return {
|
|
1465
|
+
data: { op: 'delete', result: existed },
|
|
1466
|
+
display: existed
|
|
1467
|
+
? `Deleted checkpoint "${label}".`
|
|
1468
|
+
: `Checkpoint "${label}" did not exist; no-op.`,
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
case 'list': {
|
|
1472
|
+
const list = await client.listCheckpoints();
|
|
1473
|
+
if (isErrorMessage(list)) {
|
|
1474
|
+
throw new Error(`list checkpoints failed: ${list.msg}`);
|
|
1475
|
+
}
|
|
1476
|
+
return {
|
|
1477
|
+
data: { op: 'list', checkpoints: list },
|
|
1478
|
+
display: list.length === 0
|
|
1479
|
+
? 'No checkpoints saved.'
|
|
1480
|
+
: `${list.length} checkpoint(s): ${list
|
|
1481
|
+
.map((c) => c.label)
|
|
1482
|
+
.join(', ')}.`,
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
},
|
|
1487
|
+
};
|
|
1488
|
+
// ---------------------------------------------------------------------------
|
|
1489
|
+
// Bundle
|
|
1490
|
+
// ---------------------------------------------------------------------------
|
|
1491
|
+
export const BUILDER_TOOLS = [
|
|
1492
|
+
createSheet,
|
|
1493
|
+
createBlock,
|
|
1494
|
+
addBlockRows,
|
|
1495
|
+
deleteBlockRows,
|
|
1496
|
+
setFieldRule,
|
|
1497
|
+
defineEnumSet,
|
|
1498
|
+
listBlocks,
|
|
1499
|
+
describeBlock,
|
|
1500
|
+
evalFormula,
|
|
1501
|
+
checkpoint,
|
|
1502
|
+
];
|