opentakeoff-mcp 0.9.45 → 0.9.46

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.
Files changed (2) hide show
  1. package/dist/server-core.js +211 -29
  2. package/package.json +1 -1
@@ -2677,34 +2677,92 @@ function clusterRows(spans) {
2677
2677
  return rows.map((r) => r.sort((a, b) => a.x - b.x));
2678
2678
  }
2679
2679
  var rowY = (r) => r.reduce((s, t) => s + t.y, 0) / r.length;
2680
- var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "BLDG", "BUILDING"];
2680
+ var SURFACE_WORDS = /* @__PURE__ */ new Set(["FLOOR", "BASE", "WALL", "WALLS", "CEILING", "NORTH", "SOUTH", "EAST", "WEST", "WAINSCOT"]);
2681
+ var ROOM_HEADERS = ["ROOM", "NO", "NUMBER", "NAME", "MARK", "LOCATION", "FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT", "REMARKS", "CLG", "HT", "HEIGHT", "FINISH", "BLDG", "BUILDING"];
2681
2682
  var FINISH_HEADERS = ["CODE", "MARK", "SYMBOL", "MATERIAL", "MANUFACTURER", "PRODUCT", "STYLE", "COLOR", "SIZE", "REMARKS", "DESCRIPTION", "PATTERN", "COMMENTS"];
2682
2683
  var headerLabel = (s, vocab) => {
2683
2684
  for (const w of norm(s).split(/[^A-Z]+/)) if (w && vocab.includes(w)) return w;
2684
2685
  return null;
2685
2686
  };
2687
+ function headerHits(row, vocab) {
2688
+ const out = [];
2689
+ for (const t of row) {
2690
+ const w = headerLabel(t.str, vocab);
2691
+ if (w) out.push({ label: w, span: t });
2692
+ }
2693
+ return out.sort((a, b) => a.span.x - b.span.x);
2694
+ }
2695
+ var qualifies = (hits, required, minHits) => {
2696
+ const seen = new Set(hits.map((h) => h.label));
2697
+ return seen.size >= minHits && required.some((r) => seen.has(r));
2698
+ };
2686
2699
  function findHeaderRow(rows, vocab, required, minHits) {
2687
2700
  for (let i = 0; i < rows.length; i++) {
2701
+ let hits = headerHits(rows[i], vocab);
2702
+ if (!qualifies(hits, required, minHits)) continue;
2703
+ let idx = i;
2704
+ for (; ; ) {
2705
+ let next = -1;
2706
+ for (let j = idx + 1; j < Math.min(idx + 4, rows.length); j++) {
2707
+ const h = headerHits(rows[j], vocab);
2708
+ const ratio = h.length / Math.max(1, rows[j].length);
2709
+ if (qualifies(h, required, minHits) && h.length > hits.length && ratio >= 0.6) {
2710
+ next = j;
2711
+ break;
2712
+ }
2713
+ }
2714
+ if (next < 0) break;
2715
+ idx = next;
2716
+ hits = headerHits(rows[idx], vocab);
2717
+ }
2718
+ const dup = /* @__PURE__ */ new Set();
2719
+ const once = /* @__PURE__ */ new Set();
2720
+ for (const h of hits) (once.has(h.label) ? dup : once).add(h.label);
2688
2721
  const anchors = [];
2689
- const seen = /* @__PURE__ */ new Set();
2690
- for (const t of rows[i]) {
2691
- const w = headerLabel(t.str, vocab);
2692
- if (w && !seen.has(w)) {
2693
- seen.add(w);
2694
- anchors.push({ label: w, x: t.x + (t.w || 0) / 2 });
2722
+ const used = /* @__PURE__ */ new Set();
2723
+ for (let j = 0; j < hits.length; j++) {
2724
+ const h = hits[j];
2725
+ let label = h.label;
2726
+ if (dup.has(h.label) && !SURFACE_WORDS.has(h.label)) {
2727
+ const hi = j + 1 < hits.length ? hits[j + 1].span.x : Infinity;
2728
+ const parent = parentLabelOver(rows, idx, i, h.span.x, hi, vocab);
2729
+ if (parent && parent !== h.label) label = `${parent} ${h.label}`;
2730
+ }
2731
+ if (used.has(label)) continue;
2732
+ used.add(label);
2733
+ anchors.push({ label, x: h.span.x + (h.span.w || 0) / 2 });
2734
+ }
2735
+ if (anchors.length < minHits) continue;
2736
+ if (idx > i) {
2737
+ const lo = Math.min(...anchors.map((a) => a.x)), hi = Math.max(...anchors.map((a) => a.x));
2738
+ for (let j = i; j < idx; j++) {
2739
+ for (const h of headerHits(rows[j], vocab)) {
2740
+ const cx = h.span.x + (h.span.w || 0) / 2;
2741
+ if (cx >= lo && cx <= hi) continue;
2742
+ if (used.has(h.label)) continue;
2743
+ used.add(h.label);
2744
+ anchors.push({ label: h.label, x: cx });
2745
+ }
2695
2746
  }
2696
2747
  }
2697
- if (anchors.length < minHits || !required.some((r) => seen.has(r))) continue;
2698
- return { anchors: subTierAnchors(rows, i, anchors.sort((a, b) => a.x - b.x), vocab), rowIndex: i };
2748
+ return { anchors: subTierAnchors(rows, idx, anchors.sort((a, b) => a.x - b.x), vocab), rowIndex: idx };
2699
2749
  }
2700
2750
  return null;
2701
2751
  }
2702
2752
  var SUB_LABEL_RE = /^[A-Z0-9][A-Z0-9.\/-]{0,5}$/;
2703
- function parentLabelOver(rows, hdrIdx, gx0, gx1, vocab) {
2704
- const width = Math.max(gx1 - gx0, 1);
2705
- for (let j = hdrIdx - 1; j >= 0 && j >= hdrIdx - 2; j--) {
2753
+ function parentLabelOver(rows, hdrIdx, topIdx, gx0, gx1, vocab) {
2754
+ const width = Math.max(Math.min(gx1, gx0 + 4e3) - gx0, 1);
2755
+ const hs = rows[hdrIdx].map((t) => t.h || 8).sort((a, b) => a - b);
2756
+ const near = Math.max(24, (hs[hs.length >> 1] || 8) * 4);
2757
+ const hy = rowY(rows[hdrIdx]);
2758
+ const floorIdx = Math.max(0, Math.min(topIdx, hdrIdx - 8));
2759
+ for (let j = hdrIdx - 1; j >= floorIdx; j--) {
2760
+ if (hy - rowY(rows[j]) > near) break;
2706
2761
  for (const t of rows[j]) {
2707
- if (Math.min(t.x + (t.w || 0), gx1) - Math.max(t.x, gx0) <= width * 0.3) continue;
2762
+ const cx = t.x + (t.w || 0) / 2;
2763
+ const inInterval = cx >= gx0 && cx < gx1;
2764
+ const overlaps = Math.min(t.x + (t.w || 0), gx1) - Math.max(t.x, gx0) > width * 0.3;
2765
+ if (!inInterval && !overlaps) continue;
2708
2766
  const lbl = headerLabel(t.str, vocab);
2709
2767
  if (lbl) return lbl;
2710
2768
  }
@@ -2733,7 +2791,7 @@ function subTierAnchors(rows, hdrIdx, anchors, vocab) {
2733
2791
  for (const r of runs) {
2734
2792
  if (r.length < 2) continue;
2735
2793
  const last = r[r.length - 1];
2736
- const parent = parentLabelOver(rows, hdrIdx, r[0].x, last.x + (last.w || 0), vocab);
2794
+ const parent = parentLabelOver(rows, hdrIdx, hdrIdx - 2, r[0].x, last.x + (last.w || 0), vocab);
2737
2795
  if (!parent) continue;
2738
2796
  const pitch = r.length > 1 ? r.slice(1).map((t, i) => mid(t) - mid(r[i])).sort((a, b) => a - b)[r.length - 1 >> 1] : 0;
2739
2797
  for (const t of r) {
@@ -2819,14 +2877,65 @@ function rowKeyOf(raw, kind, buildings) {
2819
2877
  }
2820
2878
  var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
2821
2879
  var centerX = (t) => t.x + (t.w || 0) / 2;
2880
+ function columnStarts(rows, anchors, cfg, x0, x1) {
2881
+ const xs = [];
2882
+ const hs = [];
2883
+ for (let i = Math.max(cfg.fromIdx, 0); i < rows.length; i++) {
2884
+ if (rowY(rows[i]) <= cfg.belowY) continue;
2885
+ for (const t of rows[i]) {
2886
+ if (t.x < x0 || t.x > x1 || revisionOf(t.str) != null) continue;
2887
+ xs.push(t.x);
2888
+ hs.push(t.h || 8);
2889
+ }
2890
+ }
2891
+ if (xs.length < anchors.length * 2) return null;
2892
+ hs.sort((a, b) => a - b);
2893
+ const tol = Math.max(4, hs[hs.length >> 1] * 0.5);
2894
+ xs.sort((a, b) => a - b);
2895
+ const clusters = [];
2896
+ for (const x of xs) {
2897
+ const last = clusters[clusters.length - 1];
2898
+ if (last && x - last.start <= tol) {
2899
+ last.n++;
2900
+ continue;
2901
+ }
2902
+ clusters.push({ start: x, n: 1 });
2903
+ }
2904
+ const maxN = Math.max(...clusters.map((c) => c.n));
2905
+ const kept = clusters.filter((c) => c.n >= Math.max(2, maxN * 0.25));
2906
+ if (kept.length < anchors.length) return null;
2907
+ const byLabel = /* @__PURE__ */ new Map();
2908
+ for (const c of kept) {
2909
+ const own = anchors.find((a) => a.x >= c.start);
2910
+ if (!own) continue;
2911
+ const cur = byLabel.get(own.label);
2912
+ if (cur == null || c.start < cur) byLabel.set(own.label, c.start);
2913
+ }
2914
+ if (byLabel.size !== anchors.length) return null;
2915
+ const named = [...byLabel.entries()].map(([label, start]) => ({ label, start })).sort((a, b) => a.start - b.start);
2916
+ const order = anchors.map((a) => a.label).join("|");
2917
+ if (named.map((n) => n.label).join("|") !== order) return null;
2918
+ return named;
2919
+ }
2822
2920
  function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
2823
2921
  const { x0, x1, medGap } = bandLimits(anchors);
2922
+ const cols = columnStarts(rows, anchors, cfg, x0, x1);
2923
+ const keyTol = cols && cols.length > 1 ? Math.max(8, (cols[1].start - cols[0].start) * 0.5) : 40;
2824
2924
  const out = [];
2825
2925
  const outY = [];
2826
2926
  let region = null;
2927
+ const columnOf = (t) => {
2928
+ if (!cols) return nearestAnchor(centerX(t), anchors);
2929
+ let label = cols[0].label;
2930
+ for (const c of cols) {
2931
+ if (t.x + 1 >= c.start) label = c.label;
2932
+ else break;
2933
+ }
2934
+ return label;
2935
+ };
2827
2936
  const add = (row, toks) => {
2828
2937
  for (const t of toks) {
2829
- const label = nearestAnchor(centerX(t), anchors);
2938
+ const label = columnOf(t);
2830
2939
  const text = t.str.trim();
2831
2940
  if (!row.cells[label]) row.cells[label] = { text, bbox: bboxOf(t) };
2832
2941
  else row.cells[label] = { text: `${row.cells[label].text} ${text}`, bbox: merge(row.cells[label].bbox, bboxOf(t)) };
@@ -2853,6 +2962,7 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
2853
2962
  orphans.push({ toks: banded, y: rowY(rows[i]) });
2854
2963
  continue;
2855
2964
  }
2965
+ if (cols && Math.abs(banded[0].x - cols[0].start) > keyTol) continue;
2856
2966
  if (cfg.keyAlign && Math.abs(centerX(banded[0]) - cfg.keyAlign.x) > cfg.keyAlign.tol) continue;
2857
2967
  const row = { key: keyed.key, sheet: sheetKey, cells: {} };
2858
2968
  if (keyed.building) row.building = keyed.building;
@@ -2860,6 +2970,21 @@ function bandDataRows(rows, anchors, kind, sheetKey, buildings, cfg) {
2860
2970
  out.push(row);
2861
2971
  outY.push(rowY(rows[i]));
2862
2972
  }
2973
+ if (out.length > 2) {
2974
+ const d = outY.slice(1).map((y, i) => y - outY[i]).filter((g) => g > 0).sort((a, b) => a - b);
2975
+ const pitch0 = d.length ? d[d.length >> 1] : 0;
2976
+ if (pitch0 > 0) {
2977
+ let end = out.length;
2978
+ for (let i = 1; i < outY.length; i++) if (outY[i] - outY[i - 1] > pitch0 * 8) {
2979
+ end = i;
2980
+ break;
2981
+ }
2982
+ if (end < out.length) {
2983
+ out.length = end;
2984
+ outY.length = end;
2985
+ }
2986
+ }
2987
+ }
2863
2988
  const gaps = outY.slice(1).map((y, i) => y - outY[i]).filter((d) => d > 0).sort((a, b) => a - b);
2864
2989
  const pitch = gaps.length ? gaps[gaps.length >> 1] : 0;
2865
2990
  const nearest = (y) => {
@@ -2922,8 +3047,12 @@ function extractTable(sheet, kind, opts = {}) {
2922
3047
  titleFrom = rows.findIndex((r) => rowY(r) >= rot.top) - 1;
2923
3048
  if (titleFrom < -1) titleFrom = rows.length - 1;
2924
3049
  }
3050
+ const hdrBand = bandLimits(anchors);
2925
3051
  let region = null;
2926
- for (const t of headerSpans) region = region ? merge(region, bboxOf(t)) : bboxOf(t);
3052
+ for (const t of headerSpans) {
3053
+ if (centerX(t) < hdrBand.x0 || centerX(t) > hdrBand.x1) continue;
3054
+ region = region ? merge(region, bboxOf(t)) : bboxOf(t);
3055
+ }
2927
3056
  const banded = bandDataRows(rows, anchors, kind, sheet.key, opts.buildings, { fromIdx: dataFrom, belowY: dataBelowY, deltas: opts.deltas });
2928
3057
  const out = banded.out;
2929
3058
  if (banded.region) region = region ? merge(region, banded.region) : banded.region;
@@ -2983,6 +3112,7 @@ function adoptContinuationRows(sheet, titleSpan, base, buildings, deltas) {
2983
3112
  };
2984
3113
  }
2985
3114
  var QUALIFIED_TAG_RE = /^([A-Z]{1,2})-(\d{2,3}[A-Z]?)$/;
3115
+ var NON_ROOM_NAME = /* @__PURE__ */ new Set(["NUMBER", "NO", "NAME", "MARK", "SYMBOL", "CODE", "TYPE", "QTY", "SIZE", "TOTAL", "SHEET", "DATE", "SCALE", "REV", "REVISION", "DESCRIPTION", "REMARKS", "COMMENTS", "DETAIL", "ROOM"]);
2986
3116
  function roomTags(sheet, opts = {}) {
2987
3117
  const out = [];
2988
3118
  const spans = sheet.spans;
@@ -3007,7 +3137,10 @@ function roomTags(sheet, opts = {}) {
3007
3137
  const dy = b[1] - cb[3];
3008
3138
  if (dy < -hgt * 0.2 || dy > hgt * 2.2) continue;
3009
3139
  if (cb[2] < b[0] - hgt || cb[0] > b[2] + hgt) continue;
3010
- if (!/^[A-Z][A-Z .'’\/&-]{2,}$/.test(norm(cand.str))) continue;
3140
+ const raw = cand.str.trim();
3141
+ if (/[a-z]/.test(raw)) continue;
3142
+ if (!/^[A-Z][A-Z .'’\/&-]{1,}$/.test(norm(raw))) continue;
3143
+ if (NON_ROOM_NAME.has(norm(raw))) continue;
3011
3144
  if (dy < best) {
3012
3145
  best = dy;
3013
3146
  name = cand.str.trim();
@@ -3054,7 +3187,7 @@ function detailCallouts(sheet) {
3054
3187
  }
3055
3188
  function buildSheetGraph(sheets) {
3056
3189
  const withText = sheets.filter((s) => s.spans.length > 0);
3057
- if (!withText.length) return { available: false, sheets: [], rooms: [], tables: [], callouts: [], buildings: [], revisions: [], notes: [] };
3190
+ if (!withText.length) return { available: false, sheets: [], rooms: [], unmatched_tags: [], tables: [], callouts: [], buildings: [], revisions: [], notes: [] };
3058
3191
  const notes = [];
3059
3192
  const deltasBySheet = /* @__PURE__ */ new Map();
3060
3193
  const revisions = [];
@@ -3132,19 +3265,46 @@ function buildSheetGraph(sheets) {
3132
3265
  const n = norm(s.sheet_number || "").replace(/[^A-Z0-9]/g, "");
3133
3266
  if (n) sheetNumbers.add(n);
3134
3267
  }
3135
- const rooms = [];
3268
+ const found = [];
3136
3269
  const callouts = [];
3137
3270
  for (const s of withText) {
3138
3271
  const role = roles.get(s.key);
3139
- if (role.role === "plan" || role.role === "unknown" || role.role === "demolition") {
3272
+ const suppresses = (role.role === "schedule" || role.role === "legend" || role.role === "elevation" || role.role === "detail") && role.confidence >= 0.6;
3273
+ if (!suppresses) {
3140
3274
  const ctxB = ctxBySheet.get(s.key);
3141
3275
  for (const r of roomTags(s, { buildings, exclude: sheetNumbers, deltas: deltasBySheet.get(s.key) })) {
3142
3276
  if (r.building == null && ctxB) r.building = ctxB;
3143
- rooms.push(r);
3277
+ found.push(r);
3144
3278
  }
3145
3279
  }
3146
3280
  callouts.push(...detailCallouts(s));
3147
3281
  }
3282
+ const roomRows = tables.filter((t) => t.kind === "room-finish");
3283
+ const scheduleNums = /* @__PURE__ */ new Set();
3284
+ for (const t of roomRows) for (const r of t.rows) scheduleNums.add(numOf(norm(r.key)));
3285
+ const rooms = [];
3286
+ const unmatched = [];
3287
+ for (const r of found) {
3288
+ const num2 = numOf(norm(r.tag).replace(/\s+/g, ""));
3289
+ const byName = !!r.name.trim();
3290
+ const bySchedule = scheduleNums.has(num2);
3291
+ if (bySchedule || byName && !roomRows.length) {
3292
+ r.corroboration = bySchedule ? byName ? "name+schedule" : "schedule" : "name";
3293
+ rooms.push(r);
3294
+ } else {
3295
+ unmatched.push({
3296
+ tag: r.tag,
3297
+ sheet: r.sheet,
3298
+ bbox: r.bbox,
3299
+ ...r.building ? { building: r.building } : {},
3300
+ ...byName ? { name: r.name } : {},
3301
+ reason: !roomRows.length ? "no room name drawn with it, and the set carries no room-finish schedule to check it against" : byName ? `"${r.name}" is drawn with it but no room-finish row answers for it \u2014 either a room the schedule omits, or a keynote/legend row; LOOK before pricing it` : "no room name drawn with it and no room-finish row answers for it \u2014 reads as a keynote, detail marker or dimension fragment rather than a room"
3302
+ });
3303
+ }
3304
+ }
3305
+ if (unmatched.length) {
3306
+ notes.push(`${unmatched.length} numbered tag(s) on plan sheets are NOT counted as rooms \u2014 no name drawn with them and no schedule row answers for them; see unmatched_tags (they are listed, never dropped)`);
3307
+ }
3148
3308
  const outSheets = withText.map((s) => {
3149
3309
  const role = roles.get(s.key);
3150
3310
  const schedules = [];
@@ -3168,7 +3328,7 @@ function buildSheetGraph(sheets) {
3168
3328
  if (b) entry.building = b;
3169
3329
  return entry;
3170
3330
  });
3171
- return { available: true, sheets: outSheets, rooms, tables, callouts, buildings: [...buildings].sort(), revisions, notes };
3331
+ return { available: true, sheets: outSheets, rooms, unmatched_tags: unmatched, tables, callouts, buildings: [...buildings].sort(), revisions, notes };
3172
3332
  }
3173
3333
  var SURFACE_HEADERS = ["FLOOR", "BASE", "WALL", "WALLS", "NORTH", "SOUTH", "EAST", "WEST", "CEILING", "WAINSCOT"];
3174
3334
  var surfaceRank = (label) => SURFACE_HEADERS.indexOf(label.split(" ")[0]);
@@ -3177,7 +3337,9 @@ function resolveTag(graph, tag) {
3177
3337
  const q = t.match(QUALIFIED_KEY_RE);
3178
3338
  const wantB = q ? q[1] : null;
3179
3339
  const num2 = q ? q[2] : t;
3180
- const rooms = graph.rooms.filter((r2) => {
3340
+ const asRoom = (u) => ({ tag: u.tag, name: u.name ?? "", sheet: u.sheet, bbox: u.bbox, ...u.building ? { building: u.building } : {} });
3341
+ const candidates = [...graph.rooms, ...graph.unmatched_tags.map(asRoom)];
3342
+ const rooms = candidates.filter((r2) => {
3181
3343
  const rt = norm(r2.tag).replace(/\s+/g, "");
3182
3344
  return rt === t || numOf(rt) === num2;
3183
3345
  });
@@ -7796,6 +7958,7 @@ var Session = class _Session {
7796
7958
  sheet: r.sheet,
7797
7959
  bbox: _Session.wireBox(r.bbox),
7798
7960
  ...r.building ? { building: r.building } : {},
7961
+ ...r.corroboration ? { corroboration: r.corroboration } : {},
7799
7962
  ...r.revision ? { revision: { rev: r.revision.rev, source: _Session.wireEvidence(r.revision.source), ...r.revision.drawn ? { drawn: true } : {} } } : {}
7800
7963
  };
7801
7964
  }
@@ -7819,11 +7982,21 @@ var Session = class _Session {
7819
7982
  }))
7820
7983
  })),
7821
7984
  rooms: g.rooms.map(_Session.wireRoom),
7985
+ ...g.unmatched_tags.length ? {
7986
+ unmatched_tags: g.unmatched_tags.map((u) => ({
7987
+ tag: u.tag,
7988
+ sheet: u.sheet,
7989
+ bbox: _Session.wireBox(u.bbox),
7990
+ ...u.building ? { building: u.building } : {},
7991
+ ...u.name ? { name: u.name } : {},
7992
+ reason: u.reason
7993
+ }))
7994
+ } : {},
7822
7995
  callouts: g.callouts.map((c) => ({ detail: c.detail, target_sheet: c.target_sheet, sheet: c.sheet, bbox: _Session.wireBox(c.bbox) })),
7823
7996
  ...g.buildings.length ? { buildings: g.buildings } : {},
7824
7997
  ...g.revisions.length ? { revisions: g.revisions.map((r) => ({ rev: r.rev, sheet: r.sheet, bbox: _Session.wireBox(r.bbox), ...r.drawn ? { drawn: true } : {} })) } : {},
7825
7998
  ...g.notes.length ? { notes: g.notes } : {},
7826
- counts: { rooms: g.rooms.length, schedules: g.tables.length, callouts: g.callouts.length }
7999
+ counts: { rooms: g.rooms.length, unmatched_tags: g.unmatched_tags.length, schedules: g.tables.length, callouts: g.callouts.length }
7827
8000
  };
7828
8001
  }
7829
8002
  /** The FLOOR finish a room's own schedule row states — assign-from-schedule's
@@ -8466,7 +8639,8 @@ var graphRoom = z.object({
8466
8639
  sheet: z.string(),
8467
8640
  bbox: wireBox,
8468
8641
  building: z.string().optional().describe("The building the room belongs to, when the set names one \u2014 its plan sheet's BUILDING/BLDG context, or the tag's own qualifier ('A-134')"),
8469
- revision: wireRevision.optional()
8642
+ revision: wireRevision.optional(),
8643
+ corroboration: z.string().optional().describe('Why this number is believed to be a room: "schedule" (a room-finish row answers for it), "name" (a name is drawn with it and the set has no room-finish schedule), or "name+schedule"')
8470
8644
  });
8471
8645
  var sheetGraphOutput = {
8472
8646
  available: z.boolean().describe("false = the set has no text layer (a scan) \u2014 the graph degrades to unavailable, never half-populates"),
@@ -8485,12 +8659,20 @@ var sheetGraphOutput = {
8485
8659
  rotated_headers: z.boolean().optional().describe("true when the column headers were read at a quarter-turn")
8486
8660
  }))
8487
8661
  })),
8488
- rooms: z.array(graphRoom).describe("Room tags read off plan-role sheets \u2014 schedule sheets contribute rows, never phantom rooms"),
8662
+ rooms: z.array(graphRoom).describe("Numbers CORROBORATED as rooms \u2014 a room-finish row answers for them, or (where the set carries no room-finish schedule) a room name is drawn with them. Each says which in `corroboration`. Schedule sheets contribute rows, never phantom rooms"),
8663
+ unmatched_tags: z.array(z.object({
8664
+ tag: z.string(),
8665
+ sheet: z.string(),
8666
+ bbox: wireBox,
8667
+ building: z.string().optional(),
8668
+ name: z.string().optional().describe("Text drawn with the number, when there is any \u2014 on a keynote legend this is the accessory description, not a room name"),
8669
+ reason: z.string().describe("WHY this number is not counted as a room. Read these: one of them may be a room the schedule left out, which is a hole in the bid")
8670
+ })).optional().describe('Numbered tags on plan sheets that are NOT counted as rooms \u2014 keynote hexagons, detail markers, dimension fragments, legend rows. Listed with a reason, never dropped. A real finish plan is covered in 2\u20133 digit numbers that are not rooms; counting them as rooms makes every one come back "no schedule row", which reads exactly like the lost-bid case and buries it'),
8489
8671
  callouts: z.array(z.object({ detail: z.string(), target_sheet: z.string(), sheet: z.string(), bbox: wireBox })).describe("Detail callouts (3/A-601) \u2014 edges to their target sheets"),
8490
8672
  buildings: z.array(z.string()).optional().describe("Every building designator the set names (sorted) \u2014 present only on multi-building-aware sets. Room numbers reused across these need qualified tags ('A-134')"),
8491
8673
  revisions: z.array(z.object({ rev: z.string(), sheet: z.string(), bbox: wireBox, drawn: z.boolean().optional() })).optional().describe("Every delta-triangle / REV-tag marker the set carries \u2014 text markers ('\u03942', 'REV 2') and DRAWN deltas (a bare digit inside a triangle of linework, drawn: true) \u2014 where one sits, the ink changed under that revision. Markers on a schedule row or room bubble also attach there (and ride resolve_tag). A revision CLOUD is arc-chain linework these detectors do not read \u2014 absence here is not absence of revisions"),
8492
8674
  notes: z.array(z.string()).optional().describe("Named gaps found while indexing (e.g. a continuation whose rows could not be aligned) \u2014 the graph refuses silently dropping anything"),
8493
- counts: z.object({ rooms: z.number().int(), schedules: z.number().int().describe("LOGICAL tables \u2014 a schedule continued across sheets counts once"), callouts: z.number().int() })
8675
+ counts: z.object({ rooms: z.number().int(), unmatched_tags: z.number().int().optional(), schedules: z.number().int().describe("LOGICAL tables \u2014 a schedule continued across sheets counts once"), callouts: z.number().int() })
8494
8676
  };
8495
8677
  var resolveTagOutput = {
8496
8678
  status: z.enum(["resolved", "unresolved"]),
@@ -10627,7 +10809,7 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
10627
10809
  outputSchema: undoLastOutput
10628
10810
  }, run("undo_last", ({ n }) => session.undoLast(n)));
10629
10811
  server.registerTool("sheet_graph", {
10630
- description: `The plan-set INDEX (#87): every sheet's role (plan / schedule / legend / \u2026, with confidence and the title evidence), the schedule tables found (kind, row count, region \u2014 a schedule CONTINUED across sheets ("\u2026 SCHEDULE \u2014 CONT'D") reads as ONE table, the continuation fragment naming its base in "continues"; rotated column headers are read at their quarter-turn and flagged), every room tag on the plan sheets (with the stacked room NAME when one exists, and the room's BUILDING on multi-building sets), the detail callouts (3/A-601 \u2192 sheet edges), the set's building designators, every REVISION marker the set carries (text markers "\u03942"/"REV 2" AND drawn deltas \u2014 a bare digit inside a triangle of linework, proven from vector geometry and flagged drawn \u2014 in "revisions", and attached to the schedule row / room tag they sit on), and named indexing gaps in "notes". Built once per document from the text layer and cached. This is how an agent decides WHAT to measure without a human enumerating the rooms: list the rooms here, resolve each with resolve_tag, then measure with one_click/detect_rooms. A scanned set (no text layer) returns available: false \u2014 unavailable, never half-populated. ${COORDS}`,
10812
+ description: `The plan-set INDEX (#87): every sheet's role (plan / schedule / legend / \u2026, with confidence and the title evidence), the schedule tables found (kind, row count, region \u2014 a schedule CONTINUED across sheets ("\u2026 SCHEDULE \u2014 CONT'D") reads as ONE table, the continuation fragment naming its base in "continues"; rotated column headers are read at their quarter-turn and flagged), every number CORROBORATED as a room (with the stacked room NAME when one exists, the room's BUILDING on multi-building sets, and "corroboration" saying why it counts as a room) plus "unmatched_tags" \u2014 the numbers that are NOT rooms (keynote hexagons, detail markers, dimension fragments, legend rows), each with a reason, listed and never dropped; READ those reasons, one of them may be a room the schedule left out, the detail callouts (3/A-601 \u2192 sheet edges), the set's building designators, every REVISION marker the set carries (text markers "\u03942"/"REV 2" AND drawn deltas \u2014 a bare digit inside a triangle of linework, proven from vector geometry and flagged drawn \u2014 in "revisions", and attached to the schedule row / room tag they sit on), and named indexing gaps in "notes". Built once per document from the text layer and cached. This is how an agent decides WHAT to measure without a human enumerating the rooms: list the rooms here, resolve each with resolve_tag, then measure with one_click/detect_rooms. A scanned set (no text layer) returns available: false \u2014 unavailable, never half-populated. ${COORDS}`,
10631
10813
  inputSchema: {},
10632
10814
  outputSchema: sheetGraphOutput
10633
10815
  }, run("sheet_graph", () => session.sheetGraph()));
@@ -10918,7 +11100,7 @@ function applyStagedTools(server, registered) {
10918
11100
  // package.json
10919
11101
  var package_default = {
10920
11102
  name: "opentakeoff-mcp",
10921
- version: "0.9.45",
11103
+ version: "0.9.46",
10922
11104
  mcpName: "io.github.Kentucky-ai/opentakeoff",
10923
11105
  type: "module",
10924
11106
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.45",
3
+ "version": "0.9.46",
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.",