opentakeoff-mcp 0.9.76 → 0.9.77

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
@@ -194,7 +194,7 @@ reads the tool list once. ([#230](https://github.com/Kentucky-ai/opentakeoff/iss
194
194
  | `find_text` | **Locate** a known string—the complement to `read_sheet_text` (which returns what a region *says*; this finds *where* a string sits). Case-insensitive substring match per pdf.js text run; each hit's center feeds straight into `one_click`'s seed. |
195
195
  | `sheet_graph` | The plan-set INDEX (#87): every sheet's role with evidence, the schedule tables found, every room tag with its stacked name, the detail callouts, and every revision marker (text `Δ2`/`REV 2` tags AND drawn deltas—a bare digit inside a triangle of linework, proven from the sheet's vector geometry—in `revisions`)—how an agent decides WHAT to measure without a human enumerating rooms. |
196
196
  | `resolve_tag` | ONE room tag → its room-finish schedule row → each code's finish/material definition, every edge cited (sheet + literal text + bbox). Refusal over guessing: `unresolved` comes back with a reason, never as silence. A delta/REV marker on the answering row rides the result as `revisions`—the codes are the post-revision answer, and you're told the ink changed. |
197
- | `find_schedule` | Locate a schedule table by kind ("room finish", "material")—sheet, title, headers, row count, a `view_sheet`-ready region, and `revised_rows` when delta/REV-marked rows exist. |
197
+ | `find_schedule` | Locate a schedule table by kind ("room finish", "material", or "equipment"—device schedules of any trade: fans, pumps, heaters, light fixtures, plumbing fixtures, diffusers, keyed by mark and proven by a device column such as CFM, WATTS, GPM, LAMPS, NECK)—sheet, title, headers, row count, a `view_sheet`-ready region, and `revised_rows` when delta/REV-marked rows exist. Every stacked schedule on a sheet is read, top to bottom. |
198
198
  | `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. |
199
199
  | `get_sheet_vectors` | The STROKES (#367): the sheet's vector layer exactly as the engine is fed it—flat `[x1, y1, x2, y2, …]` points in image px, one meta byte per segment (curve / clip / fill-only / polyline-arc flags, pen width in the high nibble), per-segment stroke luminance, the drawn figure each segment belongs to, the placed-image area, and the PDF layer table with a per-segment layer index. Nothing classified, decimated, or merged—so a reader can run its own room finder, symbol matcher, or wall classifier against the same array and commit through the existing verbs. Paged (default 20,000 segments, ceiling 100,000) with `offset + returned + dropped === total` on every page; `next_cursor` recovers exactly what `dropped` counts. Read-only and stateless. Refuses on a scan and names `view_sheet` as the path. |
200
200
  | `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; `marks` (#297) burns disclosure layers in — `question` (withheld placements, orange ?-circle), `struck` (rejections, magenta struck ×), `ring` (the sweep's seed, violet double ring) — in colors off the common CAD pens, so what a reply names, the picture shows. |
@@ -3257,6 +3257,12 @@ function isLabelBubblePx(ring, b) {
3257
3257
  // ../web/src/lib/sheetgraph.ts
3258
3258
  var bboxOf = (s) => [s.x, s.y, s.x + (s.w || 0), s.y + (s.h || 0)];
3259
3259
  var merge = (a, b) => [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
3260
+ var overlapFrac = (a, b) => {
3261
+ const w = Math.min(a[2], b[2]) - Math.max(a[0], b[0]), h = Math.min(a[3], b[3]) - Math.max(a[1], b[1]);
3262
+ if (w <= 0 || h <= 0) return 0;
3263
+ const area = Math.max(1, (a[2] - a[0]) * (a[3] - a[1]));
3264
+ return w * h / area;
3265
+ };
3260
3266
  var norm = (s) => (s || "").trim().toUpperCase();
3261
3267
  var isVertical = (s) => s.rot != null ? Math.abs(s.rot % 180) === 90 : (s.str || "").trim().length >= 4 && (s.w || 0) > 0 && (s.h || 0) > 2 * s.w;
3262
3268
  var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.'’-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.'’-]+)?( *\(?(?:CONTINUATION|CONTINUED|CONT['’]?D?)\.?\)?)?$/;
@@ -3406,6 +3412,159 @@ var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
3406
3412
  var SURFACE_WORDS = /* @__PURE__ */ new Set(["FLOOR", "BASE", "WALL", "WALLS", "CEILING", "NORTH", "SOUTH", "EAST", "WEST", "WAINSCOT"]);
3407
3413
  var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "MARK", "LOCATION", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "HEIGHT", "FINISH", "MAT", "MATERIAL", "COMMENTS", "CASEWORK", "CABINET", "COUNTER", "COUNTERTOP", "BLDG", "BUILDING"];
3408
3414
  var FINISH_HEADERS = ["CODE", "MARK", "SYMBOL", "TAG", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN", "COMMENTS"];
3415
+ var EQUIPMENT_HEADERS = [
3416
+ // identity + the shared columns
3417
+ "ID",
3418
+ "MARK",
3419
+ "TAG",
3420
+ "SYMBOL",
3421
+ "UNIT",
3422
+ "DESIGNATION",
3423
+ "TYPE",
3424
+ "FIXTURE",
3425
+ "DESCRIPTION",
3426
+ "MANUFACTURER",
3427
+ "MFR",
3428
+ "MODEL",
3429
+ "CATALOG",
3430
+ "SERVICE",
3431
+ "SERVES",
3432
+ "LOCATION",
3433
+ "AREA",
3434
+ "SIZE",
3435
+ "LENGTH",
3436
+ "WEIGHT",
3437
+ "QTY",
3438
+ "QUANTITY",
3439
+ "REMARKS",
3440
+ "NOTES",
3441
+ "COMMENTS",
3442
+ // mechanical
3443
+ "CFM",
3444
+ "GPM",
3445
+ "WATTS",
3446
+ "KW",
3447
+ "VOLTS",
3448
+ "VOLTAGE",
3449
+ "PHASE",
3450
+ "PH",
3451
+ "HZ",
3452
+ "HP",
3453
+ "MBH",
3454
+ "BTUH",
3455
+ "BTU",
3456
+ "TONS",
3457
+ "RPM",
3458
+ "ESP",
3459
+ "SP",
3460
+ "FLA",
3461
+ "MCA",
3462
+ "MOCP",
3463
+ "AMPS",
3464
+ "EAT",
3465
+ "LAT",
3466
+ "EWT",
3467
+ "LWT",
3468
+ "PSI",
3469
+ "FPM",
3470
+ "NECK",
3471
+ "THROW",
3472
+ "MOUNTING",
3473
+ "DAMPER",
3474
+ "FRAME",
3475
+ "AIRFLOW",
3476
+ // electrical
3477
+ "LAMP",
3478
+ "LAMPS",
3479
+ "LUMENS",
3480
+ "BALLAST",
3481
+ "DRIVER",
3482
+ "CIRCUIT",
3483
+ "BREAKER",
3484
+ "POLES",
3485
+ "KVA",
3486
+ "AIC",
3487
+ "WIRE",
3488
+ "CONDUIT",
3489
+ "FEEDER",
3490
+ "LOAD",
3491
+ "KVAR",
3492
+ // plumbing / fire
3493
+ "CW",
3494
+ "HW",
3495
+ "WASTE",
3496
+ "VENT",
3497
+ "TRAP",
3498
+ "DFU",
3499
+ "WSFU",
3500
+ "CONNECTION",
3501
+ "SUPPLY",
3502
+ "DRAIN",
3503
+ "SPRINKLER",
3504
+ "ORIFICE",
3505
+ "TEMPERATURE"
3506
+ ];
3507
+ var EQUIPMENT_KEY_HEADERS = ["ID", "MARK", "TAG", "SYMBOL", "UNIT", "DESIGNATION", "TYPE", "FIXTURE"];
3508
+ var EQUIPMENT_ONLY = /* @__PURE__ */ new Set([
3509
+ "CFM",
3510
+ "GPM",
3511
+ "WATTS",
3512
+ "KW",
3513
+ "VOLTS",
3514
+ "VOLTAGE",
3515
+ "PHASE",
3516
+ "PH",
3517
+ "HZ",
3518
+ "HP",
3519
+ "MBH",
3520
+ "BTUH",
3521
+ "BTU",
3522
+ "TONS",
3523
+ "RPM",
3524
+ "ESP",
3525
+ "SP",
3526
+ "FLA",
3527
+ "MCA",
3528
+ "MOCP",
3529
+ "AMPS",
3530
+ "EAT",
3531
+ "LAT",
3532
+ "EWT",
3533
+ "LWT",
3534
+ "PSI",
3535
+ "FPM",
3536
+ "NECK",
3537
+ "THROW",
3538
+ "MOUNTING",
3539
+ "DAMPER",
3540
+ "AIRFLOW",
3541
+ "LAMP",
3542
+ "LAMPS",
3543
+ "LUMENS",
3544
+ "BALLAST",
3545
+ "DRIVER",
3546
+ "CIRCUIT",
3547
+ "BREAKER",
3548
+ "POLES",
3549
+ "KVA",
3550
+ "AIC",
3551
+ "WIRE",
3552
+ "CONDUIT",
3553
+ "FEEDER",
3554
+ "KVAR",
3555
+ "CW",
3556
+ "HW",
3557
+ "WASTE",
3558
+ "VENT",
3559
+ "TRAP",
3560
+ "DFU",
3561
+ "WSFU",
3562
+ "DRAIN",
3563
+ "SPRINKLER",
3564
+ "ORIFICE"
3565
+ ]);
3566
+ var EQUIP_KEY_RE = /^[A-Z]{1,4}(?:-\d{1,3}|\d{1,2})[A-Z]?$/;
3567
+ var TYPE_KEY_RE = /^[A-Z]{1,2}\d{0,2}[A-Z]?$/;
3409
3568
  var headerLabel = (s, vocab) => headerLabels(s, vocab)[0] ?? null;
3410
3569
  var headerLabels = (s, vocab) => {
3411
3570
  const out = [];
@@ -3623,9 +3782,15 @@ var isNonFinishSchedule = (title) => {
3623
3782
  const u = norm(title);
3624
3783
  return OTHER_FAMILY_RE.test(u) && !/\b(FINISH|MATERIAL)S?\b/.test(u);
3625
3784
  };
3626
- function rowKeyOf(raw, kind, buildings) {
3785
+ function rowKeyOf(raw, kind, buildings, typeKeyed = false) {
3627
3786
  const kept = norm(raw).replace(/[^A-Z0-9/-]/g, "");
3628
3787
  const key = kept.replace(/\//g, "");
3788
+ if (kind === "equipment") {
3789
+ const parts = kept.split("/").filter(Boolean);
3790
+ const ok2 = (p) => EQUIP_KEY_RE.test(p) || typeKeyed && TYPE_KEY_RE.test(p);
3791
+ if (parts.length > 1 && parts.every(ok2)) return { key: parts.join("/") };
3792
+ return ok2(key) ? { key } : null;
3793
+ }
3629
3794
  if (kind === "finish") {
3630
3795
  const parts = kept.split("/").filter(Boolean);
3631
3796
  if (parts.length > 1 && parts.every((p) => CODE_RE.test(p))) return { key: parts.join("/") };
@@ -3722,6 +3887,7 @@ function columnStarts(rows, anchors, cfg, x0, x1) {
3722
3887
  }
3723
3888
  function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
3724
3889
  const { x0, x1, medGap } = bandLimits(anchors);
3890
+ const typeKeyed = kind === "equipment" && (anchors[0]?.label === "TYPE" || anchors[0]?.label === "FIXTURE");
3725
3891
  const cols = columnStarts(rows, anchors, cfg, x0, x1);
3726
3892
  const keyTol = cols && cols.cols.length > 1 ? Math.max(8, (cols.cols[1].start - cols.cols[0].start) * 0.5) : 40;
3727
3893
  const out = [];
@@ -3774,11 +3940,20 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
3774
3940
  if (t.x >= x0 && t.x <= x1) banded.push(t);
3775
3941
  }
3776
3942
  if (!banded.length) continue;
3777
- const keyed = rowKeyOf(banded[0].str, kind, buildings);
3943
+ if (kind === "equipment" && out.length) {
3944
+ if (rows[i].some((t) => /SCHEDULE/.test(norm(t.str)) && !EQUIP_KEY_RE.test(norm(t.str).replace(/[^A-Z0-9-]/g, "")))) break;
3945
+ const hh = headerHits(rows[i], EQUIPMENT_HEADERS);
3946
+ if (hh.length >= 3 && hh.some((h) => EQUIPMENT_KEY_HEADERS.includes(h.label))) break;
3947
+ }
3948
+ const keyed = rowKeyOf(banded[0].str, kind, buildings, typeKeyed);
3778
3949
  if (!keyed) {
3779
3950
  orphans.push({ toks: banded, y: rowY(rows[i]) });
3780
3951
  continue;
3781
3952
  }
3953
+ if (/\d{3}/.test(keyed.key) && cfg.sheetNumbers?.has(keyed.key.replace(/[^A-Z0-9]/g, ""))) {
3954
+ orphans.push({ toks: banded, y: rowY(rows[i]) });
3955
+ continue;
3956
+ }
3782
3957
  if (cols && Math.abs((cols.coord === "left" ? banded[0].x : centerX(banded[0])) - cols.cols[0].start) > keyTol) continue;
3783
3958
  if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
3784
3959
  const row = { key: keyed.key, sheet: sheetKey, cells: {} };
@@ -3835,11 +4010,35 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
3835
4010
  return { out, region };
3836
4011
  }
3837
4012
  function extractTable(sheet, kind, opts = {}) {
4013
+ const r = extractTableCore(sheet, kind, opts);
4014
+ return r && "table" in r ? r.table : null;
4015
+ }
4016
+ function extractTables(sheet, kind, opts = {}) {
4017
+ const out = [];
4018
+ let spans = sheet.spans;
4019
+ for (let guard = 0; guard < 12 && spans.length; guard++) {
4020
+ const r = extractTableCore({ ...sheet, spans }, kind, opts);
4021
+ if (!r) break;
4022
+ const mask = "table" in r ? r.table.region : r.skip;
4023
+ if ("table" in r) {
4024
+ out.push(r.table);
4025
+ if (r.table.title) mask[1] = Math.min(mask[1], r.table.title.bbox[1]);
4026
+ }
4027
+ const before = spans.length;
4028
+ spans = spans.filter((t) => {
4029
+ const cx = t.x + (t.w || 0) / 2, cy = t.y + (t.h || 0) / 2;
4030
+ return !(cx >= mask[0] - 1 && cx <= mask[2] + 1 && cy >= mask[1] - 1 && cy <= mask[3] + 1);
4031
+ });
4032
+ if (spans.length === before) break;
4033
+ }
4034
+ return out;
4035
+ }
4036
+ function extractTableCore(sheet, kind, opts = {}) {
3838
4037
  const horiz = sheet.spans.filter((s) => !isVertical(s));
3839
4038
  const vert = sheet.spans.filter(isVertical);
3840
4039
  const rows = clusterRows(horiz);
3841
- const vocab = kind === "room-finish" ? ROOM_HEADERS : FINISH_HEADERS;
3842
- const required = kind === "room-finish" ? ["FLOOR", "BASE"] : ["CODE", "MARK", "SYMBOL", "TAG"];
4040
+ const vocab = kind === "room-finish" ? ROOM_HEADERS : kind === "equipment" ? EQUIPMENT_HEADERS : FINISH_HEADERS;
4041
+ const required = kind === "room-finish" ? ["FLOOR", "BASE"] : kind === "equipment" ? EQUIPMENT_KEY_HEADERS : ["CODE", "MARK", "SYMBOL", "TAG"];
3843
4042
  const minHits = kind === "room-finish" ? 4 : 3;
3844
4043
  let anchors;
3845
4044
  let headerSpans;
@@ -3864,16 +4063,24 @@ function extractTable(sheet, kind, opts = {}) {
3864
4063
  titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
3865
4064
  if (titleFrom < -1) titleFrom = rows.length - 1;
3866
4065
  }
4066
+ if (kind === "equipment" && !anchors.some((a) => EQUIPMENT_ONLY.has(a.label))) {
4067
+ let hb = null;
4068
+ for (const t of headerSpans) hb = hb ? merge(hb, bboxOf(t)) : bboxOf(t);
4069
+ return hb ? { skip: hb } : null;
4070
+ }
3867
4071
  const hdrBand = bandLimits(anchors);
3868
4072
  let region = null;
3869
4073
  for (const t of headerSpans) {
3870
4074
  if (centerX(t) < hdrBand.x0 || centerX(t) > hdrBand.x1) continue;
3871
4075
  region = region ? merge(region, bboxOf(t)) : bboxOf(t);
3872
4076
  }
3873
- const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas });
4077
+ const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas, sheetNumbers: opts.sheetNumbers });
3874
4078
  const out = banded.out;
3875
4079
  if (banded.region) region = region ? merge(region, banded.region) : banded.region;
3876
- if (!out.length) return null;
4080
+ if (!out.length) {
4081
+ if (kind === "equipment" && region) return { skip: region };
4082
+ return null;
4083
+ }
3877
4084
  const { x0, x1 } = bandLimits(anchors);
3878
4085
  let title = null;
3879
4086
  for (let i = titleFrom; i >= 0 && i >= titleFrom - 5 && !title; i--) {
@@ -3882,7 +4089,7 @@ function extractTable(sheet, kind, opts = {}) {
3882
4089
  }
3883
4090
  const table = { kind, sheet: sheet.key, title, headers: anchors.map((a) => a.label), rows: out, region, anchors };
3884
4091
  if (rotated) table.rotated_headers = true;
3885
- return table;
4092
+ return { table };
3886
4093
  }
3887
4094
  var CONT_TAIL_RE = /[\s\-–—:.,(]*(?:CONTINUATION|CONTINUED|CONT['’]?D?)[\s.)]*$/;
3888
4095
  var isContinuationTitle = (text) => {
@@ -4028,14 +4235,25 @@ function buildSheetGraph(sheets) {
4028
4235
  const ctx = sheetBuilding(s);
4029
4236
  if (ctx) ctxBySheet.set(s.key, ctx.building);
4030
4237
  }
4238
+ const sheetNumberSet = /* @__PURE__ */ new Set();
4239
+ for (const s of sheets) {
4240
+ const n = norm(s.sheet_number || "").replace(/[^A-Z0-9]/g, "");
4241
+ if (n) sheetNumberSet.add(n);
4242
+ }
4031
4243
  const roles = /* @__PURE__ */ new Map();
4032
4244
  const fragments = [];
4033
4245
  const fragmentKinds = /* @__PURE__ */ new Map();
4034
4246
  for (const s of withText) {
4035
4247
  roles.set(s.key, classifySheetRole(s));
4248
+ const sheetFrags = [];
4249
+ const found2 = [];
4036
4250
  for (const kind of ["room-finish", "finish"]) {
4037
- const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key) });
4038
- if (!t) continue;
4251
+ const t = extractTable(s, kind, { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet });
4252
+ if (t) found2.push(t);
4253
+ }
4254
+ found2.push(...extractTables(s, "equipment", { buildings, deltas: deltasBySheet.get(s.key), sheetNumbers: sheetNumberSet }));
4255
+ for (const t of found2) {
4256
+ const kind = t.kind;
4039
4257
  if (kind === "finish" && t.title && isNonFinishSchedule(t.title.text)) {
4040
4258
  notes.push(`${s.key}: "${t.title.text}" names another schedule family, not a finish/material schedule \u2014 its ${t.rows.length} rows are NOT indexed as finish definitions`);
4041
4259
  continue;
@@ -4044,9 +4262,18 @@ function buildSheetGraph(sheets) {
4044
4262
  const b = titleB.length === 1 ? titleB[0] : ctxBySheet.get(s.key);
4045
4263
  if (b) t.building = b;
4046
4264
  for (const r of t.rows) if (r.building) buildings.add(r.building);
4265
+ sheetFrags.push(t);
4266
+ }
4267
+ const equip = sheetFrags.filter((t) => t.kind === "equipment");
4268
+ for (const t of sheetFrags) {
4269
+ const shadow = t.kind === "finish" ? equip.find((e) => overlapFrac(t.region, e.region) >= 0.5) : void 0;
4270
+ if (shadow) {
4271
+ notes.push(`${s.key}: "${t.title?.text || "untitled finish table"}" overlaps the equipment schedule "${shadow.title?.text || "untitled"}" \u2014 indexed once, as equipment, not as a finish definition`);
4272
+ continue;
4273
+ }
4047
4274
  fragments.push(t);
4048
4275
  if (!fragmentKinds.has(s.key)) fragmentKinds.set(s.key, /* @__PURE__ */ new Set());
4049
- fragmentKinds.get(s.key).add(kind);
4276
+ fragmentKinds.get(s.key).add(t.kind);
4050
4277
  }
4051
4278
  }
4052
4279
  const tables = [];
@@ -9319,7 +9546,7 @@ var Session = class _Session {
9319
9546
  const table = tb.title?.text || `${tb.kind} schedule`;
9320
9547
  for (const row of tb.rows) {
9321
9548
  for (const part of canon(row.key).split("/").filter(Boolean)) {
9322
- if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table });
9549
+ if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table, kind: tb.kind });
9323
9550
  }
9324
9551
  }
9325
9552
  }
@@ -9378,7 +9605,7 @@ var Session = class _Session {
9378
9605
  }
9379
9606
  const paired = values.find((v) => Math.abs((v.x0 + v.x1) / 2 - cx) <= Math.max(sp.x1 - sp.x0, 1.5 * h) && v.y0 >= sp.y1 - 0.4 * h && v.y0 <= sp.y1 + 2.4 * h);
9380
9607
  if (paired) {
9381
- rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key });
9608
+ rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key, by: "value" });
9382
9609
  counts[m] = (counts[m] || 0) + 1;
9383
9610
  } else {
9384
9611
  if (segs === null) segs = (await this.ensureGeometry(sh)).segs;
@@ -9388,6 +9615,12 @@ var Session = class _Session {
9388
9615
  for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
9389
9616
  if (segs[i] >= bx0 && segs[i] <= bx1 && segs[i + 1] >= by0 && segs[i + 1] <= by1 && segs[i + 2] >= bx0 && segs[i + 2] <= bx1 && segs[i + 3] >= by0 && segs[i + 3] <= by1) n++;
9390
9617
  }
9618
+ const equipmentRow = rowCite.get(m)?.kind === "equipment";
9619
+ if (equipmentRow && _Session.lineworkNear(segs, [sp.x0, sp.y0, sp.x1, sp.y1], pad) >= 3) {
9620
+ rec.counted.push({ at: [round1(cx), round1(cy)], sheet: sh.key, by: "label" });
9621
+ counts[m] = (counts[m] || 0) + 1;
9622
+ continue;
9623
+ }
9391
9624
  rec.withheld.push({
9392
9625
  at: [round1(cx), round1(cy)],
9393
9626
  sheet: sh.key,
@@ -9427,10 +9660,12 @@ var Session = class _Session {
9427
9660
  const cite = rowCite.get(m);
9428
9661
  const c = cap(rec.counted, 150);
9429
9662
  const w = cap(rec.withheld, 60);
9663
+ const byLabel = rec.counted.filter((h) => h.by === "label").length;
9430
9664
  return {
9431
9665
  mark: m,
9432
9666
  count: rec.counted.length,
9433
- ...cite ? { row: cite } : { unscheduled: true },
9667
+ ...byLabel ? { counted_by_label: byLabel } : {},
9668
+ ...cite ? { row: { sheet: cite.sheet, key: cite.key, table: cite.table } } : { unscheduled: true },
9434
9669
  occurrences: c.list,
9435
9670
  ...c.elided ? { occurrences_elided: c.elided } : {},
9436
9671
  withheld: w.list,
@@ -9459,6 +9694,18 @@ var Session = class _Session {
9459
9694
  withheld: named.slice(off + matches.length)
9460
9695
  };
9461
9696
  }
9697
+ /** Segments whose extent touches the box padded by `pad` — a leader, a device
9698
+ * outline crossing it, a tick — capped at 3 (the callers only ask "≥ 3?"). */
9699
+ static lineworkNear(segs, bbox, pad) {
9700
+ const bx0 = bbox[0] - pad, by0 = bbox[1] - pad, bx1 = bbox[2] + pad, by1 = bbox[3] + pad;
9701
+ let n = 0;
9702
+ for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
9703
+ const sx0 = Math.min(segs[i], segs[i + 2]), sx1 = Math.max(segs[i], segs[i + 2]);
9704
+ const sy0 = Math.min(segs[i + 1], segs[i + 3]), sy1 = Math.max(segs[i + 1], segs[i + 3]);
9705
+ if (sx1 >= bx0 && sx0 <= bx1 && sy1 >= by0 && sy0 <= by1) n++;
9706
+ }
9707
+ return n;
9708
+ }
9462
9709
  static labelFields(l) {
9463
9710
  return l ? { label: l.label, label_via: l.via } : {};
9464
9711
  }
@@ -9863,11 +10110,22 @@ var Session = class _Session {
9863
10110
  return sh.spans.filter((sp) => sp.str.trim().toUpperCase() === key).map((sp) => ({ cx: (sp.x0 + sp.x1) / 2, cy: (sp.y0 + sp.y1) / 2, h: Math.max(sp.y1 - sp.y0, 6), bbox: [sp.x0, sp.y0, sp.x1, sp.y1] })).sort((a, b) => a.cy - b.cy || a.cx - b.cx);
9864
10111
  };
9865
10112
  const occBySheet = planSheets.map((sh) => ({ sh, occ: occOf(sh, t) }));
9866
- const totalOcc = occBySheet.reduce((n, e) => n + e.occ.length, 0);
10113
+ const tableRegionsOf = (key) => graph.tables.filter((x) => x.sheet === key).map((x) => x.region);
10114
+ const amidLinework = (segs, o) => _Session.lineworkNear(segs, o.bbox, 2.5 * o.h) >= 3;
10115
+ const inTable = (key, o) => tableRegionsOf(key).some((r2) => o.cx >= r2[0] && o.cx <= r2[2] && o.cy >= r2[1] && o.cy <= r2[3]);
10116
+ const drawnBySheet = [];
10117
+ for (const { sh, occ } of occBySheet) {
10118
+ const geo = await this.ensureGeometry(sh);
10119
+ const drawn = geo.segs.length ? occ.filter((o) => !inTable(sh.key, o) && amidLinework(geo.segs, o)) : [];
10120
+ drawnBySheet.push({ sh, occ: drawn });
10121
+ }
10122
+ const totalOcc = drawnBySheet.reduce((n, e) => n + e.occ.length, 0);
10123
+ const mentions = occBySheet.reduce((n, e) => n + e.occ.length, 0) - totalOcc;
9867
10124
  if (!totalOcc) {
10125
+ if (mentions) throw new UserError(`Schedule row "${t}" (${table} on ${tb.sheet}) is mentioned ${mentions}\xD7 on plan sheets but never drawn \u2014 every occurrence is bare text with no linework near it (a note), so there is no instance to count. If the device is drawn without its tag, marquee one instance with symbol_sweep {scope: "set"}.`);
9868
10126
  throw new UserError(`Schedule row "${t}" (${table} on ${tb.sheet}) cannot be geometrically anchored \u2014 its tag is not drawn on any plan sheet, and a fingerprint is never guessed from text alone. If the marker is drawn untagged, marquee one instance with symbol_sweep {scope: "set"}.`);
9869
10127
  }
9870
- const withOcc = occBySheet.filter((e) => e.occ.length > 0).sort((a, b) => b.occ.length - a.occ.length || a.sh.ord - b.sh.ord);
10128
+ const withOcc = drawnBySheet.filter((e) => e.occ.length > 0).sort((a, b) => b.occ.length - a.occ.length || a.sh.ord - b.sh.ord);
9871
10129
  const anchorSheet = withOcc[0].sh;
9872
10130
  const anchor = withOcc[0].occ[0];
9873
10131
  const anchorGeo = await this.ensureGeometry(anchorSheet);
@@ -9921,9 +10179,8 @@ var Session = class _Session {
9921
10179
  break;
9922
10180
  }
9923
10181
  }
9924
- if (!fp || !anchorRect) {
9925
- throw new UserError(corro ? `Schedule row "${t}" cannot be anchored: the linework around its drawn tag on ${anchorSheet.key} does not recur at the tag's other occurrences \u2014 no repeatable marker geometry to fingerprint. Marquee one instance with symbol_sweep instead.` : `Schedule row "${t}" cannot be anchored: no fingerprintable marker linework sits around its drawn tag on ${anchorSheet.key}. Marquee one instance with symbol_sweep instead.`);
9926
- }
10182
+ const labelOnly = !fp || !anchorRect;
10183
+ const labelReason = labelOnly ? corro ? `the linework around its drawn tag on ${anchorSheet.key} does not recur at the tag's other occurrences \u2014 no repeatable marker geometry` : `no fingerprintable marker linework sits around its drawn tag on ${anchorSheet.key}` : null;
9927
10184
  const radiusFor = (sc) => (sc ? sc.footprint_px : fp.footprint) / 2 + anchor.h;
9928
10185
  const byPos = (a, b) => a.at[1] - b.at[1] || a.at[0] - b.at[0];
9929
10186
  const perSheet = [];
@@ -9933,6 +10190,17 @@ var Session = class _Session {
9933
10190
  skipped.push({ sheet: sh.key, role: "plan", reason: "no vector linework (likely a scan) \u2014 symbol matching reads the drawn segments" });
9934
10191
  continue;
9935
10192
  }
10193
+ if (labelOnly) {
10194
+ const label_only2 = [];
10195
+ const text_only2 = [];
10196
+ for (const o of occ) {
10197
+ if (inTable(sh.key, o)) continue;
10198
+ if (amidLinework(g2.segs, o)) label_only2.push({ at: [round1(o.cx), round1(o.cy)], tag_at: o.bbox });
10199
+ else text_only2.push({ at: [round1(o.cx), round1(o.cy)] });
10200
+ }
10201
+ perSheet.push({ state: sh, matches: [], withheld: [], excluded: [], text_only: text_only2, label_only: label_only2, candidates: { considered: 0, dropped: 0 }, complete: true, elapsed_ms: 0, scale: this.sweepRatio(anchorSheet, sh) });
10202
+ continue;
10203
+ }
9936
10204
  const ratio = this.sweepRatio(anchorSheet, sh);
9937
10205
  const t0 = process.hrtime.bigint();
9938
10206
  let res;
@@ -9950,6 +10218,7 @@ var Session = class _Session {
9950
10218
  const excluded = [];
9951
10219
  const withheld = [];
9952
10220
  const matchedOcc = /* @__PURE__ */ new Set();
10221
+ let duplicates = 0;
9953
10222
  for (const m of res.matches) {
9954
10223
  let oi = -1;
9955
10224
  for (let k = 0; k < occ.length; k++) {
@@ -9958,6 +10227,10 @@ var Session = class _Session {
9958
10227
  break;
9959
10228
  }
9960
10229
  }
10230
+ if (oi >= 0 && matchedOcc.has(oi)) {
10231
+ duplicates++;
10232
+ continue;
10233
+ }
9961
10234
  if (oi >= 0) {
9962
10235
  matchedOcc.add(oi);
9963
10236
  matches.push({ ...m, tag_at: occ[oi].bbox });
@@ -9977,14 +10250,27 @@ var Session = class _Session {
9977
10250
  matches.sort(byPos);
9978
10251
  excluded.sort(byPos);
9979
10252
  withheld.sort(byPos);
9980
- const text_only = occ.filter((o, k) => !matchedOcc.has(k) && !res.withheld.some((w) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R)).map((o) => ({ at: [round1(o.cx), round1(o.cy)] }));
9981
- perSheet.push({ state: sh, matches, withheld, excluded, text_only, candidates: res.candidates, complete: res.complete, elapsed_ms, scale: ratio, ...res.scaled ? { scaled: res.scaled } : {} });
9982
- }
9983
- const found = perSheet.reduce((n, p) => n + p.matches.length, 0);
10253
+ const unmatched = occ.filter((o, k) => !matchedOcc.has(k) && !res.withheld.some((w) => Math.hypot(w.at[0] - o.cx, w.at[1] - o.cy) <= R) && !inTable(sh.key, o));
10254
+ const label_only = unmatched.filter((o) => amidLinework(g2.segs, o)).map((o) => ({ at: [round1(o.cx), round1(o.cy)], tag_at: o.bbox }));
10255
+ const text_only = unmatched.filter((o) => !amidLinework(g2.segs, o)).map((o) => ({ at: [round1(o.cx), round1(o.cy)] }));
10256
+ if (duplicates) skipped.push({ sheet: sh.key, role: "plan", reason: `${duplicates} extra fingerprint hit(s) around an already-counted tag were folded into their instance \u2014 one drawn tag is one unit` });
10257
+ perSheet.push({ state: sh, matches, withheld, excluded, text_only, label_only, candidates: res.candidates, complete: res.complete, elapsed_ms, scale: ratio, ...res.scaled ? { scaled: res.scaled } : {} });
10258
+ }
10259
+ const foundByGeometry = perSheet.reduce((n, p) => n + p.matches.length, 0);
10260
+ const foundByLabel = perSheet.reduce((n, p) => n + p.label_only.length, 0);
10261
+ const found = foundByGeometry + foundByLabel;
9984
10262
  let committed;
9985
10263
  if (opts.commit && found) {
9986
10264
  const ids = [];
9987
10265
  for (const ps of perSheet) {
10266
+ for (const l of ps.label_only) {
10267
+ ids.push(this.commit(ps.state, t, "count", [l.at], { count: 1 }, {
10268
+ method: "manual",
10269
+ actor: "agent",
10270
+ reviewed: false,
10271
+ assignment: { source: "schedule", schedule_sheet: tb.sheet }
10272
+ }).id);
10273
+ }
9988
10274
  for (const m of ps.matches) {
9989
10275
  ids.push(this.commit(ps.state, t, "count", [m.at], { count: 1 }, {
9990
10276
  method: "symbol_sweep",
@@ -10010,7 +10296,9 @@ var Session = class _Session {
10010
10296
  const firstCell = r.cells[Object.keys(r.cells)[0]];
10011
10297
  const capped = perSheet.filter((p) => p.candidates.dropped > 0);
10012
10298
  const notes = [];
10013
- if (!corroborated) notes.push(`The tag "${t}" is drawn ${totalOcc === 1 ? "exactly once" : "too sparsely to cross-check"} \u2014 the fingerprint could not corroborate at a second occurrence; audit the matches with view_sheet before trusting the count.`);
10299
+ if (labelOnly) notes.push(`Counted BY LABEL: ${labelReason} \u2014 so every drawn "${t}" tag amid linework on a plan sheet counts as one instance (${foundByLabel}), and a bare mention counts for nothing. Look at each before pricing.`);
10300
+ else if (foundByLabel) notes.push(`${foundByLabel} of ${found} counted BY LABEL: the tag is drawn there amid linework the marker fingerprint did not reach (a variant of the device \u2014 a different length or size \u2014 or a rotated placement outside the sweep's reach).`);
10301
+ if (!corroborated && !labelOnly) notes.push(`The tag "${t}" is drawn ${totalOcc === 1 ? "exactly once" : "too sparsely to cross-check"} \u2014 the fingerprint could not corroborate at a second occurrence; audit the matches with view_sheet before trusting the count.`);
10014
10302
  if (opts.commit && !found) notes.push("commit requested but nothing cleared the bar \u2014 no shapes were committed.");
10015
10303
  const rowRescaled = perSheet.filter((p) => p.scaled);
10016
10304
  if (rowRescaled.length) {
@@ -10029,7 +10317,7 @@ var Session = class _Session {
10029
10317
  cells,
10030
10318
  citation: { sheet: tb.sheet, text: `${table} row ${t}`, bbox: _Session.wireBox(firstCell?.bbox || tb.region) }
10031
10319
  },
10032
- anchor: {
10320
+ anchor: labelOnly ? null : {
10033
10321
  sheet: anchorSheet.key,
10034
10322
  at: [round1(anchor.cx), round1(anchor.cy)],
10035
10323
  rect: [round1(anchorRect[0][0]), round1(anchorRect[0][1]), round1(anchorRect[1][0]), round1(anchorRect[1][1])],
@@ -10039,6 +10327,9 @@ var Session = class _Session {
10039
10327
  occurrences: totalOcc
10040
10328
  },
10041
10329
  found,
10330
+ found_by_geometry: foundByGeometry,
10331
+ found_by_label: foundByLabel,
10332
+ counted_by: foundByLabel === 0 ? "geometry" : foundByGeometry === 0 ? "label" : "mixed",
10042
10333
  sheets: perSheet.map((p) => ({
10043
10334
  sheet: p.state.key,
10044
10335
  found: p.matches.length,
@@ -10046,6 +10337,7 @@ var Session = class _Session {
10046
10337
  withheld: p.withheld.map((w) => ({ at: [round1(w.at[0]), round1(w.at[1])], score: w.score, rotation: w.rotation, mirrored: w.mirrored, reason: w.reason })),
10047
10338
  excluded: p.excluded.map((e) => ({ at: [round1(e.at[0]), round1(e.at[1])], tag: e.tag })),
10048
10339
  text_only: p.text_only,
10340
+ label_only: p.label_only.map((l) => ({ at: l.at, tag_at: _Session.wireBox(l.tag_at) })),
10049
10341
  candidates: p.candidates,
10050
10342
  complete: p.complete,
10051
10343
  elapsed_ms: p.elapsed_ms,
@@ -11344,7 +11636,7 @@ var Session = class _Session {
11344
11636
  const g = await this.ensureGraph();
11345
11637
  if (!g.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable.");
11346
11638
  const k = (kind || "").toLowerCase();
11347
- const want = /room/.test(k) ? "room-finish" : /finish|material|product|code|mark/.test(k) ? "finish" : k;
11639
+ const want = /room/.test(k) ? "room-finish" : /equip|mechanical|mep|hvac|plumb|electr|fan|pump|heater|unit|valve|diffuser|grille|fixture|device/.test(k) ? "equipment" : /finish|material|product|code|mark/.test(k) ? "finish" : k;
11348
11640
  const hits = g.tables.filter((t) => t.kind === want);
11349
11641
  if (!hits.length) {
11350
11642
  const found = g.tables.map((t) => `${t.kind} on ${t.sheet}`).join(" | ");
@@ -12222,15 +12514,19 @@ var sweepScheduleRowOutput = {
12222
12514
  length_px: z.number(),
12223
12515
  corroborated: z.boolean().describe("true = the fingerprint recurred at a second tag occurrence before being trusted; false = the tag is drawn too sparsely to cross-check (see note)"),
12224
12516
  occurrences: z.number().int().describe("Drawn occurrences of the tag across all plan sheets")
12225
- }),
12226
- found: z.number().int().describe("Matches carrying the row's own tag \u2014 the honest count, across every plan sheet"),
12517
+ }).nullable().describe("null when the sweep counted BY LABEL: no repeatable marker geometry sits around the drawn tag (the equipment convention \u2014 a device drawn to its own size, tagged by a leader), so nothing was fingerprinted"),
12518
+ found: z.number().int().describe("Instances counted across every plan sheet: geometry matches carrying the row's own tag PLUS drawn tags counted by label \u2014 see counted_by"),
12519
+ found_by_geometry: z.number().int().describe("\u2026of which marker-fingerprint matches corroborated by the tag"),
12520
+ found_by_label: z.number().int().describe("\u2026of which drawn tags amid linework the fingerprint did not (or could not) reach \u2014 an installed instance by the equipment convention, disclosed as label-counted"),
12521
+ counted_by: z.enum(["geometry", "label", "mixed"]).describe('How the count was made \u2014 "label" means no marker geometry was matched at all; look at each label_only placement before pricing'),
12227
12522
  sheets: z.array(z.object({
12228
12523
  sheet: z.string(),
12229
12524
  found: z.number().int(),
12230
12525
  matches: z.array(z.object({ ...rowSweepPlacement, tag_at: wireBox.describe("The corroborating tag text's bbox \u2014 the evidence that this marker is THIS row's") })),
12231
12526
  withheld: z.array(z.object({ ...rowSweepPlacement, reason: z.string() })).describe("Questions, never counts: markers matching the geometry but carrying no tag (an unlabeled instance or a shared bubble shape), and near-miss scores in the [0.75, 0.92) band"),
12232
12527
  excluded: z.array(z.object({ at: z.tuple([z.number(), z.number()]), tag: z.string() })).describe("Markers matching the geometry but labeled with a SIBLING row's tag \u2014 the bubble shape is shared across marks, so these belong to that row, not this one"),
12233
- text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with NO matching marker geometry nearby \u2014 a note reference or a variant marker; a question, never a count"),
12528
+ text_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]) })).describe("The tag drawn with no marker match AND no linework near it \u2014 a note mentioning the mark; a question, never a count"),
12529
+ label_only: z.array(z.object({ at: z.tuple([z.number(), z.number()]), tag_at: wireBox })).describe("Instances counted BY LABEL: the row's tag drawn amid linework that the marker fingerprint did not match (or none was anchored) \u2014 the equipment convention; each is counted and each deserves a look"),
12234
12530
  candidates: z.object({ considered: z.number().int(), dropped: z.number().int() }),
12235
12531
  complete: z.boolean().describe("True when every proposed placement on this sheet was scored \u2014 false means this sheet's count is a FLOOR, not a total (#261)"),
12236
12532
  elapsed_ms: z.number().describe("Wall-clock for this sheet's sweep"),
@@ -12404,8 +12700,9 @@ var deleteRfiOutput = {
12404
12700
  };
12405
12701
  var censusOccurrence = {
12406
12702
  at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px)"),
12407
- value: z.string().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance)"),
12408
- sheet: z.string()
12703
+ value: z.string().optional().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance); absent when counted by label"),
12704
+ sheet: z.string(),
12705
+ by: z.enum(["value", "label"]).describe('"value" = tag-over-value (air devices, fixtures); "label" = an EQUIPMENT-schedule mark drawn amid linework with no value \u2014 the leader-tag convention, counted as one instance and said so')
12409
12706
  };
12410
12707
  var censusWithheld = {
12411
12708
  at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px) \u2014 view_sheet here"),
@@ -12415,7 +12712,8 @@ var censusWithheld = {
12415
12712
  var countMarksOutput = {
12416
12713
  marks: z.array(z.object({
12417
12714
  mark: z.string(),
12418
- count: z.number().int().describe("Value-paired instances counted on plan-role sheets"),
12715
+ count: z.number().int().describe("Instances counted on plan-role sheets \u2014 value-paired tags, plus (for an equipment-schedule mark) tags drawn amid linework, see counted_by_label"),
12716
+ counted_by_label: z.number().int().optional().describe("\u2026of which counted by label (an equipment mark with a leader, no value under it)"),
12419
12717
  row: z.object({ sheet: z.string(), key: z.string(), table: z.string() }).optional().describe("The schedule row that answers for this mark (a compound key answers for each part)"),
12420
12718
  unscheduled: z.boolean().optional().describe("true when the mark was stated by the caller but no schedule row answers for it"),
12421
12719
  occurrences: z.array(z.object(censusOccurrence)),
@@ -14251,7 +14549,7 @@ function registerTools(realServer, session, opts = {}) {
14251
14549
  commitSeed: a.commit_seed
14252
14550
  })));
14253
14551
  server.registerTool("sweep_schedule_row", {
14254
- description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. The COUNT is scale-free (EA), but matching is not: where the anchor sheet and a target sheet both carry a scale, the marker is resized by their exact ratio before matching (\`scaled\` per sheet), and where one does not, the sweep runs at 1:1 and discloses it (\`scale_assumed\`) rather than reporting a confident zero. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS}`,
14552
+ description: `Take off a schedule row's mark from the row itself \u2014 the estimator's own gesture: a transition type sometimes exists only as a schedule row plus tag markers scattered across the plan sheets, and this tool mints the condition FROM the row and finds every occurrence. Pass the row's key (e.g. 'T1') and the tool (1) reads the row from the set's schedule tables (the sheet_graph/find_schedule machinery \u2014 the row is the condition's cited source), (2) anchors a geometric fingerprint on the marker the tag is DRAWN as on a plan sheet (a deterministic pad ladder around the tag text; where the tag occurs more than once the fingerprint must recur at a second occurrence before it is trusted \u2014 \`anchor.corroborated\`), and (3) sweeps every PLAN-role sheet for it. The count is geometry AND text agreeing: drafting reuses one bubble shape across many marks, so a match counts ONLY when the row's own tag sits within the marker footprint (its bbox rides the match as \`tag_at\` evidence); a match labeled with a SIBLING row's tag is excluded and says whose it is, an unlabeled match is withheld as a question, and a tag drawn with no matching marker is disclosed as text_only. REFUSAL over guessing, with the reason and the fix: no such row; the same key in two tables (ambiguous); a tag drawn on no plan sheet; no repeatable marker linework around the tag \u2014 a fingerprint is never guessed from text alone (the fallback is always: marquee one instance with symbol_sweep). commit: true commits the counted matches as EA markers under the row's own key \u2014 one undo step for the whole set-wide sweep, every marker carrying origin.assignment {source: "schedule"} plus the anchor and row citation on origin.symbol.seed. The COUNT is scale-free (EA), but matching is not: where the anchor sheet and a target sheet both carry a scale, the marker is resized by their exact ratio before matching (\`scaled\` per sheet), and where one does not, the sweep runs at 1:1 and discloses it (\`scale_assumed\`) rather than reporting a confident zero. After committing, LOOK: view_sheet {overlay: true} over each swept sheet. ${COORDS} LABEL-FIRST for devices (any trade): when the marker cannot be fingerprinted or does not reach a drawn tag amid linework \u2014 a heater bar or fan drawn to its own size, tagged by a leader \u2014 every such tag on a plan sheet counts as ONE instance BY LABEL, disclosed in found_by_label / label_only / counted_by; a bare mention in a note (no linework near it) is text_only, never a count. Rows come from every schedule family the sheet graph reads: room-finish, finish/material, and equipment (mechanical, electrical, plumbing, fire).`,
14255
14553
  inputSchema: {
14256
14554
  tag: z2.string().min(1).describe("The schedule row's key exactly as drawn, e.g. 'T1', 'TR-2' \u2014 it becomes the condition tag on commit"),
14257
14555
  commit: z2.boolean().default(false).describe("Commit every counted match as one EA count marker (excluded/withheld/text_only never commit)"),
@@ -14267,7 +14565,7 @@ function registerTools(realServer, session, opts = {}) {
14267
14565
  tolerancePx: a.tolerance_px
14268
14566
  })));
14269
14567
  server.registerTool("count_marks", {
14270
- description: `The COUNT TAKEOFF in one deterministic call \u2014 no seeds, no model, seconds: census every VALUE-ANNOTATED mark tag on the plan-role sheets, counted per schedule mark, committed as EA markers when asked. The identity rule is the annotated-device drafting pattern: a device is drawn as its mark tag with a value under it ("S1" over "200" \u2014 CFM on air devices, GPM on fixtures, a rating on equipment), so a tag WITH a paired value counts, a tag inside a schedule table's own region is a row label (excluded, tallied), and every other occurrence is WITHHELD with a reason and coordinates \u2014 a tag amid linework but unvalued may be a real device (view_sheet it), a bare tag is probably a note mention. Marks default to the set's schedule row keys (a compound row "R1 / E1" answers for R1 AND E1; each mark cites its row), or state them: {marks: ["S1","R1"]}. The complement to sweep_schedule_row: THAT tool is for marks drawn ON their marker with no value (finish tags in bubbles) and matches geometry; this one is for annotated devices and needs no fingerprint at all. Refusal-honest: scans refuse (no text layer), a set with no mark-shaped rows refuses unless marks are stated, non-plan sheets are skipped with the role that excused them. commit: true commits every counted occurrence under its mark's own tag \u2014 ONE undo step for the whole census, schedule citation on origin. Counts are scale-free (EA) \u2014 no set_scale needed. Then AUDIT: view_sheet {overlay: true} where the markers landed, and read every withheld entry \u2014 a withheld item you ignore is a hole in the bid. ${COORDS}`,
14568
+ description: `The COUNT TAKEOFF in one deterministic call \u2014 no seeds, no model, seconds: census every VALUE-ANNOTATED mark tag on the plan-role sheets, counted per schedule mark, committed as EA markers when asked. The identity rule is the annotated-device drafting pattern: a device is drawn as its mark tag with a value under it ("S1" over "200" \u2014 CFM on air devices, GPM on fixtures, a rating on equipment), so a tag WITH a paired value counts, a tag inside a schedule table's own region is a row label (excluded, tallied), and every other occurrence is WITHHELD with a reason and coordinates \u2014 a tag amid linework but unvalued may be a real device (view_sheet it), a bare tag is probably a note mention. Marks default to the set's schedule row keys (a compound row "R1 / E1" answers for R1 AND E1; each mark cites its row), or state them: {marks: ["S1","R1"]}. The complement to sweep_schedule_row: THAT tool is for marks drawn ON their marker with no value (finish tags in bubbles) and matches geometry; this one is for annotated devices and needs no fingerprint at all. Refusal-honest: scans refuse (no text layer), a set with no mark-shaped rows refuses unless marks are stated, non-plan sheets are skipped with the role that excused them. commit: true commits every counted occurrence under its mark's own tag \u2014 ONE undo step for the whole census, schedule citation on origin. Counts are scale-free (EA) \u2014 no set_scale needed. Then AUDIT: view_sheet {overlay: true} where the markers landed, and read every withheld entry \u2014 a withheld item you ignore is a hole in the bid. ${COORDS} EQUIPMENT marks (a row in an equipment/device schedule \u2014 fans, pumps, heaters, fixtures, panels) follow the leader-tag convention instead: a scheduled mark drawn amid linework with no value under it is counted BY LABEL (occurrence by: "label", counted_by_label on the mark); a bare mention in a note still withholds.`,
14271
14569
  inputSchema: {
14272
14570
  marks: z2.array(z2.string().min(1)).optional().describe(`The marks to census, e.g. ["S1", "R1"] \u2014 omit to take them from the schedule tables' row keys`),
14273
14571
  commit: z2.boolean().default(false).describe("Commit every counted occurrence as one EA count marker under its mark (withheld/excluded never commit)")
@@ -14571,7 +14869,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
14571
14869
  }, run("resolve_tag", ({ tag }) => session.resolveRoomTag(tag)));
14572
14870
  server.registerTool("find_schedule", {
14573
14871
  description: `Locate a schedule table in the set (#87): pass a kind ("room finish", "material"/"finish") and get every matching table's sheet, title, headers, TOTAL row count, and REGION \u2014 sized for a view_sheet look or a read_sheet_text pull of exactly the table. A schedule continued across sheets is ONE match whose "parts" list every fragment (base first) with its own viewable region; tables read through rotated headers say so; a table answering for one building carries "building"; a table with delta/REV-marked rows says how many in "revised_rows". Errors with what WAS found when the asked-for kind isn't in the set. ${COORDS}`,
14574
- inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes) or "finish"/"material" (codes \u2192 products)') },
14872
+ inputSchema: { kind: z2.string().describe('"room finish" (rooms \u2192 surface finishes), "finish"/"material" (codes \u2192 products), or "equipment" (MEP device schedules \u2014 fans, pumps, heaters, AHUs, VAVs, diffusers/grilles/registers \u2014 keyed by mark, proven by a powered or air-device column)') },
14575
14873
  outputSchema: findScheduleOutput
14576
14874
  }, run("find_schedule", ({ kind }) => session.findSchedule(kind)));
14577
14875
  server.registerTool("read_sheet_text", {
@@ -14953,7 +15251,7 @@ function nameTheStageInRefusals(server) {
14953
15251
  // package.json
14954
15252
  var package_default = {
14955
15253
  name: "opentakeoff-mcp",
14956
- version: "0.9.76",
15254
+ version: "0.9.77",
14957
15255
  mcpName: "io.github.Kentucky-ai/opentakeoff",
14958
15256
  type: "module",
14959
15257
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -14970,7 +15268,7 @@ var package_default = {
14970
15268
  prepublishOnly: "npm run typecheck && npm test && npm run build",
14971
15269
  typecheck: "tsc --noEmit",
14972
15270
  "check:tool-count": "node scripts/check-tool-count.mjs",
14973
- test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/scope.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
15271
+ test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
14974
15272
  },
14975
15273
  dependencies: {
14976
15274
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.76",
3
+ "version": "0.9.77",
4
4
  "mcpName": "io.github.Kentucky-ai/opentakeoff",
5
5
  "type": "module",
6
6
  "description": "OpenTakeoff MCP server — drive the takeoff engine from your MCP client over stdio.",
@@ -17,7 +17,7 @@
17
17
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
18
18
  "typecheck": "tsc --noEmit",
19
19
  "check:tool-count": "node scripts/check-tool-count.mjs",
20
- "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/labels.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/scope.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/session.test.ts test/staging.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
20
+ "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/dxf.test.ts test/e2e.test.ts test/gate.test.ts test/graphEvalRowsym.test.ts test/labels.test.ts test/mep.test.ts test/overlap.test.ts test/parity.test.ts test/proposals.test.ts test/raster.test.ts test/resources.test.ts test/safewrite.test.ts test/scalewarn.test.ts test/scope.test.ts test/session.test.ts test/staging.test.ts test/sweepguard.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
21
21
  },
22
22
  "dependencies": {
23
23
  "@modelcontextprotocol/sdk": "^1.12.0",