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 +1 -1
- package/src/klypix-merge-driver.mjs +5 -2
- package/src/merge-brains.mjs +58 -7
package/package.json
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/merge-brains.mjs
CHANGED
|
@@ -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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 &&
|
|
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
|