@ifc-lite/viewer 1.14.2 → 1.14.4

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 (80) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/{Arrow.dom-CSgnLhN4.js → Arrow.dom-_vGzMMKs.js} +1 -1
  3. package/dist/assets/basketViewActivator-BZcoCL3V.js +1 -0
  4. package/dist/assets/{browser-qSKWrKQW.js → browser-Czmf34bo.js} +1 -1
  5. package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
  6. package/dist/assets/index-CMQ_Dgkr.css +1 -0
  7. package/dist/assets/index-D7nEDctQ.js +229 -0
  8. package/dist/assets/{index-4Y4XaV8N.js → index-DX-Qf5fA.js} +72669 -61673
  9. package/dist/assets/{native-bridge-CSFDsEkg.js → native-bridge-DAOWftxE.js} +1 -1
  10. package/dist/assets/{wasm-bridge-Zf90ysEm.js → wasm-bridge-D7jYpn8a.js} +1 -1
  11. package/dist/index.html +2 -2
  12. package/package.json +21 -20
  13. package/src/App.tsx +17 -1
  14. package/src/components/viewer/BasketPresentationDock.tsx +8 -4
  15. package/src/components/viewer/ChatPanel.tsx +1402 -0
  16. package/src/components/viewer/CodeEditor.tsx +70 -4
  17. package/src/components/viewer/CommandPalette.tsx +1 -0
  18. package/src/components/viewer/HierarchyPanel.tsx +28 -13
  19. package/src/components/viewer/MainToolbar.tsx +113 -95
  20. package/src/components/viewer/ScriptPanel.tsx +351 -184
  21. package/src/components/viewer/UpgradePage.tsx +69 -0
  22. package/src/components/viewer/Viewport.tsx +23 -0
  23. package/src/components/viewer/chat/ChatMessage.tsx +144 -0
  24. package/src/components/viewer/chat/ExecutableCodeBlock.tsx +416 -0
  25. package/src/components/viewer/chat/ModelSelector.tsx +102 -0
  26. package/src/components/viewer/chat/renderTextContent.test.ts +23 -0
  27. package/src/components/viewer/chat/renderTextContent.ts +19 -0
  28. package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
  29. package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
  30. package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
  31. package/src/components/viewer/hierarchy/types.ts +6 -1
  32. package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
  33. package/src/hooks/useIfcCache.ts +1 -2
  34. package/src/hooks/useSandbox.ts +122 -6
  35. package/src/index.css +10 -0
  36. package/src/lib/attachments.ts +46 -0
  37. package/src/lib/llm/ClerkChatSync.tsx +74 -0
  38. package/src/lib/llm/clerk-auth.ts +62 -0
  39. package/src/lib/llm/code-extractor.ts +50 -0
  40. package/src/lib/llm/context-builder.test.ts +18 -0
  41. package/src/lib/llm/context-builder.ts +305 -0
  42. package/src/lib/llm/free-models.test.ts +118 -0
  43. package/src/lib/llm/message-capabilities.test.ts +131 -0
  44. package/src/lib/llm/message-capabilities.ts +94 -0
  45. package/src/lib/llm/models.ts +197 -0
  46. package/src/lib/llm/repair-loop.test.ts +91 -0
  47. package/src/lib/llm/repair-loop.ts +76 -0
  48. package/src/lib/llm/script-diagnostics.ts +445 -0
  49. package/src/lib/llm/script-edit-ops.test.ts +399 -0
  50. package/src/lib/llm/script-edit-ops.ts +954 -0
  51. package/src/lib/llm/script-preflight.test.ts +513 -0
  52. package/src/lib/llm/script-preflight.ts +990 -0
  53. package/src/lib/llm/script-preservation.test.ts +128 -0
  54. package/src/lib/llm/script-preservation.ts +152 -0
  55. package/src/lib/llm/stream-client.test.ts +97 -0
  56. package/src/lib/llm/stream-client.ts +410 -0
  57. package/src/lib/llm/system-prompt.test.ts +181 -0
  58. package/src/lib/llm/system-prompt.ts +665 -0
  59. package/src/lib/llm/types.ts +150 -0
  60. package/src/lib/scripts/templates/bim-globals.d.ts +226 -7
  61. package/src/lib/scripts/templates/create-building.ts +12 -12
  62. package/src/main.tsx +10 -1
  63. package/src/sdk/adapters/export-adapter.test.ts +24 -0
  64. package/src/sdk/adapters/export-adapter.ts +40 -16
  65. package/src/sdk/adapters/files-adapter.ts +39 -0
  66. package/src/sdk/adapters/model-compat.ts +1 -1
  67. package/src/sdk/adapters/mutate-adapter.ts +20 -6
  68. package/src/sdk/adapters/mutation-view.ts +112 -0
  69. package/src/sdk/adapters/query-adapter.ts +100 -4
  70. package/src/sdk/local-backend.ts +4 -0
  71. package/src/store/index.ts +15 -1
  72. package/src/store/slices/chatSlice.test.ts +325 -0
  73. package/src/store/slices/chatSlice.ts +468 -0
  74. package/src/store/slices/scriptSlice.test.ts +75 -0
  75. package/src/store/slices/scriptSlice.ts +256 -9
  76. package/src/vite-env.d.ts +10 -0
  77. package/vite.config.ts +21 -2
  78. package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
  79. package/dist/assets/index-ByrFvN5A.css +0 -1
  80. package/dist/assets/index-CN7qDq7G.js +0 -216
@@ -0,0 +1,954 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import type { ScriptEditOperation, ScriptEditorSelection, ScriptEditorTextChange } from './types.js';
6
+ import type { PatchScriptDiagnostic } from './script-diagnostics.js';
7
+ import { createPatchDiagnostic } from './script-diagnostics.js';
8
+ import {
9
+ type ScriptMutationIntent,
10
+ validateScriptReplacementCandidate,
11
+ } from './script-preservation.js';
12
+
13
+ const EDIT_FENCE_LANGUAGES = new Set(['ifc-script-edits', 'ifc-script-edit']);
14
+ const SEARCH_REPLACE_START = '<<<<<<< SEARCH';
15
+ const SEARCH_REPLACE_SEPARATOR = '=======';
16
+ const SEARCH_REPLACE_END = '>>>>>>> REPLACE';
17
+
18
+ type RawEditsEnvelope = {
19
+ scriptEdits?: unknown;
20
+ ops?: unknown;
21
+ };
22
+
23
+ export interface ParsedScriptEditOps {
24
+ operations: ScriptEditOperation[];
25
+ parseErrors: string[];
26
+ parseDiagnostics: PatchScriptDiagnostic[];
27
+ }
28
+
29
+ export interface ScriptEditParseOptions {
30
+ baseRevision?: number;
31
+ baseContent?: string;
32
+ intent?: ScriptMutationIntent;
33
+ requestedRepairScope?: ScriptEditOperation['scope'];
34
+ targetRootCause?: string;
35
+ }
36
+
37
+ export interface ApplyScriptEditOpsResult {
38
+ ok: boolean;
39
+ content: string;
40
+ selection: ScriptEditorSelection;
41
+ revision: number;
42
+ appliedOpIds: string[];
43
+ changes?: ScriptEditorTextChange[];
44
+ status: 'ok' | 'revision_conflict' | 'range_error' | 'semantic_error';
45
+ error?: string;
46
+ diagnostic?: PatchScriptDiagnostic;
47
+ }
48
+
49
+ function asFiniteNumber(value: unknown): number | null {
50
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
51
+ return value;
52
+ }
53
+
54
+ function asBoolean(value: unknown): boolean | undefined {
55
+ return typeof value === 'boolean' ? value : undefined;
56
+ }
57
+
58
+ function asRepairScope(value: unknown): ScriptEditOperation['scope'] | undefined {
59
+ return value === 'local' || value === 'block' || value === 'structural' || value === 'full_rewrite'
60
+ ? value
61
+ : undefined;
62
+ }
63
+
64
+ function parseOperation(raw: unknown, index: number): { op?: ScriptEditOperation; error?: string } {
65
+ if (!raw || typeof raw !== 'object') {
66
+ return { error: `scriptEdits[${index}] must be an object.` };
67
+ }
68
+
69
+ const record = raw as Record<string, unknown>;
70
+ const opId = typeof record.opId === 'string' ? record.opId.trim() : '';
71
+ const type = typeof record.type === 'string' ? record.type.trim() : '';
72
+ const baseRevision = asFiniteNumber(record.baseRevision);
73
+ const groupId = typeof record.groupId === 'string' ? record.groupId.trim() || undefined : undefined;
74
+ const scope = asRepairScope(record.scope);
75
+ const atomic = asBoolean(record.atomic);
76
+ const targetRootCause = typeof record.targetRootCause === 'string' ? record.targetRootCause.trim() || undefined : undefined;
77
+
78
+ if (!opId) return { error: `scriptEdits[${index}] is missing a valid opId.` };
79
+ if (baseRevision === null) return { error: `scriptEdits[${index}] is missing a valid baseRevision.` };
80
+
81
+ const text = typeof record.text === 'string' ? record.text : '';
82
+ const shared = { opId, baseRevision, groupId, scope, atomic, targetRootCause };
83
+
84
+ switch (type) {
85
+ case 'insert': {
86
+ const at = asFiniteNumber(record.at);
87
+ if (at === null) return { error: `insert op "${opId}" is missing a valid "at" index.` };
88
+ return { op: { type, ...shared, at, text } };
89
+ }
90
+ case 'replaceRange': {
91
+ const from = asFiniteNumber(record.from);
92
+ const to = asFiniteNumber(record.to);
93
+ const expectedText = typeof record.expectedText === 'string' ? record.expectedText : undefined;
94
+ if (from === null || to === null) {
95
+ return { error: `replaceRange op "${opId}" requires numeric "from" and "to".` };
96
+ }
97
+ return { op: { type, ...shared, from, to, text, expectedText } };
98
+ }
99
+ case 'replaceSelection':
100
+ return { op: { type, ...shared, text } };
101
+ case 'append':
102
+ return { op: { type, ...shared, text } };
103
+ case 'replaceAll':
104
+ return { op: { type, ...shared, text } };
105
+ default:
106
+ return { error: `scriptEdits[${index}] has unsupported type "${type}".` };
107
+ }
108
+ }
109
+
110
+ function stableHash(value: string): string {
111
+ let hash = 2166136261;
112
+ for (let i = 0; i < value.length; i++) {
113
+ hash ^= value.charCodeAt(i);
114
+ hash = Math.imul(hash, 16777619);
115
+ }
116
+ return (hash >>> 0).toString(36);
117
+ }
118
+
119
+ function addParseDiagnostic(
120
+ parseErrors: string[],
121
+ parseDiagnostics: PatchScriptDiagnostic[],
122
+ diagnostic: PatchScriptDiagnostic,
123
+ ) {
124
+ parseErrors.push(diagnostic.message);
125
+ parseDiagnostics.push(diagnostic);
126
+ }
127
+
128
+ function parseJsonFence(
129
+ body: string,
130
+ seenIds: Set<string>,
131
+ operations: ScriptEditOperation[],
132
+ parseErrors: string[],
133
+ parseDiagnostics: PatchScriptDiagnostic[],
134
+ ) {
135
+ let parsed: RawEditsEnvelope | null = null;
136
+ try {
137
+ parsed = JSON.parse(body) as RawEditsEnvelope;
138
+ } catch (error) {
139
+ addParseDiagnostic(
140
+ parseErrors,
141
+ parseDiagnostics,
142
+ createPatchDiagnostic(
143
+ 'patch_semantic_error',
144
+ `Invalid JSON in ifc-script-edits block: ${error instanceof Error ? error.message : String(error)}`,
145
+ 'error',
146
+ {
147
+ failureKind: 'parse_error',
148
+ fixHint: 'Return one valid `ifc-script-edits` fence. For broad model compatibility, prefer exact SEARCH/REPLACE blocks instead of raw JSON ops.',
149
+ rootCauseKey: 'malformed_repair_reply',
150
+ },
151
+ ),
152
+ );
153
+ return false;
154
+ }
155
+
156
+ const rawOps = Array.isArray(parsed.scriptEdits)
157
+ ? parsed.scriptEdits
158
+ : Array.isArray(parsed.ops)
159
+ ? parsed.ops
160
+ : null;
161
+
162
+ if (!rawOps) {
163
+ addParseDiagnostic(
164
+ parseErrors,
165
+ parseDiagnostics,
166
+ createPatchDiagnostic(
167
+ 'patch_semantic_error',
168
+ 'No "scriptEdits" array found in ifc-script-edits block.',
169
+ 'error',
170
+ {
171
+ failureKind: 'parse_error',
172
+ fixHint: 'Return either a JSON `scriptEdits` array or one or more exact SEARCH/REPLACE blocks inside the fence.',
173
+ rootCauseKey: 'malformed_repair_reply',
174
+ },
175
+ ),
176
+ );
177
+ return false;
178
+ }
179
+
180
+ rawOps.forEach((raw, index) => {
181
+ const { op, error } = parseOperation(raw, index);
182
+ if (error) {
183
+ addParseDiagnostic(
184
+ parseErrors,
185
+ parseDiagnostics,
186
+ createPatchDiagnostic('patch_semantic_error', error, 'error', {
187
+ failureKind: 'parse_error',
188
+ fixHint: 'Return valid edit objects for every entry in `scriptEdits`.',
189
+ rootCauseKey: 'malformed_repair_reply',
190
+ }),
191
+ );
192
+ return;
193
+ }
194
+ if (!op) return;
195
+ if (seenIds.has(op.opId)) return;
196
+ seenIds.add(op.opId);
197
+ operations.push(op);
198
+ });
199
+ return true;
200
+ }
201
+
202
+ function findAllOccurrences(haystack: string, needle: string): number[] {
203
+ if (!needle) return [];
204
+ const matches: number[] = [];
205
+ let fromIndex = 0;
206
+ while (fromIndex <= haystack.length) {
207
+ const index = haystack.indexOf(needle, fromIndex);
208
+ if (index === -1) break;
209
+ matches.push(index);
210
+ fromIndex = index + 1;
211
+ }
212
+ return matches;
213
+ }
214
+
215
+ function parseSearchReplaceFence(
216
+ body: string,
217
+ options: ScriptEditParseOptions | undefined,
218
+ seenIds: Set<string>,
219
+ operations: ScriptEditOperation[],
220
+ parseErrors: string[],
221
+ parseDiagnostics: PatchScriptDiagnostic[],
222
+ ) {
223
+ const normalizedBody = body.replace(/\r\n/g, '\n');
224
+ const hasSearchMarkers = normalizedBody.includes(SEARCH_REPLACE_START)
225
+ || normalizedBody.includes(SEARCH_REPLACE_SEPARATOR)
226
+ || normalizedBody.includes(SEARCH_REPLACE_END);
227
+ if (!hasSearchMarkers) return false;
228
+
229
+ if (typeof options?.baseContent !== 'string' || !Number.isInteger(options.baseRevision)) {
230
+ addParseDiagnostic(
231
+ parseErrors,
232
+ parseDiagnostics,
233
+ createPatchDiagnostic(
234
+ 'patch_semantic_error',
235
+ 'SEARCH/REPLACE edits require the current script content and revision context.',
236
+ 'error',
237
+ {
238
+ failureKind: 'missing_editor_context',
239
+ fixHint: 'Only emit SEARCH/REPLACE edits when SCRIPT EDITOR CONTEXT is present.',
240
+ rootCauseKey: 'malformed_repair_reply',
241
+ },
242
+ ),
243
+ );
244
+ return true;
245
+ }
246
+ const baseRevision = options.baseRevision as number;
247
+
248
+ const blockRegex = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
249
+ const matches: Array<{ search: string; replace: string; index: number }> = [];
250
+ let cursor = 0;
251
+ let match: RegExpExecArray | null;
252
+ while ((match = blockRegex.exec(normalizedBody)) !== null) {
253
+ const gap = normalizedBody.slice(cursor, match.index).trim();
254
+ if (gap.length > 0) {
255
+ addParseDiagnostic(
256
+ parseErrors,
257
+ parseDiagnostics,
258
+ createPatchDiagnostic(
259
+ 'patch_semantic_error',
260
+ 'Unexpected text appeared between SEARCH/REPLACE blocks.',
261
+ 'error',
262
+ {
263
+ failureKind: 'parse_error',
264
+ fixHint: 'Return only SEARCH/REPLACE blocks inside the `ifc-script-edits` fence.',
265
+ snippet: gap,
266
+ rootCauseKey: 'malformed_repair_reply',
267
+ },
268
+ ),
269
+ );
270
+ return true;
271
+ }
272
+ matches.push({ search: match[1], replace: match[2], index: matches.length });
273
+ cursor = blockRegex.lastIndex;
274
+ }
275
+
276
+ const trailing = normalizedBody.slice(cursor).trim();
277
+ if (trailing.length > 0) {
278
+ addParseDiagnostic(
279
+ parseErrors,
280
+ parseDiagnostics,
281
+ createPatchDiagnostic(
282
+ 'patch_semantic_error',
283
+ matches.length === 0
284
+ ? 'Malformed SEARCH/REPLACE block in ifc-script-edits fence.'
285
+ : 'Unexpected trailing text after SEARCH/REPLACE blocks.',
286
+ 'error',
287
+ {
288
+ failureKind: 'parse_error',
289
+ fixHint: 'Each block must use `<<<<<<< SEARCH`, `=======`, and `>>>>>>> REPLACE` exactly, with no extra prose in the fence.',
290
+ snippet: trailing,
291
+ rootCauseKey: 'malformed_repair_reply',
292
+ },
293
+ ),
294
+ );
295
+ return true;
296
+ }
297
+
298
+ const scope = options.intent === 'repair'
299
+ ? (options.requestedRepairScope ?? (matches.length > 1 ? 'block' : 'local'))
300
+ : undefined;
301
+ const groupId = options.intent === 'repair' && (scope === 'block' || scope === 'structural')
302
+ ? `search-replace-${stableHash(normalizedBody)}`
303
+ : undefined;
304
+
305
+ for (const block of matches) {
306
+ if (block.search.length === 0) {
307
+ addParseDiagnostic(
308
+ parseErrors,
309
+ parseDiagnostics,
310
+ createPatchDiagnostic(
311
+ 'patch_semantic_error',
312
+ 'SEARCH/REPLACE blocks must include a non-empty SEARCH section copied from the current script.',
313
+ 'error',
314
+ {
315
+ failureKind: 'empty_search_block',
316
+ fixHint: 'To insert new code, include unchanged surrounding context in SEARCH and add the new text inside REPLACE.',
317
+ rootCauseKey: 'malformed_repair_reply',
318
+ },
319
+ ),
320
+ );
321
+ continue;
322
+ }
323
+
324
+ const occurrences = findAllOccurrences(options.baseContent, block.search);
325
+ if (occurrences.length === 0) {
326
+ addParseDiagnostic(
327
+ parseErrors,
328
+ parseDiagnostics,
329
+ createPatchDiagnostic(
330
+ 'patch_semantic_error',
331
+ `SEARCH block ${block.index + 1} does not match the current script.`,
332
+ 'error',
333
+ {
334
+ failureKind: 'no_unique_match',
335
+ snippet: block.search,
336
+ expectedBaseRevision: options.baseRevision,
337
+ fixHint: 'Copy the SEARCH text exactly from the CURRENT script revision before replacing it.',
338
+ rootCauseKey: 'stale_patch_target',
339
+ },
340
+ ),
341
+ );
342
+ continue;
343
+ }
344
+ if (occurrences.length > 1) {
345
+ addParseDiagnostic(
346
+ parseErrors,
347
+ parseDiagnostics,
348
+ createPatchDiagnostic(
349
+ 'patch_semantic_error',
350
+ `SEARCH block ${block.index + 1} matches multiple locations in the current script.`,
351
+ 'error',
352
+ {
353
+ failureKind: 'multiple_matches',
354
+ snippet: block.search,
355
+ fixHint: 'Include more unchanged surrounding context in SEARCH so it matches exactly one location.',
356
+ rootCauseKey: 'malformed_repair_reply',
357
+ },
358
+ ),
359
+ );
360
+ continue;
361
+ }
362
+
363
+ const from = occurrences[0];
364
+ const opId = `sr-${block.index}-${stableHash(block.search)}-${stableHash(block.replace)}`;
365
+ if (seenIds.has(opId)) continue;
366
+ seenIds.add(opId);
367
+ operations.push({
368
+ opId,
369
+ type: 'replaceRange',
370
+ baseRevision,
371
+ from,
372
+ to: from + block.search.length,
373
+ expectedText: block.search,
374
+ text: block.replace,
375
+ groupId,
376
+ scope,
377
+ targetRootCause: options.intent === 'repair' ? options.targetRootCause : undefined,
378
+ });
379
+ }
380
+
381
+ return true;
382
+ }
383
+
384
+ export function extractScriptEditOps(markdown: string, options?: ScriptEditParseOptions): ParsedScriptEditOps {
385
+ const operations: ScriptEditOperation[] = [];
386
+ const parseErrors: string[] = [];
387
+ const parseDiagnostics: PatchScriptDiagnostic[] = [];
388
+ const seenIds = new Set<string>();
389
+ const fenceRegex = /```([\w-]+)\n([\s\S]*?)```/g;
390
+ let match: RegExpExecArray | null;
391
+
392
+ while ((match = fenceRegex.exec(markdown)) !== null) {
393
+ const language = (match[1] ?? '').toLowerCase();
394
+ if (!EDIT_FENCE_LANGUAGES.has(language)) continue;
395
+ const body = match[2] ?? '';
396
+ const parsedSearchReplace = parseSearchReplaceFence(
397
+ body,
398
+ options,
399
+ seenIds,
400
+ operations,
401
+ parseErrors,
402
+ parseDiagnostics,
403
+ );
404
+ if (parsedSearchReplace) continue;
405
+ parseJsonFence(body, seenIds, operations, parseErrors, parseDiagnostics);
406
+ }
407
+
408
+ return { operations, parseErrors, parseDiagnostics };
409
+ }
410
+
411
+ export function filterUnappliedScriptOps(
412
+ operations: ScriptEditOperation[],
413
+ appliedOpIds: Set<string>,
414
+ ): ScriptEditOperation[] {
415
+ return operations.filter((op) => !appliedOpIds.has(op.opId));
416
+ }
417
+
418
+ function replaceRange(content: string, from: number, to: number, insert: string): string {
419
+ return content.slice(0, from) + insert + content.slice(to);
420
+ }
421
+
422
+ function validateRange(from: number, to: number, max: number): string | null {
423
+ if (!Number.isInteger(from) || !Number.isInteger(to)) {
424
+ return 'range indices must be integers.';
425
+ }
426
+ if (from < 0 || to < 0 || from > max || to > max) {
427
+ return `range [${from}, ${to}] is outside content bounds 0..${max}.`;
428
+ }
429
+ if (from > to) {
430
+ return `range [${from}, ${to}] is invalid (from > to).`;
431
+ }
432
+ return null;
433
+ }
434
+
435
+ export function applyScriptEditOperations(params: {
436
+ content: string;
437
+ selection: ScriptEditorSelection;
438
+ revision: number;
439
+ operations: ScriptEditOperation[];
440
+ priorAcceptedOps?: ScriptEditOperation[];
441
+ acceptedBaseRevision?: number;
442
+ baseContentSnapshot?: string;
443
+ intent?: ScriptMutationIntent;
444
+ }): ApplyScriptEditOpsResult {
445
+ const { operations, revision } = params;
446
+ const expectedBaseRevision = params.acceptedBaseRevision ?? revision;
447
+ const baseContent = params.baseContentSnapshot ?? params.content;
448
+ let content = params.content;
449
+ let selection = params.selection;
450
+ const appliedOpIds: string[] = [];
451
+ const changes: ScriptEditorTextChange[] = [];
452
+ const baseMutations = buildBaseMutations(params.priorAcceptedOps ?? [], baseContent.length);
453
+ let selectionMutationSeen = (params.priorAcceptedOps ?? []).some((op) => op.type === 'replaceSelection');
454
+
455
+ if (operations.length === 0) {
456
+ return { ok: true, content, selection, revision, appliedOpIds, changes, status: 'ok' };
457
+ }
458
+
459
+ if (params.intent === 'repair') {
460
+ const metadataError = validateRepairBatchMetadata(operations);
461
+ if (metadataError) {
462
+ return {
463
+ ok: false,
464
+ content: params.content,
465
+ selection: params.selection,
466
+ revision,
467
+ appliedOpIds: [],
468
+ status: 'semantic_error',
469
+ error: metadataError.message,
470
+ diagnostic: metadataError,
471
+ };
472
+ }
473
+ }
474
+
475
+ for (const op of operations) {
476
+ if (op.baseRevision !== expectedBaseRevision) {
477
+ const attemptedOpIds = operations.map((candidate) => candidate.opId);
478
+ const diagnostic = createPatchDiagnostic(
479
+ 'patch_revision_conflict',
480
+ `Edit op "${op.opId}" targets revision ${op.baseRevision}, but expected base revision is ${expectedBaseRevision}.`,
481
+ 'error',
482
+ {
483
+ attemptedOpIds,
484
+ opBaseRevision: op.baseRevision,
485
+ currentEditorRevision: revision,
486
+ expectedBaseRevision,
487
+ appliedOpIds: [...appliedOpIds],
488
+ },
489
+ );
490
+ return {
491
+ ok: false,
492
+ content: params.content,
493
+ selection: params.selection,
494
+ revision,
495
+ appliedOpIds: [],
496
+ status: 'revision_conflict',
497
+ error: diagnostic.message,
498
+ diagnostic,
499
+ };
500
+ }
501
+
502
+ if (op.type === 'replaceAll') {
503
+ const replacementCheck = validateScriptReplacementCandidate({
504
+ previousContent: params.content,
505
+ candidateContent: op.text,
506
+ intent: params.intent ?? 'create',
507
+ source: 'replaceAll',
508
+ });
509
+ if (!replacementCheck.ok) {
510
+ return {
511
+ ok: false,
512
+ content: params.content,
513
+ selection: params.selection,
514
+ revision,
515
+ appliedOpIds: [],
516
+ status: 'semantic_error',
517
+ error: replacementCheck.diagnostic?.message,
518
+ diagnostic: replacementCheck.diagnostic,
519
+ };
520
+ }
521
+ content = op.text;
522
+ selection = { from: op.text.length, to: op.text.length };
523
+ appliedOpIds.push(op.opId);
524
+ changes.push({ from: 0, to: params.content.length, insert: op.text });
525
+ continue;
526
+ }
527
+
528
+ if (op.type === 'append') {
529
+ const at = content.length;
530
+ content = replaceRange(content, at, at, op.text);
531
+ selection = { from: at + op.text.length, to: at + op.text.length };
532
+ appliedOpIds.push(op.opId);
533
+ changes.push({ from: at, to: at, insert: op.text });
534
+ baseMutations.push({
535
+ from: baseContent.length,
536
+ to: baseContent.length,
537
+ delta: op.text.length,
538
+ opId: op.opId,
539
+ });
540
+ continue;
541
+ }
542
+
543
+ if (op.type === 'replaceSelection') {
544
+ if (params.intent === 'repair') {
545
+ const diagnostic = createPatchDiagnostic(
546
+ 'patch_semantic_error',
547
+ `replaceSelection op "${op.opId}" is not allowed for automated repair turns.`,
548
+ 'error',
549
+ {
550
+ opId: op.opId,
551
+ fixHint: 'Use replaceRange with the exact failing range and include `expectedText` from the current script.',
552
+ },
553
+ );
554
+ return {
555
+ ok: false,
556
+ content: params.content,
557
+ selection: params.selection,
558
+ revision,
559
+ appliedOpIds: [],
560
+ status: 'semantic_error',
561
+ error: diagnostic.message,
562
+ diagnostic,
563
+ };
564
+ }
565
+ const issue = validateRange(selection.from, selection.to, content.length);
566
+ if (issue) {
567
+ const diagnostic = createPatchDiagnostic(
568
+ 'patch_range_error',
569
+ `replaceSelection failed: ${issue}`,
570
+ 'error',
571
+ {
572
+ opId: op.opId,
573
+ range: { from: selection.from, to: selection.to },
574
+ contentLength: content.length,
575
+ },
576
+ );
577
+ return {
578
+ ok: false,
579
+ content: params.content,
580
+ selection: params.selection,
581
+ revision,
582
+ appliedOpIds: [],
583
+ status: 'range_error',
584
+ error: diagnostic.message,
585
+ diagnostic,
586
+ };
587
+ }
588
+ content = replaceRange(content, selection.from, selection.to, op.text);
589
+ const cursor = selection.from + op.text.length;
590
+ changes.push({ from: selection.from, to: selection.to, insert: op.text });
591
+ selection = { from: cursor, to: cursor };
592
+ appliedOpIds.push(op.opId);
593
+ selectionMutationSeen = true;
594
+ continue;
595
+ }
596
+
597
+ if (op.type === 'insert') {
598
+ if (selectionMutationSeen) {
599
+ const diagnostic = createPatchDiagnostic(
600
+ 'patch_semantic_error',
601
+ `insert op "${op.opId}" cannot follow a selection-based edit in the same patch set.`,
602
+ 'error',
603
+ {
604
+ opId: op.opId,
605
+ fixHint: 'Use only positional ops from the same base snapshot, or emit a single replaceSelection patch.',
606
+ },
607
+ );
608
+ return {
609
+ ok: false,
610
+ content: params.content,
611
+ selection: params.selection,
612
+ revision,
613
+ appliedOpIds: [],
614
+ status: 'semantic_error',
615
+ error: diagnostic.message,
616
+ diagnostic,
617
+ };
618
+ }
619
+ const issue = validateRange(op.at, op.at, baseContent.length);
620
+ if (issue) {
621
+ const diagnostic = createPatchDiagnostic(
622
+ 'patch_range_error',
623
+ `insert failed against base snapshot: ${issue}`,
624
+ 'error',
625
+ {
626
+ opId: op.opId,
627
+ at: op.at,
628
+ baseContentLength: baseContent.length,
629
+ },
630
+ );
631
+ return {
632
+ ok: false,
633
+ content: params.content,
634
+ selection: params.selection,
635
+ revision,
636
+ appliedOpIds: [],
637
+ status: 'range_error',
638
+ error: diagnostic.message,
639
+ diagnostic,
640
+ };
641
+ }
642
+ const rebasedAt = rebaseIndex(op.at, baseMutations);
643
+ if (rebasedAt === null) {
644
+ const diagnostic = createPatchDiagnostic(
645
+ 'patch_revision_conflict',
646
+ `insert op "${op.opId}" targets a stale location in the original script snapshot.`,
647
+ 'error',
648
+ {
649
+ opId: op.opId,
650
+ at: op.at,
651
+ fixHint: 'Re-read the current script and regenerate ops against the latest unchanged base snapshot.',
652
+ },
653
+ );
654
+ return {
655
+ ok: false,
656
+ content: params.content,
657
+ selection: params.selection,
658
+ revision,
659
+ appliedOpIds: [],
660
+ status: 'revision_conflict',
661
+ error: diagnostic.message,
662
+ diagnostic,
663
+ };
664
+ }
665
+ content = replaceRange(content, rebasedAt, rebasedAt, op.text);
666
+ const cursor = rebasedAt + op.text.length;
667
+ changes.push({ from: rebasedAt, to: rebasedAt, insert: op.text });
668
+ selection = { from: cursor, to: cursor };
669
+ appliedOpIds.push(op.opId);
670
+ baseMutations.push({
671
+ from: op.at,
672
+ to: op.at,
673
+ delta: op.text.length,
674
+ opId: op.opId,
675
+ });
676
+ continue;
677
+ }
678
+
679
+ if (selectionMutationSeen) {
680
+ const diagnostic = createPatchDiagnostic(
681
+ 'patch_semantic_error',
682
+ `replaceRange op "${op.opId}" cannot follow a selection-based edit in the same patch set.`,
683
+ 'error',
684
+ {
685
+ opId: op.opId,
686
+ range: { from: op.from, to: op.to },
687
+ fixHint: 'Use positional ops only, or emit a single replaceSelection patch for the selected region.',
688
+ },
689
+ );
690
+ return {
691
+ ok: false,
692
+ content: params.content,
693
+ selection: params.selection,
694
+ revision,
695
+ appliedOpIds: [],
696
+ status: 'semantic_error',
697
+ error: diagnostic.message,
698
+ diagnostic,
699
+ };
700
+ }
701
+
702
+ const issue = validateRange(op.from, op.to, baseContent.length);
703
+ if (issue) {
704
+ const diagnostic = createPatchDiagnostic(
705
+ 'patch_range_error',
706
+ `replaceRange failed against base snapshot: ${issue}`,
707
+ 'error',
708
+ {
709
+ opId: op.opId,
710
+ range: { from: op.from, to: op.to },
711
+ baseContentLength: baseContent.length,
712
+ },
713
+ );
714
+ return {
715
+ ok: false,
716
+ content: params.content,
717
+ selection: params.selection,
718
+ revision,
719
+ appliedOpIds: [],
720
+ status: 'range_error',
721
+ error: diagnostic.message,
722
+ diagnostic,
723
+ };
724
+ }
725
+ if (params.intent === 'repair') {
726
+ if (typeof op.expectedText !== 'string') {
727
+ const diagnostic = createPatchDiagnostic(
728
+ 'patch_semantic_error',
729
+ `replaceRange op "${op.opId}" must include \`expectedText\` for repair turns.`,
730
+ 'error',
731
+ {
732
+ opId: op.opId,
733
+ range: { from: op.from, to: op.to },
734
+ fixHint: 'Copy the exact current text from the failing range into `expectedText` before replacing it.',
735
+ },
736
+ );
737
+ return {
738
+ ok: false,
739
+ content: params.content,
740
+ selection: params.selection,
741
+ revision,
742
+ appliedOpIds: [],
743
+ status: 'semantic_error',
744
+ error: diagnostic.message,
745
+ diagnostic,
746
+ };
747
+ }
748
+ const actualText = baseContent.slice(op.from, op.to);
749
+ if (actualText !== op.expectedText) {
750
+ const diagnostic = createPatchDiagnostic(
751
+ 'patch_revision_conflict',
752
+ `replaceRange op "${op.opId}" no longer matches the expected text in the base snapshot.`,
753
+ 'error',
754
+ {
755
+ opId: op.opId,
756
+ range: { from: op.from, to: op.to },
757
+ expectedText: op.expectedText,
758
+ actualText,
759
+ fixHint: 'Re-read the latest script and regenerate the repair patch against the exact current text.',
760
+ },
761
+ );
762
+ return {
763
+ ok: false,
764
+ content: params.content,
765
+ selection: params.selection,
766
+ revision,
767
+ appliedOpIds: [],
768
+ status: 'revision_conflict',
769
+ error: diagnostic.message,
770
+ diagnostic,
771
+ };
772
+ }
773
+ }
774
+ if (hasOverlappingBaseMutation(op.from, op.to, baseMutations)) {
775
+ const diagnostic = createPatchDiagnostic(
776
+ 'patch_revision_conflict',
777
+ `replaceRange op "${op.opId}" overlaps an earlier edit against the same base snapshot.`,
778
+ 'error',
779
+ {
780
+ opId: op.opId,
781
+ range: { from: op.from, to: op.to },
782
+ appliedOpIds: [...appliedOpIds],
783
+ fixHint: 'Regenerate non-overlapping ops in order from the latest script snapshot.',
784
+ },
785
+ );
786
+ return {
787
+ ok: false,
788
+ content: params.content,
789
+ selection: params.selection,
790
+ revision,
791
+ appliedOpIds: [],
792
+ status: 'revision_conflict',
793
+ error: diagnostic.message,
794
+ diagnostic,
795
+ };
796
+ }
797
+
798
+ const rebasedFrom = rebaseIndex(op.from, baseMutations);
799
+ const rebasedTo = rebaseIndex(op.to, baseMutations);
800
+ if (rebasedFrom === null || rebasedTo === null) {
801
+ const diagnostic = createPatchDiagnostic(
802
+ 'patch_revision_conflict',
803
+ `replaceRange op "${op.opId}" targets stale text in the original script snapshot.`,
804
+ 'error',
805
+ {
806
+ opId: op.opId,
807
+ range: { from: op.from, to: op.to },
808
+ appliedOpIds: [...appliedOpIds],
809
+ fixHint: 'Re-read the current script and regenerate ops against the latest unchanged base snapshot.',
810
+ },
811
+ );
812
+ return {
813
+ ok: false,
814
+ content: params.content,
815
+ selection: params.selection,
816
+ revision,
817
+ appliedOpIds: [],
818
+ status: 'revision_conflict',
819
+ error: diagnostic.message,
820
+ diagnostic,
821
+ };
822
+ }
823
+
824
+ content = replaceRange(content, rebasedFrom, rebasedTo, op.text);
825
+ const cursor = rebasedFrom + op.text.length;
826
+ changes.push({ from: rebasedFrom, to: rebasedTo, insert: op.text });
827
+ selection = { from: cursor, to: cursor };
828
+ appliedOpIds.push(op.opId);
829
+ baseMutations.push({
830
+ from: op.from,
831
+ to: op.to,
832
+ delta: op.text.length - (op.to - op.from),
833
+ opId: op.opId,
834
+ });
835
+ }
836
+
837
+ return {
838
+ ok: true,
839
+ content,
840
+ selection,
841
+ revision: revision + 1,
842
+ appliedOpIds,
843
+ changes,
844
+ status: 'ok',
845
+ };
846
+ }
847
+
848
+ function validateRepairBatchMetadata(operations: ScriptEditOperation[]): PatchScriptDiagnostic | null {
849
+ const nonLocalOps = operations.filter((op) => op.scope && op.scope !== 'local');
850
+ const scopes = new Set(nonLocalOps.map((op) => op.scope));
851
+ const targetRootCauses = new Set(operations.map((op) => op.targetRootCause).filter((value): value is string => Boolean(value)));
852
+
853
+ if (scopes.size > 1) {
854
+ return createPatchDiagnostic(
855
+ 'patch_semantic_error',
856
+ 'Repair patch mixes incompatible scopes in one batch. Use one coordinated scope per repair response.',
857
+ 'error',
858
+ {
859
+ failureKind: 'mixed_repair_scopes',
860
+ fixHint: 'Emit one local/block/structural repair batch at a time.',
861
+ },
862
+ );
863
+ }
864
+
865
+ if (targetRootCauses.size > 1) {
866
+ return createPatchDiagnostic(
867
+ 'patch_semantic_error',
868
+ 'Repair patch targets multiple root causes in one batch. Focus on one grouped root cause per response.',
869
+ 'error',
870
+ {
871
+ failureKind: 'mixed_root_causes',
872
+ fixHint: 'Choose one root cause and patch only the related evidence spans in this response.',
873
+ },
874
+ );
875
+ }
876
+
877
+ const sharedScope = nonLocalOps[0]?.scope;
878
+ if (sharedScope === 'block' || sharedScope === 'structural') {
879
+ if (targetRootCauses.size === 0) {
880
+ return createPatchDiagnostic(
881
+ 'patch_semantic_error',
882
+ `A ${sharedScope} repair batch must declare \`targetRootCause\` so the system can track the broader fix session.`,
883
+ 'error',
884
+ {
885
+ failureKind: 'missing_root_cause_metadata',
886
+ fixHint: 'Set the same `targetRootCause` on each coordinated repair op.',
887
+ },
888
+ );
889
+ }
890
+
891
+ const groupIds = new Set(nonLocalOps.map((op) => op.groupId).filter((value): value is string => Boolean(value)));
892
+ if (groupIds.size !== 1) {
893
+ return createPatchDiagnostic(
894
+ 'patch_semantic_error',
895
+ `A ${sharedScope} repair batch must use one shared \`groupId\` across its coordinated ops.`,
896
+ 'error',
897
+ {
898
+ failureKind: 'missing_group_metadata',
899
+ fixHint: 'Assign the same `groupId` to every coordinated op in the batch.',
900
+ },
901
+ );
902
+ }
903
+ }
904
+
905
+ return null;
906
+ }
907
+
908
+ function rebaseIndex(
909
+ index: number,
910
+ mutations: Array<{ from: number; to: number; delta: number }>,
911
+ ): number | null {
912
+ let rebased = index;
913
+ for (const mutation of mutations) {
914
+ const isPureInsert = mutation.from === mutation.to;
915
+ if (isPureInsert) {
916
+ if (mutation.from <= index) rebased += mutation.delta;
917
+ continue;
918
+ }
919
+ if (index > mutation.from && index < mutation.to) {
920
+ return null;
921
+ }
922
+ if (mutation.to <= index) rebased += mutation.delta;
923
+ }
924
+ return rebased;
925
+ }
926
+
927
+ function hasOverlappingBaseMutation(
928
+ from: number,
929
+ to: number,
930
+ mutations: Array<{ from: number; to: number }>,
931
+ ): boolean {
932
+ return mutations.some((mutation) => {
933
+ if (mutation.from === mutation.to) return false;
934
+ return from < mutation.to && to > mutation.from;
935
+ });
936
+ }
937
+
938
+ function buildBaseMutations(
939
+ operations: ScriptEditOperation[],
940
+ baseContentLength: number,
941
+ ): Array<{ from: number; to: number; delta: number; opId: string }> {
942
+ return operations.flatMap((op) => {
943
+ switch (op.type) {
944
+ case 'insert':
945
+ return [{ from: op.at, to: op.at, delta: op.text.length, opId: op.opId }];
946
+ case 'replaceRange':
947
+ return [{ from: op.from, to: op.to, delta: op.text.length - (op.to - op.from), opId: op.opId }];
948
+ case 'append':
949
+ return [{ from: baseContentLength, to: baseContentLength, delta: op.text.length, opId: op.opId }];
950
+ default:
951
+ return [];
952
+ }
953
+ });
954
+ }