opentakeoff-mcp 0.9.56 → 0.9.60

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 +277 -30
  2. package/package.json +1 -1
@@ -77,18 +77,49 @@ var STANDARD_SCALES = [
77
77
  var SHEET_NO_RE = /^[A-Z]{1,3}[-. ]?\d{1,3}(\.\d{1,2})?[A-Z]?$/;
78
78
  function extractSheetNumber(textContent, viewport) {
79
79
  const W = viewport.width, H = viewport.height;
80
- let best = null, bestH = 0;
80
+ const placed = [];
81
+ const vscale = Math.hypot(viewport.transform?.[0] ?? 1, viewport.transform?.[1] ?? 0) || 1;
81
82
  for (const it of textContent.items || []) {
82
83
  const raw = (it.str || "").trim().toUpperCase().replace(/\s+/g, "");
83
- if (raw.length < 2 || raw.length > 8 || !SHEET_NO_RE.test(raw)) continue;
84
+ if (!raw) continue;
84
85
  const t = pdfjsLib.Util.transform(viewport.transform, it.transform);
85
86
  const x = t[4], y = t[5], h = Math.hypot(t[2], t[3]) || it.height || 0;
86
87
  if (x < W * 0.6 || y < H * 0.55) continue;
88
+ const w = it.width != null ? it.width * vscale : raw.length * 0.62 * h;
89
+ placed.push({ raw, x, y, h, w });
90
+ }
91
+ let best = null, bestScore = 0;
92
+ const consider = (raw, x, y, h) => {
93
+ if (raw.length < 2 || raw.length > 8 || !SHEET_NO_RE.test(raw)) return;
87
94
  const score = h + x / W * 4 + y / H * 4;
88
- if (score > bestH) {
89
- bestH = score;
95
+ if (score > bestScore) {
96
+ bestScore = score;
90
97
  best = raw;
91
98
  }
99
+ };
100
+ for (const p of placed) consider(p.raw, p.x, p.y, p.h);
101
+ const rows = [];
102
+ for (const p of [...placed].sort((a, b) => a.y - b.y || a.x - b.x)) {
103
+ const row = rows[rows.length - 1];
104
+ if (row && Math.abs(p.y - row[0].y) <= Math.max(2, row[0].h * 0.35)) row.push(p);
105
+ else rows.push([p]);
106
+ }
107
+ for (const row of rows) {
108
+ row.sort((a, b) => a.x - b.x);
109
+ let run2 = [];
110
+ const flush = () => {
111
+ if (run2.length > 1) {
112
+ const h = Math.max(...run2.map((r) => r.h));
113
+ consider(run2.map((r) => r.raw).join(""), run2[0].x, run2[0].y, h);
114
+ }
115
+ run2 = [];
116
+ };
117
+ for (const p of row) {
118
+ const prev = run2[run2.length - 1];
119
+ if (prev && p.x - (prev.x + prev.w) > Math.max(...run2.map((r) => r.h)) * 1.2) flush();
120
+ run2.push(p);
121
+ }
122
+ flush();
92
123
  }
93
124
  return best;
94
125
  }
@@ -2580,7 +2611,9 @@ var isVertical = (s) => s.rot != null ? Math.abs(s.rot % 180) === 90 : (s.str ||
2580
2611
  var SCHEDULE_TITLE_RE = /^[A-Z][A-Z ()/&.'’-]* SCHEDULE( *[-–] *[A-Z0-9 ()/&.'’-]+)?( *\(?(?:CONTINUATION|CONTINUED|CONT['’]?D?)\.?\)?)?$/;
2581
2612
  var ROLE_SIGNALS = [
2582
2613
  { re: /DEMOLITION\s+PLAN|DEMO\s+PLAN/, role: "demolition", conf: 0.9 },
2583
- { re: /FINISH\s+PLAN|FLOOR\s+PLAN|FURNITURE\s+PLAN|CEILING\s+PLAN/, role: "plan", conf: 0.85 },
2614
+ // every discipline draws plans, not just finishes — an M-sheet's "SECOND
2615
+ // FLOOR DUCTWORK PLAN" is as much a plan title as an A-sheet's finish plan
2616
+ { re: /(?:FINISH|FLOOR|FURNITURE|CEILING|DUCTWORK|PIPING|MECHANICAL|ELECTRICAL|LIGHTING|POWER|PLUMBING|SPRINKLER|HVAC|FRAMING|FOUNDATION|ROOF|SITE|EQUIPMENT)\s+PLAN\b/, role: "plan", conf: 0.85 },
2584
2617
  { re: SCHEDULE_TITLE_RE, role: "schedule", conf: 0.85 },
2585
2618
  { re: /SCHEDULE/, role: "schedule", conf: 0.5 },
2586
2619
  { re: /LEGEND/, role: "legend", conf: 0.5 },
@@ -2600,7 +2633,7 @@ function classifySheetRole(sheet) {
2600
2633
  }
2601
2634
  if (!hits.length) {
2602
2635
  const n = norm(sheet.sheet_number || "");
2603
- if (/^A-?1\d\d/.test(n)) return { role: "plan", confidence: 0.4, evidence: null };
2636
+ if (/^(A|M|E|P|S|FP)-?1\d\d/.test(n)) return { role: "plan", confidence: 0.4, evidence: null };
2604
2637
  return { role: "unknown", confidence: 0, evidence: null };
2605
2638
  }
2606
2639
  hits.sort((a, b) => b.conf - a.conf);
@@ -2916,13 +2949,23 @@ var isNonFinishSchedule = (title) => {
2916
2949
  return OTHER_FAMILY_RE.test(u) && !/\b(FINISH|MATERIAL)S?\b/.test(u);
2917
2950
  };
2918
2951
  function rowKeyOf(raw, kind, buildings) {
2919
- const key = norm(raw).replace(/[^A-Z0-9-]/g, "");
2920
- if (kind === "finish") return CODE_RE.test(key) ? { key } : null;
2952
+ const kept = norm(raw).replace(/[^A-Z0-9/-]/g, "");
2953
+ const key = kept.replace(/\//g, "");
2954
+ if (kind === "finish") {
2955
+ const parts = kept.split("/").filter(Boolean);
2956
+ if (parts.length > 1 && parts.every((p) => CODE_RE.test(p))) return { key: parts.join("/") };
2957
+ return CODE_RE.test(key) ? { key } : null;
2958
+ }
2921
2959
  if (ROW_KEY_RE.test(key)) return { key };
2922
2960
  const q = key.match(QUALIFIED_KEY_RE);
2923
2961
  if (q && buildings?.has(q[1])) return { key, building: q[1] };
2924
2962
  return null;
2925
2963
  }
2964
+ var rowKeyAnswersFor = (key, want) => {
2965
+ const c = norm(key).replace(/\s+/g, "");
2966
+ const w = norm(want).replace(/\s+/g, "");
2967
+ return c === w || c.split("/").filter(Boolean).includes(w);
2968
+ };
2926
2969
  var numOf = (key) => key.match(QUALIFIED_KEY_RE)?.[2] ?? key;
2927
2970
  var centerX = (t) => t.x + (t.w || 0) / 2;
2928
2971
  function columnMapFor(rows, anchors, cfg, x0, x1, coord) {
@@ -3470,7 +3513,7 @@ function resolveTag(graph, tag) {
3470
3513
  const code = norm(cell.text).replace(/[^A-Z0-9-]/g, "");
3471
3514
  const fin = { surface, code: cell.text.trim(), source: { sheet: r.sheet, text: cell.text.trim(), bbox: cell.bbox } };
3472
3515
  for (const ft of finTables) {
3473
- const def = ft.rows.find((fr) => norm(fr.key) === code);
3516
+ const def = ft.rows.find((fr) => rowKeyAnswersFor(fr.key, code));
3474
3517
  if (def) {
3475
3518
  const cells = {};
3476
3519
  for (const [k, v] of Object.entries(def.cells)) cells[k] = v.text;
@@ -4111,6 +4154,30 @@ function buildNegative(fp, segs, rect, opts = {}) {
4111
4154
  center: [Math.round(best.at[0] * 10) / 10, Math.round(best.at[1] * 10) / 10]
4112
4155
  };
4113
4156
  }
4157
+ function mergeProposals(scored, mergeR) {
4158
+ const kept = [];
4159
+ for (const s of scored) {
4160
+ const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
4161
+ if (!twin) {
4162
+ kept.push({ ...s });
4163
+ continue;
4164
+ }
4165
+ if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
4166
+ twin.at = s.at;
4167
+ twin.score = s.score;
4168
+ twin.rotation = s.rotation;
4169
+ twin.mirrored = s.mirrored;
4170
+ twin.xf = s.xf;
4171
+ }
4172
+ }
4173
+ const byBest = [...kept].sort((a, b) => b.score - a.score || a.xf - b.xf || a.at[1] - b.at[1] || a.at[0] - b.at[0]);
4174
+ const out = [];
4175
+ for (const s of byBest) {
4176
+ if (out.some((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR)) continue;
4177
+ out.push(s);
4178
+ }
4179
+ return out;
4180
+ }
4114
4181
  function matchSymbol(fp, segs, opts = {}) {
4115
4182
  const scale = opts.scale ?? 1;
4116
4183
  const tol = (opts.tolPx ?? SWEEP_TOL_PX) * Math.max(1, scale);
@@ -4221,21 +4288,7 @@ function matchSymbol(fp, segs, opts = {}) {
4221
4288
  scored.push({ at: [c.tx, c.ty], score, rotation: xforms[c.xf].rotation, mirrored: xforms[c.xf].mirrored, xf: c.xf });
4222
4289
  }
4223
4290
  const mergeR = Math.max(2 * tol, 4);
4224
- const kept = [];
4225
- for (const s of scored) {
4226
- const twin = kept.find((k) => Math.hypot(k.at[0] - s.at[0], k.at[1] - s.at[1]) <= mergeR);
4227
- if (!twin) {
4228
- kept.push(s);
4229
- continue;
4230
- }
4231
- if (s.score > twin.score || s.score === twin.score && s.xf < twin.xf) {
4232
- twin.at = s.at;
4233
- twin.score = s.score;
4234
- twin.rotation = s.rotation;
4235
- twin.mirrored = s.mirrored;
4236
- twin.xf = s.xf;
4237
- }
4238
- }
4291
+ const kept = mergeProposals(scored, mergeR);
4239
4292
  const suppressR = Math.max(mergeR, fpS.footprint / 2);
4240
4293
  const ex = opts.excludeCenter;
4241
4294
  const away = ex ? kept.filter((s) => Math.hypot(s.at[0] - ex[0], s.at[1] - ex[1]) > suppressR) : kept;
@@ -6994,6 +7047,158 @@ var Session = class _Session {
6994
7047
  * through this same path while telling the truth about the method —
6995
7048
  * symbol_sweep stamps `{method: "symbol_sweep", …, symbol: {score, …}}` per
6996
7049
  * marker; a bare place_count stays exactly the manual agent gesture it is. */
7050
+ /** count_marks — the deterministic census, the whole count takeoff in ONE
7051
+ * call: every VALUE-ANNOTATED mark tag on the plan sheets, counted per
7052
+ * schedule mark, committed as EA markers, residue disclosed.
7053
+ *
7054
+ * The identity rule is the annotated-device drafting pattern: a device is
7055
+ * drawn with its mark tag and a value under it ("S1" over "200" — CFM, GPM,
7056
+ * a fixture count). Tag text WITH a paired value counts; a tag inside a
7057
+ * schedule table's own region is a row label and is excluded; everything
7058
+ * else is WITHHELD with a reason and coordinates — a tag amid linework but
7059
+ * unvalued may be a real device (look), a bare tag is probably a note. A
7060
+ * mark drawn ON its marker with no value is sweep_schedule_row's family,
7061
+ * not this one. Refusal-honest throughout: scans refuse (no text layer),
7062
+ * a set with no mark-shaped schedule rows refuses unless the marks are
7063
+ * stated, and non-plan sheets are skipped with the role that excused them. */
7064
+ async countMarks(opts = {}) {
7065
+ const graph = await this.ensureGraph();
7066
+ if (!graph.available) {
7067
+ throw new UserError("This set has no text layer (a scan) \u2014 the census reads drawn tag text, so it cannot run. Marquee one device with symbol_sweep instead.");
7068
+ }
7069
+ const canon = (k) => (k || "").trim().toUpperCase().replace(/\s+/g, "");
7070
+ const MARK_RE = /^[A-Z]{1,3}-?\d{1,3}[A-Z]?$/;
7071
+ const rowCite = /* @__PURE__ */ new Map();
7072
+ for (const tb of graph.tables) {
7073
+ const table = tb.title?.text || `${tb.kind} schedule`;
7074
+ for (const row of tb.rows) {
7075
+ for (const part of canon(row.key).split("/").filter(Boolean)) {
7076
+ if (!rowCite.has(part)) rowCite.set(part, { sheet: tb.sheet, key: row.key, table });
7077
+ }
7078
+ }
7079
+ }
7080
+ let marks;
7081
+ if (opts.marks?.length) {
7082
+ marks = [...new Set(opts.marks.map(canon).filter(Boolean))];
7083
+ } else {
7084
+ marks = [...rowCite.keys()].filter((k) => MARK_RE.test(k)).sort();
7085
+ if (!marks.length) {
7086
+ throw new UserError('No mark-shaped schedule row keys in the set to census \u2014 state the marks yourself: count_marks { marks: ["S1", "R1"] }.');
7087
+ }
7088
+ }
7089
+ const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
7090
+ const skipped = [];
7091
+ const planSheets = [];
7092
+ for (const sh of this.sheetList()) {
7093
+ const role = roleOf.get(sh.key) ?? "unknown";
7094
+ if (role === "plan") planSheets.push(sh);
7095
+ else {
7096
+ skipped.push({
7097
+ sheet: sh.key,
7098
+ role,
7099
+ reason: role === "unknown" ? "role unknown (no classifiable title text) \u2014 tags here are not censused" : `a ${role} sheet \u2014 tags here are reference text, never installed work`
7100
+ });
7101
+ }
7102
+ }
7103
+ if (!planSheets.length) {
7104
+ throw new UserError("No plan-role sheet in the set \u2014 the census counts installed work, and every sheet classified as schedule/legend/detail/unknown. sheet_graph shows each sheet's role and evidence.");
7105
+ }
7106
+ const tableRegions = /* @__PURE__ */ new Map();
7107
+ for (const tb of graph.tables) {
7108
+ const arr2 = tableRegions.get(tb.sheet) ?? [];
7109
+ arr2.push(tb.region);
7110
+ tableRegions.set(tb.sheet, arr2);
7111
+ }
7112
+ const VAL_RE = /^[0-9][0-9,]{0,6}$/;
7113
+ const perMark = /* @__PURE__ */ new Map();
7114
+ for (const m of marks) perMark.set(m, { counted: [], withheld: [] });
7115
+ let excludedInTables = 0;
7116
+ const perSheetRows = [];
7117
+ for (const sh of planSheets) {
7118
+ if (!sh.spans) sh.spans = textSpans(sh.page);
7119
+ const regions = tableRegions.get(sh.key) ?? [];
7120
+ const values = sh.spans.filter((sp) => VAL_RE.test(sp.str.trim()));
7121
+ const counts = {};
7122
+ let segs = null;
7123
+ for (const m of marks) {
7124
+ const rec = perMark.get(m);
7125
+ for (const sp of sh.spans) {
7126
+ if (canon(sp.str) !== m) continue;
7127
+ const cx = (sp.x0 + sp.x1) / 2, cy = (sp.y0 + sp.y1) / 2;
7128
+ const h = Math.max(sp.y1 - sp.y0, 6);
7129
+ if (regions.some((r) => cx >= r[0] && cx <= r[2] && cy >= r[1] && cy <= r[3])) {
7130
+ excludedInTables++;
7131
+ continue;
7132
+ }
7133
+ 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);
7134
+ if (paired) {
7135
+ rec.counted.push({ at: [round1(cx), round1(cy)], value: paired.str.trim(), sheet: sh.key });
7136
+ counts[m] = (counts[m] || 0) + 1;
7137
+ } else {
7138
+ if (segs === null) segs = (await this.ensureGeometry(sh)).segs;
7139
+ const pad = 2.5 * h;
7140
+ const bx0 = sp.x0 - pad, by0 = sp.y0 - pad, bx1 = sp.x1 + pad, by1 = sp.y1 + pad;
7141
+ let n = 0;
7142
+ for (let i = 0; i + 3 < segs.length && n < 3; i += 4) {
7143
+ 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++;
7144
+ }
7145
+ rec.withheld.push({
7146
+ at: [round1(cx), round1(cy)],
7147
+ sheet: sh.key,
7148
+ reason: n >= 3 ? "tag amid linework but no paired value \u2014 may be a device labeled without one, or a legend/detail reference; look before counting it" : "bare tag text \u2014 no paired value, no adjacent linework; likely a note mention, not an instance"
7149
+ });
7150
+ }
7151
+ }
7152
+ }
7153
+ perSheetRows.push({ sheet: sh.key, counts });
7154
+ }
7155
+ const committedByMark = {};
7156
+ if (opts.commit) {
7157
+ for (const m of marks) {
7158
+ const rec = perMark.get(m);
7159
+ if (!rec.counted.length) continue;
7160
+ const cite = rowCite.get(m);
7161
+ let n = 0;
7162
+ for (const occ of rec.counted) {
7163
+ this.commit(this.sheet(occ.sheet), m, "count", [occ.at], { count: 1 }, {
7164
+ method: "manual",
7165
+ actor: "agent",
7166
+ reviewed: false,
7167
+ ...cite ? { assignment: { source: "schedule", schedule_sheet: cite.sheet } } : {}
7168
+ });
7169
+ n++;
7170
+ }
7171
+ const c = this.conditions.find((x) => x.finish_tag === m);
7172
+ const ea_total = this.shapes.filter((x) => x.condition_id === c.id && x.measure_role === "count").reduce((t2, x) => t2 + (x.computed.count || 1), 0);
7173
+ committedByMark[m] = { committed: n, ea_total };
7174
+ }
7175
+ if (Object.keys(committedByMark).length) this.flushCommits("count_marks");
7176
+ }
7177
+ const cap = (a, n) => a.length > n ? { list: a.slice(0, n), elided: a.length - n } : { list: a, elided: 0 };
7178
+ return {
7179
+ marks: marks.map((m) => {
7180
+ const rec = perMark.get(m);
7181
+ const cite = rowCite.get(m);
7182
+ const c = cap(rec.counted, 150);
7183
+ const w = cap(rec.withheld, 60);
7184
+ return {
7185
+ mark: m,
7186
+ count: rec.counted.length,
7187
+ ...cite ? { row: cite } : { unscheduled: true },
7188
+ occurrences: c.list,
7189
+ ...c.elided ? { occurrences_elided: c.elided } : {},
7190
+ withheld: w.list,
7191
+ ...w.elided ? { withheld_elided: w.elided } : {},
7192
+ ...committedByMark[m] ? { committed: committedByMark[m] } : {}
7193
+ };
7194
+ }),
7195
+ total: [...perMark.values()].reduce((n, r) => n + r.counted.length, 0),
7196
+ per_sheet: perSheetRows,
7197
+ ...excludedInTables ? { excluded_in_tables: excludedInTables } : {},
7198
+ skipped,
7199
+ complete: true
7200
+ };
7201
+ }
6997
7202
  placeCount(name, points, opts) {
6998
7203
  const s = this.sheet(name);
6999
7204
  const ids = points.map(([x, y], i) => this.commit(
@@ -7297,16 +7502,20 @@ var Session = class _Session {
7297
7502
  if (!t) throw new UserError('Pass a schedule-row tag as drawn, e.g. sweep_schedule_row { tag: "T1" }.');
7298
7503
  const graph = await this.ensureGraph();
7299
7504
  if (!graph.available) throw new UserError("This set has no text layer (a scan) \u2014 the sheet graph is unavailable, so schedule rows cannot be read.");
7300
- const rowHits = graph.tables.flatMap((tb2) => tb2.rows.filter((r2) => r2.key === t).map((r2) => ({ tb: tb2, r: r2 })));
7505
+ const canonKey = (k) => (k || "").trim().toUpperCase().replace(/\s+/g, "");
7506
+ const rowHits = graph.tables.flatMap((tb2) => tb2.rows.filter((r2) => rowKeyAnswersFor(r2.key, t)).map((r2) => ({ tb: tb2, r: r2 })));
7301
7507
  if (!rowHits.length) {
7302
- const found2 = graph.tables.map((x) => `${x.kind} on ${x.sheet} (${x.rows.length} rows)`).join(" | ");
7508
+ const found2 = graph.tables.map((x) => {
7509
+ const keys = x.rows.map((row) => row.key).slice(0, 12).join(", ");
7510
+ return `${x.kind} on ${x.sheet} (${x.rows.length} rows: ${keys}${x.rows.length > 12 ? ", \u2026" : ""})`;
7511
+ }).join(" | ");
7303
7512
  throw new UserError(`No schedule row "${t}" in the set \u2014 tables found: ${found2 || "none"}. Check the tag as drawn (find_schedule shows each table's region), or merge the schedule sheet in with load_plan.`);
7304
7513
  }
7305
7514
  if (rowHits.length > 1) {
7306
7515
  throw new UserError(`Ambiguous: ${rowHits.length} schedule rows carry the key "${t}" \u2014 the same mark defined twice cannot seed one sweep. Marquee the marker yourself with symbol_sweep.`);
7307
7516
  }
7308
7517
  const { tb, r } = rowHits[0];
7309
- const siblings = [...new Set(graph.tables.flatMap((x) => x.rows.map((row) => row.key)))].filter((k) => k !== t).sort();
7518
+ const siblings = [...new Set(graph.tables.flatMap((x) => x.rows.flatMap((row) => canonKey(row.key).split("/").filter(Boolean))))].filter((k) => k !== t).sort();
7310
7519
  const table = tb.title?.text || `${tb.kind} schedule`;
7311
7520
  const roleOf = new Map(graph.sheets.map((g) => [g.key, g.role]));
7312
7521
  const skipped = [];
@@ -7364,6 +7573,7 @@ var Session = class _Session {
7364
7573
  if (e instanceof Error && /region, not one symbol/.test(e.message)) break;
7365
7574
  continue;
7366
7575
  }
7576
+ if (cand.segments < 3) continue;
7367
7577
  if (!corro) {
7368
7578
  fp = cand;
7369
7579
  anchorRect = rect;
@@ -9370,6 +9580,34 @@ var linkAnnotationOutput = {
9370
9580
  condition_id: z.string().optional(),
9371
9581
  note: z.string()
9372
9582
  };
9583
+ var censusOccurrence = {
9584
+ at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px)"),
9585
+ value: z.string().describe("The paired value drawn under the tag (CFM, GPM, a count \u2014 the annotation that makes it an instance)"),
9586
+ sheet: z.string()
9587
+ };
9588
+ var censusWithheld = {
9589
+ at: z.tuple([z.number(), z.number()]).describe("The tag's center (image px) \u2014 view_sheet here"),
9590
+ sheet: z.string(),
9591
+ reason: z.string()
9592
+ };
9593
+ var countMarksOutput = {
9594
+ marks: z.array(z.object({
9595
+ mark: z.string(),
9596
+ count: z.number().int().describe("Value-paired instances counted on plan-role sheets"),
9597
+ 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)"),
9598
+ unscheduled: z.boolean().optional().describe("true when the mark was stated by the caller but no schedule row answers for it"),
9599
+ occurrences: z.array(z.object(censusOccurrence)),
9600
+ occurrences_elided: z.number().int().optional(),
9601
+ withheld: z.array(z.object(censusWithheld)).describe("Tag occurrences that did NOT count, each with the reason \u2014 read them, look, resolve or report"),
9602
+ withheld_elided: z.number().int().optional(),
9603
+ committed: z.object({ committed: z.number().int(), ea_total: z.number() }).optional()
9604
+ })),
9605
+ total: z.number().int().describe("All counted instances across every mark"),
9606
+ per_sheet: z.array(z.object({ sheet: z.string(), counts: z.record(z.number().int()) })),
9607
+ excluded_in_tables: z.number().int().optional().describe("Tag occurrences inside a schedule table's own region \u2014 row labels, never instances"),
9608
+ skipped: z.array(z.object({ sheet: z.string(), role: z.string(), reason: z.string() })),
9609
+ complete: z.boolean()
9610
+ };
9373
9611
 
9374
9612
  // src/marked.ts
9375
9613
  import path3 from "node:path";
@@ -11135,6 +11373,14 @@ function registerTools(realServer, session) {
11135
11373
  mirror: a.mirror,
11136
11374
  tolerancePx: a.tolerance_px
11137
11375
  })));
11376
+ server.registerTool("count_marks", {
11377
+ 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}`,
11378
+ inputSchema: {
11379
+ 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`),
11380
+ commit: z2.boolean().default(false).describe("Commit every counted occurrence as one EA count marker under its mark (withheld/excluded never commit)")
11381
+ },
11382
+ outputSchema: countMarksOutput
11383
+ }, run("count_marks", (a) => session.countMarks({ marks: a.marks, commit: a.commit })));
11138
11384
  server.registerTool("derive_base", {
11139
11385
  description: `Mint the wall base from committed rooms (#148) \u2014 the estimator's most mechanical derivation: base LF = room perimeter \u2212 stated door openings. For every floor_area shape of source_condition, commits ONE linear shape under condition (e.g. 'RB-1') tracing that room's boundary, quantified NET of the openings you state per room. The openings are YOUR claim to make \u2014 look at the doors with view_sheet, state {shape_id, lf} per room (repeat a shape_id to stack openings); the tool never guesses, and your claim is recorded on origin.derived (from_shape_id, gross_lf, openings_lf). All-or-nothing: an unknown shape_id, a negative lf, or openings meeting a room's whole perimeter refuses the call before anything commits. The whole derivation is ONE undo step. Deriving onto the source condition is refused \u2014 base lands on its own tag.`,
11140
11386
  inputSchema: {
@@ -11544,6 +11790,7 @@ var TOOL_STAGES = {
11544
11790
  "measure_line",
11545
11791
  "measure_surface",
11546
11792
  "place_count",
11793
+ "count_marks",
11547
11794
  "symbol_sweep",
11548
11795
  "sweep_schedule_row",
11549
11796
  "derive_base",
@@ -11659,7 +11906,7 @@ function nameTheStageInRefusals(server) {
11659
11906
  // package.json
11660
11907
  var package_default = {
11661
11908
  name: "opentakeoff-mcp",
11662
- version: "0.9.56",
11909
+ version: "0.9.60",
11663
11910
  mcpName: "io.github.Kentucky-ai/opentakeoff",
11664
11911
  type: "module",
11665
11912
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -11741,12 +11988,12 @@ function buildServer(session = new Session(), opts = {}) {
11741
11988
  "OpenTakeoff: quantity takeoff on construction plan PDFs.",
11742
11989
  "A takeoff's deliverable is the marked-up planset, not a numbers report. Standard finish for ANY takeoff:",
11743
11990
  "1. load_plan, then set_scale on each sheet you measure (quantities are px-only until the scale is set).",
11744
- "2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row).",
11991
+ "2. Commit shapes under finish-tag conditions (one_click / detect_rooms / measure_polygon / measure_line with `condition`; when the set carries a room-finish schedule, prefer detect_rooms assign_from_schedule so each room commits under its OWN row). A COUNT takeoff of value-annotated device marks (GRDs, fixtures, equipment \u2014 the tag-over-value pattern) starts with count_marks {commit: true}: the whole census in one deterministic call, then audit its withheld entries \u2014 reach for the agent-driven per-mark tools only where it refuses or withholds.",
11745
11992
  "3. DERIVE what follows from the rooms instead of re-measuring it: derive_base for base LF (perimeter \u2212 the door openings YOU state), derive_transitions for the line where two finishes meet. Both read committed floor shapes, so they come after step 2 and their output is audited in step 4 like anything else.",
11746
11993
  "4. LOOK at what landed with view_sheet overlay:true and fix misses with edit_shape before trusting totals \u2014 crop the work region tight (full-sheet renders downsample too far to audit a ring).",
11747
11994
  "5. Finish by writing the marked-up planset with export_marked_pdf and give the user its file path, alongside export_report for the numbers. Never end a takeoff with numbers alone.",
11748
11995
  "A floor split across sheets at a MATCH LINE: there is no stitch verb, deliberately \u2014 joining and aligning a match line is human judgment in the canvas (a sloppy join silently skews every seam-crossing quantity). Measure each member sheet as its own surface and tell the user a seam-crossing room needs their stitch in the app; never approximate one by combining sheets yourself.",
11749
- "WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse.",
11996
+ "WITHHELD IS NOT A FAILURE \u2014 IT IS THE ANSWER. detect_rooms, symbol_sweep, sweep_schedule_row, count_marks and derive_transitions all measure things they then decline to commit, and say why: a near-match in the score band, a room the schedule cannot answer for, adjacency across a WALL rather than a butt joint. Read those arrays, view_sheet the coordinates they hand you, and resolve them or report them. A withheld item you ignore is a hole in the bid; one you never mention is worse.",
11750
11997
  ...staged ? [STAGED_INSTRUCTIONS] : []
11751
11998
  ].join("\n")
11752
11999
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.56",
3
+ "version": "0.9.60",
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.",