opentakeoff-mcp 0.6.1 → 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
@@ -1306,11 +1497,16 @@ var Session = class {
1306
1497
  * ring. Committing both double-counts the area with no signal, which
1307
1498
  * is the worst failure mode an estimating tool has. One region commits
1308
1499
  * once; the collapsed labels ride along on `merged_labels`.
1309
- * 3. implausiblea flood trapped inside a room-number bubble, a door
1310
- * swing, or a wall cavity is fully enclosed, so it traces clean and
1311
- * `detectRegions` passes it. Area is the only thing that separates it
1312
- * from a room. Withheld below `minAreaSf` (default 5 SF smaller than
1313
- * any real finished space; a broom closet is ~10 SF). Only applied
1500
+ * 3. bubbleplans 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
1314
1510
  * once a scale exists, since without one there is no real area to
1315
1511
  * judge and nothing commits anyway. */
1316
1512
  async detectRooms(name, opts) {
@@ -1318,31 +1514,54 @@ var Session = class {
1318
1514
  const mask = await this.ensureMask(name);
1319
1515
  if (!mask) throw new UserError("This sheet has no vector linework (likely a scan); raster fallback not yet available in the MCP server.");
1320
1516
  const minAreaSf = opts.minAreaSf ?? 5;
1321
- const seeds = roomLabelSeeds(s.text);
1322
- const regions = detectRegions(mask, seeds);
1323
- const withheld = { degenerate: 0, duplicate: 0, implausible: 0 };
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 };
1324
1524
  const byRing = /* @__PURE__ */ new Map();
1325
1525
  const order = [];
1326
- for (const r of regions) {
1327
- const ring = snapVertices(traceRegion(r.flood), (px, py, d) => s.snap ? nearestSnap(s.snap, px, py, d) : null, SNAP_TOL);
1328
- if (ring.length < 3) {
1329
- withheld.degenerate++;
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++;
1330
1549
  continue;
1331
1550
  }
1332
1551
  const key = ring.map(([x, y]) => `${Math.round(x)},${Math.round(y)}`).join(";");
1333
1552
  const seen = byRing.get(key);
1334
1553
  if (seen) {
1335
- seen.merged.push(r.str);
1554
+ seen.merged.push(lb.str);
1336
1555
  withheld.duplicate++;
1337
1556
  continue;
1338
1557
  }
1339
1558
  const cand = {
1340
- label: r.str,
1559
+ label: lb.str,
1341
1560
  ring,
1342
1561
  areaPx2: ringArea(ring),
1343
1562
  perimPx: closedMetrics(ring).perim,
1344
- seed: r.seed,
1345
- hatch: !!r.flood.hatchFiltered,
1563
+ seed,
1564
+ hatch,
1346
1565
  merged: []
1347
1566
  };
1348
1567
  byRing.set(key, cand);
@@ -1378,7 +1597,8 @@ var Session = class {
1378
1597
  }
1379
1598
  return { ...common, area_sf, perimeter_lf, ...shape_id ? { shape_id } : {} };
1380
1599
  }).filter((r) => r !== null);
1381
- const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.implausible;
1600
+ this.flushCommits("detect_rooms");
1601
+ const withheldTotal = withheld.degenerate + withheld.duplicate + withheld.bubble + withheld.implausible;
1382
1602
  return {
1383
1603
  detected: rooms.length,
1384
1604
  rooms,
@@ -1387,7 +1607,7 @@ var Session = class {
1387
1607
  ...withheld,
1388
1608
  ...upp != null ? { min_area_sf: minAreaSf } : {}
1389
1609
  },
1390
- ...withheldTotal ? { note: `${withheldTotal} seed(s) withheld \u2014 ${withheld.duplicate} duplicate region(s), ${withheld.implausible} under ${minAreaSf} SF, ${withheld.degenerate} untraceable. Raise or lower min_area_sf to see more.` } : {},
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.` } : {},
1391
1611
  ...s.upp == null ? { warning: `No scale set for ${s.key} \u2014 quantities unavailable. Call set_scale${s.detected ? ` (detected: ${s.detected.label})` : ""}.` } : {}
1392
1612
  };
1393
1613
  }
@@ -1399,6 +1619,7 @@ var Session = class {
1399
1619
  const perimeter_lf = round2(met.perim * s.upp);
1400
1620
  let shape_id;
1401
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");
1402
1623
  return { area_sf, perimeter_lf, nverts: verts.length, ...shape_id ? { shape_id } : {} };
1403
1624
  }
1404
1625
  measureLine(name, pts, opts) {
@@ -1407,6 +1628,7 @@ var Session = class {
1407
1628
  const length_lf = round2(openLen(pts) * s.upp);
1408
1629
  let shape_id;
1409
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");
1410
1632
  return { length_lf, npts: pts.length, ...shape_id ? { shape_id } : {} };
1411
1633
  }
1412
1634
  summary() {
@@ -1417,9 +1639,110 @@ var Session = class {
1417
1639
  deleteShape(id) {
1418
1640
  const i = this.shapes.findIndex((x) => x.id === id);
1419
1641
  if (i < 0) throw new UserError(`No shape with id ${JSON.stringify(id)}.`);
1420
- 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 }] });
1421
1644
  return { deleted: id, shape_count: this.shapes.length };
1422
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
+ }
1423
1746
  /** The exact browser save payload (TakeoffCanvas.jsx autosave + the schema key
1424
1747
  * store.saveAnnotations stamps) — importable by the app. */
1425
1748
  exportPayload() {
@@ -1531,7 +1854,8 @@ var detectRoomsOutput = {
1531
1854
  total: z.number().int().describe("Seeds found on the sheet but not reported as rooms"),
1532
1855
  degenerate: z.number().int().describe("Traced to fewer than 3 vertices"),
1533
1856
  duplicate: z.number().int().describe("Flooded to a region another label already claimed \u2014 counted once, never twice"),
1534
- implausible: z.number().int().describe("Enclosed and clean, but smaller than min_area_sf \u2014 a label bubble, door swing, or wall cavity rather than a room"),
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"),
1535
1859
  min_area_sf: z.number().optional().describe("The plausibility floor applied (scaled mode only)")
1536
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"),
1537
1861
  note: z.string().optional().describe("Human-readable summary of what was withheld, when anything was"),
@@ -1612,11 +1936,67 @@ var deleteShapeOutput = {
1612
1936
  deleted: z.string().describe("The removed shape's id"),
1613
1937
  shape_count: z.number().int().describe("Committed shapes remaining")
1614
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
+ };
1615
1960
  var readSheetTextOutput = {
1616
1961
  sheet: z.string(),
1617
1962
  items: z.array(z.object({ str: z.string(), x: z.number(), y: z.number() })).describe("Positioned text items (image px)"),
1618
1963
  text: z.string().describe("The items joined with spaces")
1619
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
+ };
1620
2000
 
1621
2001
  // src/tools.ts
1622
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.";
@@ -1671,10 +2051,11 @@ function registerTools(server, session) {
1671
2051
  y: z2.number(),
1672
2052
  condition: z2.string().optional().describe("Finish tag to commit under (minted on first use)"),
1673
2053
  role: roleSchema,
1674
- 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")
1675
2056
  },
1676
2057
  outputSchema: oneClickOutput
1677
- }, 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 })));
1678
2059
  server.registerTool("detect_rooms", {
1679
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}`,
1680
2061
  inputSchema: {
@@ -1682,10 +2063,11 @@ function registerTools(server, session) {
1682
2063
  condition: z2.string().optional().describe("Finish tag to commit every detected room under (minted on first use)"),
1683
2064
  role: roleSchema,
1684
2065
  return_verts: z2.boolean().default(false).describe("Include each traced polygon's vertices (image px)"),
1685
- min_area_sf: z2.number().positive().default(5).describe("Plausibility floor: enclosed regions smaller than this are withheld as label bubbles/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.")
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")
1686
2068
  },
1687
2069
  outputSchema: detectRoomsOutput
1688
- }, run("detect_rooms", (a) => session.detectRooms(a.sheet, { condition: a.condition, role: a.role, returnVerts: a.return_verts, minAreaSf: a.min_area_sf })));
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 })));
1689
2071
  server.registerTool("measure_polygon", {
1690
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}`,
1691
2073
  inputSchema: {
@@ -1727,6 +2109,33 @@ function registerTools(server, session) {
1727
2109
  inputSchema: { shape_id: z2.string() },
1728
2110
  outputSchema: deleteShapeOutput
1729
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)));
1730
2139
  server.registerTool("read_sheet_text", {
1731
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}`,
1732
2141
  inputSchema: {
@@ -1837,7 +2246,7 @@ function registerResources(server, session) {
1837
2246
  // package.json
1838
2247
  var package_default = {
1839
2248
  name: "opentakeoff-mcp",
1840
- version: "0.6.1",
2249
+ version: "0.7.0",
1841
2250
  mcpName: "io.github.Kentucky-ai/opentakeoff",
1842
2251
  type: "module",
1843
2252
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -1853,7 +2262,7 @@ var package_default = {
1853
2262
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
1854
2263
  prepublishOnly: "npm run typecheck && npm test && npm run build",
1855
2264
  typecheck: "tsc --noEmit",
1856
- 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"
1857
2266
  },
1858
2267
  dependencies: {
1859
2268
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -1864,7 +2273,7 @@ var package_default = {
1864
2273
  "@types/node": "^22.19.21",
1865
2274
  typescript: "^5.7.2",
1866
2275
  tsx: "^4.19.2",
1867
- esbuild: "^0.24.2"
2276
+ esbuild: "^0.25.0"
1868
2277
  },
1869
2278
  bin: {
1870
2279
  "opentakeoff-mcp": "dist/server.js"
@@ -1889,7 +2298,11 @@ var package_default = {
1889
2298
  "pdf",
1890
2299
  "quantity-takeoff",
1891
2300
  "flooring"
1892
- ]
2301
+ ],
2302
+ overrides: {
2303
+ "fast-uri": "^3.1.4",
2304
+ "@hono/node-server": "^2.0.5"
2305
+ }
1893
2306
  };
1894
2307
 
1895
2308
  // server.ts
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.6.1",
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
+ }