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 +3 -1
- package/dist/server-core.js +150 -4
- package/package.json +1 -1
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
|
-
| `
|
|
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
|
|
package/dist/server-core.js
CHANGED
|
@@ -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
|
|
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.
|
|
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