klypix-mcp 1.48.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.48.0",
3
+ "version": "1.49.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -31,7 +31,7 @@
31
31
  // resolves to the edit — no-loss wins over delete).
32
32
 
33
33
  import fs from 'node:fs';
34
- import { mergeBrains } from './merge-brains.mjs';
34
+ import { mergeBrains, sameMeaning } from './merge-brains.mjs';
35
35
  import { parseKlypix, shard } from './klypix-format.mjs';
36
36
 
37
37
  // id -> verbatim item JSON string for one side (null for an empty/absent side).
@@ -68,8 +68,11 @@ try {
68
68
  const inA = ai.has(id), inB = ti.has(id);
69
69
  if (inA && inB) continue; // alive on both
70
70
  if (!inA && !inB) { deletedIds.push(id); continue; } // deleted on both
71
+ // "Untouched" by MEANING, not bytes — a side that merely re-saved the
72
+ // file restamps volatile fields (updatedAt), and a byte compare would
73
+ // read that as an edit and silently refuse to propagate a real delete.
71
74
  const survivorJson = inA ? ai.get(id) : ti.get(id);
72
- if (survivorJson === baseJson) deletedIds.push(id); // delete vs untouched → honor
75
+ if (sameMeaning(survivorJson, baseJson)) deletedIds.push(id); // delete vs untouched → honor
73
76
  // delete vs EDIT → no tombstone; union keeps the edited card
74
77
  }
75
78
  }
@@ -40,9 +40,56 @@ const isValidZKey = (k) => { try { generateKeyBetween(k, null); return true; } c
40
40
  const rand = () => Math.random().toString(36).slice(2, 10);
41
41
  const ARCHIVE = /^archive$/i;
42
42
 
43
- // Load one .klypix buffer into a flat, comparison-friendly shape. Unchanged
44
- // cards across ancestor/descendant buffers keep byte-identical item JSON, so a
45
- // simple string compare detects real content edits.
43
+ // ── Semantic item comparison (2026-08-01 field fix) ─────────────────────────
44
+ // A raw byte compare of item JSON was the change detector, on the assumption
45
+ // that "unchanged cards keep byte-identical JSON". That assumption DIED the
46
+ // day cards gained touch metadata: `updatedAt` is restamped whenever a card is
47
+ // written, so two sides holding the SAME card with the SAME text differ in
48
+ // bytes — and every first real sync spawned __agconf conflict twins for cards
49
+ // nobody edited (field-proven on the founder's pump-doctor brain: 5 twins,
50
+ // differing field list = ["updatedAt"] exactly).
51
+ //
52
+ // The fix is the same discipline the sync core and the brain diff already use:
53
+ // compare PARSED MEANING with volatile/derived fields stripped, key-sorted so
54
+ // two writers' key orders can't fake a difference. Byte-compare survives as the
55
+ // fallback for anything unparseable — a malformed item must never crash a merge.
56
+ //
57
+ // VOLATILE = written by the act of saving, not by a human/agent decision:
58
+ // updatedAt — touch timestamp zIndex — display order derived from zKey
59
+ // Everything else (content, colors, geometry, evidence, author…) stays load-
60
+ // bearing: a real edit to any of them is still a real conflict.
61
+ const VOLATILE_ITEM_FIELDS = ['updatedAt', 'zIndex'];
62
+
63
+ const sortedStable = (v) => JSON.stringify(v, (_k, val) =>
64
+ (val && typeof val === 'object' && !Array.isArray(val))
65
+ ? Object.fromEntries(Object.keys(val).sort().map(k => [k, val[k]]))
66
+ : val);
67
+
68
+ function itemSignature(json) {
69
+ if (json == null) return null;
70
+ try {
71
+ const obj = JSON.parse(json);
72
+ for (const f of VOLATILE_ITEM_FIELDS) delete obj[f];
73
+ return sortedStable(obj);
74
+ } catch {
75
+ return json; // unparseable → byte identity, as before
76
+ }
77
+ }
78
+
79
+ /** True when two item JSON strings mean the same thing (volatile fields aside).
80
+ * EXPORTED as the single definition of "did this card actually change" — the
81
+ * git merge driver and the Brain Sync core both decide committed-absence
82
+ * tombstones with it, so all three transports agree on what an edit is. */
83
+ export const sameMeaning = (a, b) => {
84
+ if (a === b) return true; // fast path: byte-identical
85
+ if (a == null || b == null) return false;
86
+ return itemSignature(a) === itemSignature(b);
87
+ };
88
+
89
+ // Load one .klypix buffer into a flat, comparison-friendly shape. Item JSON is
90
+ // kept VERBATIM (the merge must write back exactly what a side held); whether
91
+ // two versions actually differ is decided by sameMeaning(), never by these
92
+ // bytes — see its note on volatile fields.
46
93
  async function loadSide(buf) {
47
94
  if (!buf) return null;
48
95
  const { zip, canvas, manifest, struct } = await parseKlypix(buf);
@@ -128,9 +175,13 @@ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }
128
175
  // ── Choose CONTENT ──────────────────────────────────────────────────────
129
176
  let json, side;
130
177
  if (inO && inT) {
131
- const oChg = !inB || O.items[id] !== baseItem(id);
132
- const tChg = !inB || T.items[id] !== baseItem(id);
133
- if (inB && oChg && tChg && O.items[id] !== T.items[id]) {
178
+ // Change + divergence are judged by MEANING, not bytes (see sameMeaning):
179
+ // a restamped `updatedAt` is not an edit, and two copies of one card that
180
+ // differ only in volatile fields are not in conflict.
181
+ const oChg = !inB || !sameMeaning(O.items[id], baseItem(id));
182
+ const tChg = !inB || !sameMeaning(T.items[id], baseItem(id));
183
+ const diverged = !sameMeaning(O.items[id], T.items[id]);
184
+ if (inB && oChg && tChg && diverged) {
134
185
  // GENUINE content conflict: a card that EXISTED at open, edited differently
135
186
  // on both sides → human stays live, agent version preserved as a twin.
136
187
  json = O.items[id]; side = 'ours';
@@ -138,7 +189,7 @@ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }
138
189
  extras.push({ id: twinId, json: T.items[id], srcPos: T.positions[id] || O.positions[id], of: id });
139
190
  conflicts.push({ id, kind: 'content', keptLive: 'ours', twin: twinId });
140
191
  } else if (tChg && !oChg) { json = T.items[id]; side = 'theirs'; delta.updated.push(id); }
141
- else if (!inB && O.items[id] !== T.items[id]) {
192
+ else if (!inB && diverged) {
142
193
  // Same NEW card (same id) present on BOTH sides but never in base — e.g. an
143
194
  // agent card the app also holds via live-apply, re-serialized slightly
144
195
  // differently. It's the SAME card, NOT a conflict → take the disk/agent