opentakeoff-mcp 0.6.0 → 0.7.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
@@ -108,15 +108,39 @@ includes document text, shape vertices, or result payload content.
108
108
  | `sheet_info` | One sheet's dims, vector segment count, scale status, detected suggestion, committed shape count. |
109
109
  | `set_scale` | Set a sheet's scale — exactly one of `label`, `upp`, `calibrate {p1, p2, feet}`, `use_detected`. |
110
110
  | `one_click` | One-Click Area at (x, y): flood fill bounded by the plan linework, traced, vertices snapped. Pass `condition` to commit; `role: "deduct"` subtracts. |
111
- | `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. Only cleanly-traced rooms come back; a leaked/dense-linework label is silently withheld. Pass `condition` to commit every detected room. |
111
+ | `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. Only cleanly-traced rooms come back; everything skipped is counted and reasoned in `withheld` (degenerate / duplicate / implausible), never dropped silently. Pass `condition` to commit every detected room. |
112
112
  | `measure_polygon` | Area + perimeter of a polygon you supply (min 3 verts). Requires scale. |
113
113
  | `measure_line` | Length of an open polyline (min 2 points). Requires scale. |
114
114
  | `takeoff_summary` | Per-condition totals + grand totals, computed by the Report's rules. |
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
+ | `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. |
117
119
  | `read_sheet_text` | Positioned page text (image px), optionally restricted to a region — title blocks, room labels, finish schedules. |
120
+ | `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. |
118
121
  | `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. |
119
122
 
123
+ ### The agent revises its own work
124
+
125
+ `edit_shape` and `undo_last` exist because an agent that can only *append* has
126
+ one recovery move: delete and re-derive. The loop they enable instead —
127
+ **commit → `view_sheet overlay:true` → see the ring overshot into the corridor
128
+ → move those two vertices → look again** — is the loop a human estimator
129
+ already runs, and it is the difference between an agent that drafts and one
130
+ that works.
131
+
132
+ Two rules hold the surface honest:
133
+
134
+ - **Ink is not pencil.** A shape carrying `origin.reviewed === true` is work a
135
+ human affirmed, and no agent verb touches it. This server has no review gate
136
+ of its own, so the guard is inert here — it is the contract that makes the
137
+ surface safe to port to a host that *does* have one.
138
+ - **Self-revision is not correction.** `edit_shape` bumps `origin.agent_edits`
139
+ and touches nothing in the human-correction vocabulary (`edited`, `edits`,
140
+ `proposed_verts_norm`). Those fields mean *a human corrected the machine*;
141
+ merging a machine's own fix into them would corrupt the one signal that
142
+ measures whether the machine is getting better.
143
+
120
144
  Every JSON tool declares an **`outputSchema`**, and every reply carries the
121
145
  payload as **`structuredContent`** — typed, machine-validated on every call —
122
146
  alongside the same compact JSON in a single text item for clients that predate
@@ -216,6 +216,19 @@ function positionedText(ph) {
216
216
  }
217
217
  return out;
218
218
  }
219
+ function textSpans(ph) {
220
+ const out = [];
221
+ for (const it of ph.textContent.items || []) {
222
+ const str = it.str || "";
223
+ if (!str.trim()) continue;
224
+ const t = pdfjs.Util.transform(ph.viewport.transform, it.transform);
225
+ const x = t[4], y = t[5];
226
+ const w = (it.width || 0) * RENDER_SCALE;
227
+ const h = (it.height || 0) * RENDER_SCALE || Math.hypot(t[2], t[3]);
228
+ out.push({ str, x0: +x.toFixed(1), y0: +(y - h).toFixed(1), x1: +(x + w).toFixed(1), y1: +y.toFixed(1) });
229
+ }
230
+ return out;
231
+ }
219
232
 
220
233
  // src/format.ts
221
234
  var UserError = class extends Error {
@@ -427,16 +440,17 @@ function extractVectorGeometry(opList, transform, OPS3) {
427
440
  }
428
441
  return { points, segs, meta: Uint8Array.from(metaArr), imageArea };
429
442
  }
430
- function classifyHatchSegs(segs, meta, ws) {
443
+ function sweepHatchRuns(segs, meta, ws) {
431
444
  const n = segs.length >> 2;
432
- const soft = new Uint8Array(n);
433
- if (!meta || !n) return soft;
445
+ const clipSoft = [];
446
+ const runs = [];
447
+ if (!meta || !n) return { clipSoft, runs };
434
448
  const cand = [];
435
449
  for (let i = 0; i < n; i++) {
436
450
  const mt = meta[i];
437
451
  if (mt & SEG_CURVE) continue;
438
452
  if (mt & SEG_CLIP) {
439
- soft[i] = 1;
453
+ clipSoft.push(i);
440
454
  continue;
441
455
  }
442
456
  if (mt & SEG_FILLONLY) continue;
@@ -449,7 +463,7 @@ function classifyHatchSegs(segs, meta, ws) {
449
463
  if (ang >= 180) ang -= 180;
450
464
  cand.push({ i, ang, x1, y1, x2, y2, w: meta[i] >> 4 });
451
465
  }
452
- if (cand.length < HATCH_MIN_RUN) return soft;
466
+ if (cand.length < HATCH_MIN_RUN) return { clipSoft, runs };
453
467
  cand.sort((a, b) => a.ang - b.ang);
454
468
  const clusters = [];
455
469
  let cl = [cand[0]];
@@ -517,11 +531,33 @@ function classifyHatchSegs(segs, meta, ws) {
517
531
  const spans = [];
518
532
  for (let k = a; k <= b; k++) spans.push(rows[k].t1 - rows[k].t0);
519
533
  const medSpan = Math.max(1, median(spans));
520
- for (let k = a + 1; k < b; k++) {
521
- if (rows[k].t1 - rows[k].t0 > SPAN_PROTECT_RATIO * medSpan) continue;
522
- for (const s of rows[k].segs)
523
- if (s.w < WIDE_PROTECT_RATIO * modalW) soft[s.i] = 1;
534
+ const memberIdx = [];
535
+ const softIdx = [];
536
+ let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
537
+ let angSum = 0, angN = 0;
538
+ for (let k = a; k <= b; k++) {
539
+ const guarded = rows[k].t1 - rows[k].t0 > SPAN_PROTECT_RATIO * medSpan;
540
+ for (const s of rows[k].segs) {
541
+ memberIdx.push(s.i);
542
+ angSum += s.ang;
543
+ angN++;
544
+ bx0 = Math.min(bx0, s.x1, s.x2);
545
+ by0 = Math.min(by0, s.y1, s.y2);
546
+ bx1 = Math.max(bx1, s.x1, s.x2);
547
+ by1 = Math.max(by1, s.y1, s.y2);
548
+ if (k > a && k < b && !guarded && s.w < WIDE_PROTECT_RATIO * modalW) softIdx.push(s.i);
549
+ }
524
550
  }
551
+ const meanAng = (angSum / Math.max(1, angN) % 180 + 180) % 180;
552
+ runs.push({
553
+ angleDeg: meanAng,
554
+ pitch: med,
555
+ modalW,
556
+ rowCount: count,
557
+ bbox: [bx0, by0, bx1, by1],
558
+ memberIdx,
559
+ softIdx
560
+ });
525
561
  };
526
562
  for (let k = 1; k < rows.length; k++) {
527
563
  const gap = rows[k].d - rows[k - 1].d;
@@ -534,8 +570,34 @@ function classifyHatchSegs(segs, meta, ws) {
534
570
  }
535
571
  flushRun(runStart, rows.length - 1);
536
572
  }
573
+ return { clipSoft, runs };
574
+ }
575
+ function classifyHatchSegs(segs, meta, ws) {
576
+ const soft = new Uint8Array(segs.length >> 2);
577
+ const { clipSoft, runs } = sweepHatchRuns(segs, meta, ws);
578
+ for (const i of clipSoft) soft[i] = 1;
579
+ for (const r of runs) for (const i of r.softIdx) soft[i] = 1;
537
580
  return soft;
538
581
  }
582
+ var HATCH_ID_ANGLE_Q = 0.5;
583
+ var HATCH_ID_PITCH_Q = 0.1;
584
+ function hatchFamilies(segs, meta) {
585
+ const { runs } = sweepHatchRuns(segs, meta, 1);
586
+ const q = (v, step) => Math.round(v / step) * step;
587
+ return runs.map((r) => {
588
+ const a = q(r.angleDeg, HATCH_ID_ANGLE_Q), p = q(r.pitch, HATCH_ID_PITCH_Q);
589
+ return {
590
+ id: `h-a${a.toFixed(1)}p${p.toFixed(1)}w${r.modalW}`,
591
+ angle_deg: +r.angleDeg.toFixed(2),
592
+ pitch_px: +r.pitch.toFixed(2),
593
+ pen_w_px: r.modalW,
594
+ rows: r.rowCount,
595
+ segments: r.memberIdx.length,
596
+ bbox: r.bbox.map((v) => +v.toFixed(1)),
597
+ memberIdx: r.memberIdx
598
+ };
599
+ });
600
+ }
539
601
  function buildMask(segs, imgW, imgH, maxDim = MASK_MAX_DIM, meta = null) {
540
602
  const ws = Math.min(1, maxDim / Math.max(imgW, imgH, 1));
541
603
  const mw = Math.max(2, Math.ceil(imgW * ws)), mh = Math.max(2, Math.ceil(imgH * ws));
@@ -770,23 +832,22 @@ function ringArea(pts) {
770
832
 
771
833
  // ../web/src/lib/detectRooms.ts
772
834
  var ROOM_LABEL_RE = /^\d{2,3}[A-Z]?$/;
773
- function roomLabelSeeds(items) {
774
- const out = [];
775
- for (const it of items) {
776
- const num = (it.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
777
- if (!num) continue;
778
- out.push({ str: num, seed: [it.x, it.y] });
779
- }
780
- return out;
835
+ var BUBBLE_RATIO = 2.5;
836
+ function seedLadderPx(b) {
837
+ const cx = (b.x0 + b.x1) / 2, cy = (b.y0 + b.y1) / 2;
838
+ const h = Math.max(b.y1 - b.y0, 1);
839
+ return [[cx, cy], [cx, cy + 2 * h], [cx, cy - 2 * h], [cx, cy + 3.5 * h]];
781
840
  }
782
- function detectRegions(maskObj, seeds, sensitivity = SENS_BALANCED) {
783
- const out = [];
784
- for (const s of seeds) {
785
- const f = floodRegion(maskObj, s.seed[0], s.seed[1], sensitivity);
786
- if (f.status !== "ok") continue;
787
- out.push({ str: s.str, seed: s.seed, flood: f });
841
+ function isLabelBubblePx(ring, b) {
842
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
843
+ for (const [x, y] of ring) {
844
+ if (x < x0) x0 = x;
845
+ if (y < y0) y0 = y;
846
+ if (x > x1) x1 = x;
847
+ if (y > y1) y1 = y;
788
848
  }
789
- return out;
849
+ const lw = Math.max(b.x1 - b.x0, 1e-6), lh = Math.max(b.y1 - b.y0, 1e-6);
850
+ return x1 - x0 <= BUBBLE_RATIO * lw && y1 - y0 <= BUBBLE_RATIO * lh;
790
851
  }
791
852
 
792
853
  // ../web/src/lib/geometry.js
@@ -1016,10 +1077,35 @@ var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert"
1016
1077
  var mintUuid = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1017
1078
  var uid = (p) => `${p}-${mintUuid()}`;
1018
1079
  var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
1080
+ var CONTEXT_MIN_LEN_PX = 2;
1081
+ var CONTEXT_MAX_SEGMENTS = 4e3;
1082
+ var CONTEXT_MAX_SEGMENTS_CEIL = 2e4;
1083
+ function segIntersectsRect(x1, y1, x2, y2, r) {
1084
+ if (Math.max(x1, x2) < r.x0 || Math.min(x1, x2) > r.x1 || Math.max(y1, y2) < r.y0 || Math.min(y1, y2) > r.y1) return false;
1085
+ const dx = x2 - x1, dy = y2 - y1;
1086
+ let t0 = 0, t1 = 1;
1087
+ for (const [p, q] of [[-dx, x1 - r.x0], [dx, r.x1 - x1], [-dy, y1 - r.y0], [dy, r.y1 - y1]]) {
1088
+ if (p === 0) {
1089
+ if (q < 0) return false;
1090
+ continue;
1091
+ }
1092
+ const t = q / p;
1093
+ if (p < 0) {
1094
+ if (t > t1) return false;
1095
+ if (t > t0) t0 = t;
1096
+ } else {
1097
+ if (t < t0) return false;
1098
+ if (t < t1) t1 = t;
1099
+ }
1100
+ }
1101
+ return true;
1102
+ }
1103
+ var rectsOverlap = (a, r) => a[0] <= r.x1 && a[2] >= r.x0 && a[1] <= r.y1 && a[3] >= r.y0;
1019
1104
  var IMAGE_MAX_EDGE = 1568;
1020
1105
  var VIEW_MIN_PX = 200;
1021
1106
  var VIEW_DEFAULT_PX = 1400;
1022
1107
  var VIEW_MAX_PX = 2e3;
1108
+ var UNDO_CAP = 100;
1023
1109
  var sheetSummary = (s) => ({
1024
1110
  sheet: s.key,
1025
1111
  page: s.pageNum,
@@ -1036,6 +1122,23 @@ var Session = class {
1036
1122
  sheets = /* @__PURE__ */ new Map();
1037
1123
  conditions = [];
1038
1124
  shapes = [];
1125
+ /** Newest-last. Capped at UNDO_CAP; the oldest entry falls off the front. */
1126
+ journal = [];
1127
+ seq = 0;
1128
+ /** Ids minted by commit() since the last flush — one tool call may commit
1129
+ * many shapes (detect_rooms), and they journal as a single reversible step. */
1130
+ pendingCommits = [];
1131
+ record(entry) {
1132
+ this.journal.push({ ...entry, seq: ++this.seq });
1133
+ if (this.journal.length > UNDO_CAP) this.journal.shift();
1134
+ }
1135
+ /** Journal whatever commit() minted during this tool call, as one entry.
1136
+ * A call that committed nothing records nothing — undo steps over reads. */
1137
+ flushCommits(tool) {
1138
+ if (!this.pendingCommits.length) return;
1139
+ this.record({ op: "commit", tool, ids: this.pendingCommits });
1140
+ this.pendingCommits = [];
1141
+ }
1039
1142
  /** load_plan replaces the session's document: the old doc is destroyed and
1040
1143
  * ALL state — scales, caches, conditions, shapes — is cleared. */
1041
1144
  async loadPlan(filePath) {
@@ -1046,6 +1149,8 @@ var Session = class {
1046
1149
  this.conditions = [];
1047
1150
  this.shapes = [];
1048
1151
  this.file = null;
1152
+ this.journal = [];
1153
+ this.pendingCommits = [];
1049
1154
  const doc = await openPdf(filePath);
1050
1155
  this.doc = doc;
1051
1156
  this.file = path2.basename(filePath);
@@ -1149,6 +1254,87 @@ var Session = class {
1149
1254
  }
1150
1255
  };
1151
1256
  }
1257
+ /** sheet_context (issue #29): the classified vectors, the positioned text,
1258
+ * and the hatch-family instances of ONE region, in ONE frame — image px,
1259
+ * the space every other tool already speaks. There is deliberately no
1260
+ * transform in this method: everything below is a containment test against
1261
+ * a rect, so frame agreement with view_sheet is a contract on the echoed
1262
+ * region, not on a second renderer.
1263
+ *
1264
+ * Decimation is declared and ordered (issue #29 design comment): clip to
1265
+ * region → drop segments shorter than min_len_px → cap at max_segments
1266
+ * LONGEST-FIRST (walls are long, hatch strokes are short — truncation
1267
+ * degrades toward structure). Whole segments drop with their meta intact;
1268
+ * nothing is simplified or merged, because these are CLASSIFIED segments
1269
+ * and a merge would silently rewrite the classification. The counts ride
1270
+ * on every reply, truncated or not. */
1271
+ async sheetContext(name, opts) {
1272
+ const s = this.sheet(name);
1273
+ const clampX = (v) => Math.max(0, Math.min(v, s.widthPx));
1274
+ const clampY = (v) => Math.max(0, Math.min(v, s.heightPx));
1275
+ const r = opts.region ? { x0: clampX(opts.region.x0), y0: clampY(opts.region.y0), x1: clampX(opts.region.x1), y1: clampY(opts.region.y1) } : { x0: 0, y0: 0, x1: s.widthPx, y1: s.heightPx };
1276
+ if (!(r.x1 - r.x0 >= 1 && r.y1 - r.y0 >= 1)) {
1277
+ throw new UserError(`Empty context region \u2014 need x1 > x0 and y1 > y0 in image px inside the sheet (${s.widthPx} \xD7 ${s.heightPx}).`);
1278
+ }
1279
+ const minLen = opts.min_len_px ?? CONTEXT_MIN_LEN_PX;
1280
+ const cap = opts.max_segments ?? CONTEXT_MAX_SEGMENTS;
1281
+ const geo = await this.ensureGeometry(s);
1282
+ const hasVectors = geo.segs.length > 0;
1283
+ if (!s.hatch) s.hatch = hasVectors ? hatchFamilies(geo.segs, geo.meta) : [];
1284
+ if (!s.spans) s.spans = textSpans(s.page);
1285
+ const famBySeg = /* @__PURE__ */ new Map();
1286
+ for (const f of s.hatch) for (const i of f.memberIdx) famBySeg.set(i, f.id);
1287
+ const inRegion = [];
1288
+ const nSeg = geo.segs.length >> 2;
1289
+ for (let i = 0; i < nSeg; i++) {
1290
+ const x1 = geo.segs[i * 4], y1 = geo.segs[i * 4 + 1], x2 = geo.segs[i * 4 + 2], y2 = geo.segs[i * 4 + 3];
1291
+ if (segIntersectsRect(x1, y1, x2, y2, r)) inRegion.push({ i, len: Math.hypot(x2 - x1, y2 - y1) });
1292
+ }
1293
+ const visible = inRegion.filter((e) => e.len >= minLen);
1294
+ const droppedShort = inRegion.length - visible.length;
1295
+ let kept = visible;
1296
+ let droppedCap = 0;
1297
+ if (visible.length > cap) {
1298
+ kept = visible.slice().sort((a, b) => b.len - a.len).slice(0, cap);
1299
+ droppedCap = visible.length - cap;
1300
+ }
1301
+ const segments = [], metaOut = [], family = [];
1302
+ for (const { i } of kept) {
1303
+ segments.push([
1304
+ round1(geo.segs[i * 4]),
1305
+ round1(geo.segs[i * 4 + 1]),
1306
+ round1(geo.segs[i * 4 + 2]),
1307
+ round1(geo.segs[i * 4 + 3])
1308
+ ]);
1309
+ metaOut.push(geo.meta[i]);
1310
+ family.push(famBySeg.get(i) ?? null);
1311
+ }
1312
+ const spans = s.spans.filter((sp) => sp.x0 <= r.x1 && sp.x1 >= r.x0 && sp.y0 <= r.y1 && sp.y1 >= r.y0);
1313
+ const keptIdx = new Set(kept.map((k) => k.i));
1314
+ const families = s.hatch.filter((f) => rectsOverlap(f.bbox, r)).map(({ memberIdx, ...f }) => ({
1315
+ ...f,
1316
+ segments_in_region: memberIdx.reduce((acc, i) => acc + (keptIdx.has(i) ? 1 : 0), 0)
1317
+ }));
1318
+ return {
1319
+ sheet: s.key,
1320
+ page: s.pageNum,
1321
+ sheet_px: [s.widthPx, s.heightPx],
1322
+ region: [round1(r.x0), round1(r.y0), round1(r.x1), round1(r.y1)],
1323
+ has_vector_linework: hasVectors,
1324
+ vectors: {
1325
+ segments,
1326
+ meta: metaOut,
1327
+ family,
1328
+ kept: kept.length,
1329
+ total_in_region: inRegion.length,
1330
+ truncated: droppedShort + droppedCap > 0,
1331
+ dropped: { short: droppedShort, cap: droppedCap },
1332
+ ...droppedCap > 0 ? { note: `Region exceeds max_segments \u2014 the ${droppedCap} SHORTEST segments were dropped, so structure (walls) survives and fill (hatch) goes first. Narrow the region or raise max_segments for the full set.` } : {}
1333
+ },
1334
+ text: { spans, count: spans.length },
1335
+ hatch: { families, count: families.length }
1336
+ };
1337
+ }
1152
1338
  async ensureGeometry(s) {
1153
1339
  if (!s.geo) {
1154
1340
  const opList = await s.page.operatorList();
@@ -1246,13 +1432,14 @@ var Session = class {
1246
1432
  ...origin ? { origin } : {}
1247
1433
  };
1248
1434
  this.shapes.push(shape);
1435
+ this.pendingCommits.push(shape.id);
1249
1436
  return shape;
1250
1437
  }
1251
1438
  async oneClick(name, x, y, opts) {
1252
1439
  const s = this.sheet(name);
1253
1440
  const mask = await this.ensureMask(name);
1254
1441
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
1255
- const f = floodRegion(mask, x, y);
1442
+ const f = floodRegion(mask, x, y, opts.sensitivity ?? SENS_BALANCED);
1256
1443
  if (f.status === "leak") throw new UserError("That space isn't enclosed on the plan linework \u2014 the fill spilled through a gap or opening.");
1257
1444
  if (f.status !== "ok") throw new UserError("Landed in dense linework (hatching or text).");
1258
1445
  const ring = snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
@@ -1283,9 +1470,13 @@ var Session = class {
1283
1470
  actor: "agent",
1284
1471
  seed_norm: [x / s.widthPx, y / s.heightPx],
1285
1472
  reviewed: false,
1286
- ...f.hatchFiltered ? { hatch_filtered: true } : {}
1473
+ ...f.hatchFiltered ? { hatch_filtered: true } : {},
1474
+ // canvas-parity provenance: a non-default fill sensitivity is part of
1475
+ // how the shape was made (ShapeOrigin.fill_sensitivity)
1476
+ ...opts.sensitivity !== void 0 && opts.sensitivity !== SENS_BALANCED ? { fill_sensitivity: opts.sensitivity } : {}
1287
1477
  }).id;
1288
1478
  }
1479
+ this.flushCommits("one_click");
1289
1480
  return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
1290
1481
  }
1291
1482
  /** Batch room detection: read every room-number label off the sheet's text
@@ -1293,48 +1484,130 @@ var Session = class {
1293
1484
  * exactly like oneClick — just N of them from one call instead of N
1294
1485
  * reasoning-heavy round-trips. Same contract as oneClick: no scale → a
1295
1486
  * px-only preview per room; no condition → nothing commits (a review
1296
- * pass, not a proposal-acceptance gate — this server has none). A region
1297
- * that traces to a degenerate ring (<3 verts) is dropped from the batch
1298
- * rather than failing the whole call one bad label must not sink every
1299
- * other clean detection on the sheet. */
1487
+ * pass, not a proposal-acceptance gate — this server has none).
1488
+ *
1489
+ * Withholding nothing is committed until it survives all three, and the
1490
+ * batch NEVER silently drops work: every withheld seed is counted and
1491
+ * reasoned in `withheld`, because a room the tool knows it skipped is a
1492
+ * question the caller can ask, while a room it skipped silently is a hole
1493
+ * in a bid.
1494
+ * 1. degenerate — traced to fewer than 3 vertices.
1495
+ * 2. duplicate — two labels flooding one region (a room tagged twice, or
1496
+ * a legend number landing in the same space) trace to an identical
1497
+ * ring. Committing both double-counts the area with no signal, which
1498
+ * is the worst failure mode an estimating tool has. One region commits
1499
+ * once; the collapsed labels ride along on `merged_labels`.
1500
+ * 3. bubble — plans draw room numbers inside little boxes, and a seed at
1501
+ * the label floods the label's own BUBBLE: fully enclosed, traces
1502
+ * clean at label size, and is not a room (found live: 25 of 26). Each
1503
+ * label runs a SEED LADDER (anchor first, then label-height offsets)
1504
+ * and bubble rings are rejected scale-free (ring bbox ≈ label bbox) —
1505
+ * so the guard holds even before any scale is set. A label whose
1506
+ * every clean flood was its bubble counts here.
1507
+ * 4. implausible — enclosed, clean, non-bubble, and still smaller than
1508
+ * `minAreaSf` (default 5 SF — smaller than any real finished space; a
1509
+ * broom closet is ~10 SF): a door swing or wall cavity. Only applied
1510
+ * once a scale exists, since without one there is no real area to
1511
+ * judge and nothing commits anyway. */
1300
1512
  async detectRooms(name, opts) {
1301
1513
  const s = this.sheet(name);
1302
1514
  const mask = await this.ensureMask(name);
1303
1515
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
1304
- const seeds = roomLabelSeeds(s.text);
1305
- const regions = detectRegions(mask, seeds);
1306
- const rooms = regions.map((r) => {
1307
- const ring = snapVertices(traceRegion(r.flood), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
1308
- if (ring.length < 3) return null;
1309
- const areaPx2 = ringArea(ring);
1310
- const perimPx = closedMetrics(ring).perim;
1516
+ const minAreaSf = opts.minAreaSf ?? 5;
1517
+ if (!s.spans) s.spans = textSpans(s.page);
1518
+ const labels = [];
1519
+ for (const sp of s.spans) {
1520
+ const num = (sp.str || "").trim().split(/\s+/).find((tok) => ROOM_LABEL_RE.test(tok));
1521
+ if (num) labels.push({ str: num, bbox: sp });
1522
+ }
1523
+ const withheld = { degenerate: 0, duplicate: 0, bubble: 0, implausible: 0 };
1524
+ const byRing = /* @__PURE__ */ new Map();
1525
+ const order = [];
1526
+ for (const lb of labels) {
1527
+ let ring = null, hatch = false, seed = null;
1528
+ let sawBubble = false, sawDegenerate = false;
1529
+ for (const probe of seedLadderPx(lb.bbox)) {
1530
+ const f = floodRegion(mask, probe[0], probe[1], opts.sensitivity ?? SENS_BALANCED);
1531
+ if (f.status !== "ok") continue;
1532
+ const r = snapVertices(traceRegion(f), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
1533
+ if (r.length < 3) {
1534
+ sawDegenerate = true;
1535
+ continue;
1536
+ }
1537
+ if (isLabelBubblePx(r, lb.bbox)) {
1538
+ sawBubble = true;
1539
+ continue;
1540
+ }
1541
+ ring = r;
1542
+ hatch = !!f.hatchFiltered;
1543
+ seed = probe;
1544
+ break;
1545
+ }
1546
+ if (!ring || !seed) {
1547
+ if (sawBubble) withheld.bubble++;
1548
+ else if (sawDegenerate) withheld.degenerate++;
1549
+ continue;
1550
+ }
1551
+ const key = ring.map(([x, y]) => `${Math.round(x)},${Math.round(y)}`).join(";");
1552
+ const seen = byRing.get(key);
1553
+ if (seen) {
1554
+ seen.merged.push(lb.str);
1555
+ withheld.duplicate++;
1556
+ continue;
1557
+ }
1558
+ const cand = {
1559
+ label: lb.str,
1560
+ ring,
1561
+ areaPx2: ringArea(ring),
1562
+ perimPx: closedMetrics(ring).perim,
1563
+ seed,
1564
+ hatch,
1565
+ merged: []
1566
+ };
1567
+ byRing.set(key, cand);
1568
+ order.push(cand);
1569
+ }
1570
+ const upp = s.upp;
1571
+ const rooms = order.map((c) => {
1311
1572
  const common = {
1312
- label: r.str,
1313
- nverts: ring.length,
1314
- ...r.flood.hatchFiltered ? { hatch_filtered: true } : {},
1315
- ...opts.returnVerts ? { verts: ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
1573
+ label: c.label,
1574
+ nverts: c.ring.length,
1575
+ ...c.merged.length ? { merged_labels: c.merged } : {},
1576
+ ...c.hatch ? { hatch_filtered: true } : {},
1577
+ ...opts.returnVerts ? { verts: c.ring.map(([vx, vy]) => [round1(vx), round1(vy)]) } : {}
1316
1578
  };
1317
- if (s.upp == null) {
1318
- return { ...common, area_px2: round1(areaPx2), perimeter_px: round1(perimPx) };
1579
+ if (upp == null) {
1580
+ return { ...common, area_px2: round1(c.areaPx2), perimeter_px: round1(c.perimPx) };
1581
+ }
1582
+ const area_sf = round2(c.areaPx2 * upp * upp);
1583
+ if (area_sf < minAreaSf) {
1584
+ withheld.implausible++;
1585
+ return null;
1319
1586
  }
1320
- const upp = s.upp;
1321
- const area_sf = round2(areaPx2 * upp * upp);
1322
- const perimeter_lf = round2(perimPx * upp);
1587
+ const perimeter_lf = round2(c.perimPx * upp);
1323
1588
  let shape_id;
1324
1589
  if (opts.condition) {
1325
- shape_id = this.commit(s, opts.condition, opts.role, ring, { area_sf, perimeter_lf }, {
1590
+ shape_id = this.commit(s, opts.condition, opts.role, c.ring, { area_sf, perimeter_lf }, {
1326
1591
  method: "one_click_v1",
1327
1592
  actor: "agent",
1328
- seed_norm: [r.seed[0] / s.widthPx, r.seed[1] / s.heightPx],
1593
+ seed_norm: [c.seed[0] / s.widthPx, c.seed[1] / s.heightPx],
1329
1594
  reviewed: false,
1330
- ...r.flood.hatchFiltered ? { hatch_filtered: true } : {}
1595
+ ...c.hatch ? { hatch_filtered: true } : {}
1331
1596
  }).id;
1332
1597
  }
1333
1598
  return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
1334
1599
  }).filter((r) => r !== null);
1600
+ this.flushCommits("detect_rooms");
1601
+ const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible;
1335
1602
  return {
1336
1603
  detected: rooms.length,
1337
1604
  rooms,
1605
+ withheld: {
1606
+ total: withheldTotal,
1607
+ ...withheld,
1608
+ ...upp != null ? { min_area_sf: minAreaSf } : {}
1609
+ },
1610
+ ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.bubble} label-bubble(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable.` } : {},
1338
1611
  ...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
1339
1612
  };
1340
1613
  }
@@ -1346,6 +1619,7 @@ var Session = class {
1346
1619
  const perimeter_lf = round2(met.perim * s.upp);
1347
1620
  let shape_id;
1348
1621
  if (opts.condition) shape_id = this.commit(s, opts.condition, opts.role, verts, { area_sf, perimeter_lf }, { method: "manual", actor: "agent" }).id;
1622
+ this.flushCommits("measure_polygon");
1349
1623
  return { area_sf, perimeter_lf, nverts: verts.length, ...shape_id ? { shape_id } : {} };
1350
1624
  }
1351
1625
  measureLine(name, pts, opts) {
@@ -1354,6 +1628,7 @@ var Session = class {
1354
1628
  const length_lf = round2(openLen(pts) * s.upp);
1355
1629
  let shape_id;
1356
1630
  if (opts.condition) shape_id = this.commit(s, opts.condition, "linear", pts, { area_sf: 0, perimeter_lf: length_lf }, { method: "manual", actor: "agent" }).id;
1631
+ this.flushCommits("measure_line");
1357
1632
  return { length_lf, npts: pts.length, ...shape_id ? { shape_id } : {} };
1358
1633
  }
1359
1634
  summary() {
@@ -1364,9 +1639,110 @@ var Session = class {
1364
1639
  deleteShape(id) {
1365
1640
  const i = this.shapes.findIndex((x) => x.id === id);
1366
1641
  if (i < 0) throw new UserError(`No shape with id ${JSON.stringify(id)}.`);
1367
- this.shapes.splice(i, 1);
1642
+ const [shape] = this.shapes.splice(i, 1);
1643
+ this.record({ op: "delete", tool: "delete_shape", removed: [{ shape, index: i }] });
1368
1644
  return { deleted: id, shape_count: this.shapes.length };
1369
1645
  }
1646
+ /** Revise a committed shape in place: new geometry, a different condition, a
1647
+ * different role, or any combination. This is the verb that turns the agent
1648
+ * from an appender into an editor — it can propose a ring, look at the
1649
+ * overlay, see it overshot into the corridor, and move the two offending
1650
+ * vertices, instead of deleting and re-deriving the whole room.
1651
+ *
1652
+ * The review gate is absolute: a shape a human affirmed (origin.reviewed ===
1653
+ * true) is ink, and no agent verb touches ink. This server never sets that
1654
+ * flag itself, so the guard is inert here today — it is the contract that
1655
+ * makes this surface portable to a host that DOES have a review gate, and it
1656
+ * belongs in the code rather than in a host's good intentions.
1657
+ *
1658
+ * Provenance: agent self-revision bumps origin.agent_edits and touches
1659
+ * NOTHING in the human-correction vocabulary (edited / edits /
1660
+ * proposed_verts_norm — see web/src/lib/provenance.js). Those fields grade a
1661
+ * human's correction of a machine proposal; an agent fixing its own work is
1662
+ * not that, and conflating the two would poison the exact signal the capture
1663
+ * layer exists to collect. Freezing proposed_verts_norm stays correct on the
1664
+ * human's first edit, because the geometry a reviewer saw IS the agent's
1665
+ * final revision, not its first draft. */
1666
+ editShape(id, patch) {
1667
+ const i = this.shapes.findIndex((x) => x.id === id);
1668
+ if (i < 0) throw new UserError(`No shape with id ${JSON.stringify(id)}.`);
1669
+ const cur = this.shapes[i];
1670
+ if (cur.origin?.reviewed === true) {
1671
+ throw new UserError(`Shape ${JSON.stringify(id)} was affirmed by a human \u2014 reviewed work is ink, not pencil, and cannot be edited by an agent.`);
1672
+ }
1673
+ if (patch.verts === void 0 && patch.condition === void 0 && patch.role === void 0) {
1674
+ throw new UserError("Nothing to change \u2014 pass at least one of verts, condition, role.");
1675
+ }
1676
+ const s = this.sheet(cur.sheet_id);
1677
+ if (s.upp == null) throw new UserError(this.scaleGate(s));
1678
+ const upp = s.upp;
1679
+ const role = patch.role ?? cur.measure_role;
1680
+ const vertsPx = patch.verts ?? cur.verts_norm.map(([x, y]) => [x * s.widthPx, y * s.heightPx]);
1681
+ const minPts = role === "linear" ? 2 : 3;
1682
+ if (vertsPx.length < minPts) {
1683
+ throw new UserError(`A ${role === "linear" ? "linear shape needs at least 2 points" : "closed shape needs at least 3 vertices"} \u2014 got ${vertsPx.length}.`);
1684
+ }
1685
+ const computed = role === "linear" ? { area_sf: 0, perimeter_lf: round2(openLen(vertsPx) * upp) } : (() => {
1686
+ const met = closedMetrics(vertsPx);
1687
+ return { area_sf: round2(met.area * upp * upp), perimeter_lf: round2(met.perim * upp) };
1688
+ })();
1689
+ const before = structuredClone(cur);
1690
+ const condition_id = patch.condition !== void 0 ? this.conditionFor(patch.condition).id : cur.condition_id;
1691
+ this.shapes[i] = {
1692
+ ...cur,
1693
+ condition_id,
1694
+ measure_role: role,
1695
+ verts_norm: vertsPx.map(([x, y]) => [x / s.widthPx, y / s.heightPx]),
1696
+ computed,
1697
+ ...cur.origin ? { origin: { ...cur.origin, agent_edits: (cur.origin.agent_edits ?? 0) + 1 } } : {}
1698
+ };
1699
+ this.record({ op: "edit", tool: "edit_shape", before });
1700
+ const changed = [
1701
+ ...patch.verts !== void 0 ? ["verts"] : [],
1702
+ ...patch.condition !== void 0 ? ["condition"] : [],
1703
+ ...patch.role !== void 0 ? ["role"] : []
1704
+ ];
1705
+ return {
1706
+ shape_id: id,
1707
+ changed,
1708
+ measure_role: role,
1709
+ nverts: vertsPx.length,
1710
+ ...computed,
1711
+ agent_edits: this.shapes[i].origin?.agent_edits ?? 0
1712
+ };
1713
+ }
1714
+ /** Step back over this session's own last n mutations, newest first. Each
1715
+ * entry's inverse is exact (see JournalEntry), so this restores state rather
1716
+ * than approximating it. Reads are not journaled, so undo never has to step
1717
+ * over a look — n counts gestures that changed something. */
1718
+ undoLast(n) {
1719
+ const undone = [];
1720
+ for (let k = 0; k < n; k++) {
1721
+ const e = this.journal.pop();
1722
+ if (!e) break;
1723
+ if (e.op === "commit") {
1724
+ const dead = new Set(e.ids);
1725
+ this.shapes = this.shapes.filter((x) => !dead.has(x.id));
1726
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: e.ids.length });
1727
+ } else if (e.op === "edit") {
1728
+ const i = this.shapes.findIndex((x) => x.id === e.before.id);
1729
+ if (i >= 0) this.shapes[i] = e.before;
1730
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: i >= 0 ? 1 : 0 });
1731
+ } else {
1732
+ for (const { shape, index } of e.removed) {
1733
+ this.shapes.splice(Math.min(index, this.shapes.length), 0, shape);
1734
+ }
1735
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: e.removed.length });
1736
+ }
1737
+ }
1738
+ return {
1739
+ undone: undone.length,
1740
+ steps: undone,
1741
+ shape_count: this.shapes.length,
1742
+ remaining: this.journal.length,
1743
+ ...undone.length < n ? { note: `Only ${undone.length} step(s) were available to undo.` } : {}
1744
+ };
1745
+ }
1370
1746
  /** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
1371
1747
  * store.saveAnnotations stamps) — importable by the app. */
1372
1748
  exportPayload() {
@@ -1462,6 +1838,7 @@ var oneClickOutput = {
1462
1838
  var detectedRoom = z.object({
1463
1839
  label: z.string().describe('The room-number text the seed was read from (e.g. "104", "139A")'),
1464
1840
  nverts: z.number().int().describe("Vertex count of the traced polygon"),
1841
+ merged_labels: z.array(z.string()).optional().describe("Other labels that flooded to this same region \u2014 the area is counted once, under `label`"),
1465
1842
  hatch_filtered: z.literal(true).optional().describe("Present when hatch/pattern linework was classified out of the boundary"),
1466
1843
  verts: z.array(point).optional().describe("Traced polygon vertices (image px), when return_verts was set"),
1467
1844
  area_sf: z.number().optional().describe("Scaled mode: traced area in SF"),
@@ -1473,6 +1850,15 @@ var detectedRoom = z.object({
1473
1850
  var detectRoomsOutput = {
1474
1851
  detected: z.number().int().describe("Count of cleanly-detected rooms \u2014 may be fewer than the labels found on the sheet"),
1475
1852
  rooms: z.array(detectedRoom),
1853
+ withheld: z.object({
1854
+ total: z.number().int().describe("Seeds found on the sheet but not reported as rooms"),
1855
+ degenerate: z.number().int().describe("Traced to fewer than 3 vertices"),
1856
+ duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
1857
+ bubble: z.number().int().describe("Labels whose every clean flood was their own label BUBBLE (ring bbox \u2248 label bbox \u2014 plans box their room numbers). Scale-free, so it guards unscaled previews too"),
1858
+ implausible: z.number().int().describe("Enclosed, clean, non-bubble, but smaller than min_area_sf \u2014 a door swing or wall cavity rather than a room"),
1859
+ min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
1860
+ }).describe("What detection skipped and why \u2014 a withheld room is a question the caller can ask; a silently dropped one is a hole in a bid"),
1861
+ note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
1476
1862
  warning: z.string().optional().describe("Preview mode (no scale): why quantities are unavailable and what to do")
1477
1863
  };
1478
1864
  var measurePolygonOutput = {
@@ -1550,11 +1936,67 @@ var deleteShapeOutput = {
1550
1936
  deleted: z.string().describe("The removed shape's id"),
1551
1937
  shape_count: z.number().int().describe("Committed shapes remaining")
1552
1938
  };
1939
+ var editShapeOutput = {
1940
+ shape_id: z.string(),
1941
+ changed: z.array(z.enum(["verts", "condition", "role"])).describe("Which fields this call actually changed"),
1942
+ measure_role: z.enum(["floor_area", "deduct", "linear"]),
1943
+ nverts: z.number().int(),
1944
+ area_sf: z.number().describe("0 for linear shapes"),
1945
+ perimeter_lf: z.number().describe("Length for linear shapes, perimeter for closed ones"),
1946
+ agent_edits: z.number().int().describe("How many times the agent has revised this shape \u2014 separate from the human-correction tally")
1947
+ };
1948
+ var undoLastOutput = {
1949
+ undone: z.number().int().describe("Steps actually reversed"),
1950
+ steps: z.array(z.object({
1951
+ seq: z.number().int(),
1952
+ op: z.enum(["commit", "edit", "delete"]),
1953
+ tool: z.string().describe("The tool call this step came from"),
1954
+ shapes: z.number().int().describe("Shapes affected by reversing this step")
1955
+ })).describe("Newest first"),
1956
+ shape_count: z.number().int().describe("Committed shapes after the undo"),
1957
+ remaining: z.number().int().describe("Steps still available to undo"),
1958
+ note: z.string().optional()
1959
+ };
1553
1960
  var readSheetTextOutput = {
1554
1961
  sheet: z.string(),
1555
1962
  items: z.array(z.object({ str: z.string(), x: z.number(), y: z.number() })).describe("Positioned text items (image px)"),
1556
1963
  text: z.string().describe("The items joined with spaces")
1557
1964
  };
1965
+ var hatchFamilyRow = z.object({
1966
+ id: z.string().describe("Content hash of the quantized (angle, pitch, pen-width) signature \u2014 the SAME id for the same pattern spec anywhere on the sheet, so legend\u2194plan matching is id === id. Identifies a pattern, not a material; the legend maps pattern \u2192 material."),
1967
+ angle_deg: z.number().describe("Raw mean angle [0, 180) \u2014 rides beside the id for tolerance matching at bucket boundaries"),
1968
+ pitch_px: z.number().describe("Raw median row pitch, image px"),
1969
+ pen_w_px: z.number().int().describe("Modal device pen width of the members"),
1970
+ rows: z.number().int(),
1971
+ segments: z.number().int().describe("Member segments in the whole instance"),
1972
+ segments_in_region: z.number().int().describe("\u2026of which this many were returned in vectors (post-decimation)"),
1973
+ bbox: z.array(z.number()).length(4).describe("The instance's tight bbox [x0, y0, x1, y1], image px")
1974
+ });
1975
+ var sheetContextOutput = {
1976
+ sheet: z.string(),
1977
+ page: z.number().int(),
1978
+ sheet_px: z.array(z.number()).length(2),
1979
+ region: z.array(z.number()).length(4).describe("The region actually resolved, post-clamp \u2014 pass this same rect to view_sheet and the render is in the same frame by construction"),
1980
+ has_vector_linework: z.boolean().describe("false = a scan: vectors and hatch are empty because there are none, not because the region is blank"),
1981
+ vectors: z.object({
1982
+ segments: z.array(z.array(z.number()).length(4)).describe("[x0, y0, x1, y1] per segment, image px, endpoints exactly as drawn \u2014 clipped by KEEPING whole intersecting segments, never by rewriting them"),
1983
+ meta: z.array(z.number().int()).describe("One byte per segment, aligned with segments: bit 1 = curve chord, bit 2 = clip-only, bit 4 = filled-not-stroked; high nibble = device pen width"),
1984
+ family: z.array(z.string().nullable()).describe("Aligned with segments: the hatch-family id this segment belongs to, or null for structural linework"),
1985
+ kept: z.number().int(),
1986
+ total_in_region: z.number().int().describe("Segments intersecting the region before any decimation \u2014 kept + dropped always reconciles to this"),
1987
+ truncated: z.boolean(),
1988
+ dropped: z.object({
1989
+ short: z.number().int().describe("Below min_len_px (invisible ink)"),
1990
+ cap: z.number().int().describe("Over max_segments \u2014 the SHORTEST went first, so walls survive")
1991
+ }),
1992
+ note: z.string().optional()
1993
+ }),
1994
+ text: z.object({
1995
+ spans: z.array(z.object({ str: z.string(), x0: z.number(), y0: z.number(), x1: z.number(), y1: z.number() })).describe("Text with bboxes, image px, same frame as the vectors"),
1996
+ count: z.number().int()
1997
+ }),
1998
+ hatch: z.object({ families: z.array(hatchFamilyRow), count: z.number().int() })
1999
+ };
1558
2000
 
1559
2001
  // src/tools.ts
1560
2002
  var COORDS = "Coordinates are image px at render scale 2.0: PDF pt \xD7 2, origin top-left, y down (the browser canvas's native space). Sheet payloads carry dims in both px and pt.";
@@ -1609,20 +2051,23 @@ function registerTools(server, session) {
1609
2051
  y: z2.number(),
1610
2052
  condition: z2.string().optional().describe("Finish tag to commit under (minted on first use)"),
1611
2053
  role: roleSchema,
1612
- return_verts: z2.boolean().default(false).describe("Include the traced polygon's vertices (image px)")
2054
+ return_verts: z2.boolean().default(false).describe("Include the traced polygon's vertices (image px)"),
2055
+ sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing")
1613
2056
  },
1614
2057
  outputSchema: oneClickOutput
1615
- }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
2058
+ }, run("one_click", (a) => session.oneClick(a.sheet, a.x, a.y, { condition: a.condition, role: a.role, returnVerts: a.return_verts, sensitivity: a.sensitivity })));
1616
2059
  server.registerTool("detect_rooms", {
1617
- description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. Only cleanly-traced rooms are returned; a label that leaked or landed in dense linework is silently withheld, never reported as a bad room. With the sheet's scale set, returns area_sf/perimeter_lf per room; pass condition to commit every detected room under that finish tag (role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing. ${COORDS}`,
2060
+ description: `Batch room detection: reads every room-number label off the sheet's text layer (e.g. "134", "OFFICE 101") and runs One-Click at each \u2014 one call instead of read_sheet_text + reasoning + N one_click calls. A seed is only reported as a room once it survives three gates, and everything skipped is counted and reasoned in \`withheld\` \u2014 never dropped silently, because a room the tool tells you it skipped is a question you can ask, while one it hides is a hole in a bid. The gates: a flood that leaked or landed in dense linework never becomes a region; two labels flooding the SAME region commit once (the extra labels ride on \`merged_labels\` \u2014 double-counting an area is the worst failure an estimating tool has); and a flood that is enclosed and clean but smaller than min_area_sf is a room-number bubble, a door swing, or a wall cavity rather than a room. With the sheet's scale set, returns area_sf/perimeter_lf per room; pass condition to commit every detected room under that finish tag (role "deduct" makes them subtract). Without a scale, returns px-only quantities per room and commits nothing \u2014 the plausibility floor needs real units, so it only applies once a scale is set. ${COORDS}`,
1618
2061
  inputSchema: {
1619
2062
  sheet: z2.string(),
1620
2063
  condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
1621
2064
  role: roleSchema,
1622
- return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)")
2065
+ return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
2066
+ min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed non-bubble regions smaller than this are withheld as cavities, not rooms. Default 5 SF \u2014 below any real finished space (a broom closet is ~10 SF). Lower it to inspect what was skipped."),
2067
+ sensitivity: z2.number().min(0).max(1).optional().describe("Fill sensitivity, the same knob the canvas has: 0 strict (hatch/light linework always blocks), 0.5 balanced (default), 1 aggressive (crosses more hatch, tolerates more growth). Raise it when a flood stops short at hatching INSIDE the room; verify the grown ring with view_sheet overlay before committing")
1623
2068
  },
1624
2069
  outputSchema: detectRoomsOutput
1625
- }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts })));
2070
+ }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf, sensitivity: a.sensitivity })));
1626
2071
  server.registerTool("measure_polygon", {
1627
2072
  description: `Measure a closed polygon you supply (min 3 vertices, image px): area_sf and perimeter_lf at the sheet's scale. Requires the scale to be set. Pass condition to commit it; role "deduct" subtracts. ${COORDS}`,
1628
2073
  inputSchema: {
@@ -1664,6 +2109,33 @@ function registerTools(server, session) {
1664
2109
  inputSchema: { shape_id: z2.string() },
1665
2110
  outputSchema: deleteShapeOutput
1666
2111
  }, run("delete_shape", ({ shape_id }) => session.deleteShape(shape_id)));
2112
+ server.registerTool("sheet_context", {
2113
+ description: `The sheet's STRUCTURE in one call and one frame: the classified vector segments, the positioned text spans, and the hatch-family instances of a region \u2014 everything the engine itself floods against, exposed as data instead of pixels. Use it when you need to REASON about a region rather than look at it: which lines bound this space and at what pen weight, what the region says, and which periodic fill pattern covers it. The join is the point \u2014 all three arrive in image px with no reconciliation left to do, and the reply echoes the post-clamp region so passing that same rect to view_sheet gives you the matching render by construction. Hatch families carry a content-derived id (same pattern spec \u21D2 same id, anywhere on the sheet), so matching a plan region to a legend swatch is comparing two ids, not guessing from a render \u2014 read the legend region, read the room region, match ids, and cite both bboxes as evidence. Decimation is declared, ordered, and counted on every reply: segments shorter than min_len_px drop first (invisible ink), then a max_segments cap applies LONGEST-FIRST so walls survive and hatch strokes go; kept + dropped always reconciles to total_in_region, and whole segments drop with their meta intact \u2014 nothing is ever simplified or merged, because these are classified segments and a merge would rewrite the classification. A scan returns has_vector_linework: false with empty vectors \u2014 absence of linework, never a claim the region is blank. ${COORDS}`,
2114
+ inputSchema: {
2115
+ sheet: z2.string(),
2116
+ 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"),
2117
+ min_len_px: z2.number().min(0).default(CONTEXT_MIN_LEN_PX).describe(`Drop segments shorter than this (default ${CONTEXT_MIN_LEN_PX} \u2014 one PDF point at render scale 2.0, below any pen width). 0 keeps everything`),
2118
+ max_segments: z2.number().int().min(1).max(CONTEXT_MAX_SEGMENTS_CEIL).default(CONTEXT_MAX_SEGMENTS).describe(`Segment cap, applied longest-first (default ${CONTEXT_MAX_SEGMENTS}). The reply's dropped.cap says exactly what a smaller region would recover`)
2119
+ },
2120
+ outputSchema: sheetContextOutput
2121
+ }, run("sheet_context", (a) => session.sheetContext(a.sheet, { region: a.region, min_len_px: a.min_len_px, max_segments: a.max_segments })));
2122
+ server.registerTool("edit_shape", {
2123
+ description: `REVISE a shape you already committed, instead of deleting it and starting over: pass new verts to move the geometry, condition to reassign it to a different finish tag, role to switch between floor_area / deduct / linear, or any combination. Quantities are recomputed from the result \u2014 a role flip alone re-measures (closed area vs open length). The loop this is for: one_click or measure_polygon to commit, view_sheet with overlay:true to LOOK at what landed, then edit_shape to fix the two vertices that overshot into the corridor. Shapes a human affirmed (origin.reviewed) are ink and are refused \u2014 an agent revises its own pencil and nothing else. Agent self-revision is tallied on origin.agent_edits, kept deliberately separate from the human-correction fields. ${COORDS}`,
2124
+ inputSchema: {
2125
+ shape_id: z2.string().describe("Id returned when the shape was committed"),
2126
+ verts: z2.array(pointSchema).optional().describe("Replacement geometry (image px): \u22653 vertices for an area shape, \u22652 points for a linear one"),
2127
+ condition: z2.string().optional().describe("Reassign to this finish tag (minted on first use)"),
2128
+ role: z2.enum(["floor_area", "deduct", "linear"]).optional().describe("Switch what the shape measures")
2129
+ },
2130
+ outputSchema: editShapeOutput
2131
+ }, run("edit_shape", (a) => session.editShape(a.shape_id, { verts: a.verts, condition: a.condition, role: a.role })));
2132
+ 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.`,
2134
+ inputSchema: {
2135
+ n: z2.number().int().min(1).max(UNDO_CAP).default(1).describe(`How many steps to reverse (1\u2013${UNDO_CAP})`)
2136
+ },
2137
+ outputSchema: undoLastOutput
2138
+ }, run("undo_last", ({ n }) => session.undoLast(n)));
1667
2139
  server.registerTool("read_sheet_text", {
1668
2140
  description: `The sheet's text with positions \u2014 items [{str, x, y}] in image px plus the joined text. Optionally restrict to a region {x0, y0, x1, y1}. Use it to read title blocks, room labels, finish schedules, and scale notes. ${COORDS}`,
1669
2141
  inputSchema: {
@@ -1774,7 +2246,7 @@ function registerResources(server, session) {
1774
2246
  // package.json
1775
2247
  var package_default = {
1776
2248
  name: "opentakeoff-mcp",
1777
- version: "0.6.0",
2249
+ version: "0.7.0",
1778
2250
  mcpName: "io.github.Kentucky-ai/opentakeoff",
1779
2251
  type: "module",
1780
2252
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -1790,7 +2262,7 @@ var package_default = {
1790
2262
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
1791
2263
  prepublishOnly: "npm run typecheck && npm test && npm run build",
1792
2264
  typecheck: "tsc --noEmit",
1793
- test: "node --import tsx --test test/conformance.test.ts test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
2265
+ test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
1794
2266
  },
1795
2267
  dependencies: {
1796
2268
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -1801,7 +2273,7 @@ var package_default = {
1801
2273
  "@types/node": "^22.19.21",
1802
2274
  typescript: "^5.7.2",
1803
2275
  tsx: "^4.19.2",
1804
- esbuild: "^0.24.2"
2276
+ esbuild: "^0.25.0"
1805
2277
  },
1806
2278
  bin: {
1807
2279
  "opentakeoff-mcp": "dist/server.js"
@@ -1826,7 +2298,11 @@ var package_default = {
1826
2298
  "pdf",
1827
2299
  "quantity-takeoff",
1828
2300
  "flooring"
1829
- ]
2301
+ ],
2302
+ overrides: {
2303
+ "fast-uri": "^3.1.4",
2304
+ "@hono/node-server": "^2.0.5"
2305
+ }
1830
2306
  };
1831
2307
 
1832
2308
  // server.ts
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
- "description": "OpenTakeoff MCP server drive the takeoff engine from your MCP client over stdio.",
6
+ "description": "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
7
7
  "license": "Apache-2.0",
8
8
  "engines": {
9
9
  "node": ">=20"
@@ -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/e2e.test.ts test/resources.test.ts test/session.test.ts test/tools.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/resources.test.ts test/session.test.ts test/tools.test.ts test/view.test.ts"
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -27,7 +27,7 @@
27
27
  "@types/node": "^22.19.21",
28
28
  "typescript": "^5.7.2",
29
29
  "tsx": "^4.19.2",
30
- "esbuild": "^0.24.2"
30
+ "esbuild": "^0.25.0"
31
31
  },
32
32
  "bin": {
33
33
  "opentakeoff-mcp": "dist/server.js"
@@ -52,5 +52,9 @@
52
52
  "pdf",
53
53
  "quantity-takeoff",
54
54
  "flooring"
55
- ]
56
- }
55
+ ],
56
+ "overrides": {
57
+ "fast-uri": "^3.1.4",
58
+ "@hono/node-server": "^2.0.5"
59
+ }
60
+ }