opentakeoff-mcp 0.9.30 → 0.9.32

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 +378 -15
  2. package/package.json +2 -2
@@ -3043,6 +3043,136 @@ var fail = (err) => ({
3043
3043
  var round2 = (n) => +n.toFixed(2);
3044
3044
  var round1 = (n) => +n.toFixed(1);
3045
3045
 
3046
+ // ../web/src/lib/variants.ts
3047
+ var SEP = " \u2013 ";
3048
+ var ROW_LINK_FIELDS = ["id", "origin_id", "inherited"];
3049
+ function baseTagOf(tag) {
3050
+ const s = String(tag ?? "");
3051
+ const i = s.indexOf(SEP);
3052
+ return (i === -1 ? s : s.slice(0, i)).trim();
3053
+ }
3054
+ function variantTag(baseOrTag, label) {
3055
+ const base = baseTagOf(baseOrTag);
3056
+ const l = String(label ?? "").trim();
3057
+ return l ? `${base}${SEP}${l}` : base;
3058
+ }
3059
+ function copyRow(row, id, originId) {
3060
+ return { ...row, id, origin_id: originId, inherited: true };
3061
+ }
3062
+ function childrenOf(conds, parentId) {
3063
+ return conds.filter((c) => c.variant_of === parentId);
3064
+ }
3065
+ function stripLinks(patch) {
3066
+ const out = { ...patch };
3067
+ for (const f of ROW_LINK_FIELDS) delete out[f];
3068
+ return out;
3069
+ }
3070
+ function mintTwin(parent, opts) {
3071
+ const family_id = parent.family_id || opts.mintId("fam");
3072
+ const materials = (parent.materials || []).map((r) => copyRow(r, opts.mintId("mat"), r.id));
3073
+ const twin = {
3074
+ ...parent,
3075
+ id: opts.mintId("cnd"),
3076
+ created_at: opts.nowIso(),
3077
+ finish_tag: opts.tag || variantTag(parent.finish_tag, opts.label),
3078
+ family_id,
3079
+ variant_of: parent.id,
3080
+ variant_label: String(opts.label ?? "").trim() || void 0,
3081
+ materials,
3082
+ ...opts.nextHatch ? { hatch: opts.nextHatch } : {}
3083
+ };
3084
+ delete twin.materials_dropped;
3085
+ delete twin.updated_at;
3086
+ if (twin.attrs && typeof twin.attrs === "object") twin.attrs = { ...twin.attrs };
3087
+ return { twin, parentPatch: parent.family_id ? null : { family_id } };
3088
+ }
3089
+ function propagateRowPatch(conds, parentId, originId, patch, seen = /* @__PURE__ */ new Set()) {
3090
+ if (seen.has(parentId)) return conds;
3091
+ seen.add(parentId);
3092
+ const clean = stripLinks(patch);
3093
+ let out = conds;
3094
+ for (const child of childrenOf(conds, parentId)) {
3095
+ if ((child.materials_dropped || []).includes(originId)) continue;
3096
+ const row = (child.materials || []).find((r) => r.origin_id === originId && r.inherited);
3097
+ if (!row) continue;
3098
+ out = out.map((c) => c.id !== child.id ? c : {
3099
+ ...c,
3100
+ materials: (c.materials || []).map((r) => r.id !== row.id ? r : { ...r, ...clean, id: r.id, origin_id: r.origin_id, inherited: true })
3101
+ });
3102
+ out = propagateRowPatch(out, child.id, row.id, clean, seen);
3103
+ }
3104
+ return out;
3105
+ }
3106
+ function propagateRowAdd(conds, parentId, parentRow, mintId, seen = /* @__PURE__ */ new Set()) {
3107
+ if (seen.has(parentId)) return conds;
3108
+ seen.add(parentId);
3109
+ let out = conds;
3110
+ for (const child of childrenOf(conds, parentId)) {
3111
+ if ((child.materials_dropped || []).includes(parentRow.id)) continue;
3112
+ if ((child.materials || []).some((r) => r.origin_id === parentRow.id)) continue;
3113
+ const row = copyRow(parentRow, mintId("mat"), parentRow.id);
3114
+ out = out.map((c) => c.id !== child.id ? c : { ...c, materials: [...c.materials || [], row] });
3115
+ out = propagateRowAdd(out, child.id, row, mintId, seen);
3116
+ }
3117
+ return out;
3118
+ }
3119
+ function propagateRowRemove(conds, parentId, originId, seen = /* @__PURE__ */ new Set()) {
3120
+ if (seen.has(parentId)) return conds;
3121
+ seen.add(parentId);
3122
+ let out = conds;
3123
+ for (const child of childrenOf(conds, parentId)) {
3124
+ const row = (child.materials || []).find((r) => r.origin_id === originId);
3125
+ if (!row) continue;
3126
+ if (row.inherited) {
3127
+ out = out.map((c) => c.id !== child.id ? c : { ...c, materials: (c.materials || []).filter((r) => r.id !== row.id) });
3128
+ out = propagateRowRemove(out, child.id, row.id, seen);
3129
+ } else {
3130
+ out = out.map((c) => c.id !== child.id ? c : {
3131
+ ...c,
3132
+ materials: (c.materials || []).map((r) => {
3133
+ if (r.id !== row.id) return r;
3134
+ const rest = { ...r };
3135
+ delete rest.origin_id;
3136
+ delete rest.inherited;
3137
+ return rest;
3138
+ })
3139
+ });
3140
+ }
3141
+ }
3142
+ return out;
3143
+ }
3144
+ function markRowLocal(cond, id) {
3145
+ return {
3146
+ ...cond,
3147
+ materials: (cond.materials || []).map((r) => r.id === id ? { ...r, inherited: false } : r)
3148
+ };
3149
+ }
3150
+ function dropRowLocal(cond, id) {
3151
+ const row = (cond.materials || []).find((r) => r.id === id);
3152
+ const materials = (cond.materials || []).filter((r) => r.id !== id);
3153
+ if (!row?.inherited || !row.origin_id) return { ...cond, materials };
3154
+ const dropped = [...cond.materials_dropped || []];
3155
+ if (!dropped.includes(row.origin_id)) dropped.push(row.origin_id);
3156
+ return { ...cond, materials, materials_dropped: dropped };
3157
+ }
3158
+ function splitFromFamily(conds, condId) {
3159
+ return conds.map((c) => {
3160
+ if (c.id !== condId) return c;
3161
+ const next = {
3162
+ ...c,
3163
+ materials: (c.materials || []).map((r) => {
3164
+ const rest = { ...r };
3165
+ delete rest.origin_id;
3166
+ delete rest.inherited;
3167
+ return rest;
3168
+ })
3169
+ };
3170
+ delete next.variant_of;
3171
+ delete next.materials_dropped;
3172
+ return next;
3173
+ });
3174
+ }
3175
+
3046
3176
  // ../web/src/lib/confidence.ts
3047
3177
  var CONF_RASTER = 0.9;
3048
3178
  var CONF_HATCH = 0.95;
@@ -4500,6 +4630,7 @@ var PALETTE = ["#c96442", "#2f7d54", "#2563eb", "#9333ea", "#b8860b", "#0d9488",
4500
4630
  var HATCH_IDS = ["solid", "diag", "diag2", "cross", "diagdense", "horiz", "vert", "grid", "brick", "plank", "herring", "basket", "checker", "wave", "dots", "speckle", "iso", "honeycomb", "scan", "plus", "circuit", "topo"];
4501
4631
  var mintUuid2 = () => globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
4502
4632
  var uid = (p) => `${p}-${mintUuid2()}`;
4633
+ var nowIso2 = () => (/* @__PURE__ */ new Date()).toISOString();
4503
4634
  var ANN_SCHEMA = "opentakeoff.takeoff_canvas.v1";
4504
4635
  var sanitizeApprovals2 = sanitizeApprovals;
4505
4636
  var applyApprovalCommand2 = applyApprovalCommand;
@@ -6249,7 +6380,19 @@ var Session = class _Session {
6249
6380
  if (!Object.keys(patch[i].fields).length) throw new UserError(`patch[${i}]: fields must be non-empty.`);
6250
6381
  }
6251
6382
  const c = this.conditionFor(tag);
6252
- const before = structuredClone(c.materials);
6383
+ const cid = c.id;
6384
+ const snap = [cid, ...this.descendantConditionIds(cid)].map((id) => {
6385
+ const x = this.conditions.find((q) => q.id === id);
6386
+ return {
6387
+ condition_id: id,
6388
+ before: structuredClone(x.materials),
6389
+ ...x.materials_dropped ? { dropped_before: [...x.materials_dropped] } : {}
6390
+ };
6391
+ });
6392
+ let conds = this.conditions;
6393
+ const isParent = () => conds.some((x) => x.variant_of === cid);
6394
+ const target = () => conds.find((x) => x.id === cid);
6395
+ const mint = (p) => uid(p);
6253
6396
  const added = [];
6254
6397
  for (const a of add) {
6255
6398
  const row = {
@@ -6261,23 +6404,57 @@ var Session = class _Session {
6261
6404
  round: a.round ?? true,
6262
6405
  ...a.note ? { note: a.note } : {}
6263
6406
  };
6264
- c.materials.push(row);
6407
+ conds = conds.map((x) => x.id !== cid ? x : { ...x, materials: [...x.materials || [], row] });
6265
6408
  added.push(row.id);
6409
+ if (isParent()) conds = propagateRowAdd(conds, cid, row, mint);
6266
6410
  }
6267
6411
  const removed = new Set(remove);
6268
- if (removed.size) c.materials = c.materials.filter((m) => !removed.has(m.id));
6412
+ for (const id of remove) {
6413
+ if (target().variant_of) {
6414
+ conds = conds.map((x) => x.id !== cid ? x : dropRowLocal(x, id));
6415
+ } else {
6416
+ conds = conds.map((x) => x.id !== cid ? x : { ...x, materials: (x.materials || []).filter((r) => r.id !== id) });
6417
+ conds = propagateRowRemove(conds, cid, id);
6418
+ }
6419
+ }
6269
6420
  const patched = [];
6270
6421
  for (const p of patch) {
6271
- const m = c.materials.find((x) => x.id === p.id);
6272
- Object.assign(m, p.fields);
6422
+ conds = conds.map((x) => x.id !== cid ? x : {
6423
+ ...x,
6424
+ materials: (x.materials || []).map((r) => {
6425
+ if (r.id !== p.id) return r;
6426
+ const merged = { ...r, ...p.fields, id: r.id };
6427
+ if (r.origin_id === void 0) delete merged.origin_id;
6428
+ else merged.origin_id = r.origin_id;
6429
+ if (r.inherited === void 0) delete merged.inherited;
6430
+ else merged.inherited = r.inherited;
6431
+ return merged;
6432
+ })
6433
+ });
6273
6434
  patched.push(p.id);
6274
- }
6275
- this.record({ op: "materials", tool: "edit_materials", condition_id: c.id, before });
6435
+ const cur = target();
6436
+ if (cur.variant_of) {
6437
+ conds = conds.map((x) => x.id !== cid ? x : markRowLocal(x, p.id));
6438
+ } else if (isParent()) {
6439
+ const row = (cur.materials || []).find((r) => r.id === p.id);
6440
+ if (row) conds = propagateRowPatch(conds, cid, p.id, row);
6441
+ }
6442
+ }
6443
+ this.conditions = conds;
6444
+ const [primary, ...familySnap] = snap;
6445
+ this.record({
6446
+ op: "materials",
6447
+ tool: "edit_materials",
6448
+ condition_id: cid,
6449
+ before: primary.before,
6450
+ ...primary.dropped_before ? { dropped_before: primary.dropped_before } : {},
6451
+ ...familySnap.length ? { family: familySnap } : {}
6452
+ });
6276
6453
  return {
6277
6454
  condition: tag,
6278
- condition_id: c.id,
6455
+ condition_id: cid,
6279
6456
  changed: { added, removed: [...removed], patched },
6280
- materials: c.materials
6457
+ materials: target().materials
6281
6458
  };
6282
6459
  }
6283
6460
  /** Set a condition's quantity knobs — waste % and multiplier. Both are
@@ -6347,6 +6524,117 @@ var Session = class _Session {
6347
6524
  ...roll ? { roll } : {}
6348
6525
  };
6349
6526
  }
6527
+ /**
6528
+ * Twin a condition — the same finish measured somewhere else, with its own materials.
6529
+ *
6530
+ * The same sheet goods over a slab and over a raised deck take the same field material and
6531
+ * different preparation underneath. The twin carries the original's whole materials list and
6532
+ * keeps FOLLOWING it: change a coverage rate on the original and every twin that hasn't
6533
+ * touched that row gets it; edit a row on the twin and only that row goes local. The rule
6534
+ * lives in web/src/lib/variants.ts — one copy, shared with the canvas, so a headless session
6535
+ * and the app can never disagree about what a twin holds.
6536
+ *
6537
+ * A twin needs its OWN tag, and that is not cosmetic here: every tool in this server resolves
6538
+ * a condition by finish tag and takes the FIRST match, so two conditions sharing one would
6539
+ * make the second permanently unreachable — and a takeoff re-import collapses them last-wins.
6540
+ * So the label is required and a collision is refused rather than de-collided.
6541
+ */
6542
+ /** Every condition below `rootId` in the family tree, children first. The
6543
+ * propagate functions walk this same tree; undo snapshots ride it so a
6544
+ * family edit's inverse restores exactly the set the write could touch.
6545
+ * The `seen` guard mirrors variants.ts — a hand-edited payload with a
6546
+ * cycle must not hang the session. */
6547
+ descendantConditionIds(rootId) {
6548
+ const out = [];
6549
+ const seen = /* @__PURE__ */ new Set([rootId]);
6550
+ const walk = (pid) => {
6551
+ for (const child of this.conditions) {
6552
+ if (child.variant_of === pid && !seen.has(child.id)) {
6553
+ seen.add(child.id);
6554
+ out.push(child.id);
6555
+ walk(child.id);
6556
+ }
6557
+ }
6558
+ };
6559
+ walk(rootId);
6560
+ return out;
6561
+ }
6562
+ duplicateCondition(tag, label) {
6563
+ const src = this.conditions.find((x) => x.finish_tag === tag);
6564
+ if (!src) {
6565
+ const known = this.conditions.map((x) => x.finish_tag);
6566
+ throw new UserError(`No condition ${JSON.stringify(tag)} to duplicate.${known.length ? ` Known tags: ${known.join(", ")}.` : ""}`);
6567
+ }
6568
+ const lab = String(label || "").trim();
6569
+ if (!lab) throw new UserError("label is required \u2014 it is what gives the twin its own finish tag, and a tag is how every tool here resolves a condition.");
6570
+ const newTag = variantTag(src.finish_tag, lab);
6571
+ if (this.conditions.some((x) => x.finish_tag.trim().toUpperCase() === newTag.trim().toUpperCase())) {
6572
+ throw new UserError(`A condition is already called ${JSON.stringify(newTag)} \u2014 pick a different label. Two conditions sharing a tag would make one of them unreachable to every tool.`);
6573
+ }
6574
+ const { twin, parentPatch } = mintTwin(src, {
6575
+ label: lab,
6576
+ tag: newTag,
6577
+ mintId: (p) => uid(p),
6578
+ nowIso: nowIso2,
6579
+ nextHatch: HATCH_IDS[1 + (this.conditions.length + 1) % (HATCH_IDS.length - 1)]
6580
+ });
6581
+ if (parentPatch) Object.assign(src, parentPatch);
6582
+ this.conditions.push(twin);
6583
+ this.record({
6584
+ op: "duplicate_condition",
6585
+ tool: "duplicate_condition",
6586
+ condition_id: twin.id,
6587
+ parent_id: src.id,
6588
+ parent_had_family: !parentPatch
6589
+ });
6590
+ return {
6591
+ condition: newTag,
6592
+ condition_id: twin.id,
6593
+ variant_of: src.id,
6594
+ variant_label: lab,
6595
+ family_id: twin.family_id,
6596
+ inherited_rows: (twin.materials || []).length,
6597
+ note: `Materials follow ${src.finish_tag} until you edit them on this condition. No takeoffs came along \u2014 measure into ${newTag}.`
6598
+ };
6599
+ }
6600
+ /** Cut a twin loose: every following row freezes where it stands. It KEEPS its family_id, so
6601
+ * it still groups with its siblings — only the inheritance ends. */
6602
+ splitCondition(tag) {
6603
+ const c = this.conditions.find((x) => x.finish_tag === tag);
6604
+ if (!c) {
6605
+ const known = this.conditions.map((x) => x.finish_tag);
6606
+ throw new UserError(`No condition ${JSON.stringify(tag)}.${known.length ? ` Known tags: ${known.join(", ")}.` : ""}`);
6607
+ }
6608
+ const cond = c;
6609
+ if (!cond.variant_of) {
6610
+ return {
6611
+ condition: tag,
6612
+ condition_id: c.id,
6613
+ split: false,
6614
+ frozen_rows: 0,
6615
+ note: "Already owns its materials \u2014 nothing was following."
6616
+ };
6617
+ }
6618
+ const frozen = (cond.materials || []).filter((r) => r.inherited).length;
6619
+ const before = structuredClone({
6620
+ variant_of: cond.variant_of,
6621
+ materials: cond.materials,
6622
+ materials_dropped: cond.materials_dropped
6623
+ });
6624
+ const [next] = splitFromFamily([cond], cond.id);
6625
+ Object.assign(c, next);
6626
+ delete c.variant_of;
6627
+ delete c.materials_dropped;
6628
+ this.record({ op: "split_condition", tool: "split_condition", condition_id: c.id, before });
6629
+ return {
6630
+ condition: tag,
6631
+ condition_id: c.id,
6632
+ split: true,
6633
+ frozen_rows: frozen,
6634
+ family_id: cond.family_id,
6635
+ note: "Frozen at its current values; edits to the original no longer reach it. It still groups with its family."
6636
+ };
6637
+ }
6350
6638
  /** Step back over this session's own last n mutations, newest first. Each
6351
6639
  * entry's inverse is exact (see JournalEntry), so this restores state rather
6352
6640
  * than approximating it. Reads are not journaled, so undo never has to step
@@ -6365,8 +6653,15 @@ var Session = class _Session {
6365
6653
  if (i >= 0) this.shapes[i] = e.before;
6366
6654
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: i >= 0 ? 1 : 0 });
6367
6655
  } else if (e.op === "materials") {
6368
- const c = this.conditions.find((x) => x.id === e.condition_id);
6369
- if (c) c.materials = e.before;
6656
+ const put = (condition_id, before, dropped) => {
6657
+ const cc = this.conditions.find((x) => x.id === condition_id);
6658
+ if (!cc) return;
6659
+ cc.materials = before;
6660
+ if (dropped === void 0) delete cc.materials_dropped;
6661
+ else cc.materials_dropped = dropped;
6662
+ };
6663
+ put(e.condition_id, e.before, e.dropped_before);
6664
+ for (const f of e.family ?? []) put(f.condition_id, f.before, f.dropped_before);
6370
6665
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
6371
6666
  } else if (e.op === "condition") {
6372
6667
  const c = this.conditions.find((x) => x.id === e.condition_id);
@@ -6379,6 +6674,24 @@ var Session = class _Session {
6379
6674
  else c.roll_setup = e.before.roll_setup;
6380
6675
  }
6381
6676
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
6677
+ } else if (e.op === "duplicate_condition") {
6678
+ const at = this.conditions.findIndex((x) => x.id === e.condition_id);
6679
+ if (at >= 0) this.conditions.splice(at, 1);
6680
+ if (!e.parent_had_family) {
6681
+ const parent = this.conditions.find((x) => x.id === e.parent_id);
6682
+ if (parent) delete parent.family_id;
6683
+ }
6684
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
6685
+ } else if (e.op === "split_condition") {
6686
+ const c = this.conditions.find((x) => x.id === e.condition_id);
6687
+ if (c) {
6688
+ if (e.before.variant_of === void 0) delete c.variant_of;
6689
+ else c.variant_of = e.before.variant_of;
6690
+ c.materials = structuredClone(e.before.materials);
6691
+ if (e.before.materials_dropped === void 0) delete c.materials_dropped;
6692
+ else c.materials_dropped = structuredClone(e.before.materials_dropped);
6693
+ }
6694
+ undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
6382
6695
  } else if (e.op === "approval") {
6383
6696
  this.approvals = applyApprovalCommand2(this.approvals, e.inverse).approvals;
6384
6697
  undone.push({ seq: e.seq, op: e.op, tool: e.tool, shapes: 0 });
@@ -7201,7 +7514,7 @@ var undoLastOutput = {
7201
7514
  undone: z.number().int().describe("Steps actually reversed"),
7202
7515
  steps: z.array(z.object({
7203
7516
  seq: z.number().int(),
7204
- op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval"]),
7517
+ op: z.enum(["commit", "edit", "delete", "materials", "condition", "approval", "duplicate_condition", "split_condition"]),
7205
7518
  tool: z.string().describe("The tool call this step came from"),
7206
7519
  shapes: z.number().int().describe("Shapes affected by reversing this step \u2014 0 for a materials step (it restores a condition's supporting-materials rows, not shapes), for a condition step (it restores the waste/multiplier pair), and for an approval step (it re-seats or removes a verdict mark)")
7207
7520
  })).describe("Newest first"),
@@ -7227,7 +7540,9 @@ var materialRow = z.object({
7227
7540
  basis: z.enum(["area", "linear", "count"]).describe("Which of the condition's totals this row's quantity is computed against"),
7228
7541
  unit: z.string(),
7229
7542
  round: z.boolean().describe("true = round up to whole purchase units (the default \u2014 you buy whole bags/buckets)"),
7230
- note: z.string().optional()
7543
+ note: z.string().optional(),
7544
+ origin_id: z.string().optional().describe("On a twin: the parent row this one follows (the variants.ts family link)"),
7545
+ inherited: z.boolean().optional().describe("On a twin: true while the row still follows the family \u2014 a patch on it takes it local, split_condition freezes them all")
7231
7546
  });
7232
7547
  var editMaterialsOutput = {
7233
7548
  condition: z.string().describe("The finish tag passed in"),
@@ -7282,6 +7597,23 @@ var exportMarkedPdfOutput = {
7282
7597
  approvals_drawn: z.number().int().describe("Approval-family glyphs burned in (#176) \u2014 estimator APPROVED rings + agent AGENT diamonds; the cover tallies the split when any exist"),
7283
7598
  note: z.string()
7284
7599
  };
7600
+ var duplicateConditionOutput = {
7601
+ condition: z.string().describe("The twin's finish tag \u2014 base tag + the label, e.g. 'CPT-1 \u2013 Level 2'"),
7602
+ condition_id: z.string().describe("The TWIN \u2014 measure the new area against this"),
7603
+ variant_of: z.string().describe("The condition whose material rows this one follows"),
7604
+ variant_label: z.string(),
7605
+ family_id: z.string().describe("Shared by every variant of this finish \u2014 survives a split"),
7606
+ inherited_rows: z.number().int().describe("Material rows copied, all still following the original"),
7607
+ note: z.string()
7608
+ };
7609
+ var splitConditionOutput = {
7610
+ condition: z.string(),
7611
+ condition_id: z.string(),
7612
+ split: z.boolean().describe("false = it already owned its materials; nothing was following"),
7613
+ frozen_rows: z.number().int().describe("Following rows frozen at their current values"),
7614
+ family_id: z.string().optional().describe("Kept \u2014 it still groups with its siblings"),
7615
+ note: z.string()
7616
+ };
7285
7617
  var editConditionOutput = {
7286
7618
  condition: z.string().describe("The finish tag passed in"),
7287
7619
  condition_id: z.string(),
@@ -8789,6 +9121,7 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
8789
9121
  const byTag = new Map(conditions.map((c) => [tagKey(c.finish_tag), c.id]));
8790
9122
  const condIds = new Set(conditions.map((c) => c.id));
8791
9123
  const condMap = /* @__PURE__ */ new Map();
9124
+ const addedCondIds = /* @__PURE__ */ new Set();
8792
9125
  let condMerged = 0, condAdded = 0;
8793
9126
  for (const c of impConds) {
8794
9127
  const hit = byTag.get(tagKey(c.finish_tag));
@@ -8803,8 +9136,23 @@ function mergeTakeoffImport(current, imported, knownFiles = null) {
8803
9136
  condIds.add(id);
8804
9137
  byTag.set(tagKey(c.finish_tag), id);
8805
9138
  condMap.set(c.id, id);
9139
+ addedCondIds.add(id);
8806
9140
  condAdded++;
8807
9141
  }
9142
+ for (let i = 0; i < conditions.length; i++) {
9143
+ const c = conditions[i];
9144
+ if (!c?.variant_of || !addedCondIds.has(c.id)) continue;
9145
+ const mapped = condMap.get(c.variant_of);
9146
+ const parentArrived = !!mapped && addedCondIds.has(mapped);
9147
+ if (parentArrived && mapped !== c.variant_of) {
9148
+ conditions[i] = { ...c, variant_of: mapped };
9149
+ } else if (!parentArrived) {
9150
+ const next = { ...c, materials: (c.materials || []).map(({ origin_id: _o, inherited: _i, ...m }) => m) };
9151
+ delete next.variant_of;
9152
+ delete next.materials_dropped;
9153
+ conditions[i] = next;
9154
+ }
9155
+ }
8808
9156
  const shapeIds = new Set(arr(cur.shapes).map((s) => s.id));
8809
9157
  const addedShapes = impShapes.filter((s) => !shapeIds.has(s.id)).map((s) => condMap.has(s.condition_id) && condMap.get(s.condition_id) !== s.condition_id ? { ...s, condition_id: condMap.get(s.condition_id) } : s);
8810
9158
  const markupIds = new Set(arr(cur.markups).map((m) => m?.id).filter(Boolean));
@@ -9195,6 +9543,21 @@ All-or-nothing, like derive_base: an unknown tag, a transition landing on either
9195
9543
  },
9196
9544
  outputSchema: editConditionOutput
9197
9545
  }, run("edit_condition", (a) => session.editCondition(a.condition, { waste_pct: a.waste_pct, multiplier: a.multiplier, height_ft: a.height_ft, roll_setup: a.roll_setup })));
9546
+ server.registerTool("duplicate_condition", {
9547
+ description: `Twin a condition \u2014 the same finish measured somewhere else, with its own supporting materials. One finish in two areas is not two conditions and it is not one either: the same sheet goods over a slab and over a raised deck take the same field material and different preparation underneath (one wants a moisture barrier, the other a primer and a different adhesive). The twin arrives carrying the original's whole materials list and keeps FOLLOWING it \u2014 change a coverage rate on the original and every twin that has not touched that row gets it; edit a row on the twin and only THAT row stops following. \`label\` is REQUIRED and becomes the tag suffix ('CPT-1' + 'Level 2' \u2192 'CPT-1 \u2013 Level 2'), because every tool in this server resolves a condition by finish tag and takes the FIRST match: two conditions sharing a tag would make one permanently unreachable, and a takeoff re-import collapses them last-wins. A label already in use is refused rather than de-collided. No takeoffs come along \u2014 measure the new area against the returned condition_id. Reversible with undo_last; use split_condition to end the inheritance permanently.`,
9548
+ inputSchema: {
9549
+ condition: z2.string().describe("Finish tag of the condition to twin, e.g. 'CPT-1'"),
9550
+ label: z2.string().describe("What makes this one different, usually the area: 'Level 2', 'Building B', 'Phase 2'")
9551
+ },
9552
+ outputSchema: duplicateConditionOutput
9553
+ }, run("duplicate_condition", (a) => session.duplicateCondition(a.condition, a.label)));
9554
+ server.registerTool("split_condition", {
9555
+ description: `Cut a twin loose from its family: every following material row freezes at its current values and edits to the original stop reaching it. It keeps its finish tag and still groups with its siblings \u2014 only the inheritance ends. Use when two variants have diverged far enough that following one another is wrong. A condition that already owns its materials returns split:false rather than erroring. Reversible with undo_last.`,
9556
+ inputSchema: {
9557
+ condition: z2.string().describe("Finish tag of the twin to split, e.g. 'CPT-1 \u2013 Level 2'")
9558
+ },
9559
+ outputSchema: splitConditionOutput
9560
+ }, run("split_condition", (a) => session.splitCondition(a.condition)));
9198
9561
  server.registerTool("undo_last", {
9199
9562
  description: `Step back over your OWN last n mutations, newest first \u2014 a committed one_click, a whole detect_rooms sweep, an edit_shape, a delete_shape, an edit_materials call, or an edit_condition call. Each step is reversed exactly (a commit is removed, an edit is restored verbatim, a delete is re-inserted where it was, a materials edit's whole array is restored, a condition edit's waste/multiplier pair is restored), so this restores state rather than approximating it. Reads are never journaled, so n counts gestures that changed something, not tool calls you made. Use it when a sweep committed against the wrong condition or a batch went in on the wrong sheet \u2014 one call instead of N deletes. Scope: this session's own history only. It is not the browser canvas's undo stack, and load_plan clears it along with the shapes it refers to.`,
9200
9563
  inputSchema: {
@@ -9396,7 +9759,7 @@ function registerResources(server, session) {
9396
9759
  // package.json
9397
9760
  var package_default = {
9398
9761
  name: "opentakeoff-mcp",
9399
- version: "0.9.30",
9762
+ version: "0.9.32",
9400
9763
  mcpName: "io.github.Kentucky-ai/opentakeoff",
9401
9764
  type: "module",
9402
9765
  description: "OpenTakeoff MCP server \u2014 drive the takeoff engine from your MCP client over stdio.",
@@ -9412,7 +9775,7 @@ var package_default = {
9412
9775
  mcpb: "npm run build && node scripts/build-mcpb.mjs",
9413
9776
  prepublishOnly: "npm run typecheck && npm test && npm run build",
9414
9777
  typecheck: "tsc --noEmit",
9415
- test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/view.test.ts"
9778
+ test: "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
9416
9779
  },
9417
9780
  dependencies: {
9418
9781
  "@modelcontextprotocol/sdk": "^1.12.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opentakeoff-mcp",
3
- "version": "0.9.30",
3
+ "version": "0.9.32",
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.",
@@ -16,7 +16,7 @@
16
16
  "mcpb": "npm run build && node scripts/build-mcpb.mjs",
17
17
  "prepublishOnly": "npm run typecheck && npm test && npm run build",
18
18
  "typecheck": "tsc --noEmit",
19
- "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/view.test.ts"
19
+ "test": "node --import tsx --test test/conformance.test.ts test/context.test.ts test/e2e.test.ts test/parity.test.ts test/raster.test.ts test/resources.test.ts test/scalewarn.test.ts test/session.test.ts test/tools.test.ts test/transitions.test.ts test/twins.test.ts test/view.test.ts"
20
20
  },
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.12.0",