opentakeoff-mcp 0.9.38 → 0.9.40
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 +31 -1
- package/dist/server-core.js +316 -49
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -101,13 +101,35 @@ Each tool call writes one JSON line to stderr with the tool name, duration,
|
|
|
101
101
|
sheet, result size, and error flag. The trace never writes to stdout and never
|
|
102
102
|
includes document text, shape vertices, or result payload content.
|
|
103
103
|
|
|
104
|
+
### Staged tool exposure (opt-in)
|
|
105
|
+
|
|
106
|
+
By default every client gets all 40 tool schemas on `tools/list` — the flat
|
|
107
|
+
contract every published client already expects. Forty descriptions is real
|
|
108
|
+
token weight for an agent session that may never touch half of them, so the
|
|
109
|
+
server can instead stage the surface along the workflow it already teaches:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
OPENTAKEOFF_MCP_STAGED_TOOLS=1 npx -y opentakeoff-mcp
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Staged, only the **setup** stage (load, scale, read the set — 10 tools) starts
|
|
116
|
+
enabled, plus one opener: `open_tool_stage`. Calling it with `"measure"`,
|
|
117
|
+
`"revise"`, or `"handoff"` enables that stage's tools and fires
|
|
118
|
+
`tools/list_changed`, so any client that supports dynamic tool lists (Claude
|
|
119
|
+
Code, Claude Desktop, anything built against the current spec) sees the group
|
|
120
|
+
appear the moment the agent asks for it. Opening is idempotent and never
|
|
121
|
+
closes anything — the surface only grows. The initialize instructions state
|
|
122
|
+
the scheme, so an agent knows to open a stage before it needs one. Requires a
|
|
123
|
+
client that honors `tools/list_changed`; leave the flag unset for one that
|
|
124
|
+
reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/issues/230))
|
|
125
|
+
|
|
104
126
|
## Tools
|
|
105
127
|
|
|
106
128
|
| Tool | What it does |
|
|
107
129
|
|---|---|
|
|
108
130
|
| `load_plan` | Open a plan PDF from disk. Default replaces the whole session; **`merge: true` ADDS the document to the working set** (#152) — plans + schedule + addenda as one takeoff, sheet graph spanning the whole set, marked set covering every worked sheet. Returns per-sheet dims, title-block `sheet_number`, and the detected drawn scale where present. |
|
|
109
131
|
| `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
|
|
110
|
-
| `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
|
|
132
|
+
| `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. **Lands unconfirmed** (`confirmed: false`) until a human confirms in the canvas — see Scale rules. |
|
|
111
133
|
| `one_click` | One-Click Area at (x, y): the sealed flood engine bounded by the plan linework, traced, vertices snapped — the SAME feet-true arguments the canvas passes at a click (gap sealing up to a door width, door-swing wedge inclusion, the half-foot minimum-passage rule), so an MCP trace and a canvas click at one seed measure the same square footage (pinned against the bench corpus goldens in `test/parity.test.ts`). Every trace carries the engine's account of itself: `confidence` 0–1 with `confidence_factors` naming each deduction (`gap_sealed_px`, `door_wedges`, `min_pass_delta`, …) — a review prioritizer, never a verification; a low score is a `view_sheet {overlay: true}` audit prompt, not a fact. On a SCANNED sheet (no usable linework) the flood falls back automatically to the rendered pixels — same engine as the canvas — with `raster_traced` disclosed on the reply and on the shape's origin (#154). Pass `condition` to commit (the full account stamps `origin` centrally at the commit); `role: "deduct"` subtracts. |
|
|
112
134
|
| `detect_rooms` | Batch One-Click: reads every room-number label off the sheet's text layer and floods each — one call instead of `read_sheet_text` + reasoning + N `one_click` calls, through the SAME sealed engine per room (confidence + the engine account ride each room and its committed origin). Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible / unresolved), never dropped silently. To commit: `assign_from_schedule: true` routes each room through its OWN room-finish schedule row and commits under the FLOOR finish that row states (rooms the schedule can't answer for return in `unresolved[]` with reasons and re-seedable coordinates); or pass `condition` to commit every room under one stated tag. |
|
|
113
135
|
| `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
|
|
@@ -216,6 +238,14 @@ space, which makes them usable directly as click targets.
|
|
|
216
238
|
|
|
217
239
|
- A detected scale is a **suggestion** — it is never applied automatically.
|
|
218
240
|
Adopting it is always an explicit `set_scale { use_detected: true }`.
|
|
241
|
+
- **Agent proposes, human confirms.** `set_scale` is the agent surface, so a
|
|
242
|
+
scale set here lands **unconfirmed** (`confirmed: false` in the reply).
|
|
243
|
+
Quantities still flow — the gate is a flag, never a refusal — but
|
|
244
|
+
`takeoff_summary` names the affected sheets in
|
|
245
|
+
`scale_unconfirmed`, and the export/report carry `scale_confirmed` so the
|
|
246
|
+
canvas can ask the estimator to confirm (its scale menu grows a
|
|
247
|
+
**Confirm agent-set scale** row on import). Only a human act in the canvas
|
|
248
|
+
clears the flag.
|
|
219
249
|
- `measure_polygon` and `measure_line` refuse without a scale:
|
|
220
250
|
`Set the scale for <sheet> first — use set_scale (detected: <label>).`
|
|
221
251
|
- `one_click` without a scale returns a **px-only preview**
|
package/dist/server-core.js
CHANGED
|
@@ -1005,12 +1005,12 @@ function circleFitOk(segs, chain, c0, c1) {
|
|
|
1005
1005
|
my /= m;
|
|
1006
1006
|
let sxx = 0, sxy = 0, syy = 0, sxz = 0, syz = 0;
|
|
1007
1007
|
for (let i = 0; i < m; i++) {
|
|
1008
|
-
const x = xs[i] - mx, y = ys[i] - my,
|
|
1008
|
+
const x = xs[i] - mx, y = ys[i] - my, z4 = x * x + y * y;
|
|
1009
1009
|
sxx += x * x;
|
|
1010
1010
|
sxy += x * y;
|
|
1011
1011
|
syy += y * y;
|
|
1012
|
-
sxz += x *
|
|
1013
|
-
syz += y *
|
|
1012
|
+
sxz += x * z4;
|
|
1013
|
+
syz += y * z4;
|
|
1014
1014
|
}
|
|
1015
1015
|
const det = sxx * syy - sxy * sxy;
|
|
1016
1016
|
if (Math.abs(det) < 1e-9) return null;
|
|
@@ -1993,12 +1993,12 @@ function arcClusterFit(cl, mw, mask) {
|
|
|
1993
1993
|
my /= m;
|
|
1994
1994
|
let sxx = 0, sxy = 0, syy = 0, sxz = 0, syz = 0;
|
|
1995
1995
|
for (const i of cl) {
|
|
1996
|
-
const x = X(i) - mx, y = Y(i) - my,
|
|
1996
|
+
const x = X(i) - mx, y = Y(i) - my, z4 = x * x + y * y;
|
|
1997
1997
|
sxx += x * x;
|
|
1998
1998
|
sxy += x * y;
|
|
1999
1999
|
syy += y * y;
|
|
2000
|
-
sxz += x *
|
|
2001
|
-
syz += y *
|
|
2000
|
+
sxz += x * z4;
|
|
2001
|
+
syz += y * z4;
|
|
2002
2002
|
}
|
|
2003
2003
|
const tr = sxx + syy, dsc = Math.sqrt(Math.max(0, ((sxx - syy) / 2) ** 2 + sxy * sxy));
|
|
2004
2004
|
const l1 = tr / 2 + dsc;
|
|
@@ -4199,7 +4199,11 @@ function reportJson({ projectName = "", rows = [], bySheet = [], scaleInfo = [],
|
|
|
4199
4199
|
schema: "opentakeoff.report.v1",
|
|
4200
4200
|
project_name: projectName || null,
|
|
4201
4201
|
generated_with: "OpenTakeoff",
|
|
4202
|
-
|
|
4202
|
+
// scale_confirmed (scale gate): false = an agent set this sheet's scale and
|
|
4203
|
+
// no human confirmed it — the report's consumer should treat those sheets'
|
|
4204
|
+
// quantities as standing on an unverified number. Absent input = true
|
|
4205
|
+
// (human-era payloads predate the flag).
|
|
4206
|
+
sheets: scaleInfo.map((si) => ({ sheet_id: si.sheet_id, sheet: label(si.sheet_id), scale_source: si.scale_source ?? si.source ?? "unknown", scale_confirmed: si.scale_confirmed !== false })),
|
|
4203
4207
|
// custom-column values APPEND after materials (row key order otherwise
|
|
4204
4208
|
// untouched). Iterating the DEFINED columns — never raw attrs — naturally
|
|
4205
4209
|
// drops orphaned colIds; attrValue (the shared assigned-value rule) keeps
|
|
@@ -4856,7 +4860,7 @@ function drawShapes(ctx, toCanvas, shapes, sheetW, sheetH, longEdge) {
|
|
|
4856
4860
|
var SNAP_CELL = 24;
|
|
4857
4861
|
var SNAP_TOL = 7;
|
|
4858
4862
|
var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488", "#be185d", "#1f2937", "#dc2626", "#0891b2"];
|
|
4859
|
-
var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo"];
|
|
4863
|
+
var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo", "woodgrain", "chevron", "pinwheel", "harlequin", "hexagon", "penny", "octagondot", "fleur", "concrete"];
|
|
4860
4864
|
var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
4861
4865
|
var uid = (p) => `${p}-${mintUuid2()}`;
|
|
4862
4866
|
var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -5399,11 +5403,13 @@ var Session = class _Session {
|
|
|
5399
5403
|
}
|
|
5400
5404
|
s.upp = upp;
|
|
5401
5405
|
s.scaleSource = source === "label" ? "standard" : source === "calibrate" ? "calibrated" : source;
|
|
5406
|
+
s.scaleConfirmed = false;
|
|
5402
5407
|
return {
|
|
5403
5408
|
sheet: s.key,
|
|
5404
5409
|
upp,
|
|
5405
5410
|
...label ? { label } : {},
|
|
5406
5411
|
source,
|
|
5412
|
+
confirmed: false,
|
|
5407
5413
|
// #153 — several DISTINCT scale notes on one sheet means enlarged plans
|
|
5408
5414
|
// or details are likely; region measurements will warn when a
|
|
5409
5415
|
// disagreeing note sits inside them, but say it up front too
|
|
@@ -6679,7 +6685,8 @@ var Session = class _Session {
|
|
|
6679
6685
|
summary() {
|
|
6680
6686
|
const rows = conditionTotals(this.conditions, this.shapes, this.seamCtx());
|
|
6681
6687
|
const lean = rows.map(({ color, fill, hatch, materials, ...rest }) => rest);
|
|
6682
|
-
|
|
6688
|
+
const unconfirmed = [...this.sheets.values()].filter((s) => s.upp != null && s.scaleConfirmed === false).map((s) => s.key);
|
|
6689
|
+
return { conditions: lean, totals: grandTotals(rows), ...unconfirmed.length ? { scale_unconfirmed: unconfirmed } : {} };
|
|
6683
6690
|
}
|
|
6684
6691
|
deleteShape(id) {
|
|
6685
6692
|
const i = this.shapes.findIndex((x) => x.id === id);
|
|
@@ -7458,7 +7465,15 @@ var Session = class _Session {
|
|
|
7458
7465
|
schema: ANN_SCHEMA,
|
|
7459
7466
|
project_name: "",
|
|
7460
7467
|
units: "imperial",
|
|
7461
|
-
sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({
|
|
7468
|
+
sheets: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({
|
|
7469
|
+
sheet_id: s.key,
|
|
7470
|
+
units_per_px: s.upp,
|
|
7471
|
+
// provenance rides the payload (it used to be dropped here): the canvas
|
|
7472
|
+
// hydrates scale_source for its report and scale_confirmed for the
|
|
7473
|
+
// scale gate's confirm affordance — absent = confirmed (pre-flag docs)
|
|
7474
|
+
...s.scaleSource ? { scale_source: s.scaleSource } : {},
|
|
7475
|
+
...s.scaleConfirmed === false ? { scale_confirmed: false } : {}
|
|
7476
|
+
})),
|
|
7462
7477
|
conditions: this.conditions,
|
|
7463
7478
|
shapes: this.shapes,
|
|
7464
7479
|
markups: this.markups,
|
|
@@ -7488,7 +7503,7 @@ var Session = class _Session {
|
|
|
7488
7503
|
projectName,
|
|
7489
7504
|
rows,
|
|
7490
7505
|
bySheet: sheetTotals(this.conditions, this.shapes),
|
|
7491
|
-
scaleInfo: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, scale_source: s.scaleSource ?? "unknown" })),
|
|
7506
|
+
scaleInfo: [...this.sheets.values()].filter((s) => s.upp != null).map((s) => ({ sheet_id: s.key, scale_source: s.scaleSource ?? "unknown", scale_confirmed: s.scaleConfirmed !== false })),
|
|
7492
7507
|
markups: this.markups,
|
|
7493
7508
|
rfis: [],
|
|
7494
7509
|
rollGoods: rollReportRows(byCond, rows)
|
|
@@ -7722,6 +7737,7 @@ var setScaleOutput = {
|
|
|
7722
7737
|
upp: z.number().describe("Real feet per image px at render scale 2.0"),
|
|
7723
7738
|
label: z.string().optional().describe("The standard scale label, when set by label or detected note"),
|
|
7724
7739
|
source: z.enum(["label", "upp", "calibrate", "detected"]),
|
|
7740
|
+
confirmed: z.boolean().describe("Always false here: set_scale is the agent surface, and an agent-set scale stays UNCONFIRMED until a human confirms it in the canvas \u2014 quantities still flow, wearing the caveat"),
|
|
7725
7741
|
warning: z.string().optional().describe("Present when the sheet carries MULTIPLE distinct scale notes (#153) \u2014 enlarged plans/details likely; region measurements under a disagreeing note will warn")
|
|
7726
7742
|
};
|
|
7727
7743
|
var oneClickOutput = {
|
|
@@ -7888,7 +7904,8 @@ var takeoffSummaryOutput = {
|
|
|
7888
7904
|
lf_net: z.number(),
|
|
7889
7905
|
ea: z.number(),
|
|
7890
7906
|
sy_net: z.number()
|
|
7891
|
-
}).passthrough()
|
|
7907
|
+
}).passthrough(),
|
|
7908
|
+
scale_unconfirmed: z.array(z.string()).optional().describe("Sheets whose scale is agent-set and no human has confirmed \u2014 these totals stand on an unverified scale; verify against a stated dimension or confirm in the canvas")
|
|
7892
7909
|
};
|
|
7893
7910
|
var exportTakeoffOutput = {
|
|
7894
7911
|
schema: z.string(),
|
|
@@ -8790,6 +8807,80 @@ function rfiStatus(id) {
|
|
|
8790
8807
|
return STATUS_BY_ID[id] || RFI_STATUSES[0];
|
|
8791
8808
|
}
|
|
8792
8809
|
|
|
8810
|
+
// ../web/src/lib/stitches.ts
|
|
8811
|
+
function stitchExtent(members, dims) {
|
|
8812
|
+
let w = 0, h = 0;
|
|
8813
|
+
for (const m of members) {
|
|
8814
|
+
const d = dims[m.key];
|
|
8815
|
+
if (!d?.w) continue;
|
|
8816
|
+
w = Math.max(w, m.dx + d.w);
|
|
8817
|
+
h = Math.max(h, m.dy + d.h);
|
|
8818
|
+
}
|
|
8819
|
+
return { w: Math.ceil(w), h: Math.ceil(h) };
|
|
8820
|
+
}
|
|
8821
|
+
function memberBoxes(members, dims) {
|
|
8822
|
+
return members.map((m) => {
|
|
8823
|
+
const d = dims[m.key] || { w: 0, h: 0 };
|
|
8824
|
+
return { x0: m.dx, y0: m.dy, x1: m.dx + d.w, y1: m.dy + d.h };
|
|
8825
|
+
});
|
|
8826
|
+
}
|
|
8827
|
+
function seamClips(members, dims) {
|
|
8828
|
+
const boxes = memberBoxes(members, dims);
|
|
8829
|
+
const clips = boxes.map((b) => ({ ...b }));
|
|
8830
|
+
if (boxes.length < 2) return clips;
|
|
8831
|
+
const cxs = boxes.map((b) => (b.x0 + b.x1) / 2), cys = boxes.map((b) => (b.y0 + b.y1) / 2);
|
|
8832
|
+
const spread = (a) => Math.max(...a) - Math.min(...a);
|
|
8833
|
+
const horizontal = spread(cxs) >= spread(cys);
|
|
8834
|
+
const order = boxes.map((_, i) => i).sort((a, b) => horizontal ? boxes[a].x0 - boxes[b].x0 : boxes[a].y0 - boxes[b].y0);
|
|
8835
|
+
for (let k = 0; k + 1 < order.length; k++) {
|
|
8836
|
+
const i = order[k], j = order[k + 1];
|
|
8837
|
+
if (horizontal && boxes[j].x0 < boxes[i].x1) {
|
|
8838
|
+
const seam = (boxes[j].x0 + boxes[i].x1) / 2;
|
|
8839
|
+
clips[i].x1 = Math.min(clips[i].x1, seam);
|
|
8840
|
+
clips[j].x0 = Math.max(clips[j].x0, seam);
|
|
8841
|
+
} else if (!horizontal && boxes[j].y0 < boxes[i].y1) {
|
|
8842
|
+
const seam = (boxes[j].y0 + boxes[i].y1) / 2;
|
|
8843
|
+
clips[i].y1 = Math.min(clips[i].y1, seam);
|
|
8844
|
+
clips[j].y0 = Math.max(clips[j].y0, seam);
|
|
8845
|
+
}
|
|
8846
|
+
}
|
|
8847
|
+
return clips;
|
|
8848
|
+
}
|
|
8849
|
+
function stitchPagePlan(members, dims) {
|
|
8850
|
+
const clips = seamClips(members, dims);
|
|
8851
|
+
return {
|
|
8852
|
+
extent: stitchExtent(members, dims),
|
|
8853
|
+
members: members.map((m, i) => ({ key: m.key, dx: m.dx, dy: m.dy, clip: clips[i] }))
|
|
8854
|
+
};
|
|
8855
|
+
}
|
|
8856
|
+
function memberEmbed(vpTransform, member, pageH, renderScale) {
|
|
8857
|
+
const [a, b, c, d, e, f] = Array.from(vpTransform);
|
|
8858
|
+
const det = a * d - b * c;
|
|
8859
|
+
const inv = (x, y) => [(d * (x - e) - c * (y - f)) / det, (-b * (x - e) + a * (y - f)) / det];
|
|
8860
|
+
const { clip, dx, dy } = member;
|
|
8861
|
+
const corners = [
|
|
8862
|
+
inv(clip.x0 - dx, clip.y0 - dy),
|
|
8863
|
+
inv(clip.x1 - dx, clip.y0 - dy),
|
|
8864
|
+
inv(clip.x0 - dx, clip.y1 - dy),
|
|
8865
|
+
inv(clip.x1 - dx, clip.y1 - dy)
|
|
8866
|
+
];
|
|
8867
|
+
const xs = corners.map((p) => p[0]), ys = corners.map((p) => p[1]);
|
|
8868
|
+
const z4 = (n) => n + 0 || 0;
|
|
8869
|
+
return {
|
|
8870
|
+
bbox: { left: z4(Math.min(...xs)), bottom: z4(Math.min(...ys)), right: z4(Math.max(...xs)), top: z4(Math.max(...ys)) },
|
|
8871
|
+
// user → visual px (vpTransform), + stitch offset, ÷ renderScale into
|
|
8872
|
+
// points, y flipped into PDF's y-up — composed into one affine
|
|
8873
|
+
matrix: [
|
|
8874
|
+
a / renderScale,
|
|
8875
|
+
-b / renderScale,
|
|
8876
|
+
c / renderScale,
|
|
8877
|
+
-d / renderScale,
|
|
8878
|
+
(e + dx) / renderScale,
|
|
8879
|
+
pageH - (f + dy) / renderScale
|
|
8880
|
+
]
|
|
8881
|
+
};
|
|
8882
|
+
}
|
|
8883
|
+
|
|
8793
8884
|
// ../web/src/lib/lineStyles.js
|
|
8794
8885
|
var LINE_STYLES = {
|
|
8795
8886
|
solid: { label: "Solid", dash: null },
|
|
@@ -9165,7 +9256,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
|
|
|
9165
9256
|
for (const sh of marked) {
|
|
9166
9257
|
if (y < 90) break;
|
|
9167
9258
|
const items = shapesBy.get(sh.key) || [];
|
|
9168
|
-
|
|
9259
|
+
const where = sh.stitch ? `stitched \xB7 ${sh.stitch.members.length} sheets` : `page ${sh.page}`;
|
|
9260
|
+
draw(`${sh.label} \xB7 ${where} \xB7 ${items.length + (marksBy.get(sh.key) || []).length + (apBy.get(sh.key) || []).length} item(s)`, { x: 52, y, size: 9.5, font: bold, color: ink });
|
|
9169
9261
|
y -= 13;
|
|
9170
9262
|
for (const r of bySheetId.get(sh.key)?.rows || []) {
|
|
9171
9263
|
if (y < 92) break;
|
|
@@ -9244,37 +9336,100 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
|
|
|
9244
9336
|
}
|
|
9245
9337
|
}
|
|
9246
9338
|
const srcDocs = /* @__PURE__ */ new Map();
|
|
9339
|
+
const srcDocFor = async (file) => {
|
|
9340
|
+
let src = srcDocs.get(file);
|
|
9341
|
+
if (!src) {
|
|
9342
|
+
src = await PDFDocument.load(await loadPdfData(file), { ignoreEncryption: true });
|
|
9343
|
+
srcDocs.set(file, src);
|
|
9344
|
+
}
|
|
9345
|
+
return src;
|
|
9346
|
+
};
|
|
9247
9347
|
for (const sh of marked) {
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
9256
|
-
|
|
9257
|
-
|
|
9258
|
-
|
|
9259
|
-
|
|
9260
|
-
|
|
9261
|
-
|
|
9262
|
-
|
|
9263
|
-
|
|
9264
|
-
|
|
9265
|
-
|
|
9348
|
+
let pg, toPage, chipRot = degrees(0), W, H;
|
|
9349
|
+
if (sh.stitch) {
|
|
9350
|
+
const members = sh.stitch.members;
|
|
9351
|
+
const pages = [];
|
|
9352
|
+
const dims = {};
|
|
9353
|
+
for (const m of members) {
|
|
9354
|
+
const page = await getPage(m.file, m.page);
|
|
9355
|
+
const vpR = page.getViewport({ scale: RENDER_SCALE });
|
|
9356
|
+
dims[m.key] = { w: vpR.width, h: vpR.height };
|
|
9357
|
+
pages.push({ m, page, vpR });
|
|
9358
|
+
}
|
|
9359
|
+
const plan = stitchPagePlan(members, dims);
|
|
9360
|
+
W = plan.extent.w;
|
|
9361
|
+
H = plan.extent.h;
|
|
9362
|
+
const pageW = W / RENDER_SCALE, pageH = H / RENDER_SCALE;
|
|
9363
|
+
if (dark) {
|
|
9364
|
+
const s = Math.min(RASTER_MAX / Math.max(pageW, pageH), 4);
|
|
9365
|
+
const cv = document.createElement("canvas");
|
|
9366
|
+
cv.width = Math.ceil(pageW * s);
|
|
9367
|
+
cv.height = Math.ceil(pageH * s);
|
|
9368
|
+
const ctx = cv.getContext("2d");
|
|
9369
|
+
ctx.fillStyle = "#fff";
|
|
9370
|
+
ctx.fillRect(0, 0, cv.width, cv.height);
|
|
9371
|
+
const k = s / RENDER_SCALE;
|
|
9372
|
+
for (let i = 0; i < pages.length; i++) {
|
|
9373
|
+
const { page } = pages[i], pm = plan.members[i];
|
|
9374
|
+
const vp = page.getViewport({ scale: s });
|
|
9375
|
+
const mc = document.createElement("canvas");
|
|
9376
|
+
mc.width = Math.ceil(vp.width);
|
|
9377
|
+
mc.height = Math.ceil(vp.height);
|
|
9378
|
+
await page.render({ canvasContext: mc.getContext("2d"), viewport: vp }).promise;
|
|
9379
|
+
ctx.save();
|
|
9380
|
+
ctx.beginPath();
|
|
9381
|
+
ctx.rect(pm.clip.x0 * k, pm.clip.y0 * k, (pm.clip.x1 - pm.clip.x0) * k, (pm.clip.y1 - pm.clip.y0) * k);
|
|
9382
|
+
ctx.clip();
|
|
9383
|
+
ctx.drawImage(mc, pm.dx * k, pm.dy * k);
|
|
9384
|
+
ctx.restore();
|
|
9385
|
+
}
|
|
9386
|
+
invertPixels(cv);
|
|
9387
|
+
const png = await doc.embedPng(cv.toDataURL("image/png"));
|
|
9388
|
+
pg = doc.addPage([pageW, pageH]);
|
|
9389
|
+
pg.drawImage(png, { x: 0, y: 0, width: pageW, height: pageH });
|
|
9390
|
+
} else {
|
|
9391
|
+
pg = doc.addPage([pageW, pageH]);
|
|
9392
|
+
for (let i = 0; i < pages.length; i++) {
|
|
9393
|
+
const { m, vpR } = pages[i], pm = plan.members[i];
|
|
9394
|
+
const src = await srcDocFor(m.file);
|
|
9395
|
+
const { bbox, matrix } = memberEmbed(vpR.transform, pm, pageH, RENDER_SCALE);
|
|
9396
|
+
const emb = await doc.embedPage(src.getPage(m.page - 1), bbox, matrix);
|
|
9397
|
+
pg.drawPage(emb, { x: 0, y: 0 });
|
|
9398
|
+
}
|
|
9399
|
+
}
|
|
9400
|
+
toPage = (x, y) => [x / RENDER_SCALE, pageH - y / RENDER_SCALE];
|
|
9266
9401
|
} else {
|
|
9267
|
-
|
|
9268
|
-
|
|
9269
|
-
|
|
9270
|
-
|
|
9271
|
-
|
|
9272
|
-
|
|
9273
|
-
|
|
9274
|
-
|
|
9275
|
-
|
|
9276
|
-
|
|
9277
|
-
|
|
9402
|
+
const page = await getPage(sh.file, sh.page);
|
|
9403
|
+
const vpR = page.getViewport({ scale: RENDER_SCALE });
|
|
9404
|
+
W = vpR.width;
|
|
9405
|
+
H = vpR.height;
|
|
9406
|
+
if (dark) {
|
|
9407
|
+
const vp1 = page.getViewport({ scale: 1 });
|
|
9408
|
+
const s = Math.min(RASTER_MAX / Math.max(vp1.width, vp1.height), 4);
|
|
9409
|
+
const vp = page.getViewport({ scale: s });
|
|
9410
|
+
const cv = document.createElement("canvas");
|
|
9411
|
+
cv.width = Math.ceil(vp.width);
|
|
9412
|
+
cv.height = Math.ceil(vp.height);
|
|
9413
|
+
await page.render({ canvasContext: cv.getContext("2d"), viewport: vp }).promise;
|
|
9414
|
+
invertPixels(cv);
|
|
9415
|
+
const png = await doc.embedPng(cv.toDataURL("image/png"));
|
|
9416
|
+
pg = doc.addPage([vp1.width, vp1.height]);
|
|
9417
|
+
pg.drawImage(png, { x: 0, y: 0, width: vp1.width, height: vp1.height });
|
|
9418
|
+
const k = vp1.width / W;
|
|
9419
|
+
toPage = (x, y) => [x * k, vp1.height - y * k];
|
|
9420
|
+
} else {
|
|
9421
|
+
let src = srcDocs.get(sh.file);
|
|
9422
|
+
if (!src) {
|
|
9423
|
+
src = await PDFDocument.load(await loadPdfData(sh.file), { ignoreEncryption: true });
|
|
9424
|
+
srcDocs.set(sh.file, src);
|
|
9425
|
+
}
|
|
9426
|
+
const [copied] = await doc.copyPages(src, [sh.page - 1]);
|
|
9427
|
+
pg = doc.addPage(copied);
|
|
9428
|
+
const [a, b, c, d, e, f] = vpR.transform;
|
|
9429
|
+
const det = a * d - b * c;
|
|
9430
|
+
toPage = (x, y) => [(d * (x - e) - c * (y - f)) / det, (-b * (x - e) + a * (y - f)) / det];
|
|
9431
|
+
chipRot = degrees(page.rotate || 0);
|
|
9432
|
+
}
|
|
9278
9433
|
}
|
|
9279
9434
|
const ptScale = Math.hypot(...(() => {
|
|
9280
9435
|
const p0 = toPage(0, 0), p1 = toPage(1, 0);
|
|
@@ -9478,7 +9633,8 @@ async function buildMarkedSetPdf({ projectName, dark, sheets, shapes, markups, a
|
|
|
9478
9633
|
const tw = bold.widthOfTextAtSize(label, size);
|
|
9479
9634
|
pg.drawText(label, { x: pcx - tw / 2, y: pcy - size / 2.7, size, font: bold, color: acol, rotate: chipRot });
|
|
9480
9635
|
}
|
|
9481
|
-
|
|
9636
|
+
const stamp = sh.stitch ? `${sh.label} \xB7 stitched composite (${sh.stitch.members.map((m) => m.label || m.key).join(" + ")}) \xB7 marked set` : `${sh.label} \xB7 marked set`;
|
|
9637
|
+
text(stamp, 14, 20, 8, muted);
|
|
9482
9638
|
}
|
|
9483
9639
|
const allPages = doc.getPages();
|
|
9484
9640
|
const lastPg = allPages[allPages.length - 1];
|
|
@@ -9792,6 +9948,7 @@ async function importTakeoff(session, filePath) {
|
|
|
9792
9948
|
if (s && s.upp == null && row.units_per_px > 0) {
|
|
9793
9949
|
s.upp = row.units_per_px;
|
|
9794
9950
|
s.scaleSource = row.scale_source ?? "upp";
|
|
9951
|
+
if (row.scale_confirmed === false) s.scaleConfirmed = false;
|
|
9795
9952
|
}
|
|
9796
9953
|
}
|
|
9797
9954
|
const tagKey2 = (t) => String(t || "").trim().toUpperCase();
|
|
@@ -9838,7 +9995,16 @@ var run = (tool, fn) => async (args) => {
|
|
|
9838
9995
|
traceToolCall(tool, args, startedAt, reply);
|
|
9839
9996
|
return reply;
|
|
9840
9997
|
};
|
|
9841
|
-
function registerTools(
|
|
9998
|
+
function registerTools(realServer, session) {
|
|
9999
|
+
const registered = /* @__PURE__ */ new Map();
|
|
10000
|
+
const server = {
|
|
10001
|
+
registerTool(name, meta, handler) {
|
|
10002
|
+
const tool = realServer.registerTool(name, meta, handler);
|
|
10003
|
+
registered.set(name, tool);
|
|
10004
|
+
return tool;
|
|
10005
|
+
},
|
|
10006
|
+
sendResourceListChanged: () => realServer.sendResourceListChanged()
|
|
10007
|
+
};
|
|
9842
10008
|
server.registerTool("load_plan", {
|
|
9843
10009
|
description: `Open a plan PDF from disk. Default: replace the whole session (previous documents, scales, conditions, and shapes are cleared). merge: true ADDS the document to the working set instead (#152) \u2014 a bid set is plans + schedule + addenda, not one PDF \u2014 keeping every scale, condition, and shape; sheet keys carry file names so documents never collide, the sheet graph spans the whole set (resolve_tag can chain a plan tag on one file to a schedule row in another), and the marked set covers every worked sheet. Re-loading an already-merged file is refused \u2014 reload = replace, deliberately. Returns file, files, page_count, and one entry per sheet. The loaded sheets also become browsable resources (takeoff://sheets). ${COORDS}`,
|
|
9844
10010
|
inputSchema: {
|
|
@@ -10294,6 +10460,7 @@ No review gate: the pencil-not-ink rule exists to stop an agent inventing geomet
|
|
|
10294
10460
|
},
|
|
10295
10461
|
outputSchema: deleteVerdictOutput
|
|
10296
10462
|
}, run("delete_verdict", ({ verdict_id }) => session.deleteVerdict(verdict_id)));
|
|
10463
|
+
return registered;
|
|
10297
10464
|
}
|
|
10298
10465
|
|
|
10299
10466
|
// src/resources.ts
|
|
@@ -10372,10 +10539,107 @@ function registerResources(server, session) {
|
|
|
10372
10539
|
);
|
|
10373
10540
|
}
|
|
10374
10541
|
|
|
10542
|
+
// src/staging.ts
|
|
10543
|
+
import { z as z3 } from "zod";
|
|
10544
|
+
var TOOL_STAGES = {
|
|
10545
|
+
// Always enabled: an agent needs these to orient before anything else is useful.
|
|
10546
|
+
setup: [
|
|
10547
|
+
"load_plan",
|
|
10548
|
+
"sheet_info",
|
|
10549
|
+
"set_scale",
|
|
10550
|
+
"sheet_graph",
|
|
10551
|
+
"resolve_tag",
|
|
10552
|
+
"find_schedule",
|
|
10553
|
+
"read_sheet_text",
|
|
10554
|
+
"find_text",
|
|
10555
|
+
"sheet_context",
|
|
10556
|
+
"view_sheet"
|
|
10557
|
+
],
|
|
10558
|
+
measure: [
|
|
10559
|
+
"one_click",
|
|
10560
|
+
"detect_rooms",
|
|
10561
|
+
"measure_polygon",
|
|
10562
|
+
"cut_out",
|
|
10563
|
+
"measure_line",
|
|
10564
|
+
"measure_surface",
|
|
10565
|
+
"place_count",
|
|
10566
|
+
"symbol_sweep",
|
|
10567
|
+
"sweep_schedule_row",
|
|
10568
|
+
"derive_base",
|
|
10569
|
+
"derive_transitions"
|
|
10570
|
+
],
|
|
10571
|
+
revise: [
|
|
10572
|
+
"list_shapes",
|
|
10573
|
+
"delete_shape",
|
|
10574
|
+
"edit_shape",
|
|
10575
|
+
"edit_materials",
|
|
10576
|
+
"edit_condition",
|
|
10577
|
+
"duplicate_condition",
|
|
10578
|
+
"split_condition",
|
|
10579
|
+
"undo_last",
|
|
10580
|
+
"annotate",
|
|
10581
|
+
"list_annotations",
|
|
10582
|
+
"link_annotation",
|
|
10583
|
+
"mark_verdict",
|
|
10584
|
+
"delete_verdict"
|
|
10585
|
+
],
|
|
10586
|
+
handoff: [
|
|
10587
|
+
"takeoff_summary",
|
|
10588
|
+
"export_takeoff",
|
|
10589
|
+
"export_report",
|
|
10590
|
+
"import_takeoff",
|
|
10591
|
+
"apply_rules",
|
|
10592
|
+
"export_marked_pdf"
|
|
10593
|
+
]
|
|
10594
|
+
};
|
|
10595
|
+
var OPENABLE = ["measure", "revise", "handoff"];
|
|
10596
|
+
var openToolStageOutput = {
|
|
10597
|
+
stage: z3.string().describe("The stage that was opened"),
|
|
10598
|
+
enabled: z3.array(z3.string()).describe("Tool names enabled by this call (empty if the stage was already open)"),
|
|
10599
|
+
open_stages: z3.array(z3.string()).describe("Every stage currently enabled, setup included"),
|
|
10600
|
+
closed_stages: z3.array(z3.string()).describe("Stages still closed \u2014 open them here when the work reaches them")
|
|
10601
|
+
};
|
|
10602
|
+
var STAGED_INSTRUCTIONS = 'TOOL EXPOSURE IS STAGED: only the setup tools are enabled at start. Before measuring, call open_tool_stage {stage:"measure"}; likewise "revise" for edit/annotate/verdict tools and "handoff" for summaries and exports. Opening a stage is instant, idempotent, and never closes anything.';
|
|
10603
|
+
function applyStagedTools(server, registered) {
|
|
10604
|
+
const openStages = /* @__PURE__ */ new Set(["setup"]);
|
|
10605
|
+
for (const stage of OPENABLE) {
|
|
10606
|
+
for (const name of TOOL_STAGES[stage]) registered.get(name)?.disable();
|
|
10607
|
+
}
|
|
10608
|
+
server.registerTool("open_tool_stage", {
|
|
10609
|
+
description: `Enable a stage of this server's tools. Tool exposure is staged to match the takeoff workflow: "setup" (orient: load, scale, read the set) is always enabled; "measure" (commit shapes: one_click, detect_rooms, measure_*, sweeps and derives), "revise" (edit, annotate, verdict-mark, undo), and "handoff" (summaries, exports, the marked set) start closed and open here on demand. Opening a stage is idempotent and never closes another \u2014 the surface only grows. Call it the moment the work reaches a closed stage; the reply lists exactly which tools just became available.`,
|
|
10610
|
+
inputSchema: {
|
|
10611
|
+
stage: z3.enum(OPENABLE).describe('Which stage to enable: "measure", "revise", or "handoff"')
|
|
10612
|
+
},
|
|
10613
|
+
outputSchema: openToolStageOutput
|
|
10614
|
+
}, async ({ stage }) => {
|
|
10615
|
+
try {
|
|
10616
|
+
const names = TOOL_STAGES[stage];
|
|
10617
|
+
const enabled = [];
|
|
10618
|
+
for (const name of names) {
|
|
10619
|
+
const tool = registered.get(name);
|
|
10620
|
+
if (!tool) throw new UserError(`Stage table names an unregistered tool: ${name}`);
|
|
10621
|
+
if (!tool.enabled) {
|
|
10622
|
+
tool.enable();
|
|
10623
|
+
enabled.push(name);
|
|
10624
|
+
}
|
|
10625
|
+
}
|
|
10626
|
+
openStages.add(stage);
|
|
10627
|
+
return ok({
|
|
10628
|
+
stage,
|
|
10629
|
+
enabled,
|
|
10630
|
+
open_stages: Object.keys(TOOL_STAGES).filter((s) => openStages.has(s)),
|
|
10631
|
+
closed_stages: Object.keys(TOOL_STAGES).filter((s) => !openStages.has(s))
|
|
10632
|
+
});
|
|
10633
|
+
} catch (e) {
|
|
10634
|
+
return fail(e);
|
|
10635
|
+
}
|
|
10636
|
+
});
|
|
10637
|
+
}
|
|
10638
|
+
|
|
10375
10639
|
// package.json
|
|
10376
10640
|
var package_default = {
|
|
10377
10641
|
name: "opentakeoff-mcp",
|
|
10378
|
-
version: "0.9.
|
|
10642
|
+
version: "0.9.40",
|
|
10379
10643
|
mcpName: "io.github.Kentucky-ai/opentakeoff",
|
|
10380
10644
|
type: "module",
|
|
10381
10645
|
description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -10391,7 +10655,7 @@ var package_default = {
|
|
|
10391
10655
|
mcpb: "npm run build && node scripts/build-mcpb.mjs",
|
|
10392
10656
|
prepublishOnly: "npm run typecheck && npm test && npm run build",
|
|
10393
10657
|
typecheck: "tsc --noEmit",
|
|
10394
|
-
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
10658
|
+
test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
10395
10659
|
},
|
|
10396
10660
|
dependencies: {
|
|
10397
10661
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
@@ -10448,7 +10712,8 @@ var package_default = {
|
|
|
10448
10712
|
};
|
|
10449
10713
|
|
|
10450
10714
|
// server.ts
|
|
10451
|
-
function buildServer(session = new Session()) {
|
|
10715
|
+
function buildServer(session = new Session(), opts = {}) {
|
|
10716
|
+
const staged = opts.stagedTools ?? process.env.OPENTAKEOFF_MCP_STAGED_TOOLS === "1";
|
|
10452
10717
|
const server = new McpServer2({ name: "opentakeoff", version: package_default.version }, {
|
|
10453
10718
|
// Served to every client at initialize — the discipline that makes agent
|
|
10454
10719
|
// takeoffs land as reviewable work instead of a bare numbers report.
|
|
@@ -10460,11 +10725,13 @@ function buildServer(session = new Session()) {
|
|
|
10460
10725
|
"3. DERIVE what follows from the rooms instead of re-measuring it: derive_base for base LF (perimeter \u2212 the door openings YOU state), derive_transitions for the line where two finishes meet. Both read committed floor shapes, so they come after step 2 and their output is audited in step 4 like anything else.",
|
|
10461
10726
|
"4. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
|
|
10462
10727
|
"5. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone.",
|
|
10463
|
-
"WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse."
|
|
10728
|
+
"WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse.",
|
|
10729
|
+
...staged ? [STAGED_INSTRUCTIONS] : []
|
|
10464
10730
|
].join("\n")
|
|
10465
10731
|
});
|
|
10466
|
-
registerTools(server, session);
|
|
10732
|
+
const registered = registerTools(server, session);
|
|
10467
10733
|
registerResources(server, session);
|
|
10734
|
+
if (staged) applyStagedTools(server, registered);
|
|
10468
10735
|
return server;
|
|
10469
10736
|
}
|
|
10470
10737
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opentakeoff-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.40",
|
|
4
4
|
"mcpName": "io.github.Kentucky-ai/opentakeoff",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"mcpb": "npm run build && node scripts/build-mcpb.mjs",
|
|
17
17
|
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
-
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
19
|
+
"test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/labels.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.12.0",
|