codex-overleaf-link 2.5.1 → 2.6.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.
Files changed (93) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +78 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +11 -2
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +920 -276
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsPanel.js +18 -0
  14. package/extension/src/content/generated/content.bundle.js +4496 -924
  15. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  16. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  17. package/extension/src/content/localSkillsPanel.js +2 -2
  18. package/extension/src/content/markdownDomRenderer.js +5 -3
  19. package/extension/src/content/markdownText.js +42 -5
  20. package/extension/src/content/modelPicker.js +1 -0
  21. package/extension/src/content/nativeCompatibilityController.js +29 -10
  22. package/extension/src/content/otWarmMirror.js +1 -1
  23. package/extension/src/content/pageBridgeClient.js +2 -2
  24. package/extension/src/content/panelMaintenance.js +6 -2
  25. package/extension/src/content/panelRenderer.js +51 -27
  26. package/extension/src/content/pendingInputView.js +1 -1
  27. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  28. package/extension/src/content/providerSettingsDialog.js +2 -1
  29. package/extension/src/content/recentProjects.js +4 -3
  30. package/extension/src/content/runActivitySummary.js +122 -14
  31. package/extension/src/content/runCompletionMeta.js +194 -6
  32. package/extension/src/content/runController.js +2 -1
  33. package/extension/src/content/runFailureNotice.js +8 -1
  34. package/extension/src/content/runPresence.js +18 -8
  35. package/extension/src/content/runQueueScheduler.js +9 -5
  36. package/extension/src/content/runRecoveryActions.js +2 -1
  37. package/extension/src/content/runResultActions.js +22 -20
  38. package/extension/src/content/runReviewController.js +94 -35
  39. package/extension/src/content/runTimelineView.js +224 -25
  40. package/extension/src/content/scopedPersistencePanelState.js +92 -23
  41. package/extension/src/content/sensitivePreflight.js +14 -1
  42. package/extension/src/content/sessionManager.js +49 -3
  43. package/extension/src/content/sessionMenuView.js +54 -18
  44. package/extension/src/content/sessionPersistence.js +25 -5
  45. package/extension/src/content/settingsPanel.js +15 -2
  46. package/extension/src/content/settingsWorkbench.js +127 -89
  47. package/extension/src/content/sharedSessionViewSync.js +4 -3
  48. package/extension/src/content/subagentActivityView.js +23 -7
  49. package/extension/src/content/updateNotice.js +60 -6
  50. package/extension/src/content/writebackOrchestrator.js +35 -22
  51. package/extension/src/page/compileBridge.js +193 -180
  52. package/extension/src/page/overleafCapabilities.js +4 -1
  53. package/extension/src/page/textCoordinates.js +17 -1
  54. package/extension/src/page/textFileCreator.js +9 -1
  55. package/extension/src/page/writebackRouter.js +24 -77
  56. package/extension/src/pageBridge.js +107 -66
  57. package/extension/src/popup.js +1 -1
  58. package/extension/src/shared/agentTranscript.js +350 -17
  59. package/extension/src/shared/auditRecords.js +2 -0
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/src/shared/compileAdapter.js +58 -17
  62. package/extension/src/shared/composerAttachmentDraft.js +6 -1
  63. package/extension/src/shared/contextUsage.js +125 -0
  64. package/extension/src/shared/failureReasons.js +42 -3
  65. package/extension/src/shared/i18n.js +299 -29
  66. package/extension/src/shared/lineReferences.js +38 -31
  67. package/extension/src/shared/pageRpcContract.js +1 -1
  68. package/extension/src/shared/panelIcons.js +39 -0
  69. package/extension/src/shared/reviewing.js +130 -1
  70. package/extension/src/shared/runActivityModel.js +70 -4
  71. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  72. package/extension/src/shared/sessionState.js +63 -4
  73. package/extension/src/shared/sessionTitle.js +14 -1
  74. package/extension/src/shared/sharedSessionBridge.js +81 -13
  75. package/extension/src/shared/staleGuard.js +72 -63
  76. package/extension/src/shared/storageDb.js +35 -3
  77. package/extension/src/shared/storageMigration.js +7 -1
  78. package/extension/src/shared/storageRunActions.js +62 -8
  79. package/extension/src/shared/undoOperations.js +126 -28
  80. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  81. package/extension/src/shared/writebackSettlement.js +21 -3
  82. package/extension/styles/panel.css +1106 -490
  83. package/native-host/src/bootstrapUpdate.js +52 -16
  84. package/native-host/src/chatBridgeRequest.js +60 -6
  85. package/native-host/src/chatBridgeResponse.js +10 -1
  86. package/native-host/src/chatCompletionsBridge.js +29 -2
  87. package/native-host/src/codexPromptAssembly.js +86 -118
  88. package/native-host/src/codexSessionRunner.js +126 -17
  89. package/native-host/src/providerContextError.js +20 -0
  90. package/native-host/src/taskRunnerRuntime.js +18 -6
  91. package/native-host/src/textPatch.js +473 -377
  92. package/native-host/src/updateManager.js +120 -15
  93. package/package.json +5 -8
@@ -49,37 +49,17 @@ function isValidNaturalPatchSet(oldText, newText, patches) {
49
49
 
50
50
 
51
51
 
52
+ // Builds the natural patches for one line-anchored change group.
53
+ //
54
+ // Annotated `% [original]` / `% [revised]` blocks are always replaced as one
55
+ // unit. Every other group goes through sentence alignment
56
+ // (`computeSentenceAlignedPatches`): unchanged sentences are skipped and each
57
+ // changed sentence is patched on its own.
52
58
  function computeNaturalGroupPatches(group) {
53
- const tokenPatches = computeTokenAnchoredPatches(
54
- group.oldText,
55
- group.newText,
56
- group.oldStart
57
- );
58
- const metrics = computeGroupMetrics(group, tokenPatches);
59
- const classification = classifyChangedGroup(group, tokenPatches, metrics);
60
-
61
- if (classification.type === 'annotated_block') {
59
+ if (hasAnyAnnotatedMarker(group.oldText) || hasAnyAnnotatedMarker(group.newText)) {
62
60
  return singleGroupPatch(group);
63
61
  }
64
- if (classification.type === 'paragraph_rewrite') {
65
- const paragraphPatches = computeParagraphPatches(group);
66
- return paragraphPatches !== null
67
- ? paragraphPatches
68
- : singleGroupPatch(group);
69
- }
70
- if (classification.type === 'sentence_rewrite') {
71
- const sentencePatches = computeSentencePatches(group, tokenPatches);
72
- return sentencePatches !== null
73
- ? sentencePatches
74
- : coalesceTokenPatches(group, tokenPatches);
75
- }
76
- if (classification.type === 'small_edit') {
77
- return coalesceTokenPatches(group, tokenPatches);
78
- }
79
-
80
- return tokenPatches.length > 0
81
- ? coalesceTokenPatches(group, tokenPatches)
82
- : singleGroupPatch(group);
62
+ return computeSentenceAlignedPatches(group);
83
63
  }
84
64
 
85
65
  function computeSingleTextPatch(oldValue, newValue, offset = 0) {
@@ -108,6 +88,58 @@ function computeSingleTextPatch(oldValue, newValue, offset = 0) {
108
88
  };
109
89
  }
110
90
 
91
+ // Characters that form a word for boundary alignment: the word class of
92
+ // `splitTextTokens` plus `\`, so a LaTeX command name stays attached to its
93
+ // backslash. CJK characters are deliberately excluded: they have no word
94
+ // boundaries, and widening across them would swallow whole clauses.
95
+ function isWordBoundaryChar(char) {
96
+ return char === '\\' || isWordTokenChar(char);
97
+ }
98
+
99
+ // Like `computeSingleTextPatch`, but neither patch boundary may fall inside a
100
+ // word. The common-prefix / common-suffix trim can land mid-word in either the
101
+ // old or the new text (`" performs"` → `"s perform"`); such a cut is widened
102
+ // outward to the enclosing word boundaries, with the widened characters copied
103
+ // into both `expected` and `insert`, so the written bytes are unchanged and
104
+ // the tracked change reads as whole words.
105
+ function computeWordAlignedTextPatch(oldValue, newValue, offset = 0) {
106
+ const patch = computeSingleTextPatch(oldValue, newValue, offset);
107
+ let from = patch.from - offset;
108
+ let to = patch.to - offset;
109
+ // Offset in `newValue` where the shared suffix begins; it moves with `to`.
110
+ let newTo = from + patch.insert.length;
111
+
112
+ // The prefix is shared, so `newValue[index] === oldValue[index]` for every
113
+ // index below the original cut; checking both sides only matters at the cut.
114
+ while (
115
+ from > 0
116
+ && isWordBoundaryChar(oldValue[from - 1])
117
+ && (isWordBoundaryChar(oldValue[from]) || isWordBoundaryChar(newValue[from]))
118
+ ) {
119
+ from -= 1;
120
+ }
121
+ while (
122
+ to < oldValue.length
123
+ && isWordBoundaryChar(oldValue[to])
124
+ && (isWordBoundaryChar(oldValue[to - 1]) || isWordBoundaryChar(newValue[newTo - 1]))
125
+ ) {
126
+ to += 1;
127
+ newTo += 1;
128
+ }
129
+
130
+ if (from === patch.from - offset && to === patch.to - offset) {
131
+ return patch;
132
+ }
133
+ const prefix = oldValue.slice(from, patch.from - offset);
134
+ const suffix = oldValue.slice(patch.to - offset, to);
135
+ return {
136
+ from: offset + from,
137
+ to: offset + to,
138
+ expected: oldValue.slice(from, to),
139
+ insert: prefix + patch.insert + suffix
140
+ };
141
+ }
142
+
111
143
 
112
144
  function computeLineAnchoredChangeGroups(oldValue, newValue) {
113
145
  if (oldValue === newValue) {
@@ -690,6 +722,18 @@ function discoverNaturalTokenAnchors(
690
722
  return selectNaturalMonotonicAnchors(candidates, ANCHOR_SPAN);
691
723
  }
692
724
 
725
+ // Scripts without word boundaries: each character is its own token so an
726
+ // edit inside a Chinese or Japanese sentence stays local instead of covering
727
+ // the whole run between two spaces.
728
+ const CJK_TOKEN_CHAR = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
729
+
730
+ // Letters, digits, and `_` of any script except the CJK scripts above.
731
+ function isWordTokenChar(char) {
732
+ return char !== undefined
733
+ && /[\p{L}\p{N}_]/u.test(char)
734
+ && !CJK_TOKEN_CHAR.test(char);
735
+ }
736
+
693
737
  function splitTextTokens(text) {
694
738
  const tokens = [];
695
739
  let index = 0;
@@ -710,9 +754,11 @@ function splitTextTokens(text) {
710
754
  } else if (index < text.length) {
711
755
  index += 1;
712
756
  }
713
- } else if (/[A-Za-z0-9_]/.test(char)) {
757
+ } else if (CJK_TOKEN_CHAR.test(char)) {
714
758
  index += 1;
715
- while (index < text.length && /[A-Za-z0-9_]/.test(text[index])) {
759
+ } else if (isWordTokenChar(char)) {
760
+ index += 1;
761
+ while (index < text.length && isWordTokenChar(text[index])) {
716
762
  index += 1;
717
763
  }
718
764
  } else {
@@ -721,7 +767,8 @@ function splitTextTokens(text) {
721
767
  index < text.length
722
768
  && !/\s/.test(text[index])
723
769
  && text[index] !== '\\'
724
- && !/[A-Za-z0-9_]/.test(text[index])
770
+ && !isWordTokenChar(text[index])
771
+ && !CJK_TOKEN_CHAR.test(text[index])
725
772
  ) {
726
773
  index += 1;
727
774
  }
@@ -739,98 +786,6 @@ function splitTextParts(text) {
739
786
  return String(text || '').match(/[^\n]*\n|[^\n]+/g) || [];
740
787
  }
741
788
 
742
- function computePartEdits(oldParts, newParts) {
743
- const n = oldParts.length;
744
- const m = newParts.length;
745
- const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
746
-
747
- for (let i = n - 1; i >= 0; i -= 1) {
748
- for (let j = m - 1; j >= 0; j -= 1) {
749
- dp[i][j] = oldParts[i] === newParts[j]
750
- ? dp[i + 1][j + 1] + 1
751
- : Math.max(dp[i + 1][j], dp[i][j + 1]);
752
- }
753
- }
754
-
755
- const edits = [];
756
- let i = 0;
757
- let j = 0;
758
- while (i < n && j < m) {
759
- if (oldParts[i] === newParts[j]) {
760
- edits.push({ type: 'equal', oldIndex: i, newIndex: j });
761
- i += 1;
762
- } else if (dp[i + 1][j] >= dp[i][j + 1]) {
763
- edits.push({ type: 'remove', oldIndex: i });
764
- i += 1;
765
- continue;
766
- } else {
767
- edits.push({ type: 'add', newIndex: j });
768
- }
769
- j += 1;
770
- }
771
- while (i < n) {
772
- edits.push({ type: 'remove', oldIndex: i });
773
- i += 1;
774
- }
775
- while (j < m) {
776
- edits.push({ type: 'add', newIndex: j });
777
- j += 1;
778
- }
779
- return edits;
780
- }
781
-
782
- function countNonEmptyLines(text) {
783
- const value = String(text ?? '');
784
- let count = 0;
785
- for (const line of value.split('\n')) {
786
- if (line.trim() !== '') {
787
- count += 1;
788
- }
789
- }
790
- return count;
791
- }
792
-
793
- function countSentenceTerminators(text) {
794
- const value = String(text ?? '');
795
- let count = 0;
796
- for (let index = 0; index < value.length; index += 1) {
797
- const char = value[index];
798
- if (char === '。' || char === '?' || char === '!') {
799
- count += 1;
800
- continue;
801
- }
802
- if (char === '.' || char === '?' || char === '!') {
803
- if (
804
- char === '.'
805
- && /[0-9]/.test(value[index - 1] || '')
806
- && /[0-9]/.test(value[index + 1] || '')
807
- ) {
808
- // Decimal point inside a number such as `1.23` is not a boundary.
809
- continue;
810
- }
811
- count += 1;
812
- }
813
- }
814
- return count;
815
- }
816
-
817
- function hasOriginalMarkerLine(text) {
818
- return String(text ?? '')
819
- .split('\n')
820
- .some(line => /^\s*%\s*\[original\]\s*$/.test(line));
821
- }
822
-
823
- function hasLaterRevisedMarkerLine(text) {
824
- const lines = String(text ?? '').split('\n');
825
- const originalIndex = lines.findIndex(line => /^\s*%\s*\[original\]\s*$/.test(line));
826
- if (originalIndex === -1) {
827
- return false;
828
- }
829
- return lines.some((line, index) => (
830
- index > originalIndex && /^\s*%\s*\[revised\]\s*$/.test(line)
831
- ));
832
- }
833
-
834
789
  function hasAnyAnnotatedMarker(text) {
835
790
  return String(text ?? '')
836
791
  .split('\n')
@@ -839,24 +794,6 @@ function hasAnyAnnotatedMarker(text) {
839
794
  ));
840
795
  }
841
796
 
842
- function splitParagraphs(text) {
843
- const value = String(text ?? '');
844
- const separator = /\n\s*\n/g;
845
- const segments = [];
846
- let lastIndex = 0;
847
- let match = separator.exec(value);
848
-
849
- while (match) {
850
- segments.push({ text: value.slice(lastIndex, match.index), start: lastIndex });
851
- segments.push({ text: match[0], start: match.index });
852
- lastIndex = match.index + match[0].length;
853
- match = separator.exec(value);
854
- }
855
- segments.push({ text: value.slice(lastIndex), start: lastIndex });
856
-
857
- return segments;
858
- }
859
-
860
797
  // Lowercase abbreviations whose trailing `.` is conservatively NOT a sentence
861
798
  // boundary. The word ending in the dot is matched case-insensitively, so this
862
799
  // also covers `Fig.`, `Eq.`, `No.`, etc.
@@ -983,267 +920,355 @@ function splitSentences(text) {
983
920
  }
984
921
 
985
922
 
986
-
987
- function computeGroupMetrics(group, tokenPatches) {
988
- const oldNonEmptyLineCount = countNonEmptyLines(group.oldText);
989
- const newNonEmptyLineCount = countNonEmptyLines(group.newText);
990
- return {
991
- oldNonEmptyLineCount,
992
- newNonEmptyLineCount,
993
- maxNonEmptyLineCount: Math.max(oldNonEmptyLineCount, newNonEmptyLineCount),
994
- changedSpanChars: Math.max(group.oldText.length, group.newText.length),
995
- tokenPatchCount: Array.isArray(tokenPatches) ? tokenPatches.length : null,
996
- totalTokenChangedChars: Array.isArray(tokenPatches)
997
- ? tokenPatches.reduce(
998
- (sum, patch) => sum + Math.max(patch.expected.length, patch.insert.length),
999
- 0
1000
- )
1001
- : null,
1002
- oldSentenceTerminatorCount: countSentenceTerminators(group.oldText),
1003
- newSentenceTerminatorCount: countSentenceTerminators(group.newText)
1004
- };
923
+ function singleGroupPatch(group) {
924
+ return [computeWordAlignedTextPatch(group.oldText, group.newText, group.oldStart)];
1005
925
  }
1006
926
 
1007
- function resolveTokenPatchSentenceSpan(group, tokenPatches) {
1008
- const empty = {
1009
- fitsOneSpan: false,
1010
- spanChars: 0,
1011
- spanTokenCount: 0,
1012
- spanStart: 0,
1013
- spanEnd: 0
1014
- };
1015
- if (tokenPatches === null || tokenPatches.length === 0) {
1016
- return empty;
927
+ // Sentence alignment: the layer between line windows and token patches.
928
+ //
929
+ // A changed group is split into sentence spans on both sides
930
+ // (`splitSentences`, which partitions the text exactly). An LCS over the span
931
+ // texts keeps every unchanged sentence as an anchor. Inside each run between
932
+ // anchors, old and new sentences are paired by similarity
933
+ // (`pairSentencesBySimilarity`); every pair is patched by
934
+ // `computeSentencePairPatches`, sentences left without a partner on one side
935
+ // are inserted or deleted whole, and leftovers on both sides (a sentence that
936
+ // was split, merged, or rewritten beyond recognition) are patched as one run.
937
+ //
938
+ // Finally `mergeSemanticNeighbourPatches` joins adjacent results that are
939
+ // separated only by short whitespace / punctuation, so two rewritten
940
+ // neighbouring sentences surface as one tracked change.
941
+ //
942
+ // A group with one side empty (lines inserted or deleted) is a single patch.
943
+ // A group whose sentence count product exceeds the LCS budget is patched as
944
+ // one sentence pair instead.
945
+ const MAX_SENTENCE_ALIGN_PRODUCT = 4_000_000;
946
+
947
+ // Largest old×new sentence count of a run for which pairwise similarities are
948
+ // computed. Larger runs are patched as a single pair.
949
+ const MAX_SENTENCE_PAIRING_PRODUCT = 400;
950
+
951
+ // Fraction of a sentence that must have changed before the sentence is
952
+ // replaced as a whole instead of being patched word by word. Measured with
953
+ // trimmed edit sizes (`computeTrimmedChangeSize`), so `model` → `models`
954
+ // counts as one character, not six.
955
+ const SENTENCE_REWRITE_RATIO = 0.5;
956
+
957
+ // A sentence is also replaced as a whole when its edits are scattered: at
958
+ // least `SENTENCE_REWRITE_MIN_SITES` separate patches remain after
959
+ // coalescing and together they change at least
960
+ // `SENTENCE_REWRITE_SCATTERED_RATIO` of the sentence. Many word swaps spread
961
+ // over one sentence read as a retyped sentence, not as separate corrections;
962
+ // a handful of typo fixes (tiny mass) or a few word swaps stay word-level.
963
+ // Calibrated on a 37-word sentence: 6 swaps (21%) stay word-level, 9 swaps
964
+ // (30%) replace the sentence.
965
+ const SENTENCE_REWRITE_MIN_SITES = 3;
966
+ const SENTENCE_REWRITE_SCATTERED_RATIO = 0.25;
967
+
968
+ // Minimum similarity for two sentences to be treated as the same sentence
969
+ // edited: a sentence that changed by less than the rewrite threshold is still
970
+ // recognisably the same sentence.
971
+ const SENTENCE_PAIR_MIN_SIMILARITY = 1 - SENTENCE_REWRITE_RATIO;
972
+
973
+ function computeSentenceAlignedPatches(group) {
974
+ if (group.oldText.length === 0 || group.newText.length === 0) {
975
+ return singleGroupPatch(group);
1017
976
  }
1018
977
 
1019
- const sentenceSpans = splitSentences(group.oldText);
1020
- let containingSpan = null;
978
+ const oldSpans = splitSentences(group.oldText);
979
+ const newSpans = splitSentences(group.newText);
980
+ if (oldSpans.length * newSpans.length > MAX_SENTENCE_ALIGN_PRODUCT) {
981
+ return computeSentencePairPatches(group);
982
+ }
1021
983
 
1022
- for (const span of sentenceSpans) {
1023
- const containsEveryPatch = tokenPatches.every(patch => {
1024
- const relativeFrom = patch.from - group.oldStart;
1025
- const relativeTo = patch.to - group.oldStart;
1026
- return relativeFrom >= span.start && relativeTo <= span.end;
1027
- });
1028
- if (!containsEveryPatch) {
1029
- continue;
1030
- }
1031
- if (containingSpan !== null) {
1032
- // More than one span contains every patch (possible for a zero-length
1033
- // patch sitting on a span boundary). Not a confident single sentence.
1034
- return empty;
984
+ const matches = computeNaturalLcsMatches(
985
+ oldSpans.map(span => span.text),
986
+ newSpans.map(span => span.text)
987
+ );
988
+ const patches = [];
989
+ let oldCursor = 0;
990
+ let newCursor = 0;
991
+
992
+ const appendRun = (oldTo, newTo) => {
993
+ if (oldCursor === oldTo && newCursor === newTo) {
994
+ return;
1035
995
  }
1036
- containingSpan = span;
1037
- }
996
+ patches.push(...computeSentenceRunPatches(
997
+ group,
998
+ oldSpans,
999
+ newSpans,
1000
+ oldCursor,
1001
+ oldTo,
1002
+ newCursor,
1003
+ newTo
1004
+ ));
1005
+ };
1038
1006
 
1039
- if (containingSpan === null) {
1040
- return empty;
1007
+ for (const match of matches) {
1008
+ appendRun(match.oldIndex, match.newIndex);
1009
+ oldCursor = match.oldIndex + 1;
1010
+ newCursor = match.newIndex + 1;
1041
1011
  }
1042
- return {
1043
- fitsOneSpan: true,
1044
- spanChars: containingSpan.text.length,
1045
- spanTokenCount: splitTextTokens(containingSpan.text).length,
1046
- spanStart: containingSpan.start,
1047
- spanEnd: containingSpan.end
1048
- };
1049
- }
1012
+ appendRun(oldSpans.length, newSpans.length);
1050
1013
 
1051
- // Classifies a changed group into a natural review granularity. Pure function.
1052
- //
1053
- // `group` is `{oldStart, oldText, newText}`; `tokenPatches` is the array from
1054
- // `computeTokenAnchoredPatches(group.oldText, group.newText, group.oldStart)`
1055
- // or `null`; `metrics` is the object from `computeGroupMetrics(group,
1056
- // tokenPatches)`.
1057
- //
1058
- // Returns `{type}` where `type` is one of `annotated_block`,
1059
- // `paragraph_rewrite`, `sentence_rewrite`, `small_edit`, `fallback`. The
1060
- // predicates are evaluated in first-match order: annotated_block →
1061
- // paragraph_rewrite → sentence_rewrite → small_edit → fallback. When
1062
- // `tokenPatches === null`, every token-dependent predicate is false, so the
1063
- // only reachable results are `annotated_block`, `paragraph_rewrite` (via the
1064
- // line-count or sentence-terminator branch), and `fallback`.
1014
+ return mergeSemanticNeighbourPatches(group, patches);
1015
+ }
1065
1016
 
1017
+ function resolveSpanBoundary(spans, index, textLength) {
1018
+ return index < spans.length ? spans[index].start : textLength;
1019
+ }
1066
1020
 
1021
+ // The sub-group covering old spans `[oldFrom, oldTo)` and new spans
1022
+ // `[newFrom, newTo)`, with an absolute `oldStart`.
1023
+ function resolveSentenceRun(group, oldSpans, newSpans, oldFrom, oldTo, newFrom, newTo) {
1024
+ const oldStart = resolveSpanBoundary(oldSpans, oldFrom, group.oldText.length);
1025
+ const oldEnd = resolveSpanBoundary(oldSpans, oldTo, group.oldText.length);
1026
+ const newStart = resolveSpanBoundary(newSpans, newFrom, group.newText.length);
1027
+ const newEnd = resolveSpanBoundary(newSpans, newTo, group.newText.length);
1028
+ return {
1029
+ oldStart: group.oldStart + oldStart,
1030
+ oldText: group.oldText.slice(oldStart, oldEnd),
1031
+ newText: group.newText.slice(newStart, newEnd)
1032
+ };
1033
+ }
1067
1034
 
1068
- function classifyChangedGroup(group, tokenPatches, metrics) {
1069
- if (hasAnyAnnotatedMarker(group.oldText) || hasAnyAnnotatedMarker(group.newText)) {
1070
- return { type: 'annotated_block' };
1071
- }
1072
-
1073
- const hasTokenPatches = Array.isArray(tokenPatches) && tokenPatches.length > 0;
1074
- const maxTokenPatchChars = hasTokenPatches
1075
- ? tokenPatches.reduce(
1076
- (maximum, patch) => Math.max(
1077
- maximum,
1078
- patch.expected.length,
1079
- patch.insert.length
1080
- ),
1081
- 0
1082
- )
1083
- : null;
1084
- const editDensity = hasTokenPatches
1085
- ? metrics.totalTokenChangedChars / Math.max(1, metrics.changedSpanChars)
1086
- : null;
1087
- const sentenceSpan = hasTokenPatches
1088
- ? resolveTokenPatchSentenceSpan(group, tokenPatches)
1089
- : null;
1090
- const sentenceEditDensity = sentenceSpan?.fitsOneSpan
1091
- ? metrics.totalTokenChangedChars / Math.max(1, sentenceSpan.spanChars)
1092
- : null;
1093
-
1094
- const isDenseTokenRewrite = hasTokenPatches
1095
- && metrics.tokenPatchCount >= 6
1096
- && metrics.changedSpanChars >= 160
1097
- && editDensity >= 0.20
1098
- && metrics.tokenPatchCount / Math.max(1, metrics.maxNonEmptyLineCount) >= 2;
1099
- if (isDenseTokenRewrite) {
1100
- return { type: 'paragraph_rewrite' };
1101
- }
1102
-
1103
- // A dense rewrite wholly contained in one confident sentence stays a
1104
- // sentence patch even when the surrounding line contains unchanged prose.
1105
- if (
1106
- hasTokenPatches
1107
- && metrics.tokenPatchCount >= 3
1108
- && sentenceSpan.fitsOneSpan
1109
- && sentenceSpan.spanChars >= 80
1110
- && sentenceEditDensity >= 0.20
1111
- ) {
1112
- return { type: 'sentence_rewrite' };
1035
+ // Patches a run of sentences that has no exact anchor inside it.
1036
+ function computeSentenceRunPatches(
1037
+ group,
1038
+ oldSpans,
1039
+ newSpans,
1040
+ oldFrom,
1041
+ oldTo,
1042
+ newFrom,
1043
+ newTo
1044
+ ) {
1045
+ const run = resolveSentenceRun(group, oldSpans, newSpans, oldFrom, oldTo, newFrom, newTo);
1046
+ if (run.oldText === run.newText) {
1047
+ return [];
1113
1048
  }
1114
-
1115
- // A collection of bounded, low-density edits stays local regardless of the
1116
- // file size, line count, or number of repeated replacements.
1117
- if (
1118
- hasTokenPatches
1119
- && maxTokenPatchChars <= 96
1120
- && editDensity < 0.20
1121
- ) {
1122
- return { type: 'small_edit' };
1049
+ if (oldFrom === oldTo || newFrom === newTo) {
1050
+ // Whole sentences inserted or deleted.
1051
+ return singleGroupPatch(run);
1123
1052
  }
1124
-
1125
- if (metrics.maxNonEmptyLineCount >= 3) {
1126
- return { type: 'paragraph_rewrite' };
1053
+ if ((oldTo - oldFrom) * (newTo - newFrom) > MAX_SENTENCE_PAIRING_PRODUCT) {
1054
+ return computeSentencePairPatches(run);
1127
1055
  }
1128
- if (
1129
- metrics.oldSentenceTerminatorCount >= 2
1130
- && metrics.newSentenceTerminatorCount >= 2
1131
- && !sentenceSpan?.fitsOneSpan
1132
- ) {
1133
- return { type: 'paragraph_rewrite' };
1056
+
1057
+ // Segments partition the run in order: paired sentences and the leftovers
1058
+ // between them (one-sided = inserted/deleted sentences, two-sided =
1059
+ // sentences with no recognisable partner).
1060
+ const pairs = pairSentencesBySimilarity(oldSpans, newSpans, oldFrom, oldTo, newFrom, newTo);
1061
+ const segments = [];
1062
+ let oldCursor = oldFrom;
1063
+ let newCursor = newFrom;
1064
+ const pushLeftover = (oldEnd, newEnd) => {
1065
+ if (oldCursor !== oldEnd || newCursor !== newEnd) {
1066
+ segments.push({ oldFrom: oldCursor, oldTo: oldEnd, newFrom: newCursor, newTo: newEnd, paired: false });
1067
+ }
1068
+ };
1069
+ for (const pair of pairs) {
1070
+ pushLeftover(pair.oldIndex, pair.newIndex);
1071
+ segments.push({
1072
+ oldFrom: pair.oldIndex,
1073
+ oldTo: pair.oldIndex + 1,
1074
+ newFrom: pair.newIndex,
1075
+ newTo: pair.newIndex + 1,
1076
+ paired: true
1077
+ });
1078
+ oldCursor = pair.oldIndex + 1;
1079
+ newCursor = pair.newIndex + 1;
1134
1080
  }
1081
+ pushLeftover(oldTo, newTo);
1082
+ absorbSplitSentences(group, oldSpans, newSpans, segments);
1135
1083
 
1136
- if (
1137
- hasTokenPatches
1138
- && metrics.tokenPatchCount >= 3
1139
- && sentenceSpan.fitsOneSpan
1140
- && sentenceSpan.spanChars >= 80
1141
- ) {
1142
- return { type: 'sentence_rewrite' };
1084
+ const patches = [];
1085
+ for (const segment of segments) {
1086
+ const segmentRun = resolveSentenceRun(
1087
+ group,
1088
+ oldSpans,
1089
+ newSpans,
1090
+ segment.oldFrom,
1091
+ segment.oldTo,
1092
+ segment.newFrom,
1093
+ segment.newTo
1094
+ );
1095
+ if (segmentRun.oldText === segmentRun.newText) {
1096
+ continue;
1097
+ }
1098
+ if (segment.oldFrom === segment.oldTo || segment.newFrom === segment.newTo) {
1099
+ patches.push(...singleGroupPatch(segmentRun));
1100
+ } else {
1101
+ patches.push(...computeSentencePairPatches(segmentRun));
1102
+ }
1143
1103
  }
1104
+ return patches;
1105
+ }
1144
1106
 
1145
- if (
1146
- hasTokenPatches
1147
- && (
1148
- metrics.tokenPatchCount <= 2
1149
- || metrics.totalTokenChangedChars < 80
1150
- )
1151
- ) {
1152
- return { type: 'small_edit' };
1107
+ // A sentence that was split in two, or two sentences merged into one, shows
1108
+ // up as a pair next to a one-sided leftover. Such a leftover is absorbed into
1109
+ // a neighbouring pair when the combined pair still clears
1110
+ // `SENTENCE_PAIR_MIN_SIMILARITY`, i.e. still reads as the same sentence with
1111
+ // a local edit; when both neighbours qualify, the one that explains the
1112
+ // leftover with fewer changed characters wins. Genuine insertions and
1113
+ // deletions fail the similarity test and stay separate. Mutates `segments`.
1114
+ function absorbSplitSentences(group, oldSpans, newSpans, segments) {
1115
+ const isOneSided = segment => segment.oldFrom === segment.oldTo || segment.newFrom === segment.newTo;
1116
+ // Change mass of the pair that would result from merging `first` and
1117
+ // `second`, or `null` when that pair would no longer read as one sentence.
1118
+ const combinedCost = (first, second) => {
1119
+ const run = resolveSentenceRun(
1120
+ group,
1121
+ oldSpans,
1122
+ newSpans,
1123
+ first.oldFrom,
1124
+ second.oldTo,
1125
+ first.newFrom,
1126
+ second.newTo
1127
+ );
1128
+ const changeMass = computeSentenceChangeMass(run.oldText, run.newText);
1129
+ const similarity = 1 - Math.min(
1130
+ 1,
1131
+ changeMass / Math.max(run.oldText.length, run.newText.length, 1)
1132
+ );
1133
+ return similarity >= SENTENCE_PAIR_MIN_SIMILARITY ? changeMass : null;
1134
+ };
1135
+
1136
+ for (let index = 0; index < segments.length; index += 1) {
1137
+ const segment = segments[index];
1138
+ if (!isOneSided(segment)) {
1139
+ continue;
1140
+ }
1141
+ const previous = segments[index - 1];
1142
+ const next = segments[index + 1];
1143
+ const previousCost = previous?.paired ? combinedCost(previous, segment) : null;
1144
+ const nextCost = next?.paired ? combinedCost(segment, next) : null;
1145
+ if (previousCost === null && nextCost === null) {
1146
+ continue;
1147
+ }
1148
+ if (nextCost !== null && (previousCost === null || nextCost <= previousCost)) {
1149
+ next.oldFrom = segment.oldFrom;
1150
+ next.newFrom = segment.newFrom;
1151
+ } else {
1152
+ previous.oldTo = segment.oldTo;
1153
+ previous.newTo = segment.newTo;
1154
+ }
1155
+ segments.splice(index, 1);
1156
+ index -= 1;
1153
1157
  }
1158
+ }
1154
1159
 
1155
- return { type: 'fallback' };
1160
+ // Summed trimmed size of the token patches between two texts: how many
1161
+ // characters actually have to change to turn one into the other.
1162
+ function computeSentenceChangeMass(oldText, newText) {
1163
+ if (oldText === newText) {
1164
+ return 0;
1165
+ }
1166
+ return computeTokenAnchoredPatches(oldText, newText, 0).reduce(
1167
+ (sum, patch) => sum + computeTrimmedChangeSize(patch),
1168
+ 0
1169
+ );
1156
1170
  }
1157
1171
 
1158
- function singleGroupPatch(group) {
1159
- return [computeSingleTextPatch(group.oldText, group.newText, group.oldStart)];
1172
+ // `1 - changeMass / length`. Identical sentences score 1.
1173
+ function computeSentenceSimilarity(oldText, newText) {
1174
+ const changeMass = computeSentenceChangeMass(oldText, newText);
1175
+ return 1 - Math.min(1, changeMass / Math.max(oldText.length, newText.length, 1));
1160
1176
  }
1161
1177
 
1162
- // Builds paragraph-level patches for a changed group (spec §4).
1163
- //
1164
- // Segments `group.oldText` and `group.newText` with `splitParagraphs`, which
1165
- // yields alternating [content, separator, content, ...] segments. When both
1166
- // sides share the SAME separator structure (same segment count and identical
1167
- // separator segments) the content paragraphs are paired positionally and one
1168
- // patch is emitted per changed pair, with `from`/`to` as absolute offsets
1169
- // (`group.oldStart` + the old paragraph segment's start). A single-paragraph
1170
- // group is the degenerate case of this rule: one pair, one patch.
1171
- //
1172
- // Returns `null` when pairing is ambiguous (separator counts differ or a
1173
- // separator segment changed), so the caller can fall back to a group patch.
1174
- function computeParagraphPatches(group) {
1175
- const oldSegments = splitParagraphs(group.oldText);
1176
- const newSegments = splitParagraphs(group.newText);
1177
-
1178
- if (oldSegments.length !== newSegments.length) {
1179
- return null;
1180
- }
1181
- // splitParagraphs always yields an odd count: content at even indices,
1182
- // blank-line separators at odd indices. Every separator must be unchanged
1183
- // for positional pairing of the content paragraphs to be sound.
1184
- for (let index = 1; index < oldSegments.length; index += 2) {
1185
- if (oldSegments[index].text !== newSegments[index].text) {
1186
- return null;
1178
+ // Monotonic pairing of old spans `[oldFrom, oldTo)` with new spans
1179
+ // `[newFrom, newTo)` that maximises the summed similarity of the pairs, only
1180
+ // allowing pairs at or above `SENTENCE_PAIR_MIN_SIMILARITY`. Returns
1181
+ // `[{oldIndex, newIndex}]` in ascending order.
1182
+ function pairSentencesBySimilarity(oldSpans, newSpans, oldFrom, oldTo, newFrom, newTo) {
1183
+ const oldCount = oldTo - oldFrom;
1184
+ const newCount = newTo - newFrom;
1185
+ const threshold = SENTENCE_PAIR_MIN_SIMILARITY;
1186
+ const similarity = new Float64Array(oldCount * newCount);
1187
+ for (let i = 0; i < oldCount; i += 1) {
1188
+ for (let j = 0; j < newCount; j += 1) {
1189
+ similarity[i * newCount + j] = computeSentenceSimilarity(
1190
+ oldSpans[oldFrom + i].text,
1191
+ newSpans[newFrom + j].text
1192
+ );
1187
1193
  }
1188
1194
  }
1189
1195
 
1190
- const patches = [];
1191
- for (let index = 0; index < oldSegments.length; index += 2) {
1192
- const oldParagraph = oldSegments[index];
1193
- const newParagraph = newSegments[index];
1194
- if (oldParagraph.text === newParagraph.text) {
1195
- continue;
1196
+ const width = newCount + 1;
1197
+ const score = new Float64Array((oldCount + 1) * width);
1198
+ for (let i = 1; i <= oldCount; i += 1) {
1199
+ for (let j = 1; j <= newCount; j += 1) {
1200
+ let best = Math.max(score[(i - 1) * width + j], score[i * width + j - 1]);
1201
+ const pairScore = similarity[(i - 1) * newCount + (j - 1)];
1202
+ if (pairScore >= threshold) {
1203
+ best = Math.max(best, score[(i - 1) * width + (j - 1)] + pairScore);
1204
+ }
1205
+ score[i * width + j] = best;
1196
1206
  }
1197
- patches.push(computeSingleTextPatch(
1198
- oldParagraph.text,
1199
- newParagraph.text,
1200
- group.oldStart + oldParagraph.start
1201
- ));
1202
1207
  }
1203
- return patches;
1208
+
1209
+ const pairs = [];
1210
+ let i = oldCount;
1211
+ let j = newCount;
1212
+ while (i > 0 && j > 0) {
1213
+ const cell = score[i * width + j];
1214
+ const pairScore = similarity[(i - 1) * newCount + (j - 1)];
1215
+ if (
1216
+ pairScore >= threshold
1217
+ && cell === score[(i - 1) * width + (j - 1)] + pairScore
1218
+ ) {
1219
+ pairs.push({ oldIndex: oldFrom + i - 1, newIndex: newFrom + j - 1 });
1220
+ i -= 1;
1221
+ j -= 1;
1222
+ } else if (cell === score[(i - 1) * width + j]) {
1223
+ i -= 1;
1224
+ } else {
1225
+ j -= 1;
1226
+ }
1227
+ }
1228
+ return pairs.reverse();
1204
1229
  }
1205
1230
 
1206
- // Builds a single sentence-level patch for a `sentence_rewrite` group (spec
1207
- // §5). Every token patch lies inside one confident sentence span `[a,b)` of
1208
- // `group.oldText`. Because all token changes are inside that span, the regions
1209
- // `group.oldText.slice(0,a)` and `group.oldText.slice(b)` are unchanged, so
1210
- // `group.newText` is `prefix + <new sentence> + suffix` with the same prefix
1211
- // and suffix; `<new sentence>` is derived by stripping them.
1212
- //
1213
- // Returns `[patch]` whose `from`/`to` cover only that old sentence span
1214
- // (absolute offsets), or `null` when the single span cannot be identified or
1215
- // the unchanged prefix/suffix do not actually match (defensive).
1216
- function computeSentencePatches(group, tokenPatches) {
1217
- const span = resolveTokenPatchSentenceSpan(group, tokenPatches);
1218
- if (!span.fitsOneSpan) {
1219
- return null;
1220
- }
1221
-
1222
- const { spanStart, spanEnd } = span;
1223
- const oldPrefix = group.oldText.slice(0, spanStart);
1224
- const oldSuffix = group.oldText.slice(spanEnd);
1225
- const oldSentence = group.oldText.slice(spanStart, spanEnd);
1226
-
1227
- // The regions outside the sentence span must be byte-identical between old
1228
- // and new text; otherwise a change leaked outside the span and a single
1229
- // sentence patch would be wrong.
1231
+ // Patches one old/new sentence pair. Token patches are computed inside the
1232
+ // pair and coalesced; the pair becomes a single word-aligned replacement (a
1233
+ // human would have retyped the sentence) when the trimmed change mass reaches
1234
+ // `SENTENCE_REWRITE_RATIO` of the sentence, or when the edits are scattered
1235
+ // over `SENTENCE_REWRITE_MIN_SITES` or more sites that together reach
1236
+ // `SENTENCE_REWRITE_SCATTERED_RATIO`. Otherwise the coalesced token patches
1237
+ // are returned.
1238
+ function computeSentencePairPatches(pair) {
1239
+ const tokenPatches = computeTokenAnchoredPatches(
1240
+ pair.oldText,
1241
+ pair.newText,
1242
+ pair.oldStart
1243
+ );
1244
+ if (tokenPatches.length === 0) {
1245
+ return singleGroupPatch(pair);
1246
+ }
1247
+
1248
+ const changeMass = tokenPatches.reduce(
1249
+ (sum, patch) => sum + computeTrimmedChangeSize(patch),
1250
+ 0
1251
+ );
1252
+ const changeRatio = changeMass / Math.max(pair.oldText.length, pair.newText.length, 1);
1253
+ if (changeRatio >= SENTENCE_REWRITE_RATIO) {
1254
+ return singleGroupPatch(pair);
1255
+ }
1256
+
1257
+ const coalesced = coalesceTokenPatches(pair, tokenPatches);
1230
1258
  if (
1231
- !group.newText.startsWith(oldPrefix)
1232
- || !group.newText.endsWith(oldSuffix)
1233
- || group.newText.length < oldPrefix.length + oldSuffix.length
1259
+ coalesced.length >= SENTENCE_REWRITE_MIN_SITES
1260
+ && changeRatio >= SENTENCE_REWRITE_SCATTERED_RATIO
1234
1261
  ) {
1235
- return null;
1262
+ return singleGroupPatch(pair);
1236
1263
  }
1264
+ return coalesced;
1265
+ }
1237
1266
 
1238
- const newSentence = group.newText.slice(
1239
- oldPrefix.length,
1240
- group.newText.length - oldSuffix.length
1241
- );
1242
- return [computeSingleTextPatch(
1243
- oldSentence,
1244
- newSentence,
1245
- group.oldStart + spanStart
1246
- )];
1267
+ // Size of the part of a patch that actually differs, after trimming the
1268
+ // common prefix and suffix of `expected` / `insert`.
1269
+ function computeTrimmedChangeSize(patch) {
1270
+ const trimmed = computeSingleTextPatch(patch.expected, patch.insert);
1271
+ return Math.max(trimmed.expected.length, trimmed.insert.length);
1247
1272
  }
1248
1273
 
1249
1274
  // Short function words whose presence inside a coalescing gap does not block a
@@ -1267,7 +1292,7 @@ function isCoalesceFillerGap(gap) {
1267
1292
  if (/^\s+$/.test(text)) {
1268
1293
  continue;
1269
1294
  }
1270
- if (!/[A-Za-z0-9]/.test(text)) {
1295
+ if (!/[\p{L}\p{N}]/u.test(text)) {
1271
1296
  // Pure punctuation / symbols.
1272
1297
  continue;
1273
1298
  }
@@ -1287,6 +1312,11 @@ function isCoalesceFillerGap(gap) {
1287
1312
  // `expected` the original slice and `insert` the merged inserts interleaved
1288
1313
  // with the unchanged gap text. When nothing qualifies the token patches are
1289
1314
  // returned unchanged. Absolute offsets are preserved throughout.
1315
+ //
1316
+ // The run-based pass is followed by `mergeSemanticNeighbourPatches`, which
1317
+ // joins any two remaining neighbours whose unchanged gap is pure whitespace /
1318
+ // punctuation and no longer than either edit, so one continuous rewrite
1319
+ // surfaces as a single tracked change.
1290
1320
 
1291
1321
  function coalesceTokenPatches(group, tokenPatches) {
1292
1322
  if (!Array.isArray(tokenPatches) || tokenPatches.length <= 1) {
@@ -1328,7 +1358,78 @@ function coalesceTokenPatches(group, tokenPatches) {
1328
1358
  }
1329
1359
  }
1330
1360
  flushRun();
1331
- return result;
1361
+ return mergeSemanticNeighbourPatches(group, result);
1362
+ }
1363
+
1364
+ // Semantic neighbour merge, using the diff-match-patch `diff_cleanupSemantic`
1365
+ // criterion: an unchanged stretch that is shorter than the edits on both sides
1366
+ // of it is noise, not an anchor. Two neighbouring patches (sorted by `from`)
1367
+ // are merged when the original text between them is at most 40 chars made
1368
+ // only of whitespace and punctuation (no letters, digits, or line breaks) and
1369
+ // is no longer than the smaller of the two edits, where an edit's size is
1370
+ // `max(expected.length, insert.length)`. The gap is kept verbatim in both
1371
+ // `expected` and `insert`, so the bytes written do not change. Merging chains:
1372
+ // the merged patch is compared with the next neighbour. Gaps that contain a
1373
+ // line break never merge, preserving the line-window split.
1374
+ function mergeSemanticNeighbourPatches(group, patches) {
1375
+ if (!Array.isArray(patches) || patches.length <= 1) {
1376
+ return patches;
1377
+ }
1378
+
1379
+ const merged = [patches[0]];
1380
+ for (let index = 1; index < patches.length; index += 1) {
1381
+ const previous = merged[merged.length - 1];
1382
+ const current = patches[index];
1383
+ if (current.from < previous.to) {
1384
+ merged.push(current);
1385
+ continue;
1386
+ }
1387
+ const gap = group.oldText.slice(
1388
+ previous.to - group.oldStart,
1389
+ current.from - group.oldStart
1390
+ );
1391
+ if (isSemanticNoiseGap(gap, previous, current)) {
1392
+ merged[merged.length - 1] = mergeTokenPatchRun(group, [previous, current]);
1393
+ } else {
1394
+ merged.push(current);
1395
+ }
1396
+ }
1397
+ return merged;
1398
+ }
1399
+
1400
+ // Largest trimmed edit that still counts as a "small" edit for the sentence
1401
+ // boundary rule below: a word or two, not a rewritten clause.
1402
+ const SMALL_EDIT_CHARS = 24;
1403
+
1404
+ function isSemanticNoiseGap(gap, previous, current) {
1405
+ if (gap.length > 40 || /[\r\n]/.test(gap)) {
1406
+ return false;
1407
+ }
1408
+ if (!/^[\s\p{P}\p{S}]*$/u.test(gap)) {
1409
+ return false;
1410
+ }
1411
+ const previousSize = Math.max(previous.expected.length, previous.insert.length);
1412
+ const currentSize = Math.max(current.expected.length, current.insert.length);
1413
+ if (gap.length > Math.min(previousSize, currentSize)) {
1414
+ return false;
1415
+ }
1416
+ // The last word of one sentence and the first word of the next are two
1417
+ // edits, not one; only rewrites large enough to be whole clauses merge
1418
+ // across a sentence boundary.
1419
+ if (
1420
+ isSentenceBoundaryGap(gap)
1421
+ && Math.min(computeTrimmedChangeSize(previous), computeTrimmedChangeSize(current)) <= SMALL_EDIT_CHARS
1422
+ ) {
1423
+ return false;
1424
+ }
1425
+ return true;
1426
+ }
1427
+
1428
+ // True when the unchanged text between two patches ends a sentence: a CJK
1429
+ // terminator anywhere, or an ASCII terminator followed by whitespace (so the
1430
+ // `.` inside `91.2` does not count).
1431
+ function isSentenceBoundaryGap(gap) {
1432
+ return /[。?!]/.test(gap) || /[.?!]\s/.test(gap);
1332
1433
  }
1333
1434
 
1334
1435
  function computeNaturalPatchRunDensity(group, run) {
@@ -1370,19 +1471,14 @@ function mergeTokenPatchRun(group, run) {
1370
1471
  module.exports = {
1371
1472
  computeTextPatches,
1372
1473
  computeSingleTextPatch,
1474
+ computeWordAlignedTextPatch,
1475
+ computeSentenceAlignedPatches,
1476
+ computeSentencePairPatches,
1477
+ computeTrimmedChangeSize,
1373
1478
  computeLineAnchoredChangeGroups,
1374
1479
  computeTokenAnchoredPatches,
1375
- computeGroupMetrics,
1376
- classifyChangedGroup,
1377
- splitParagraphs,
1378
1480
  splitSentences,
1379
- hasOriginalMarkerLine,
1380
- hasLaterRevisedMarkerLine,
1381
1481
  hasAnyAnnotatedMarker,
1382
- countNonEmptyLines,
1383
- countSentenceTerminators,
1384
1482
  singleGroupPatch,
1385
- computeParagraphPatches,
1386
- computeSentencePatches,
1387
1483
  coalesceTokenPatches
1388
1484
  };