opentakeoff-mcp 0.7.0 → 0.8.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 CHANGED
@@ -115,8 +115,10 @@ includes document text, shape vertices, or result payload content.
115
115
  | `export_takeoff` | The full `opentakeoff.takeoff_canvas.v1` payload — exactly what the app autosaves. Inline, and to disk with `path`. |
116
116
  | `delete_shape` | Remove a committed shape by id. |
117
117
  | `edit_shape` | **Revise** a committed shape instead of redoing it: new `verts`, a different `condition`, a different `role`, or any combination — quantities recomputed from the result. Refuses shapes a human affirmed. |
118
- | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was. A whole `detect_rooms` sweep is **one** step. |
118
+ | `edit_materials` | Add/remove/patch supporting-materials rows on a condition the coverage-rate lines (adhesive at N sf/gal, grout at N lf/bag, …) that turn a measured quantity into an order quantity, matching the canvas's Supporting Materials panel. `condition` mints on first touch, like `one_click`/`measure_polygon`. No review gate (materials rows are quantity config, not traced geometry) — edits directly, reversible with `undo_last`. |
119
+ | `undo_last` | Step back over your own last `n` mutations, newest first. Exact inverses: a commit is removed, an edit restored verbatim, a delete re-inserted where it was, a materials edit's whole array restored. A whole `detect_rooms` sweep is **one** step. |
119
120
  | `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
121
+ | `find_text` | **Locate** a known string — the complement to `read_sheet_text` (which returns what a region *says*; this finds *where* a string sits). Case-insensitive substring match per pdf.js text run; each hit's center feeds straight into `one_click`'s seed. |
120
122
  | `sheet_context` | The region's STRUCTURE in one frame: classified vector segments (endpoints as drawn, meta byte per segment), text spans with bboxes, and hatch-family instances with content-derived ids — same pattern spec ⇒ same id anywhere on the sheet, so plan↔legend matching is `id === id`. Decimation is declared and counted on every reply: `kept + dropped === total_in_region`, cap applies longest-first so walls survive. |
121
123
  | `view_sheet` | The agent's eyes: render the sheet (or an image-px crop) to PNG. `overlay` burns committed shapes in (solid = human-affirmed, dashed = unreviewed) to verify geometry landed; `grid` burns in a calibrated 1-ft/5-ft measuring grid with foot labels (`"auto"` from the set scale, or the drawing scale like `"1/4"`) so dimensions are counted off cells, not guessed. |
122
124
 
@@ -1711,6 +1711,64 @@ var Session = class {
1711
1711
  agent_edits: this.shapes[i].origin?.agent_edits ?? 0
1712
1712
  };
1713
1713
  }
1714
+ /** Add/remove/patch supporting-materials rows on a condition, in one call.
1715
+ * Unlike editShape there is no review gate to check — materials rows carry
1716
+ * no origin/reviewed field, because they are quantity CONFIG (a coverage
1717
+ * rate), not geometry a human traced. Validated all-or-nothing before
1718
+ * anything is written: a bad id anywhere in remove/patch throws and nothing
1719
+ * changes, same discipline as the shapes tools. Reversible with undo_last —
1720
+ * one journal entry snapshots the condition's whole materials array before
1721
+ * the call, restored verbatim on undo (same pattern as editShape's `before`
1722
+ * capture, simpler here because there is no per-row provenance to preserve). */
1723
+ editMaterials(tag, opts) {
1724
+ const add = opts.add ?? [], remove = opts.remove ?? [], patch = opts.patch ?? [];
1725
+ if (!add.length && !remove.length && !patch.length) {
1726
+ throw new UserError("Nothing to change \u2014 pass at least one of add, remove, patch.");
1727
+ }
1728
+ for (let i = 0; i < add.length; i++) {
1729
+ if (!add[i].name.trim()) throw new UserError(`add[${i}]: name required.`);
1730
+ }
1731
+ const existingMaterials = this.conditions.find((x) => x.finish_tag === tag)?.materials ?? [];
1732
+ const byId = new Map(existingMaterials.map((m) => [m.id, m]));
1733
+ for (const id of remove) {
1734
+ if (!byId.has(id)) throw new UserError(`remove: no material row ${JSON.stringify(id)} on condition ${JSON.stringify(tag)}.`);
1735
+ }
1736
+ for (let i = 0; i < patch.length; i++) {
1737
+ if (!byId.has(patch[i].id)) throw new UserError(`patch[${i}]: no material row ${JSON.stringify(patch[i].id)} on condition ${JSON.stringify(tag)}.`);
1738
+ if (!Object.keys(patch[i].fields).length) throw new UserError(`patch[${i}]: fields must be non-empty.`);
1739
+ }
1740
+ const c = this.conditionFor(tag);
1741
+ const before = structuredClone(c.materials);
1742
+ const added = [];
1743
+ for (const a of add) {
1744
+ const row = {
1745
+ id: uid("mat"),
1746
+ name: a.name.trim(),
1747
+ per: Math.max(0, a.per ?? 0),
1748
+ basis: a.basis ?? "area",
1749
+ unit: a.unit ?? "",
1750
+ round: a.round ?? true,
1751
+ ...a.note ? { note: a.note } : {}
1752
+ };
1753
+ c.materials.push(row);
1754
+ added.push(row.id);
1755
+ }
1756
+ const removed = new Set(remove);
1757
+ if (removed.size) c.materials = c.materials.filter((m) => !removed.has(m.id));
1758
+ const patched = [];
1759
+ for (const p of patch) {
1760
+ const m = c.materials.find((x) => x.id === p.id);
1761
+ Object.assign(m, p.fields);
1762
+ patched.push(p.id);
1763
+ }
1764
+ this.record({ op: "materials", tool: "edit_materials", condition_id: c.id, before });
1765
+ return {
1766
+ condition: tag,
1767
+ condition_id: c.id,
1768
+ changed: { added, removed: [...removed], patched },
1769
+ materials: c.materials
1770
+ };
1771
+ }
1714
1772
  /** Step back over this session's own last n mutations, newest first. Each
1715
1773
  * entry's inverse is exact (see JournalEntry), so this restores state rather
1716
1774
  * than approximating it. Reads are not journaled, so undo never has to step
@@ -1728,6 +1786,10 @@ var Session = class {
1728
1786
  const i = this.shapes.findIndex((x) => x.id === e.before.id);
1729
1787
  if (i >= 0) this.shapes[i] = e.before;
1730
1788
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: i >= 0 ? 1 : 0 });
1789
+ } else if (e.op === "materials") {
1790
+ const c = this.conditions.find((x) => x.id === e.condition_id);
1791
+ if (c) c.materials = e.before;
1792
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
1731
1793
  } else {
1732
1794
  for (const { shape, index } of e.removed) {
1733
1795
  this.shapes.splice(Math.min(index, this.shapes.length), 0, shape);
@@ -1766,6 +1828,30 @@ var Session = class {
1766
1828
  const items = region ? s.text.filter((t) => t.x >= region.x0 && t.x <= region.x1 && t.y >= region.y0 && t.y <= region.y1) : s.text;
1767
1829
  return { sheet: s.key, items, text: items.map((t) => t.str).join(" ") };
1768
1830
  }
1831
+ /** LOCATE a known string — the complement to readSheetText (which returns
1832
+ * what a region SAYS; this finds WHERE a string you already know sits).
1833
+ * Case-insensitive substring match per pdf.js text run, so a room label
1834
+ * split across runs ("OFFICE" then "134" as separate items) needs its own
1835
+ * find_text call per fragment, or read_sheet_text over a region to see the
1836
+ * whole thing at once — this tool doesn't merge runs into lines. Reuses the
1837
+ * bbox spans sheet_context lazily builds (same cache, same textSpans()
1838
+ * call), so calling both on one sheet costs the extraction once. */
1839
+ findText(name, q, opts = {}) {
1840
+ const query = q.trim();
1841
+ if (!query) throw new UserError("q must be a non-empty string.");
1842
+ const s = this.sheet(name);
1843
+ if (!s.spans) s.spans = textSpans(s.page);
1844
+ const r = opts.region;
1845
+ const needle = query.toLowerCase();
1846
+ const limit = opts.limit ?? 200;
1847
+ const all = s.spans.filter((sp) => sp.str.toLowerCase().includes(needle) && (!r || sp.x0 <= r.x1 && sp.x1 >= r.x0 && sp.y0 <= r.y1 && sp.y1 >= r.y0));
1848
+ const hits = all.slice(0, limit).map((sp) => ({
1849
+ str: sp.str,
1850
+ bbox: [sp.x0, sp.y0, sp.x1, sp.y1],
1851
+ center: [round1((sp.x0 + sp.x1) / 2), round1((sp.y0 + sp.y1) / 2)]
1852
+ }));
1853
+ return { sheet: s.key, q: query, count: all.length, truncated: all.length > hits.length, hits };
1854
+ }
1769
1855
  };
1770
1856
 
1771
1857
  // src/tools.ts
@@ -1949,14 +2035,44 @@ var undoLastOutput = {
1949
2035
  undone: z.number().int().describe("Steps actually reversed"),
1950
2036
  steps: z.array(z.object({
1951
2037
  seq: z.number().int(),
1952
- op: z.enum(["commit", "edit", "delete"]),
2038
+ op: z.enum(["commit", "edit", "delete", "materials"]),
1953
2039
  tool: z.string().describe("The tool call this step came from"),
1954
- shapes: z.number().int().describe("Shapes affected by reversing this step")
2040
+ shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes)")
1955
2041
  })).describe("Newest first"),
1956
2042
  shape_count: z.number().int().describe("Committed shapes after the undo"),
1957
2043
  remaining: z.number().int().describe("Steps still available to undo"),
1958
2044
  note: z.string().optional()
1959
2045
  };
2046
+ var findTextOutput = {
2047
+ sheet: z.string(),
2048
+ q: z.string(),
2049
+ count: z.number().int().describe("Total matches before the limit cap"),
2050
+ truncated: z.boolean().describe("true = count exceeds hits.length; narrow the region or raise limit"),
2051
+ hits: z.array(z.object({
2052
+ str: z.string().describe("The matched pdf.js text run, verbatim (may be shorter than the full label \u2014 runs aren't merged into lines)"),
2053
+ bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]).describe("[x0, y0, x1, y1] image px"),
2054
+ center: z.tuple([z.number(), z.number()]).describe("Bbox center, image px \u2014 feed straight into one_click's seed")
2055
+ }))
2056
+ };
2057
+ var materialRow = z.object({
2058
+ id: z.string(),
2059
+ name: z.string(),
2060
+ per: z.number().describe("Coverage rate: basis \xF7 per = order quantity"),
2061
+ basis: z.enum(["area", "linear", "count"]).describe("Which of the condition's totals this row's quantity is computed against"),
2062
+ unit: z.string(),
2063
+ round: z.boolean().describe("true = round up to whole purchase units (the default \u2014 you buy whole bags/buckets)"),
2064
+ note: z.string().optional()
2065
+ });
2066
+ var editMaterialsOutput = {
2067
+ condition: z.string().describe("The finish tag passed in"),
2068
+ condition_id: z.string(),
2069
+ changed: z.object({
2070
+ added: z.array(z.string()).describe("Ids of newly added rows"),
2071
+ removed: z.array(z.string()).describe("Ids removed"),
2072
+ patched: z.array(z.string()).describe("Ids whose fields changed")
2073
+ }),
2074
+ materials: z.array(materialRow).describe("The condition's full materials array after this write")
2075
+ };
1960
2076
  var readSheetTextOutput = {
1961
2077
  sheet: z.string(),
1962
2078
  items: z.array(z.object({ str: z.string(), x: z.number(), y: z.number() })).describe("Positioned text items (image px)"),
@@ -2129,8 +2245,28 @@ function registerTools(server, session) {
2129
2245
  },
2130
2246
  outputSchema: editShapeOutput
2131
2247
  }, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role })));
2248
+ server.registerTool("edit_materials", {
2249
+ description: `Add, remove, or patch supporting-materials rows on a condition \u2014 the coverage-rate lines that turn a measured area/length/count into an order quantity (adhesive at N sf/gal, grout at N lf/bag, \u2026), matching the canvas's per-condition Supporting Materials panel. Each row is {name, per, basis, unit, round, note}: quantity = the condition's basis total (area/linear/count) \xF7 per, rounded up to whole purchase units unless round:false. condition names an existing OR NEW finish tag (minted on first touch, same as one_click/measure_polygon) \u2014 add alone is enough to seed materials on a condition before you've traced anything. remove/patch target existing row ids from this reply or export_takeoff (takeoff_summary strips materials for a compact quantities-only reply); a bad id 404s the WHOLE call before anything is written, and referencing an id on a tag with no condition yet errors rather than silently minting an empty one. No review gate here \u2014 materials rows are quantity config, not traced geometry, so this edits directly; undo_last reverses a call in one step (the condition's whole materials array, snapshotted before the write, restored verbatim).`,
2250
+ inputSchema: {
2251
+ condition: z2.string().describe("Finish tag, e.g. 'CPT-1'"),
2252
+ add: z2.array(z2.object({
2253
+ name: z2.string().min(1),
2254
+ per: z2.number().min(0).optional().describe("Coverage rate \u2014 basis units per purchase unit, e.g. 250 for 1 gal / 250 sf. Default 0 (quantity 0 until set)"),
2255
+ basis: z2.enum(["area", "linear", "count"]).optional().describe("Which of the condition's totals this row divides against \u2014 default 'area' (total SF)"),
2256
+ unit: z2.string().optional().describe("Purchase unit, e.g. 'gal', 'bag', 'roll'"),
2257
+ round: z2.boolean().optional().describe("Round up to whole purchase units \u2014 default true"),
2258
+ note: z2.string().optional()
2259
+ })).optional().describe("New rows to add"),
2260
+ remove: z2.array(z2.string()).optional().describe("Existing row ids to remove"),
2261
+ patch: z2.array(z2.object({
2262
+ id: z2.string(),
2263
+ fields: z2.record(z2.union([z2.string(), z2.number(), z2.boolean()])).describe("Field:value pairs \u2014 name/per/basis/unit/round/note only")
2264
+ })).optional().describe("Field changes on existing rows")
2265
+ },
2266
+ outputSchema: editMaterialsOutput
2267
+ }, run("edit_materials", (a) => session.editMaterials(a.condition, { add: a.add, remove: a.remove, patch: a.patch })));
2132
2268
  server.registerTool("undo_last", {
2133
- description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, or a delete_shape. Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
2269
+ description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, a delete_shape, or an edit_materials call. Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was, a materials edit's whole array is restored), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
2134
2270
  inputSchema: {
2135
2271
  n: z2.number().int().min(1).max(UNDO_CAP).default(1).describe(`How many steps to reverse (1\u2013${UNDO_CAP})`)
2136
2272
  },
@@ -2144,6 +2280,16 @@ function registerTools(server, session) {
2144
2280
  },
2145
2281
  outputSchema: readSheetTextOutput
2146
2282
  }, run("read_sheet_text", (a) => session.readSheetText(a.sheet, a.region)));
2283
+ server.registerTool("find_text", {
2284
+ description: `LOCATE a known string on a sheet \u2014 the complement to read_sheet_text (which returns what a region SAYS; this finds WHERE a string you already know sits). Case-insensitive substring match against each pdf.js text run, so a room label split across runs ("OFFICE" then "134" as separate items) needs a find_text call per fragment, or read_sheet_text over a region to see the whole thing joined. Every hit's center feeds straight into one_click as the seed \u2014 the locate-then-trace workflow: find_text the room number, one_click at (or just past) its center. Optionally restrict to a region {x0, y0, x1, y1}; results cap at limit (default 200), with count/truncated telling you exactly how much a tighter region or higher limit would recover. ${COORDS}`,
2285
+ inputSchema: {
2286
+ sheet: z2.string(),
2287
+ q: z2.string().min(1).describe("Text to find \u2014 a room number ('134'), a label fragment ('RECEPTION'), a schedule tag ('CPT-1')"),
2288
+ region: z2.object({ x0: z2.number(), y0: z2.number(), x1: z2.number(), y1: z2.number() }).optional().describe("Rect in image px (origin top-left, y down); omit for the full sheet"),
2289
+ limit: z2.number().int().min(1).max(2e3).default(200).describe("Max hits returned")
2290
+ },
2291
+ outputSchema: findTextOutput
2292
+ }, run("find_text", (a) => session.findText(a.sheet, a.q, { region: a.region, limit: a.limit })));
2147
2293
  server.registerTool("view_sheet", {
2148
2294
  description: `SEE the sheet \u2014 render the page (or a crop of it) to a PNG image. This is your eyes on the plan: full-sheet overview first, then tight crops at higher px until dimension strings and room labels read cleanly. region is in image px \u2014 the same space as every other tool \u2014 so a feature at pixel (ix, iy) of the returned image sits at x = region_x0 + ix \xD7 (region_x1 \u2212 region_x0) / img_w (same for y), and those coordinates go straight into one_click, measure_polygon, or read_sheet_text. overlay:true burns the session's committed shapes into the render (human-affirmed ink solid red, unreviewed machine shapes dashed blue) \u2014 render again after committing to verify your geometry landed where you intended, and sanity-check what you see: a fixture-sized ring where a room should be means the seed landed inside a stall or casework; an outsized ring means the flood escaped through an opening. To MEASURE rather than guess, pass grid: a calibrated measuring grid is burned in \u2014 thin lines every 1 ft, heavy blue every 5 ft, foot labels along the crop edges, feet counted from the crop's top-left corner. Count grid cells between walls exactly like an estimator scaling a plan; never derive a dimension by eye when the grid can give it to you. grid "auto" uses the sheet's set scale; before set_scale, pass the drawing scale read off the title block as inches-per-foot \u2014 "1/4" for a 1/4" = 1'-0" plan, "3/16", "0.25". Rendering needs the optional native canvas (@napi-rs/canvas); where it isn't installed this tool errors cleanly and every other tool still works. ${COORDS}`,
2149
2295
  inputSchema: {
@@ -2246,7 +2392,7 @@ function registerResources(server, session) {
2246
2392
  // package.json
2247
2393
  var package_default = {
2248
2394
  name: "opentakeoff-mcp",
2249
- version: "0.7.0",
2395
+ version: "0.8.0",
2250
2396
  mcpName: "io.github.Kentucky-ai/opentakeoff",
2251
2397
  type: "module",
2252
2398
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",