openwriter 0.12.1 → 0.14.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/dist/client/assets/index-BxI3DazW.js +212 -0
- package/dist/client/assets/{index-CRImKlcp.css → index-OV13QtgQ.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/backlinks.js +323 -0
- package/dist/server/documents.js +11 -11
- package/dist/server/index.js +132 -6
- package/dist/server/markdown-parse.js +187 -9
- package/dist/server/markdown-serialize.js +97 -2
- package/dist/server/markdown.js +32 -0
- package/dist/server/marks.js +9 -0
- package/dist/server/mcp.js +148 -6
- package/dist/server/node-blocks.js +256 -0
- package/dist/server/node-fingerprint.js +264 -0
- package/dist/server/node-matcher.js +564 -0
- package/dist/server/node-sync-check.js +110 -0
- package/dist/server/state.js +210 -43
- package/dist/server/workspace-routes.js +31 -3
- package/dist/server/workspaces.js +85 -0
- package/package.json +1 -1
- package/skill/SKILL.md +4 -7
- package/dist/client/assets/index-CNmzNvB_.js +0 -211
- package/skill/docs/anti-ai.md +0 -71
- package/skill/docs/voices.md +0 -88
- package/skill/voices/authority.md +0 -102
- package/skill/voices/business.md +0 -103
- package/skill/voices/logical.md +0 -104
- package/skill/voices/provocateur.md +0 -101
- package/skill/voices/storyteller.md +0 -104
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matcher — given an original node graph (with IDs) and a new doc body,
|
|
3
|
+
* produce an updated node graph where surviving blocks keep their IDs.
|
|
4
|
+
*
|
|
5
|
+
* Core principle: the slot is innocent until proven changed. Every mutation
|
|
6
|
+
* rule is a deterministic detector for a specific kind of slot change. If
|
|
7
|
+
* no rule fires for an orphan + unmatched pair, the slot-continuity fallback
|
|
8
|
+
* pairs them by structural position (same type, between same pinned anchors).
|
|
9
|
+
*
|
|
10
|
+
* Rule order:
|
|
11
|
+
* Phase 1: exact fingerprint match, two-pass (mutual-unique + slot-aware)
|
|
12
|
+
* Phase 2 rules (each math-only, deterministic):
|
|
13
|
+
* - N-way split detection (sentence-array concatenation)
|
|
14
|
+
* - N-way merge detection (sentence-array concatenation, reversed)
|
|
15
|
+
* - Type-change detection (TipTap convention: content survives, type changes)
|
|
16
|
+
* - Edit detection (shared sentence tuples, slot-region constrained)
|
|
17
|
+
* - Slot-continuity fallback (same type, same pinned-anchor neighborhood)
|
|
18
|
+
* - Graveyard restore (paste-back / undo of recently deleted blocks)
|
|
19
|
+
* - Insert (any block still unmatched → fresh ID)
|
|
20
|
+
* Phase 3: orphans = previousNodes entries no rule claimed (= deletes)
|
|
21
|
+
*
|
|
22
|
+
* Fingerprints use math signals (per-sentence char count, 3-char prefix/suffix,
|
|
23
|
+
* terminator, word-length sequence) plus full word arrays for math-collision
|
|
24
|
+
* disambiguation. Documented in node-fingerprint.ts.
|
|
25
|
+
*
|
|
26
|
+
* adr: adr/node-identity-matcher.md
|
|
27
|
+
*/
|
|
28
|
+
import { generateNodeId } from './helpers.js';
|
|
29
|
+
import { fingerprintAll, isExactMatch, isSameContent, sentenceArraysEqual, sentenceTuplesEqual, } from './node-fingerprint.js';
|
|
30
|
+
/**
|
|
31
|
+
* Run the matcher.
|
|
32
|
+
*
|
|
33
|
+
* @param previousNodes - frontmatter `nodes` map from the prior save
|
|
34
|
+
* @param newBlocks - block list of the new doc body
|
|
35
|
+
* @param options.graveyard - optional array of recently-deleted entries.
|
|
36
|
+
* Lets paste-back/undo restore the original ID.
|
|
37
|
+
*/
|
|
38
|
+
export function matchNodes(previousNodes, newBlocks, options = {}) {
|
|
39
|
+
const graveyard = options.graveyard || [];
|
|
40
|
+
const newFingerprints = fingerprintAll(newBlocks);
|
|
41
|
+
const pinned = [];
|
|
42
|
+
const claimedPrevIds = new Set();
|
|
43
|
+
const claimedGraveIds = new Set();
|
|
44
|
+
const unmatched = newBlocks.map((block, i) => ({
|
|
45
|
+
position: newFingerprints[i].position,
|
|
46
|
+
fingerprint: newFingerprints[i],
|
|
47
|
+
block,
|
|
48
|
+
}));
|
|
49
|
+
pinExactMatches(unmatched, previousNodes, claimedPrevIds, pinned);
|
|
50
|
+
applySplitRule(unmatched, previousNodes, claimedPrevIds, pinned);
|
|
51
|
+
applyMergeRule(unmatched, previousNodes, claimedPrevIds, pinned);
|
|
52
|
+
applyTypeChangeRule(unmatched, previousNodes, claimedPrevIds, pinned);
|
|
53
|
+
applyEditRule(unmatched, previousNodes, claimedPrevIds, pinned);
|
|
54
|
+
applySlotContinuityRule(unmatched, previousNodes, claimedPrevIds, pinned);
|
|
55
|
+
applyGraveyardRestoreRule(unmatched, graveyard, claimedGraveIds, pinned);
|
|
56
|
+
applyInsertRule(unmatched, pinned);
|
|
57
|
+
const orphaned = previousNodes
|
|
58
|
+
.filter((prev) => !claimedPrevIds.has(prev.id))
|
|
59
|
+
.map((prev) => ({ id: prev.id, fingerprint: prev.fingerprint }));
|
|
60
|
+
const remainingGraveyard = graveyard.filter((g) => !claimedGraveIds.has(g.id));
|
|
61
|
+
return {
|
|
62
|
+
pinned,
|
|
63
|
+
unmatched,
|
|
64
|
+
orphaned,
|
|
65
|
+
graveyardRestored: pinned.filter((p) => p.mutation === 'graveyard-restore'),
|
|
66
|
+
nextGraveyard: [...orphaned, ...remainingGraveyard],
|
|
67
|
+
summary: {
|
|
68
|
+
totalBlocks: newBlocks.length,
|
|
69
|
+
pinnedCount: pinned.length,
|
|
70
|
+
unmatchedCount: unmatched.length,
|
|
71
|
+
orphanedCount: orphaned.length,
|
|
72
|
+
coverage: newBlocks.length > 0 ? pinned.length / newBlocks.length : 1,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Thread state between sequential matcher runs. */
|
|
77
|
+
export function rebuildPreviousFromResult(result) {
|
|
78
|
+
return result.pinned.map((p) => ({ id: p.id, fingerprint: p.fingerprint }));
|
|
79
|
+
}
|
|
80
|
+
/** Build a previousNodes map from a block list (bootstrap on first save). */
|
|
81
|
+
export function bootstrapPreviousNodes(originalBlocks) {
|
|
82
|
+
const fps = fingerprintAll(originalBlocks);
|
|
83
|
+
return originalBlocks.map((_block, i) => ({
|
|
84
|
+
id: generateNodeId(),
|
|
85
|
+
fingerprint: fps[i],
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
// ----------------------------------------------------------------------
|
|
89
|
+
// Phase 1 — exact-match pinning, two-pass
|
|
90
|
+
// ----------------------------------------------------------------------
|
|
91
|
+
function pinExactMatches(unmatched, previousNodes, claimedPrevIds, pinned) {
|
|
92
|
+
// -------- PASS A: mutual-unique pairs --------
|
|
93
|
+
let changedA = true;
|
|
94
|
+
while (changedA) {
|
|
95
|
+
changedA = false;
|
|
96
|
+
const prevToCands = new Map();
|
|
97
|
+
const candToPrevs = new Map();
|
|
98
|
+
for (const prev of previousNodes) {
|
|
99
|
+
if (claimedPrevIds.has(prev.id))
|
|
100
|
+
continue;
|
|
101
|
+
const cands = unmatched.filter((u) => isExactMatch(prev.fingerprint, u.fingerprint));
|
|
102
|
+
prevToCands.set(prev.id, cands);
|
|
103
|
+
for (const c of cands) {
|
|
104
|
+
if (!candToPrevs.has(c.position))
|
|
105
|
+
candToPrevs.set(c.position, []);
|
|
106
|
+
candToPrevs.get(c.position).push(prev);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const [prevId, cands] of prevToCands) {
|
|
110
|
+
if (claimedPrevIds.has(prevId))
|
|
111
|
+
continue;
|
|
112
|
+
if (cands.length !== 1)
|
|
113
|
+
continue;
|
|
114
|
+
const cand = cands[0];
|
|
115
|
+
const prevs = candToPrevs.get(cand.position);
|
|
116
|
+
if (!prevs || prevs.length !== 1)
|
|
117
|
+
continue;
|
|
118
|
+
if (!unmatched.includes(cand))
|
|
119
|
+
continue;
|
|
120
|
+
const prev = previousNodes.find((p) => p.id === prevId);
|
|
121
|
+
const origPos = prev.fingerprint.position;
|
|
122
|
+
claimedPrevIds.add(prevId);
|
|
123
|
+
pinned.push({
|
|
124
|
+
id: prevId,
|
|
125
|
+
position: cand.position,
|
|
126
|
+
fingerprint: cand.fingerprint,
|
|
127
|
+
block: cand.block,
|
|
128
|
+
mutation: origPos !== cand.position ? 'moved' : 'unchanged',
|
|
129
|
+
});
|
|
130
|
+
const idx = unmatched.indexOf(cand);
|
|
131
|
+
unmatched.splice(idx, 1);
|
|
132
|
+
changedA = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// -------- PASS B: slot-aware position-distance --------
|
|
136
|
+
let changedB = true;
|
|
137
|
+
while (changedB) {
|
|
138
|
+
changedB = false;
|
|
139
|
+
for (const prev of previousNodes) {
|
|
140
|
+
if (claimedPrevIds.has(prev.id))
|
|
141
|
+
continue;
|
|
142
|
+
const candidates = unmatched.filter((u) => isExactMatch(prev.fingerprint, u.fingerprint));
|
|
143
|
+
if (candidates.length === 0)
|
|
144
|
+
continue;
|
|
145
|
+
const prevIdx = previousNodes.findIndex((p) => p.id === prev.id);
|
|
146
|
+
const lo = slotLowBound(previousNodes, claimedPrevIds, pinned, prevIdx);
|
|
147
|
+
const hi = slotHighBound(previousNodes, claimedPrevIds, pinned, prevIdx);
|
|
148
|
+
let inRange;
|
|
149
|
+
if (lo + 1 < hi) {
|
|
150
|
+
inRange = candidates.filter((c) => c.position > lo && c.position < hi);
|
|
151
|
+
}
|
|
152
|
+
else if (lo > hi) {
|
|
153
|
+
inRange = candidates; // inverted: anchors swapped, full pos-distance
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
inRange = []; // empty slot
|
|
157
|
+
}
|
|
158
|
+
if (inRange.length === 0)
|
|
159
|
+
continue;
|
|
160
|
+
const origPos = prev.fingerprint.position;
|
|
161
|
+
let best = inRange[0];
|
|
162
|
+
let bestDist = Math.abs(best.position - origPos);
|
|
163
|
+
for (const c of inRange) {
|
|
164
|
+
const d = Math.abs(c.position - origPos);
|
|
165
|
+
if (d < bestDist) {
|
|
166
|
+
best = c;
|
|
167
|
+
bestDist = d;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
claimedPrevIds.add(prev.id);
|
|
171
|
+
pinned.push({
|
|
172
|
+
id: prev.id,
|
|
173
|
+
position: best.position,
|
|
174
|
+
fingerprint: best.fingerprint,
|
|
175
|
+
block: best.block,
|
|
176
|
+
mutation: origPos !== best.position ? 'moved' : 'unchanged',
|
|
177
|
+
});
|
|
178
|
+
const idx = unmatched.indexOf(best);
|
|
179
|
+
unmatched.splice(idx, 1);
|
|
180
|
+
changedB = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// ----------------------------------------------------------------------
|
|
185
|
+
// Split rule — N-way
|
|
186
|
+
// ----------------------------------------------------------------------
|
|
187
|
+
function applySplitRule(unmatched, previousNodes, claimedPrevIds, pinned) {
|
|
188
|
+
let progress = true;
|
|
189
|
+
while (progress) {
|
|
190
|
+
progress = false;
|
|
191
|
+
for (const orphan of previousNodes) {
|
|
192
|
+
if (claimedPrevIds.has(orphan.id))
|
|
193
|
+
continue;
|
|
194
|
+
const orphanSents = orphan.fingerprint.sentences;
|
|
195
|
+
if (!orphanSents || orphanSents.length < 2)
|
|
196
|
+
continue;
|
|
197
|
+
let claimed = false;
|
|
198
|
+
for (let startIdx = 0; startIdx < unmatched.length && !claimed; startIdx++) {
|
|
199
|
+
const start = unmatched[startIdx];
|
|
200
|
+
if (start.fingerprint.type !== orphan.fingerprint.type)
|
|
201
|
+
continue;
|
|
202
|
+
const group = [startIdx];
|
|
203
|
+
let concatLen = start.fingerprint.sentences.length;
|
|
204
|
+
for (let next = startIdx + 1; next < unmatched.length; next++) {
|
|
205
|
+
const prev = unmatched[next - 1];
|
|
206
|
+
const cur = unmatched[next];
|
|
207
|
+
if (cur.position !== prev.position + 1)
|
|
208
|
+
break;
|
|
209
|
+
if (cur.fingerprint.type !== orphan.fingerprint.type)
|
|
210
|
+
break;
|
|
211
|
+
group.push(next);
|
|
212
|
+
concatLen += cur.fingerprint.sentences.length;
|
|
213
|
+
if (concatLen > orphanSents.length)
|
|
214
|
+
break;
|
|
215
|
+
if (concatLen < orphanSents.length)
|
|
216
|
+
continue;
|
|
217
|
+
const concat = [];
|
|
218
|
+
for (const gi of group)
|
|
219
|
+
concat.push(...unmatched[gi].fingerprint.sentences);
|
|
220
|
+
if (!sentenceArraysEqual(concat, orphanSents))
|
|
221
|
+
break;
|
|
222
|
+
claimedPrevIds.add(orphan.id);
|
|
223
|
+
for (let i = 0; i < group.length; i++) {
|
|
224
|
+
const c = unmatched[group[i]];
|
|
225
|
+
pinned.push({
|
|
226
|
+
id: i === 0 ? orphan.id : generateNodeId(),
|
|
227
|
+
position: c.position,
|
|
228
|
+
fingerprint: c.fingerprint,
|
|
229
|
+
block: c.block,
|
|
230
|
+
mutation: i === 0 ? 'split-first' : `split-${i + 1}`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
for (let i = group.length - 1; i >= 0; i--)
|
|
234
|
+
unmatched.splice(group[i], 1);
|
|
235
|
+
claimed = true;
|
|
236
|
+
progress = true;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ----------------------------------------------------------------------
|
|
244
|
+
// Merge rule — N-way
|
|
245
|
+
// ----------------------------------------------------------------------
|
|
246
|
+
function applyMergeRule(unmatched, previousNodes, claimedPrevIds, pinned) {
|
|
247
|
+
let progress = true;
|
|
248
|
+
while (progress) {
|
|
249
|
+
progress = false;
|
|
250
|
+
for (let ui = 0; ui < unmatched.length; ui++) {
|
|
251
|
+
const candidate = unmatched[ui];
|
|
252
|
+
const candidateSents = candidate.fingerprint.sentences;
|
|
253
|
+
if (!candidateSents || candidateSents.length < 2)
|
|
254
|
+
continue;
|
|
255
|
+
let merged = false;
|
|
256
|
+
for (let startOrphIdx = 0; startOrphIdx < previousNodes.length && !merged; startOrphIdx++) {
|
|
257
|
+
const start = previousNodes[startOrphIdx];
|
|
258
|
+
if (claimedPrevIds.has(start.id))
|
|
259
|
+
continue;
|
|
260
|
+
if (start.fingerprint.type !== candidate.fingerprint.type)
|
|
261
|
+
continue;
|
|
262
|
+
const group = [startOrphIdx];
|
|
263
|
+
let concatLen = start.fingerprint.sentences.length;
|
|
264
|
+
for (let next = startOrphIdx + 1; next < previousNodes.length; next++) {
|
|
265
|
+
const prev = previousNodes[next - 1];
|
|
266
|
+
const cur = previousNodes[next];
|
|
267
|
+
if (claimedPrevIds.has(cur.id))
|
|
268
|
+
break;
|
|
269
|
+
if (cur.fingerprint.type !== candidate.fingerprint.type)
|
|
270
|
+
break;
|
|
271
|
+
if (cur.fingerprint.position !== prev.fingerprint.position + 1)
|
|
272
|
+
break;
|
|
273
|
+
group.push(next);
|
|
274
|
+
concatLen += cur.fingerprint.sentences.length;
|
|
275
|
+
if (concatLen > candidateSents.length)
|
|
276
|
+
break;
|
|
277
|
+
if (concatLen < candidateSents.length)
|
|
278
|
+
continue;
|
|
279
|
+
const concat = [];
|
|
280
|
+
for (const gi of group)
|
|
281
|
+
concat.push(...previousNodes[gi].fingerprint.sentences);
|
|
282
|
+
if (!sentenceArraysEqual(concat, candidateSents))
|
|
283
|
+
break;
|
|
284
|
+
for (const gi of group)
|
|
285
|
+
claimedPrevIds.add(previousNodes[gi].id);
|
|
286
|
+
pinned.push({
|
|
287
|
+
id: previousNodes[group[0]].id,
|
|
288
|
+
position: candidate.position,
|
|
289
|
+
fingerprint: candidate.fingerprint,
|
|
290
|
+
block: candidate.block,
|
|
291
|
+
mutation: `merge-${group.length}-way`,
|
|
292
|
+
});
|
|
293
|
+
unmatched.splice(ui, 1);
|
|
294
|
+
merged = true;
|
|
295
|
+
progress = true;
|
|
296
|
+
ui--;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (merged)
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// ----------------------------------------------------------------------
|
|
306
|
+
// Type-change rule (TipTap convention)
|
|
307
|
+
// ----------------------------------------------------------------------
|
|
308
|
+
function applyTypeChangeRule(unmatched, previousNodes, claimedPrevIds, pinned) {
|
|
309
|
+
let progress = true;
|
|
310
|
+
while (progress) {
|
|
311
|
+
progress = false;
|
|
312
|
+
for (let ui = 0; ui < unmatched.length; ui++) {
|
|
313
|
+
const candidate = unmatched[ui];
|
|
314
|
+
const candidateOrphans = previousNodes.filter((p) => {
|
|
315
|
+
if (claimedPrevIds.has(p.id))
|
|
316
|
+
return false;
|
|
317
|
+
if (p.fingerprint.type === candidate.fingerprint.type)
|
|
318
|
+
return false;
|
|
319
|
+
if (!isSameContent(p.fingerprint, candidate.fingerprint))
|
|
320
|
+
return false;
|
|
321
|
+
const orphanIdx = previousNodes.findIndex((x) => x.id === p.id);
|
|
322
|
+
const lo = slotLowBound(previousNodes, claimedPrevIds, pinned, orphanIdx);
|
|
323
|
+
const hi = slotHighBound(previousNodes, claimedPrevIds, pinned, orphanIdx);
|
|
324
|
+
return candidate.position > lo && candidate.position < hi;
|
|
325
|
+
});
|
|
326
|
+
if (candidateOrphans.length === 0)
|
|
327
|
+
continue;
|
|
328
|
+
const origPos = candidate.position;
|
|
329
|
+
let best = candidateOrphans[0];
|
|
330
|
+
let bestDist = Math.abs(best.fingerprint.position - origPos);
|
|
331
|
+
for (const o of candidateOrphans) {
|
|
332
|
+
const d = Math.abs(o.fingerprint.position - origPos);
|
|
333
|
+
if (d < bestDist) {
|
|
334
|
+
best = o;
|
|
335
|
+
bestDist = d;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
claimedPrevIds.add(best.id);
|
|
339
|
+
pinned.push({
|
|
340
|
+
id: best.id,
|
|
341
|
+
position: candidate.position,
|
|
342
|
+
fingerprint: candidate.fingerprint,
|
|
343
|
+
block: candidate.block,
|
|
344
|
+
mutation: `type-change-${best.fingerprint.type}-to-${candidate.fingerprint.type}`,
|
|
345
|
+
});
|
|
346
|
+
unmatched.splice(ui, 1);
|
|
347
|
+
progress = true;
|
|
348
|
+
ui--;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// ----------------------------------------------------------------------
|
|
353
|
+
// Edit rule — content drifted, but at least one sentence tuple still matches
|
|
354
|
+
// ----------------------------------------------------------------------
|
|
355
|
+
function applyEditRule(unmatched, previousNodes, claimedPrevIds, pinned) {
|
|
356
|
+
const unmatchedByPos = [...unmatched].sort((a, b) => a.position - b.position);
|
|
357
|
+
for (const candidate of unmatchedByPos) {
|
|
358
|
+
if (!unmatched.includes(candidate))
|
|
359
|
+
continue;
|
|
360
|
+
const candidateOrphans = previousNodes.filter((p) => {
|
|
361
|
+
if (claimedPrevIds.has(p.id))
|
|
362
|
+
return false;
|
|
363
|
+
if (p.fingerprint.type !== candidate.fingerprint.type)
|
|
364
|
+
return false;
|
|
365
|
+
if (!shareAnySentenceTuple(p.fingerprint.sentences, candidate.fingerprint.sentences))
|
|
366
|
+
return false;
|
|
367
|
+
const orphanIdx = previousNodes.findIndex((x) => x.id === p.id);
|
|
368
|
+
const lo = slotLowBound(previousNodes, claimedPrevIds, pinned, orphanIdx);
|
|
369
|
+
const hi = slotHighBound(previousNodes, claimedPrevIds, pinned, orphanIdx);
|
|
370
|
+
return candidate.position > lo && candidate.position < hi;
|
|
371
|
+
});
|
|
372
|
+
if (candidateOrphans.length !== 1)
|
|
373
|
+
continue;
|
|
374
|
+
const orphan = candidateOrphans[0];
|
|
375
|
+
claimedPrevIds.add(orphan.id);
|
|
376
|
+
pinned.push({
|
|
377
|
+
id: orphan.id,
|
|
378
|
+
position: candidate.position,
|
|
379
|
+
fingerprint: candidate.fingerprint,
|
|
380
|
+
block: candidate.block,
|
|
381
|
+
mutation: 'edited',
|
|
382
|
+
});
|
|
383
|
+
const idx = unmatched.indexOf(candidate);
|
|
384
|
+
unmatched.splice(idx, 1);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ----------------------------------------------------------------------
|
|
388
|
+
// Slot-continuity fallback
|
|
389
|
+
// ----------------------------------------------------------------------
|
|
390
|
+
function applySlotContinuityRule(unmatched, previousNodes, claimedPrevIds, pinned) {
|
|
391
|
+
let progress = true;
|
|
392
|
+
while (progress) {
|
|
393
|
+
progress = false;
|
|
394
|
+
for (let ui = 0; ui < unmatched.length; ui++) {
|
|
395
|
+
const candidate = unmatched[ui];
|
|
396
|
+
const matchingOrphans = previousNodes.filter((orphan) => {
|
|
397
|
+
if (claimedPrevIds.has(orphan.id))
|
|
398
|
+
return false;
|
|
399
|
+
if (orphan.fingerprint.type !== candidate.fingerprint.type)
|
|
400
|
+
return false;
|
|
401
|
+
const orphanIdx = previousNodes.findIndex((x) => x.id === orphan.id);
|
|
402
|
+
const prevAnchor = findPinnedNeighbor(previousNodes, claimedPrevIds, orphanIdx, -1);
|
|
403
|
+
const nextAnchor = findPinnedNeighbor(previousNodes, claimedPrevIds, orphanIdx, +1);
|
|
404
|
+
const prevNewPos = prevAnchor ? findPinnedPosition(pinned, prevAnchor.id) : -1;
|
|
405
|
+
const nextNewPos = nextAnchor ? findPinnedPosition(pinned, nextAnchor.id) : Infinity;
|
|
406
|
+
return candidate.position > prevNewPos && candidate.position < nextNewPos;
|
|
407
|
+
});
|
|
408
|
+
if (matchingOrphans.length === 0)
|
|
409
|
+
continue;
|
|
410
|
+
const scored = matchingOrphans.map((orphan) => ({
|
|
411
|
+
orphan,
|
|
412
|
+
score: sentenceSignalOverlapScore(orphan.fingerprint, candidate.fingerprint),
|
|
413
|
+
}));
|
|
414
|
+
scored.sort((a, b) => b.score - a.score);
|
|
415
|
+
const topScore = scored[0].score;
|
|
416
|
+
const tied = scored.filter((s) => s.score === topScore);
|
|
417
|
+
if (tied.length > 1 && topScore === 0)
|
|
418
|
+
continue;
|
|
419
|
+
let best = tied[0].orphan;
|
|
420
|
+
let bestDist = Math.abs(best.fingerprint.position - candidate.position);
|
|
421
|
+
for (const t of tied) {
|
|
422
|
+
const d = Math.abs(t.orphan.fingerprint.position - candidate.position);
|
|
423
|
+
if (d < bestDist) {
|
|
424
|
+
best = t.orphan;
|
|
425
|
+
bestDist = d;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
claimedPrevIds.add(best.id);
|
|
429
|
+
pinned.push({
|
|
430
|
+
id: best.id,
|
|
431
|
+
position: candidate.position,
|
|
432
|
+
fingerprint: candidate.fingerprint,
|
|
433
|
+
block: candidate.block,
|
|
434
|
+
mutation: 'slot-preserved',
|
|
435
|
+
});
|
|
436
|
+
unmatched.splice(ui, 1);
|
|
437
|
+
ui--;
|
|
438
|
+
progress = true;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Lightweight content overlap signal used by slot-continuity scoring.
|
|
444
|
+
* Per sentence-pair: +1 f, +1 l, +1 t, +2 wls-equal, +3×shared-words,
|
|
445
|
+
* +10 full word-array equality. Word-level overlap is the disambiguator
|
|
446
|
+
* when math signals collide.
|
|
447
|
+
*/
|
|
448
|
+
function sentenceSignalOverlapScore(a, b) {
|
|
449
|
+
if (!a.sentences || !b.sentences)
|
|
450
|
+
return 0;
|
|
451
|
+
let score = 0;
|
|
452
|
+
for (const sa of a.sentences) {
|
|
453
|
+
for (const sb of b.sentences) {
|
|
454
|
+
if (sa.f === sb.f)
|
|
455
|
+
score++;
|
|
456
|
+
if (sa.l === sb.l)
|
|
457
|
+
score++;
|
|
458
|
+
if (sa.t === sb.t)
|
|
459
|
+
score++;
|
|
460
|
+
if (arraysEqual(sa.wls, sb.wls))
|
|
461
|
+
score += 2;
|
|
462
|
+
if (Array.isArray(sa.w) && Array.isArray(sb.w)) {
|
|
463
|
+
const aSet = new Set(sa.w);
|
|
464
|
+
let shared = 0;
|
|
465
|
+
for (const w of sb.w)
|
|
466
|
+
if (aSet.has(w))
|
|
467
|
+
shared++;
|
|
468
|
+
score += shared * 3;
|
|
469
|
+
if (arraysEqual(sa.w, sb.w))
|
|
470
|
+
score += 10;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return score;
|
|
475
|
+
}
|
|
476
|
+
// ----------------------------------------------------------------------
|
|
477
|
+
// Graveyard restore
|
|
478
|
+
// ----------------------------------------------------------------------
|
|
479
|
+
function applyGraveyardRestoreRule(unmatched, graveyard, claimedGraveIds, pinned) {
|
|
480
|
+
for (let ui = unmatched.length - 1; ui >= 0; ui--) {
|
|
481
|
+
const candidate = unmatched[ui];
|
|
482
|
+
const ghostMatches = graveyard.filter((g) => !claimedGraveIds.has(g.id) && isExactMatch(g.fingerprint, candidate.fingerprint));
|
|
483
|
+
if (ghostMatches.length === 0)
|
|
484
|
+
continue;
|
|
485
|
+
const candPos = candidate.position;
|
|
486
|
+
let best = ghostMatches[0];
|
|
487
|
+
let bestDist = Math.abs(best.fingerprint.position - candPos);
|
|
488
|
+
for (const g of ghostMatches) {
|
|
489
|
+
const d = Math.abs(g.fingerprint.position - candPos);
|
|
490
|
+
if (d < bestDist) {
|
|
491
|
+
best = g;
|
|
492
|
+
bestDist = d;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
claimedGraveIds.add(best.id);
|
|
496
|
+
pinned.push({
|
|
497
|
+
id: best.id,
|
|
498
|
+
position: candidate.position,
|
|
499
|
+
fingerprint: candidate.fingerprint,
|
|
500
|
+
block: candidate.block,
|
|
501
|
+
mutation: 'graveyard-restore',
|
|
502
|
+
});
|
|
503
|
+
unmatched.splice(ui, 1);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// ----------------------------------------------------------------------
|
|
507
|
+
// Insert rule (last resort)
|
|
508
|
+
// ----------------------------------------------------------------------
|
|
509
|
+
function applyInsertRule(unmatched, pinned) {
|
|
510
|
+
for (let i = unmatched.length - 1; i >= 0; i--) {
|
|
511
|
+
const candidate = unmatched[i];
|
|
512
|
+
pinned.push({
|
|
513
|
+
id: generateNodeId(),
|
|
514
|
+
position: candidate.position,
|
|
515
|
+
fingerprint: candidate.fingerprint,
|
|
516
|
+
block: candidate.block,
|
|
517
|
+
mutation: 'inserted',
|
|
518
|
+
});
|
|
519
|
+
unmatched.splice(i, 1);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// ----------------------------------------------------------------------
|
|
523
|
+
// Helpers
|
|
524
|
+
// ----------------------------------------------------------------------
|
|
525
|
+
function findPinnedNeighbor(previousNodes, claimedPrevIds, startIdx, direction) {
|
|
526
|
+
for (let i = startIdx + direction; i >= 0 && i < previousNodes.length; i += direction) {
|
|
527
|
+
if (claimedPrevIds.has(previousNodes[i].id))
|
|
528
|
+
return previousNodes[i];
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
function findPinnedPosition(pinned, id) {
|
|
533
|
+
const entry = pinned.find((p) => p.id === id);
|
|
534
|
+
return entry ? entry.position : -1;
|
|
535
|
+
}
|
|
536
|
+
function slotLowBound(previousNodes, claimedPrevIds, pinned, orphanIdx) {
|
|
537
|
+
const prev = findPinnedNeighbor(previousNodes, claimedPrevIds, orphanIdx, -1);
|
|
538
|
+
return prev ? findPinnedPosition(pinned, prev.id) : -1;
|
|
539
|
+
}
|
|
540
|
+
function slotHighBound(previousNodes, claimedPrevIds, pinned, orphanIdx) {
|
|
541
|
+
const next = findPinnedNeighbor(previousNodes, claimedPrevIds, orphanIdx, +1);
|
|
542
|
+
return next ? findPinnedPosition(pinned, next.id) : Infinity;
|
|
543
|
+
}
|
|
544
|
+
function shareAnySentenceTuple(a, b) {
|
|
545
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
546
|
+
return false;
|
|
547
|
+
for (const sa of a) {
|
|
548
|
+
for (const sb of b) {
|
|
549
|
+
if (sentenceTuplesEqual(sa, sb))
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
function arraysEqual(a, b) {
|
|
556
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
557
|
+
return false;
|
|
558
|
+
if (a.length !== b.length)
|
|
559
|
+
return false;
|
|
560
|
+
for (let i = 0; i < a.length; i++)
|
|
561
|
+
if (a[i] !== b[i])
|
|
562
|
+
return false;
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync observer — detects when TipTap state and markdown body have drifted
|
|
3
|
+
* out of structural alignment.
|
|
4
|
+
*
|
|
5
|
+
* The two views of a document MUST describe the same shape:
|
|
6
|
+
* - The TipTap tree (what the editor renders and the agent operates on)
|
|
7
|
+
* - The markdown body (what gets persisted to disk)
|
|
8
|
+
*
|
|
9
|
+
* If a save/load cycle produces a different shape than it started with,
|
|
10
|
+
* a node has been misapplied — content ended up attached to the wrong
|
|
11
|
+
* block, or a structural element was added/dropped during the round-trip.
|
|
12
|
+
*
|
|
13
|
+
* The shape is a flat ordered list of (type, charCount, sentenceCount)
|
|
14
|
+
* per block. Comparing two shapes by position immediately localizes any
|
|
15
|
+
* drift to the exact block where the round-trip broke.
|
|
16
|
+
*
|
|
17
|
+
* Wire points:
|
|
18
|
+
* - Save-time: after serialize, re-parse and confirm shape preserved.
|
|
19
|
+
* - Load-time: after parse, compare body shape to TipTap-tree shape.
|
|
20
|
+
*
|
|
21
|
+
* Output is logged (not thrown) so saves and loads still complete; the
|
|
22
|
+
* report tells the consumer / operator exactly where to look.
|
|
23
|
+
*
|
|
24
|
+
* adr: adr/node-identity-matcher.md
|
|
25
|
+
*/
|
|
26
|
+
import { splitSentences } from './node-fingerprint.js';
|
|
27
|
+
import { tiptapToBlocks } from './node-blocks.js';
|
|
28
|
+
/** Reduce a block list to its structural signature. */
|
|
29
|
+
export function computeShape(blocks) {
|
|
30
|
+
return blocks.map((b) => ({
|
|
31
|
+
type: b.type,
|
|
32
|
+
charCount: (b.text || '').length,
|
|
33
|
+
sentenceCount: splitSentences(b.text || '').length,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
/** Compute the shape signature of a TipTap document. */
|
|
37
|
+
export function shapeOfTiptap(doc) {
|
|
38
|
+
return computeShape(tiptapToBlocks(doc));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compare two shapes. Returns ok=true if every position aligns.
|
|
42
|
+
* mismatches[] lists every position where the shapes disagree — the first
|
|
43
|
+
* entry is typically the root cause; the rest are downstream consequences.
|
|
44
|
+
*/
|
|
45
|
+
export function compareShapes(expected, actual) {
|
|
46
|
+
const mismatches = [];
|
|
47
|
+
const maxLen = Math.max(expected.length, actual.length);
|
|
48
|
+
for (let i = 0; i < maxLen; i++) {
|
|
49
|
+
const e = expected[i] ?? null;
|
|
50
|
+
const a = actual[i] ?? null;
|
|
51
|
+
if (!e || !a) {
|
|
52
|
+
mismatches.push({
|
|
53
|
+
position: i,
|
|
54
|
+
expected: e,
|
|
55
|
+
actual: a,
|
|
56
|
+
reason: !e ? 'extra block in actual' : 'missing block in actual',
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (e.type !== a.type) {
|
|
61
|
+
mismatches.push({
|
|
62
|
+
position: i,
|
|
63
|
+
expected: e,
|
|
64
|
+
actual: a,
|
|
65
|
+
reason: `type mismatch: expected ${e.type}, got ${a.type}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else if (e.charCount !== a.charCount) {
|
|
69
|
+
mismatches.push({
|
|
70
|
+
position: i,
|
|
71
|
+
expected: e,
|
|
72
|
+
actual: a,
|
|
73
|
+
reason: `charCount mismatch: expected ${e.charCount}, got ${a.charCount}`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
else if (e.sentenceCount !== a.sentenceCount) {
|
|
77
|
+
mismatches.push({
|
|
78
|
+
position: i,
|
|
79
|
+
expected: e,
|
|
80
|
+
actual: a,
|
|
81
|
+
reason: `sentenceCount mismatch: expected ${e.sentenceCount}, got ${a.sentenceCount}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
ok: mismatches.length === 0,
|
|
87
|
+
expectedLength: expected.length,
|
|
88
|
+
actualLength: actual.length,
|
|
89
|
+
mismatches,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Format a sync report for human-readable logging. Returns a multi-line string
|
|
94
|
+
* pointing at the first mismatch and counting any cascade.
|
|
95
|
+
*/
|
|
96
|
+
export function formatSyncReport(report, context) {
|
|
97
|
+
if (report.ok)
|
|
98
|
+
return `[sync-check ${context}] OK (${report.expectedLength} blocks aligned)`;
|
|
99
|
+
const first = report.mismatches[0];
|
|
100
|
+
const lines = [
|
|
101
|
+
`[sync-check ${context}] FAIL: ${report.mismatches.length} mismatch(es) across ${report.expectedLength}/${report.actualLength} blocks`,
|
|
102
|
+
` first mismatch at position ${first.position}: ${first.reason}`,
|
|
103
|
+
` expected: ${first.expected ? JSON.stringify(first.expected) : 'none'}`,
|
|
104
|
+
` actual: ${first.actual ? JSON.stringify(first.actual) : 'none'}`,
|
|
105
|
+
];
|
|
106
|
+
if (report.mismatches.length > 1) {
|
|
107
|
+
lines.push(` (${report.mismatches.length - 1} more mismatch(es) — likely cascade from first)`);
|
|
108
|
+
}
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
}
|